rust-py-audit 0.2.2__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.
Files changed (44) hide show
  1. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/Cargo.lock +1 -1
  2. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/Cargo.toml +1 -1
  3. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/PKG-INFO +1 -1
  4. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/pyproject.toml +1 -1
  5. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/python/rust_py_audit/__init__.py +1 -1
  6. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/src/audit_logger.rs +221 -127
  7. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/src/pyconv.rs +26 -2
  8. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/src/storage.rs +116 -0
  9. rust_py_audit-0.2.3/tests/test_concurrency.py +76 -0
  10. rust_py_audit-0.2.3/tests/test_metadata_bigint.py +76 -0
  11. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/.github/workflows/release.yml +0 -0
  12. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/.gitignore +0 -0
  13. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/LICENSE +0 -0
  14. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/README.md +0 -0
  15. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/examples/__init__.py +0 -0
  16. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/examples/basic_usage.py +0 -0
  17. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/examples/django_example/__init__.py +0 -0
  18. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/examples/django_example/settings.py +0 -0
  19. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/examples/django_example/urls.py +0 -0
  20. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/examples/django_example/views.py +0 -0
  21. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/examples/fastapi_app.py +0 -0
  22. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/examples/immutablelog_basic.py +0 -0
  23. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/python/rust_py_audit/django.py +0 -0
  24. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/python/rust_py_audit/fastapi.py +0 -0
  25. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/src/errors.rs +0 -0
  26. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/src/event.rs +0 -0
  27. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/src/hash.rs +0 -0
  28. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/src/immutablelog_client.rs +0 -0
  29. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/src/immutablelog_config.rs +0 -0
  30. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/src/immutablelog_receipt.rs +0 -0
  31. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/src/lib.rs +0 -0
  32. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/src/retry.rs +0 -0
  33. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/src/verifier.rs +0 -0
  34. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/tests/conftest.py +0 -0
  35. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/tests/test_audit_logger.py +0 -0
  36. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/tests/test_django_immutablelog.py +0 -0
  37. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/tests/test_django_middleware.py +0 -0
  38. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/tests/test_fastapi_immutablelog.py +0 -0
  39. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/tests/test_fastapi_middleware.py +0 -0
  40. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/tests/test_flush_pending.py +0 -0
  41. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/tests/test_hello.py +0 -0
  42. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/tests/test_immutablelog_hybrid.py +0 -0
  43. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/tests/test_immutablelog_remote.py +0 -0
  44. {rust_py_audit-0.2.2 → rust_py_audit-0.2.3}/tests/test_verify.py +0 -0
@@ -1597,7 +1597,7 @@ dependencies = [
1597
1597
 
1598
1598
  [[package]]
1599
1599
  name = "rust_py_audit"
1600
- version = "0.2.2"
1600
+ version = "0.2.3"
1601
1601
  dependencies = [
1602
1602
  "httpmock",
1603
1603
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "rust_py_audit"
3
- version = "0.2.2"
3
+ version = "0.2.3"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rust-py-audit
3
- Version: 0.2.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
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "rust-py-audit"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
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"
@@ -8,5 +8,5 @@ que existe um módulo nativo por trás.
8
8
 
9
9
  from ._rust_py_audit import AuditLogger, hello
10
10
 
11
- __version__ = "0.2.2"
11
+ __version__ = "0.2.3"
12
12
  __all__ = ["AuditLogger", "hello"]
@@ -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
- fn last_hash(&self) -> Option<String> {
197
- self.last_hash.clone()
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
- &mut self,
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
- // Valida `severity` já aqui (falha rápido), mesmo em
235
- // `mode="local"` — se alguém passa um valor, ele precisa ser
236
- // válido independente do modo. `resolved_severity` é o valor
237
- // efetivo usado na PRIMEIRA tentativa de envio (default "info"
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". `map_err` converte o erro de
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
- // Montamos o evento com `hash` vazio primeiro: o hash só pode
254
- // ser calculado depois que todos os OUTROS campos já existem
255
- // (Etapa 5 — `compute_hash` ignora o campo `hash` de propósito,
256
- // mas ainda precisamos de algum valor para inicializar o
257
- // struct).
258
- let mut event = AuditEvent {
259
- id: Uuid::new_v4().to_string(),
260
- timestamp,
261
- app_name: self.app_name.clone(),
262
- actor_id,
263
- action,
264
- resource,
265
- resource_id,
266
- metadata: metadata_value,
267
- previous_hash: self.last_hash.clone(),
268
- hash: String::new(),
269
- // O hash é calculado a partir só dos campos acima (ver
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
- let mut flushed = 0usize;
380
- let mut still_pending = 0usize;
381
-
382
- for event in &pending_events {
383
- let severity = resolve_severity(event.severity.as_deref()).map_err(crate::errors::AuditError::Config)?;
384
- match self.send_to_immutablelog(py, event, severity, event.immutable_trail.as_deref()) {
385
- Ok(receipt) => {
386
- storage::update_event_receipt(&self.file_path, &event.id, &receipt)?;
387
- storage::remove_event(&pending_path, &event.id)?;
388
- flushed += 1;
389
- }
390
- Err(_) => {
391
- // Continua pendente — fica na fila para a próxima
392
- // chamada de `flush_pending()` tentar de novo.
393
- still_pending += 1;
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", pending_events.len())?;
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
- /// Envia `event` ao ImmutableLog (com retry), liberando o GIL
411
- /// durante a chamada HTTP bloqueante — sem isso, qualquer outra
412
- /// thread Python (ex.: o event loop do FastAPI rodando o
413
- /// middleware noutra thread, ou um servidor WSGI multi-thread)
414
- /// ficaria travada pela duração inteira da requisição/retry.
415
- fn send_to_immutablelog(
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
- ) -> PyResult<ImmutableLogReceipt> {
422
- let client = self.client.as_ref().ok_or_else(|| {
423
- crate::errors::AuditError::Config(format!(
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
- let result = py.allow_threads(|| {
432
- send_with_retry(client, event, severity, immutable_trail, retry_enabled, max_retries)
433
- });
434
- result.map_err(|err| crate::errors::AuditError::from(err).into())
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
- let number: i64 = integer.extract()?;
40
- return Ok(serde_json::Value::from(number));
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