rust-py-audit 0.2.1__tar.gz → 0.2.3__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.2.1 → rust_py_audit-0.2.3}/Cargo.lock +1 -1
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/Cargo.toml +1 -1
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/PKG-INFO +2 -2
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/pyproject.toml +2 -2
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/python/rust_py_audit/__init__.py +1 -1
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/src/audit_logger.rs +221 -127
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/src/pyconv.rs +26 -2
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/src/storage.rs +116 -0
- rust_py_audit-0.2.3/tests/test_concurrency.py +76 -0
- rust_py_audit-0.2.3/tests/test_metadata_bigint.py +76 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/.github/workflows/release.yml +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/.gitignore +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/LICENSE +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/README.md +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/examples/__init__.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/examples/basic_usage.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/examples/django_example/__init__.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/examples/django_example/settings.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/examples/django_example/urls.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/examples/django_example/views.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/examples/fastapi_app.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/examples/immutablelog_basic.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/python/rust_py_audit/django.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/python/rust_py_audit/fastapi.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/src/errors.rs +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/src/event.rs +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/src/hash.rs +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/src/immutablelog_client.rs +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/src/immutablelog_config.rs +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/src/immutablelog_receipt.rs +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/src/lib.rs +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/src/retry.rs +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/src/verifier.rs +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/tests/conftest.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/tests/test_audit_logger.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/tests/test_django_immutablelog.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/tests/test_django_middleware.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/tests/test_fastapi_immutablelog.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/tests/test_fastapi_middleware.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/tests/test_flush_pending.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/tests/test_hello.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/tests/test_immutablelog_hybrid.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/tests/test_immutablelog_remote.py +0 -0
- {rust_py_audit-0.2.1 → rust_py_audit-0.2.3}/tests/test_verify.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rust-py-audit
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -26,7 +26,7 @@ Provides-Extra: dev
|
|
|
26
26
|
Provides-Extra: django
|
|
27
27
|
Provides-Extra: fastapi
|
|
28
28
|
License-File: LICENSE
|
|
29
|
-
Summary:
|
|
29
|
+
Summary: Fast, tamper-evident event auditing (hash chain) for Python apps, with a Rust core.
|
|
30
30
|
Keywords: audit,audit-log,security,django,fastapi,rust,integrity,hash-chain
|
|
31
31
|
Author-email: Roberto Lima <robertolima.izphera@gmail.com>
|
|
32
32
|
License-Expression: MIT
|
|
@@ -4,8 +4,8 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rust-py-audit"
|
|
7
|
-
version = "0.2.
|
|
8
|
-
description = "
|
|
7
|
+
version = "0.2.3"
|
|
8
|
+
description = "Fast, tamper-evident event auditing (hash chain) for Python apps, with a Rust core."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
authors = [
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
use std::collections::{HashMap, HashSet};
|
|
1
2
|
use std::path::PathBuf;
|
|
3
|
+
use std::sync::Mutex;
|
|
2
4
|
|
|
3
5
|
use pyo3::prelude::*;
|
|
4
6
|
use pyo3::types::PyDict;
|
|
@@ -34,6 +36,27 @@ use crate::verifier::verify_chain;
|
|
|
34
36
|
/// `AuditLogger` (`new()`), e depois mantemos o cache atualizado a cada
|
|
35
37
|
/// `log()` bem-sucedido.
|
|
36
38
|
///
|
|
39
|
+
/// O `Mutex` em volta de `last_hash` serve a dois propósitos ligados a
|
|
40
|
+
/// concorrência:
|
|
41
|
+
/// 1. Permite que `log()` e `flush_pending()` recebam `&self` (em vez de
|
|
42
|
+
/// `&mut self`). Com `&mut self`, o PyO3 mantém um *borrow* mutável
|
|
43
|
+
/// exclusivo durante TODO o método — e como `log()` libera o GIL
|
|
44
|
+
/// durante a chamada de rede (`allow_threads`), uma segunda thread
|
|
45
|
+
/// (ex.: WSGI multi-thread compartilhando o mesmo logger) que
|
|
46
|
+
/// chamasse `log()` ao mesmo tempo receberia `RuntimeError: Already
|
|
47
|
+
/// borrowed`, perdendo o evento. Com `&self` + `Mutex`, isso não
|
|
48
|
+
/// acontece.
|
|
49
|
+
/// 2. Garante que a leitura do `last_hash`, o cálculo do `previous_hash`
|
|
50
|
+
/// e a atualização do cache aconteçam atomicamente — duas threads
|
|
51
|
+
/// nunca encadeiam dois eventos no MESMO `previous_hash` (o que
|
|
52
|
+
/// bifurcaria a cadeia).
|
|
53
|
+
///
|
|
54
|
+
/// REGRA CRÍTICA: este `Mutex` só pode ser travado com o GIL JÁ LIBERADO
|
|
55
|
+
/// (dentro de `py.allow_threads`). Travar segurando o GIL poderia
|
|
56
|
+
/// causar deadlock com a thread que liberou o GIL durante a rede e ainda
|
|
57
|
+
/// segura o lock. Por isso `log()`/`flush_pending()`/`last_hash()`
|
|
58
|
+
/// envolvem o uso do lock em `allow_threads`.
|
|
59
|
+
///
|
|
37
60
|
/// Limitação consciente do MVP: esse cache assume que só **este**
|
|
38
61
|
/// `AuditLogger` (este processo) escreve no arquivo. Se dois processos
|
|
39
62
|
/// diferentes apontarem para o mesmo `file_path` e chamarem `log()`
|
|
@@ -42,11 +65,21 @@ use crate::verifier::verify_chain;
|
|
|
42
65
|
/// íntegra (cada processo encadeia com o que *ele* sabia ser o último
|
|
43
66
|
/// hash), mas teríamos duas pontas de cadeia em paralelo. Resolver isso
|
|
44
67
|
/// exigiria lock de arquivo entre processos, fora do escopo do MVP.
|
|
68
|
+
///
|
|
69
|
+
/// Limitação específica de `mode="remote"`: como esse modo NÃO grava
|
|
70
|
+
/// JSONL local, `last_hash` vive apenas em memória. Ao recriar o
|
|
71
|
+
/// `AuditLogger` (reinício do processo), `new()` relê `file_path` —
|
|
72
|
+
/// que está vazio em remote puro — e `last_hash` volta a `None`, ou
|
|
73
|
+
/// seja, o `previous_hash` REINICIA a cada restart. A continuidade da
|
|
74
|
+
/// cadeia entre reinícios, nesse modo, depende inteiramente do
|
|
75
|
+
/// ImmutableLog do lado servidor, não do encadeamento local. Quem
|
|
76
|
+
/// precisa de cadeia local contínua entre reinícios deve usar
|
|
77
|
+
/// `mode="hybrid"`.
|
|
45
78
|
#[pyclass]
|
|
46
79
|
pub struct AuditLogger {
|
|
47
80
|
app_name: String,
|
|
48
81
|
file_path: PathBuf,
|
|
49
|
-
last_hash: Option<String
|
|
82
|
+
last_hash: Mutex<Option<String>>,
|
|
50
83
|
/// Modo de operação (`local`/`remote`/`hybrid`). Por ora só fica
|
|
51
84
|
/// guardado e exposto via getter — `log()` ainda se comporta como
|
|
52
85
|
/// antes (sempre local) independente do valor aqui. O envio remoto
|
|
@@ -132,7 +165,7 @@ impl AuditLogger {
|
|
|
132
165
|
Ok(AuditLogger {
|
|
133
166
|
app_name,
|
|
134
167
|
file_path,
|
|
135
|
-
last_hash,
|
|
168
|
+
last_hash: Mutex::new(last_hash),
|
|
136
169
|
mode,
|
|
137
170
|
immutablelog,
|
|
138
171
|
client,
|
|
@@ -193,8 +226,11 @@ impl AuditLogger {
|
|
|
193
226
|
/// Implementação O(1): só devolve o cache em memória (`Option`
|
|
194
227
|
/// clonado), sem tocar no arquivo em disco — diferente de
|
|
195
228
|
/// `verify()`, que releria tudo de propósito.
|
|
196
|
-
|
|
197
|
-
|
|
229
|
+
///
|
|
230
|
+
/// O `allow_threads` em volta do lock segue a REGRA CRÍTICA descrita
|
|
231
|
+
/// na doc da struct: nunca travar o `Mutex` segurando o GIL.
|
|
232
|
+
fn last_hash(&self, py: Python<'_>) -> Option<String> {
|
|
233
|
+
py.allow_threads(|| self.lock_last_hash().clone())
|
|
198
234
|
}
|
|
199
235
|
|
|
200
236
|
/// Registra um novo evento de auditoria.
|
|
@@ -216,7 +252,7 @@ impl AuditLogger {
|
|
|
216
252
|
/// depois disso.
|
|
217
253
|
#[pyo3(signature = (actor_id, action, resource, resource_id, metadata = None, severity = None, immutable_trail = None))]
|
|
218
254
|
fn log(
|
|
219
|
-
&
|
|
255
|
+
&self,
|
|
220
256
|
py: Python<'_>,
|
|
221
257
|
actor_id: String,
|
|
222
258
|
action: String,
|
|
@@ -226,107 +262,46 @@ impl AuditLogger {
|
|
|
226
262
|
severity: Option<String>,
|
|
227
263
|
immutable_trail: Option<String>,
|
|
228
264
|
) -> PyResult<PyObject> {
|
|
265
|
+
// A conversão de `metadata` (e a validação de `severity`) precisa
|
|
266
|
+
// do GIL e é feita ANTES de liberar threads: `python_to_json`
|
|
267
|
+
// inspeciona objetos Python, e validar cedo faz `log()` falhar
|
|
268
|
+
// rápido mesmo em `mode="local"`.
|
|
229
269
|
let metadata_value = match metadata {
|
|
230
270
|
Some(value) => python_to_json(&value)?,
|
|
231
271
|
None => serde_json::Value::Object(serde_json::Map::new()),
|
|
232
272
|
};
|
|
233
273
|
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
// quando `severity` é `None`); `event.severity` guarda só o que
|
|
239
|
-
// foi passado (ou `None`), para `flush_pending()` reenviar mais
|
|
240
|
-
// tarde com a mesma classificação original.
|
|
274
|
+
// `resolved_severity` é o valor efetivo usado na PRIMEIRA
|
|
275
|
+
// tentativa de envio (default "info" quando `severity` é `None`);
|
|
276
|
+
// `event.severity` guarda só o que foi passado (ou `None`), para
|
|
277
|
+
// `flush_pending()` reenviar mais tarde com a mesma classificação.
|
|
241
278
|
let resolved_severity =
|
|
242
279
|
resolve_severity(severity.as_deref()).map_err(crate::errors::AuditError::Config)?;
|
|
243
280
|
let sanitized_trail = sanitize_trail(immutable_trail.as_deref());
|
|
244
281
|
|
|
245
282
|
// RFC3339 em UTC produz exatamente o formato do exemplo do MVP:
|
|
246
|
-
// "2026-06-17T10:00:00Z".
|
|
247
|
-
// formatação (praticamente impossível de ocorrer aqui, mas
|
|
248
|
-
// ainda é um `Result`) em `PyErr`, evitando `.unwrap()`.
|
|
283
|
+
// "2026-06-17T10:00:00Z".
|
|
249
284
|
let timestamp = OffsetDateTime::now_utc()
|
|
250
285
|
.format(&Rfc3339)
|
|
251
286
|
.map_err(|err| pyo3::exceptions::PyValueError::new_err(err.to_string()))?;
|
|
252
287
|
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
// `hash.rs::HashPayload`) e NUNCA depois — o que vier do
|
|
271
|
-
// ImmutableLog é só metadado operacional, anexado depois,
|
|
272
|
-
// sem poder alterar essa "impressão digital" do evento.
|
|
273
|
-
severity,
|
|
274
|
-
immutable_trail: sanitized_trail,
|
|
275
|
-
immutablelog: None,
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
event.hash = compute_hash(&event)?;
|
|
279
|
-
|
|
280
|
-
match self.mode {
|
|
281
|
-
AuditMode::Local => {
|
|
282
|
-
storage::append_event(&self.file_path, &event)?;
|
|
283
|
-
self.last_hash = Some(event.hash.clone());
|
|
284
|
-
}
|
|
285
|
-
AuditMode::Remote => {
|
|
286
|
-
// Não grava JSONL local neste modo — se o envio falhar,
|
|
287
|
-
// o erro sobe como exceção Python e `self.last_hash`
|
|
288
|
-
// continua apontando para o último evento que de fato
|
|
289
|
-
// foi confirmado pelo ImmutableLog.
|
|
290
|
-
let receipt = self.send_to_immutablelog(
|
|
291
|
-
py,
|
|
292
|
-
&event,
|
|
293
|
-
resolved_severity,
|
|
294
|
-
event.immutable_trail.as_deref(),
|
|
295
|
-
)?;
|
|
296
|
-
event.immutablelog = Some(receipt);
|
|
297
|
-
self.last_hash = Some(event.hash.clone());
|
|
298
|
-
}
|
|
299
|
-
AuditMode::Hybrid => {
|
|
300
|
-
// Salva local PRIMEIRO: mesmo que o processo morra antes
|
|
301
|
-
// do envio terminar, o evento já está persistido (sem
|
|
302
|
-
// receipt ainda) — nunca perdemos o registro local por
|
|
303
|
-
// causa de uma falha de rede.
|
|
304
|
-
storage::append_event(&self.file_path, &event)?;
|
|
305
|
-
self.last_hash = Some(event.hash.clone());
|
|
306
|
-
|
|
307
|
-
match self.send_to_immutablelog(
|
|
308
|
-
py,
|
|
309
|
-
&event,
|
|
310
|
-
resolved_severity,
|
|
311
|
-
event.immutable_trail.as_deref(),
|
|
312
|
-
) {
|
|
313
|
-
Ok(receipt) => {
|
|
314
|
-
event.immutablelog = Some(receipt.clone());
|
|
315
|
-
storage::update_event_receipt(&self.file_path, &event.id, &receipt)?;
|
|
316
|
-
}
|
|
317
|
-
Err(_) => {
|
|
318
|
-
// Falha em hybrid NÃO é exceção Python: o evento
|
|
319
|
-
// já está seguro localmente, então devolvemos
|
|
320
|
-
// status "pending" e deixamos para
|
|
321
|
-
// `flush_pending()` tentar de novo depois.
|
|
322
|
-
let pending_receipt = ImmutableLogReceipt::pending();
|
|
323
|
-
event.immutablelog = Some(pending_receipt.clone());
|
|
324
|
-
storage::update_event_receipt(&self.file_path, &event.id, &pending_receipt)?;
|
|
325
|
-
storage::append_event(&storage::pending_path_for(&self.file_path), &event)?;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
}
|
|
288
|
+
// Toda a seção crítica (travar `last_hash`, encadear, gravar
|
|
289
|
+
// local e/ou enviar à rede, atualizar o cache) roda com o GIL
|
|
290
|
+
// LIBERADO — ver a REGRA CRÍTICA na doc da struct. `append_chained`
|
|
291
|
+
// é puro Rust, não toca em objetos Python.
|
|
292
|
+
let event = py.allow_threads(|| {
|
|
293
|
+
self.append_chained(
|
|
294
|
+
actor_id,
|
|
295
|
+
action,
|
|
296
|
+
resource,
|
|
297
|
+
resource_id,
|
|
298
|
+
metadata_value,
|
|
299
|
+
timestamp,
|
|
300
|
+
severity,
|
|
301
|
+
sanitized_trail,
|
|
302
|
+
resolved_severity,
|
|
303
|
+
)
|
|
304
|
+
})?;
|
|
330
305
|
|
|
331
306
|
event_to_pydict(py, &event)
|
|
332
307
|
}
|
|
@@ -375,30 +350,44 @@ impl AuditLogger {
|
|
|
375
350
|
fn flush_pending(&self, py: Python<'_>) -> PyResult<PyObject> {
|
|
376
351
|
let pending_path = storage::pending_path_for(&self.file_path);
|
|
377
352
|
let pending_events = storage::read_events(&pending_path)?;
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
353
|
+
let total = pending_events.len();
|
|
354
|
+
|
|
355
|
+
// Igual a `log()`: tudo que trava o `Mutex` ou faz rede roda com
|
|
356
|
+
// o GIL liberado (REGRA CRÍTICA na doc da struct).
|
|
357
|
+
let (flushed, still_pending) = py.allow_threads(|| -> Result<(usize, usize), crate::errors::AuditError> {
|
|
358
|
+
// Primeiro a parte cara/lenta (rede), SEM segurar o lock:
|
|
359
|
+
// acumulamos os receipts entregues e os ids a remover da fila.
|
|
360
|
+
let mut delivered: HashMap<String, ImmutableLogReceipt> = HashMap::new();
|
|
361
|
+
let mut flushed_ids: HashSet<String> = HashSet::new();
|
|
362
|
+
|
|
363
|
+
for event in &pending_events {
|
|
364
|
+
let severity = resolve_severity(event.severity.as_deref())
|
|
365
|
+
.map_err(crate::errors::AuditError::Config)?;
|
|
366
|
+
if let Ok(receipt) = self.deliver(event, severity, event.immutable_trail.as_deref())
|
|
367
|
+
{
|
|
368
|
+
delivered.insert(event.id.clone(), receipt);
|
|
369
|
+
flushed_ids.insert(event.id.clone());
|
|
394
370
|
}
|
|
371
|
+
// Em falha o evento simplesmente continua na fila para a
|
|
372
|
+
// próxima chamada de `flush_pending()` tentar de novo.
|
|
395
373
|
}
|
|
396
|
-
|
|
374
|
+
|
|
375
|
+
// Só agora travamos, para a parte que mexe nos arquivos —
|
|
376
|
+
// serializando contra um `log()` concorrente. As funções em
|
|
377
|
+
// lote releem os arquivos aqui dentro, então eventos
|
|
378
|
+
// acrescentados nesse meio-tempo são preservados.
|
|
379
|
+
let _guard = self.lock_last_hash();
|
|
380
|
+
storage::update_event_receipts(&self.file_path, &delivered)?;
|
|
381
|
+
storage::remove_events(&pending_path, &flushed_ids)?;
|
|
382
|
+
|
|
383
|
+
let flushed = flushed_ids.len();
|
|
384
|
+
Ok((flushed, total - flushed))
|
|
385
|
+
})?;
|
|
397
386
|
|
|
398
387
|
let dict = PyDict::new_bound(py);
|
|
399
388
|
dict.set_item("flushed", flushed)?;
|
|
400
389
|
dict.set_item("still_pending", still_pending)?;
|
|
401
|
-
dict.set_item("total",
|
|
390
|
+
dict.set_item("total", total)?;
|
|
402
391
|
Ok(dict.into())
|
|
403
392
|
}
|
|
404
393
|
}
|
|
@@ -407,30 +396,135 @@ impl AuditLogger {
|
|
|
407
396
|
/// propósito: nada aqui deve ser exposto como método Python de
|
|
408
397
|
/// `AuditLogger`.
|
|
409
398
|
impl AuditLogger {
|
|
410
|
-
///
|
|
411
|
-
///
|
|
412
|
-
///
|
|
413
|
-
///
|
|
414
|
-
///
|
|
415
|
-
|
|
399
|
+
/// Trava o `Mutex` de `last_hash`, recuperando-se de um eventual
|
|
400
|
+
/// *poisoning* (que só ocorreria se uma thread tivesse entrado em
|
|
401
|
+
/// panic segurando o lock — improvável aqui, já que a seção crítica
|
|
402
|
+
/// não chama `.unwrap()`/`panic!`). Recuperar em vez de propagar o
|
|
403
|
+
/// panic evita derrubar o processo Python inteiro por causa de um
|
|
404
|
+
/// lock envenenado.
|
|
405
|
+
///
|
|
406
|
+
/// IMPORTANTE: só chamar com o GIL liberado (ver REGRA CRÍTICA na
|
|
407
|
+
/// doc da struct).
|
|
408
|
+
fn lock_last_hash(&self) -> std::sync::MutexGuard<'_, Option<String>> {
|
|
409
|
+
self.last_hash
|
|
410
|
+
.lock()
|
|
411
|
+
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/// Envia `event` ao ImmutableLog (com retry). Rust puro: NÃO toca no
|
|
415
|
+
/// GIL nem o libera — quem chama (`append_chained`/`flush_pending`)
|
|
416
|
+
/// já está dentro de um `allow_threads`, então a chamada HTTP
|
|
417
|
+
/// bloqueante já roda com o GIL liberado, sem travar outras threads
|
|
418
|
+
/// Python.
|
|
419
|
+
fn deliver(
|
|
416
420
|
&self,
|
|
417
|
-
py: Python<'_>,
|
|
418
421
|
event: &AuditEvent,
|
|
419
422
|
severity: &str,
|
|
420
423
|
immutable_trail: Option<&str>,
|
|
421
|
-
) ->
|
|
422
|
-
|
|
423
|
-
|
|
424
|
+
) -> Result<ImmutableLogReceipt, crate::immutablelog_client::ImmutableLogClientError> {
|
|
425
|
+
use crate::immutablelog_client::ImmutableLogClientError;
|
|
426
|
+
let client = self.client.as_ref().ok_or_else(|| ImmutableLogClientError::Permanent {
|
|
427
|
+
status: None,
|
|
428
|
+
reason: format!(
|
|
424
429
|
"mode='{}' exige um cliente ImmutableLog inicializado (estado inconsistente)",
|
|
425
430
|
self.mode.as_str()
|
|
426
|
-
)
|
|
431
|
+
),
|
|
427
432
|
})?;
|
|
428
|
-
let retry_enabled = self.immutablelog.retry_enabled;
|
|
429
|
-
let max_retries = self.immutablelog.max_retries;
|
|
430
433
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
434
|
+
send_with_retry(
|
|
435
|
+
client,
|
|
436
|
+
event,
|
|
437
|
+
severity,
|
|
438
|
+
immutable_trail,
|
|
439
|
+
self.immutablelog.retry_enabled,
|
|
440
|
+
self.immutablelog.max_retries,
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/// Seção crítica de `log()`, em Rust puro (sem GIL): trava o
|
|
445
|
+
/// `last_hash`, monta o evento já encadeado, calcula o hash, grava
|
|
446
|
+
/// local e/ou envia à rede conforme o modo, e atualiza o cache. O
|
|
447
|
+
/// lock é mantido por toda a operação — inclusive a chamada de rede
|
|
448
|
+
/// em `remote`/`hybrid` — de propósito: a cadeia de hashes é
|
|
449
|
+
/// inerentemente sequencial, então serializar `log()` é o
|
|
450
|
+
/// comportamento correto (impede duas threads de encadearem no mesmo
|
|
451
|
+
/// `previous_hash`).
|
|
452
|
+
#[allow(clippy::too_many_arguments)]
|
|
453
|
+
fn append_chained(
|
|
454
|
+
&self,
|
|
455
|
+
actor_id: String,
|
|
456
|
+
action: String,
|
|
457
|
+
resource: String,
|
|
458
|
+
resource_id: String,
|
|
459
|
+
metadata: serde_json::Value,
|
|
460
|
+
timestamp: String,
|
|
461
|
+
severity: Option<String>,
|
|
462
|
+
immutable_trail: Option<String>,
|
|
463
|
+
resolved_severity: &str,
|
|
464
|
+
) -> Result<AuditEvent, crate::errors::AuditError> {
|
|
465
|
+
let mut guard = self.lock_last_hash();
|
|
466
|
+
|
|
467
|
+
// `hash` começa vazio: só dá para calculá-lo depois que todos os
|
|
468
|
+
// outros campos existem (`compute_hash` ignora o campo `hash`).
|
|
469
|
+
let mut event = AuditEvent {
|
|
470
|
+
id: Uuid::new_v4().to_string(),
|
|
471
|
+
timestamp,
|
|
472
|
+
app_name: self.app_name.clone(),
|
|
473
|
+
actor_id,
|
|
474
|
+
action,
|
|
475
|
+
resource,
|
|
476
|
+
resource_id,
|
|
477
|
+
metadata,
|
|
478
|
+
previous_hash: guard.clone(),
|
|
479
|
+
hash: String::new(),
|
|
480
|
+
severity,
|
|
481
|
+
immutable_trail,
|
|
482
|
+
immutablelog: None,
|
|
483
|
+
};
|
|
484
|
+
event.hash = compute_hash(&event)?;
|
|
485
|
+
|
|
486
|
+
match self.mode {
|
|
487
|
+
AuditMode::Local => {
|
|
488
|
+
storage::append_event(&self.file_path, &event)?;
|
|
489
|
+
*guard = Some(event.hash.clone());
|
|
490
|
+
}
|
|
491
|
+
AuditMode::Remote => {
|
|
492
|
+
// Não grava JSONL local: se o envio falhar, o erro sobe
|
|
493
|
+
// como exceção Python e `last_hash` continua apontando
|
|
494
|
+
// para o último evento que de fato foi confirmado.
|
|
495
|
+
let receipt = self
|
|
496
|
+
.deliver(&event, resolved_severity, event.immutable_trail.as_deref())
|
|
497
|
+
.map_err(crate::errors::AuditError::from)?;
|
|
498
|
+
event.immutablelog = Some(receipt);
|
|
499
|
+
*guard = Some(event.hash.clone());
|
|
500
|
+
}
|
|
501
|
+
AuditMode::Hybrid => {
|
|
502
|
+
// Salva local PRIMEIRO: mesmo que o processo morra antes
|
|
503
|
+
// do envio terminar, o evento já está persistido (sem
|
|
504
|
+
// receipt) — nunca perdemos o registro local por uma
|
|
505
|
+
// falha de rede.
|
|
506
|
+
storage::append_event(&self.file_path, &event)?;
|
|
507
|
+
*guard = Some(event.hash.clone());
|
|
508
|
+
|
|
509
|
+
match self.deliver(&event, resolved_severity, event.immutable_trail.as_deref()) {
|
|
510
|
+
Ok(receipt) => {
|
|
511
|
+
event.immutablelog = Some(receipt.clone());
|
|
512
|
+
storage::update_event_receipt(&self.file_path, &event.id, &receipt)?;
|
|
513
|
+
}
|
|
514
|
+
Err(_) => {
|
|
515
|
+
// Falha em hybrid NÃO é exceção Python: o evento
|
|
516
|
+
// já está seguro localmente, então devolvemos
|
|
517
|
+
// status "pending" e deixamos `flush_pending()`
|
|
518
|
+
// tentar de novo depois.
|
|
519
|
+
let pending_receipt = ImmutableLogReceipt::pending();
|
|
520
|
+
event.immutablelog = Some(pending_receipt.clone());
|
|
521
|
+
storage::update_event_receipt(&self.file_path, &event.id, &pending_receipt)?;
|
|
522
|
+
storage::append_event(&storage::pending_path_for(&self.file_path), &event)?;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
Ok(event)
|
|
435
529
|
}
|
|
436
530
|
}
|
|
@@ -36,8 +36,26 @@ pub fn python_to_json(value: &Bound<'_, PyAny>) -> PyResult<serde_json::Value> {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
if let Ok(integer) = value.downcast::<PyInt>() {
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// Python tem inteiros de precisão arbitrária; `i64` não cobre,
|
|
40
|
+
// por exemplo, IDs de 64 bits sem sinal (acima de i64::MAX) ou
|
|
41
|
+
// timestamps em nanossegundos. Tentamos `i64` primeiro (cobre
|
|
42
|
+
// negativos), depois `u64` (cobre o resto do range positivo de
|
|
43
|
+
// 64 bits) e, só então, caímos em `f64` para inteiros ainda
|
|
44
|
+
// maiores — preservando o valor (com possível perda de precisão
|
|
45
|
+
// além de 2^53, inevitável em JSON) em vez de estourar
|
|
46
|
+
// `OverflowError` e derrubar o `log()` inteiro.
|
|
47
|
+
if let Ok(number) = integer.extract::<i64>() {
|
|
48
|
+
return Ok(serde_json::Value::from(number));
|
|
49
|
+
}
|
|
50
|
+
if let Ok(number) = integer.extract::<u64>() {
|
|
51
|
+
return Ok(serde_json::Value::from(number));
|
|
52
|
+
}
|
|
53
|
+
let number: f64 = integer.extract()?;
|
|
54
|
+
return serde_json::Number::from_f64(number)
|
|
55
|
+
.map(serde_json::Value::Number)
|
|
56
|
+
.ok_or_else(|| {
|
|
57
|
+
PyTypeError::new_err("inteiro grande demais para representar em metadata")
|
|
58
|
+
});
|
|
41
59
|
}
|
|
42
60
|
|
|
43
61
|
if let Ok(float) = value.downcast::<PyFloat>() {
|
|
@@ -95,6 +113,12 @@ pub fn json_to_python(py: Python<'_>, value: &serde_json::Value) -> PyResult<PyO
|
|
|
95
113
|
serde_json::Value::Number(number) => {
|
|
96
114
|
if let Some(integer) = number.as_i64() {
|
|
97
115
|
Ok(integer.into_py(py))
|
|
116
|
+
} else if let Some(unsigned) = number.as_u64() {
|
|
117
|
+
// Inteiros acima de i64::MAX (ex.: IDs de 64 bits sem
|
|
118
|
+
// sinal) precisam de `as_u64` ANTES de `as_f64`, ou
|
|
119
|
+
// voltariam como float — quebrando o round-trip exato
|
|
120
|
+
// do que `python_to_json` gravou como u64.
|
|
121
|
+
Ok(unsigned.into_py(py))
|
|
98
122
|
} else if let Some(float) = number.as_f64() {
|
|
99
123
|
Ok(float.into_py(py))
|
|
100
124
|
} else {
|
|
@@ -167,6 +167,55 @@ pub fn remove_event(path: &Path, event_id: &str) -> Result<(), AuditError> {
|
|
|
167
167
|
rewrite_events(path, &events)
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/// Versão em lote de `update_event_receipt`: anexa o receipt
|
|
171
|
+
/// correspondente a cada evento cujo `id` esteja em `receipts`, numa
|
|
172
|
+
/// ÚNICA leitura + reescrita do arquivo. `flush_pending()` usa isso para
|
|
173
|
+
/// não pagar um O(n) (ler+reescrever o arquivo inteiro) por evento
|
|
174
|
+
/// confirmado — o que tornaria o flush O(n*m) com `m` pendentes.
|
|
175
|
+
///
|
|
176
|
+
/// Relê o arquivo na hora da escrita (em vez de receber a lista pronta)
|
|
177
|
+
/// de propósito: assim eventos gravados concorrentemente por um `log()`
|
|
178
|
+
/// entre o início do flush e este ponto são preservados, não
|
|
179
|
+
/// sobrescritos.
|
|
180
|
+
pub fn update_event_receipts(
|
|
181
|
+
path: &Path,
|
|
182
|
+
receipts: &std::collections::HashMap<String, ImmutableLogReceipt>,
|
|
183
|
+
) -> Result<(), AuditError> {
|
|
184
|
+
if receipts.is_empty() {
|
|
185
|
+
return Ok(());
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let mut events = read_events(path)?;
|
|
189
|
+
for event in events.iter_mut() {
|
|
190
|
+
if let Some(receipt) = receipts.get(&event.id) {
|
|
191
|
+
event.immutablelog = Some(receipt.clone());
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
rewrite_events(path, &events)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/// Versão em lote de `remove_event`: remove todos os eventos cujo `id`
|
|
199
|
+
/// esteja em `ids`, numa única leitura + reescrita. Remover por `id`
|
|
200
|
+
/// (em vez de reescrever a fila com um snapshot antigo) preserva
|
|
201
|
+
/// eventos pendentes que tenham sido acrescentados concorrentemente
|
|
202
|
+
/// durante o flush.
|
|
203
|
+
pub fn remove_events(
|
|
204
|
+
path: &Path,
|
|
205
|
+
ids: &std::collections::HashSet<String>,
|
|
206
|
+
) -> Result<(), AuditError> {
|
|
207
|
+
if ids.is_empty() {
|
|
208
|
+
return Ok(());
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let events: Vec<AuditEvent> = read_events(path)?
|
|
212
|
+
.into_iter()
|
|
213
|
+
.filter(|event| !ids.contains(&event.id))
|
|
214
|
+
.collect();
|
|
215
|
+
|
|
216
|
+
rewrite_events(path, &events)
|
|
217
|
+
}
|
|
218
|
+
|
|
170
219
|
#[cfg(test)]
|
|
171
220
|
mod tests {
|
|
172
221
|
use super::*;
|
|
@@ -300,6 +349,73 @@ mod tests {
|
|
|
300
349
|
let _ = std::fs::remove_file(&path);
|
|
301
350
|
}
|
|
302
351
|
|
|
352
|
+
#[test]
|
|
353
|
+
fn update_event_receipts_applies_many_in_a_single_rewrite() {
|
|
354
|
+
let path = temp_path();
|
|
355
|
+
let first = sample_event("LOGIN", None);
|
|
356
|
+
let second = sample_event("LOGOUT", Some(&first.hash));
|
|
357
|
+
let third = sample_event("DELETE", Some(&second.hash));
|
|
358
|
+
append_event(&path, &first).expect("append não deveria falhar");
|
|
359
|
+
append_event(&path, &second).expect("append não deveria falhar");
|
|
360
|
+
append_event(&path, &third).expect("append não deveria falhar");
|
|
361
|
+
|
|
362
|
+
let mut receipts = std::collections::HashMap::new();
|
|
363
|
+
receipts.insert(first.id.clone(), ImmutableLogReceipt::pending());
|
|
364
|
+
receipts.insert(
|
|
365
|
+
third.id.clone(),
|
|
366
|
+
crate::immutablelog_receipt::ImmutableLogReceipt {
|
|
367
|
+
status: "delivered".to_string(),
|
|
368
|
+
tx_id: Some("tx_3".to_string()),
|
|
369
|
+
..Default::default()
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
update_event_receipts(&path, &receipts).expect("update em lote não deveria falhar");
|
|
373
|
+
|
|
374
|
+
let events = read_events(&path).expect("read não deveria falhar");
|
|
375
|
+
assert_eq!(events.len(), 3);
|
|
376
|
+
// Ordem preservada; só os ids no mapa ganharam receipt.
|
|
377
|
+
assert_eq!(events[0].immutablelog.as_ref().map(|r| r.status.as_str()), Some("pending"));
|
|
378
|
+
assert_eq!(events[1].immutablelog, None);
|
|
379
|
+
assert_eq!(events[2].immutablelog.as_ref().and_then(|r| r.tx_id.clone()), Some("tx_3".to_string()));
|
|
380
|
+
|
|
381
|
+
let _ = std::fs::remove_file(&path);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#[test]
|
|
385
|
+
fn remove_events_drops_only_the_matching_ids_in_one_pass() {
|
|
386
|
+
let path = temp_path();
|
|
387
|
+
let first = sample_event("LOGIN", None);
|
|
388
|
+
let second = sample_event("LOGOUT", Some(&first.hash));
|
|
389
|
+
let third = sample_event("DELETE", Some(&second.hash));
|
|
390
|
+
append_event(&path, &first).expect("append não deveria falhar");
|
|
391
|
+
append_event(&path, &second).expect("append não deveria falhar");
|
|
392
|
+
append_event(&path, &third).expect("append não deveria falhar");
|
|
393
|
+
|
|
394
|
+
let mut ids = std::collections::HashSet::new();
|
|
395
|
+
ids.insert(first.id.clone());
|
|
396
|
+
ids.insert(third.id.clone());
|
|
397
|
+
remove_events(&path, &ids).expect("remove em lote não deveria falhar");
|
|
398
|
+
|
|
399
|
+
let events = read_events(&path).expect("read não deveria falhar");
|
|
400
|
+
assert_eq!(events, vec![second]);
|
|
401
|
+
|
|
402
|
+
let _ = std::fs::remove_file(&path);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
#[test]
|
|
406
|
+
fn batch_helpers_are_noops_when_empty() {
|
|
407
|
+
let path = temp_path();
|
|
408
|
+
append_event(&path, &sample_event("LOGIN", None)).expect("append não deveria falhar");
|
|
409
|
+
|
|
410
|
+
update_event_receipts(&path, &std::collections::HashMap::new()).expect("noop");
|
|
411
|
+
remove_events(&path, &std::collections::HashSet::new()).expect("noop");
|
|
412
|
+
|
|
413
|
+
let events = read_events(&path).expect("read não deveria falhar");
|
|
414
|
+
assert_eq!(events.len(), 1);
|
|
415
|
+
|
|
416
|
+
let _ = std::fs::remove_file(&path);
|
|
417
|
+
}
|
|
418
|
+
|
|
303
419
|
#[test]
|
|
304
420
|
fn remove_event_drops_only_the_matching_event() {
|
|
305
421
|
let path = temp_path();
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Concorrência: várias threads compartilhando o MESMO `AuditLogger`.
|
|
3
|
+
|
|
4
|
+
Cenário real: servidor WSGI multi-thread (ex.: `gunicorn --threads N`)
|
|
5
|
+
compartilha uma única instância do middleware — e, portanto, um único
|
|
6
|
+
`AuditLogger` — entre as threads de requisição.
|
|
7
|
+
|
|
8
|
+
Antes do `AuditLogger` virar thread-safe (`&self` + `Mutex`, em vez de
|
|
9
|
+
`&mut self`), uma segunda thread chamando `log()` enquanto a primeira
|
|
10
|
+
tinha liberado o GIL durante a chamada de rede (`mode="remote"`/
|
|
11
|
+
`"hybrid"`) recebia `RuntimeError: Already borrowed` — perdendo o evento
|
|
12
|
+
silenciosamente. Estes testes travam justamente esse caso.
|
|
13
|
+
"""
|
|
14
|
+
import threading
|
|
15
|
+
|
|
16
|
+
from rust_py_audit import AuditLogger
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _run_concurrently(target, n):
|
|
20
|
+
errors: list[Exception] = []
|
|
21
|
+
|
|
22
|
+
def worker(i):
|
|
23
|
+
try:
|
|
24
|
+
target(i)
|
|
25
|
+
except Exception as exc: # noqa: BLE001 — queremos capturar QUALQUER erro
|
|
26
|
+
errors.append(exc)
|
|
27
|
+
|
|
28
|
+
threads = [threading.Thread(target=worker, args=(i,)) for i in range(n)]
|
|
29
|
+
for t in threads:
|
|
30
|
+
t.start()
|
|
31
|
+
for t in threads:
|
|
32
|
+
t.join()
|
|
33
|
+
return errors
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_concurrent_hybrid_logging_does_not_raise_and_keeps_chain_valid(tmp_path, immutablelog_server):
|
|
37
|
+
# O delay força a sobreposição das threads: enquanto uma está no
|
|
38
|
+
# envio HTTP (GIL liberado), as outras tentam chamar `log()`.
|
|
39
|
+
immutablelog_server.set_delay(0.05)
|
|
40
|
+
audit = AuditLogger(
|
|
41
|
+
app_name="billing-api",
|
|
42
|
+
file_path=str(tmp_path / "audit.jsonl"),
|
|
43
|
+
mode="hybrid",
|
|
44
|
+
immutablelog_url=immutablelog_server.url,
|
|
45
|
+
immutablelog_api_key="iml_live_ok",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
n = 12
|
|
49
|
+
errors = _run_concurrently(
|
|
50
|
+
lambda i: audit.log(actor_id=f"u{i}", action="POST", resource="/r", resource_id=str(i)),
|
|
51
|
+
n,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
assert errors == [], f"log() concorrente levantou exceção(ões): {errors}"
|
|
55
|
+
|
|
56
|
+
# A cadeia precisa ter exatamente N eventos e continuar íntegra —
|
|
57
|
+
# nenhuma thread pode ter encadeado no mesmo previous_hash de outra
|
|
58
|
+
# (o que bifurcaria/quebraria a cadeia).
|
|
59
|
+
result = audit.verify()
|
|
60
|
+
assert result["valid"] is True
|
|
61
|
+
assert result["total_events"] == n
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_concurrent_local_logging_keeps_chain_valid(tmp_path):
|
|
65
|
+
audit = AuditLogger(app_name="billing-api", file_path=str(tmp_path / "audit.jsonl"))
|
|
66
|
+
|
|
67
|
+
n = 50
|
|
68
|
+
errors = _run_concurrently(
|
|
69
|
+
lambda i: audit.log(actor_id=f"u{i}", action="POST", resource="/r", resource_id=str(i)),
|
|
70
|
+
n,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
assert errors == []
|
|
74
|
+
result = audit.verify()
|
|
75
|
+
assert result["valid"] is True
|
|
76
|
+
assert result["total_events"] == n
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Inteiros de precisão arbitrária em `metadata`.
|
|
3
|
+
|
|
4
|
+
Python permite inteiros sem limite; o core Rust precisa aceitá-los sem
|
|
5
|
+
estourar `OverflowError` (regressão: antes só tentávamos `i64`). A regra
|
|
6
|
+
é: `i64` -> `u64` -> `f64` (fallback para inteiros acima de 2^64, com a
|
|
7
|
+
perda de precisão inevitável em JSON).
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from rust_py_audit import AuditLogger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _logger(tmp_path):
|
|
15
|
+
return AuditLogger(app_name="billing-api", file_path=str(tmp_path / "audit.jsonl"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_metadata_accepts_ints_beyond_i64_range(tmp_path):
|
|
19
|
+
audit = _logger(tmp_path)
|
|
20
|
+
|
|
21
|
+
above_i64 = 2**63 + 1 # > i64::MAX, ainda cabe em u64
|
|
22
|
+
u64_max = 2**64 - 1 # 18446744073709551615
|
|
23
|
+
negative = -(2**40)
|
|
24
|
+
|
|
25
|
+
event = audit.log(
|
|
26
|
+
actor_id="user_1",
|
|
27
|
+
action="LOGIN",
|
|
28
|
+
resource="session",
|
|
29
|
+
resource_id="s1",
|
|
30
|
+
metadata={"above_i64": above_i64, "u64_max": u64_max, "negative": negative},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Round-trip exato, sem virar float nem estourar.
|
|
34
|
+
assert event["metadata"]["above_i64"] == above_i64
|
|
35
|
+
assert isinstance(event["metadata"]["above_i64"], int)
|
|
36
|
+
assert event["metadata"]["u64_max"] == u64_max
|
|
37
|
+
assert isinstance(event["metadata"]["u64_max"], int)
|
|
38
|
+
assert event["metadata"]["negative"] == negative
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_metadata_bigint_persists_and_chain_stays_valid(tmp_path):
|
|
42
|
+
file_path = tmp_path / "audit.jsonl"
|
|
43
|
+
audit = _logger(tmp_path)
|
|
44
|
+
|
|
45
|
+
audit.log(
|
|
46
|
+
actor_id="user_1",
|
|
47
|
+
action="LOGIN",
|
|
48
|
+
resource="session",
|
|
49
|
+
resource_id="s1",
|
|
50
|
+
metadata={"snowflake": 2**63 + 12345},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# O valor grande precisa ter sido gravado como número JSON inteiro
|
|
54
|
+
# (entra no hash), e a cadeia tem que continuar verificável.
|
|
55
|
+
persisted = json.loads(file_path.read_text().strip())
|
|
56
|
+
assert persisted["metadata"]["snowflake"] == 2**63 + 12345
|
|
57
|
+
|
|
58
|
+
assert audit.verify()["valid"] is True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_metadata_int_above_u64_falls_back_to_float(tmp_path):
|
|
62
|
+
audit = _logger(tmp_path)
|
|
63
|
+
|
|
64
|
+
huge = 2**100 # acima de u64::MAX -> só representável como float
|
|
65
|
+
|
|
66
|
+
event = audit.log(
|
|
67
|
+
actor_id="user_1",
|
|
68
|
+
action="LOGIN",
|
|
69
|
+
resource="session",
|
|
70
|
+
resource_id="s1",
|
|
71
|
+
metadata={"huge": huge},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
assert isinstance(event["metadata"]["huge"], float)
|
|
75
|
+
assert event["metadata"]["huge"] == float(huge)
|
|
76
|
+
assert audit.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
|