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.
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/.github/workflows/release.yml +17 -7
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/.gitignore +4 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/CHANGELOG.md +74 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/Cargo.lock +14 -3
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/Cargo.toml +6 -1
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/PKG-INFO +52 -3
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/README.md +51 -2
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/ROADMAP.md +13 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/pyproject.toml +1 -1
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/python/rust_py_audit/__init__.py +1 -1
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/audit_logger.rs +91 -20
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/storage.rs +237 -18
- rust_py_audit-0.4.2/tests/test_multiprocess.py +80 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/LICENSE +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/Makefile +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/clippy.toml +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/examples/__init__.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/examples/basic_usage.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/examples/django_example/__init__.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/examples/django_example/settings.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/examples/django_example/urls.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/examples/django_example/views.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/examples/fastapi_app.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/examples/immutablelog_basic.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/python/rust_py_audit/django.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/python/rust_py_audit/fastapi.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/errors.rs +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/event.rs +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/hash.rs +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/immutablelog_client.rs +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/immutablelog_config.rs +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/immutablelog_receipt.rs +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/lib.rs +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/pyconv.rs +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/retry.rs +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/verifier.rs +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/conftest.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_audit_logger.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_concurrency.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_django_immutablelog.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_django_middleware.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_fastapi_immutablelog.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_fastapi_middleware.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_flush_pending.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_hello.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_immutablelog_hybrid.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_immutablelog_remote.py +0 -0
- {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_metadata_bigint.py +0 -0
- {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
|
-
-
|
|
155
|
+
- name: Build wheels (x86_64)
|
|
156
|
+
uses: PyO3/maturin-action@v1
|
|
153
157
|
with:
|
|
154
|
-
target:
|
|
158
|
+
target: x86_64
|
|
155
159
|
args: --release --out dist --find-interpreter
|
|
156
|
-
-
|
|
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
|
|
168
|
+
name: wheels-macos
|
|
159
169
|
path: dist
|
|
160
170
|
|
|
161
171
|
sdist:
|
|
@@ -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.
|
|
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.
|
|
2366
|
+
version = "1.0.10"
|
|
2356
2367
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2357
|
-
checksum = "
|
|
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
|
+
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
|
+
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** —
|
|
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** —
|
|
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.
|
|
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
|
|
@@ -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
|
-
///
|
|
75
|
-
///
|
|
76
|
-
///
|
|
77
|
-
///
|
|
78
|
-
///
|
|
79
|
-
///
|
|
80
|
-
///
|
|
81
|
-
///
|
|
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<
|
|
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(
|
|
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
|
-
|
|
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<'_,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|