rust-py-audit 0.3.2__tar.gz → 0.4.0__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 (51) hide show
  1. rust_py_audit-0.4.0/.github/workflows/release.yml +328 -0
  2. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/.gitignore +4 -0
  3. rust_py_audit-0.4.0/CHANGELOG.md +201 -0
  4. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/Cargo.lock +12 -1
  5. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/Cargo.toml +6 -1
  6. rust_py_audit-0.4.0/Makefile +68 -0
  7. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/PKG-INFO +53 -5
  8. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/README.md +51 -2
  9. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/ROADMAP.md +13 -0
  10. rust_py_audit-0.4.0/clippy.toml +11 -0
  11. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/pyproject.toml +9 -4
  12. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/python/rust_py_audit/__init__.py +1 -1
  13. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/src/audit_logger.rs +121 -31
  14. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/src/errors.rs +1 -3
  15. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/src/event.rs +2 -1
  16. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/src/hash.rs +2 -2
  17. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/src/immutablelog_client.rs +88 -35
  18. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/src/retry.rs +24 -7
  19. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/src/storage.rs +268 -28
  20. rust_py_audit-0.4.0/tests/test_multiprocess.py +80 -0
  21. rust_py_audit-0.3.2/.github/workflows/release.yml +0 -208
  22. rust_py_audit-0.3.2/CHANGELOG.md +0 -50
  23. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/LICENSE +0 -0
  24. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/examples/__init__.py +0 -0
  25. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/examples/basic_usage.py +0 -0
  26. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/examples/django_example/__init__.py +0 -0
  27. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/examples/django_example/settings.py +0 -0
  28. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/examples/django_example/urls.py +0 -0
  29. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/examples/django_example/views.py +0 -0
  30. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/examples/fastapi_app.py +0 -0
  31. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/examples/immutablelog_basic.py +0 -0
  32. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/python/rust_py_audit/django.py +0 -0
  33. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/python/rust_py_audit/fastapi.py +0 -0
  34. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/src/immutablelog_config.rs +0 -0
  35. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/src/immutablelog_receipt.rs +0 -0
  36. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/src/lib.rs +0 -0
  37. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/src/pyconv.rs +0 -0
  38. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/src/verifier.rs +0 -0
  39. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/tests/conftest.py +0 -0
  40. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/tests/test_audit_logger.py +0 -0
  41. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/tests/test_concurrency.py +0 -0
  42. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/tests/test_django_immutablelog.py +0 -0
  43. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/tests/test_django_middleware.py +0 -0
  44. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/tests/test_fastapi_immutablelog.py +0 -0
  45. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/tests/test_fastapi_middleware.py +0 -0
  46. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/tests/test_flush_pending.py +0 -0
  47. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/tests/test_hello.py +0 -0
  48. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/tests/test_immutablelog_hybrid.py +0 -0
  49. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/tests/test_immutablelog_remote.py +0 -0
  50. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/tests/test_metadata_bigint.py +0 -0
  51. {rust_py_audit-0.3.2 → rust_py_audit-0.4.0}/tests/test_verify.py +0 -0
@@ -0,0 +1,328 @@
1
+ name: CI / Release
2
+
3
+ # Push e PR rodam `lint` + `test` e param por aí. Os jobs de build são
4
+ # liberados por uma tag `v*` ou por um `workflow_dispatch` manual — o segundo
5
+ # existe para o ensaio no TestPyPI, abaixo.
6
+ #
7
+ # **Só a tag publica no PyPI de produção.** O job `release` é o único guardado
8
+ # apenas por `startsWith(github.ref, 'refs/tags/')`, sem a cláusula de
9
+ # dispatch: um disparo manual constrói tudo e vai para o TestPyPI, nunca para
10
+ # o índice de verdade. Publicação no PyPI não se desfaz, então essa assimetria
11
+ # é proposital.
12
+ on:
13
+ push:
14
+ branches: [main]
15
+ tags:
16
+ - "v*"
17
+ pull_request:
18
+ workflow_dispatch: {}
19
+
20
+ permissions:
21
+ contents: read
22
+
23
+ jobs:
24
+ # O lint faz parte do portão de release, não de um workflow paralelo.
25
+ # Nos repositórios de workflow único, um `lint` vermelho ao lado de uma
26
+ # publicação bem-sucedida seria ruído difícil de ler — a leitura passa a ser
27
+ # inequívoca: vermelho significa que não publicou.
28
+ #
29
+ # A toolchain é fixa, não "stable": com `-D warnings`, um clippy mais novo
30
+ # no runner reprova código que ninguém tocou, e a CI ficaria vermelha por
31
+ # conta do calendário. Subir este número vira uma mudança deliberada.
32
+ lint:
33
+ name: fmt + clippy
34
+ runs-on: ubuntu-latest
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+ - uses: dtolnay/rust-toolchain@1.88.0
38
+ with:
39
+ components: rustfmt, clippy
40
+ - uses: Swatinem/rust-cache@v2
41
+ - name: cargo fmt
42
+ run: cargo fmt --all -- --check
43
+ # clippy só checa tipos, não linka, então --all-features é seguro aqui
44
+ # (diferente do `cargo test`, que precisa de --no-default-features).
45
+ - name: cargo clippy
46
+ run: cargo clippy --all-targets --all-features -- -D warnings
47
+
48
+ # Nothing gets built, and nothing reaches PyPI, until this passes. Publishing
49
+ # to PyPI is irreversible: a bad wheel cannot be replaced, only yanked.
50
+ test:
51
+ name: Test (Rust + Python)
52
+ runs-on: ubuntu-latest
53
+ steps:
54
+ - uses: actions/checkout@v4
55
+ - uses: dtolnay/rust-toolchain@stable
56
+ - uses: actions/setup-python@v5
57
+ with:
58
+ python-version: "3.12"
59
+
60
+ # --no-default-features drops extension-module, which tells the linker to
61
+ # leave libpython symbols undefined. That is correct for the wheel but
62
+ # produces a test binary that cannot link on Linux.
63
+ - name: Rust unit tests
64
+ env:
65
+ PYO3_PYTHON: python
66
+ run: cargo test --no-default-features --lib
67
+
68
+ - name: Build, install the extension and run the Python tests
69
+ # `pip install` drives the maturin build backend, so it compiles the
70
+ # Rust extension and installs it (plus the dev extras) into the
71
+ # runner's Python — no virtualenv needed, unlike `maturin develop`,
72
+ # which aborts when it cannot find one.
73
+ run: |
74
+ python -m pip install --upgrade pip
75
+ python -m pip install ".[dev]"
76
+ python -m pytest
77
+
78
+ linux:
79
+ runs-on: ubuntu-latest
80
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
81
+ needs: [test, lint]
82
+ strategy:
83
+ matrix:
84
+ target: [x86_64, aarch64]
85
+ steps:
86
+ - uses: actions/checkout@v4
87
+ - uses: PyO3/maturin-action@v1
88
+ env:
89
+ # `ring` (puxado por `rustls`, via `reqwest`) falha ao cross-compilar
90
+ # sua assembly pregenerada para aarch64 dentro do container manylinux
91
+ # do maturin-action: o cross-gcc não define `__ARM_ARCH` por padrão
92
+ # nesse ambiente, e o preprocessor de `ring` exige essa macro
93
+ # (erro: "ARM assembler must define __ARM_ARCH"). Forçar a macro via
94
+ # CFLAGS é o workaround padrão para esse problema conhecido — não
95
+ # afeta o build x86_64 (a env var é específica do target aarch64).
96
+ CFLAGS_aarch64_unknown_linux_gnu: -D__ARM_ARCH=8
97
+ with:
98
+ target: ${{ matrix.target }}
99
+ args: --release --out dist --find-interpreter
100
+ manylinux: auto
101
+ - uses: actions/upload-artifact@v4
102
+ with:
103
+ name: wheels-linux-${{ matrix.target }}
104
+ path: dist
105
+
106
+ musllinux:
107
+ runs-on: ubuntu-latest
108
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
109
+ needs: [test, lint]
110
+ strategy:
111
+ matrix:
112
+ target: [x86_64, aarch64]
113
+ steps:
114
+ - uses: actions/checkout@v4
115
+ - uses: PyO3/maturin-action@v1
116
+ with:
117
+ target: ${{ matrix.target }}
118
+ args: --release --out dist --find-interpreter
119
+ manylinux: musllinux_1_2
120
+ - uses: actions/upload-artifact@v4
121
+ with:
122
+ name: wheels-musllinux-${{ matrix.target }}
123
+ path: dist
124
+
125
+ windows:
126
+ runs-on: windows-latest
127
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
128
+ needs: [test, lint]
129
+ steps:
130
+ - uses: actions/checkout@v4
131
+ - uses: actions/setup-python@v5
132
+ with:
133
+ architecture: x64
134
+ - uses: PyO3/maturin-action@v1
135
+ with:
136
+ target: x64
137
+ args: --release --out dist --find-interpreter
138
+ - uses: actions/upload-artifact@v4
139
+ with:
140
+ name: wheels-windows-x64
141
+ path: dist
142
+
143
+ macos:
144
+ runs-on: macos-latest
145
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
146
+ needs: [test, lint]
147
+ strategy:
148
+ matrix:
149
+ target: [x86_64, aarch64]
150
+ steps:
151
+ - uses: actions/checkout@v4
152
+ - uses: PyO3/maturin-action@v1
153
+ with:
154
+ target: ${{ matrix.target }}
155
+ args: --release --out dist --find-interpreter
156
+ - uses: actions/upload-artifact@v4
157
+ with:
158
+ name: wheels-macos-${{ matrix.target }}
159
+ path: dist
160
+
161
+ sdist:
162
+ runs-on: ubuntu-latest
163
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
164
+ needs: [test, lint]
165
+ steps:
166
+ - uses: actions/checkout@v4
167
+ - uses: PyO3/maturin-action@v1
168
+ with:
169
+ command: sdist
170
+ args: --out dist
171
+ - uses: actions/upload-artifact@v4
172
+ with:
173
+ name: wheels-sdist
174
+ path: dist
175
+
176
+ # Ensaio da troca OIDC, sem tocar no índice de produção. É o que permite
177
+ # descobrir um publisher mal cadastrado num lugar onde nada é irreversível —
178
+ # no PyPI, um nome de arquivo publicado nunca pode ser reusado.
179
+ #
180
+ # ⚠️ Precisa do seu próprio publisher, cadastrado no TestPyPI (que é uma
181
+ # conta separada do PyPI), com owner/repo, workflow `release.yml` e
182
+ # environment `testpypi`.
183
+ #
184
+ # Não há verificação pós-upload aqui de propósito: o que se está validando é
185
+ # a troca de token, e o TestPyPI expira distribuições — reprovar por um
186
+ # arquivo que o índice de teste descartou seria alarme falso.
187
+ publish-testpypi:
188
+ name: Publish to TestPyPI (ensaio)
189
+ runs-on: ubuntu-latest
190
+ if: github.event_name == 'workflow_dispatch'
191
+ needs: [lint, test, linux, musllinux, windows, macos, sdist]
192
+ environment: testpypi
193
+ permissions:
194
+ id-token: write
195
+ steps:
196
+ - uses: actions/download-artifact@v4
197
+ with:
198
+ pattern: wheels-*
199
+ path: dist
200
+ merge-multiple: true
201
+ - uses: pypa/gh-action-pypi-publish@release/v1
202
+ with:
203
+ repository-url: https://test.pypi.org/legacy/
204
+ packages-dir: dist
205
+ skip-existing: true
206
+
207
+ release:
208
+ name: Publish to PyPI
209
+ runs-on: ubuntu-latest
210
+ if: startsWith(github.ref, 'refs/tags/')
211
+ needs: [lint, test, linux, musllinux, windows, macos, sdist]
212
+
213
+ # Trusted publishing (OIDC). Não existe mais `secrets.PYPI_API_TOKEN` aqui:
214
+ # o GitHub emite um JWT curto afirmando repositório, workflow e ref, e o
215
+ # PyPI o troca por um token de upload válido por poucos minutos e só para
216
+ # este projeto. Some o segredo de longa duração que, se vazasse, publicaria
217
+ # até alguém revogá-lo à mão.
218
+ #
219
+ # ⚠️ Os três valores abaixo precisam bater EXATAMENTE com o publisher
220
+ # cadastrado em PyPI → Manage → Publishing:
221
+ # owner/repo .... robertolima-dev/rust-py-audit
222
+ # workflow ...... release.yml (o nome deste arquivo)
223
+ # environment ... pypi
224
+ # Divergência em qualquer um deles é rejeitada na troca do token.
225
+ environment: pypi
226
+ permissions:
227
+ id-token: write
228
+
229
+ steps:
230
+ - uses: actions/download-artifact@v4
231
+ with:
232
+ pattern: wheels-*
233
+ path: dist
234
+ merge-multiple: true
235
+ - uses: actions/setup-python@v5
236
+ with:
237
+ python-version: "3.12"
238
+ # `requests` é só para a verificação pós-upload, mais abaixo. A maturin
239
+ # não é mais instalada aqui: quem publica agora é a action oficial do
240
+ # PyPA, que é a que sabe fazer a troca OIDC.
241
+ - name: Install the verification dependency
242
+ run: python -m pip install --upgrade requests
243
+
244
+ # A sanity check before anything leaves the runner: publishing an
245
+ # incomplete set is worse than not publishing, because PyPI never lets a
246
+ # filename be reused — a half-uploaded version can only be completed,
247
+ # never replaced.
248
+ - name: Verify the artifact set is complete
249
+ run: |
250
+ set -euo pipefail
251
+ ls -la dist/
252
+ test -n "$(find dist -name '*.tar.gz' -print -quit)" \
253
+ || { echo "::error::sdist ausente — abortando antes de publicar"; exit 1; }
254
+ wheels=$(find dist -name '*.whl' | wc -l)
255
+ echo "wheels: $wheels"
256
+ test "$wheels" -ge 7 \
257
+ || { echo "::error::só $wheels wheels; esperado >= 7 (2 linux, 2 musl, 1 win, 2 macos)"; exit 1; }
258
+
259
+ # O PyPI devolve 500 transitório com frequência suficiente para importar,
260
+ # e o upload é arquivo por arquivo: uma falha no meio deixa a versão
261
+ # publicada pela metade. `skip-existing` é o que torna a segunda
262
+ # tentativa segura — sem ele, ela morre nos arquivos que já subiram.
263
+ #
264
+ # São dois passos explícitos em vez de um wrapper de retry porque o
265
+ # trusted publishing exige a action oficial, e ela não dá para acionar
266
+ # por linha de comando de dentro do `nick-fields/retry`. Mesma forma que
267
+ # o `rust_py_cache` e o `rust_py_rate_limit` já rodam.
268
+ - name: Publish to PyPI (attempt 1)
269
+ id: publish1
270
+ continue-on-error: true
271
+ uses: pypa/gh-action-pypi-publish@release/v1
272
+ with:
273
+ packages-dir: dist
274
+ skip-existing: true
275
+
276
+ - name: Wait before retrying
277
+ if: steps.publish1.outcome == 'failure'
278
+ run: sleep 45
279
+
280
+ - name: Publish to PyPI (attempt 2)
281
+ if: steps.publish1.outcome == 'failure'
282
+ uses: pypa/gh-action-pypi-publish@release/v1
283
+ with:
284
+ packages-dir: dist
285
+ skip-existing: true
286
+
287
+ # Proves the release is actually complete, rather than trusting an exit
288
+ # code that a partial upload can still produce.
289
+ - name: Verify every file reached PyPI
290
+ run: |
291
+ set -euo pipefail
292
+ python - <<'PY'
293
+ import os, sys, glob, time, requests
294
+ version = os.environ["GITHUB_REF_NAME"].lstrip("v")
295
+ # Só .whl e .tar.gz: são os únicos tipos de arquivo que o PyPI hospeda.
296
+ # `pypa/gh-action-pypi-publish` deixa um .publish.attestation por
297
+ # distribuição em dist/ depois de publicar (atestados PEP 740, que
298
+ # sobem junto do arquivo, não como arquivo próprio). Um glob amplo os
299
+ # conta como "faltando no PyPI" e reprova uma release que deu certo.
300
+ #
301
+ # ⚠️ Isto deixou de ser precaução e passou a valer aqui: com trusted
302
+ # publishing a action gera os atestados, coisa que o `maturin upload`
303
+ # não fazia. O recorte veio do alarme falso da rate_limit 0.2.1
304
+ # (P1-9) e é o que impede esta migração de reprovar o primeiro
305
+ # release por um motivo inventado.
306
+ local = {
307
+ os.path.basename(p)
308
+ for pattern in ("dist/*.whl", "dist/*.tar.gz")
309
+ for p in glob.glob(pattern)
310
+ }
311
+ url = f"https://pypi.org/pypi/rust-py-audit/{version}/json"
312
+ for attempt in range(6): # PyPI's JSON API is cached
313
+ response = requests.get(url, timeout=30)
314
+ if response.status_code == 200:
315
+ remote = {f["filename"] for f in response.json()["urls"]}
316
+ missing = local - remote
317
+ if not missing:
318
+ print(f"OK: {len(remote)} arquivos publicados na {version}")
319
+ sys.exit(0)
320
+ else:
321
+ missing = local
322
+ print(f"tentativa {attempt + 1}: faltam {len(missing)}")
323
+ time.sleep(20)
324
+ print("::error::faltaram arquivos no PyPI:")
325
+ for name in sorted(missing):
326
+ print(f" - {name}")
327
+ sys.exit(1)
328
+ PY
@@ -9,3 +9,7 @@ build/
9
9
  *.pyd
10
10
  .pytest_cache/
11
11
  *.jsonl
12
+ *.jsonl.lock
13
+
14
+ # macOS
15
+ .DS_Store
@@ -0,0 +1,201 @@
1
+ # Changelog
2
+
3
+ Todas as mudanças relevantes deste projeto são registradas aqui.
4
+
5
+ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
6
+ e o versionamento segue [SemVer](https://semver.org/lang/pt-BR/).
7
+
8
+ ## [0.4.0] — não publicada
9
+
10
+ Cadeia de hashes segura com vários processos (**P2-4**).
11
+
12
+ ### Corrigido
13
+
14
+ - **Vários workers no mesmo JSONL quebravam a cadeia.** Cada processo
15
+ encadeava no `last_hash` que tinha em memória; com gunicorn ou
16
+ `uvicorn --workers`, dois eventos saíam com o mesmo `previous_hash` e o
17
+ `verify()` acusava `broken_chain` num arquivo que ninguém adulterou. O modo
18
+ de falha era silencioso até alguém rodar `verify()`. Reproduzido na 0.3.3
19
+ com 4 processos × 50 eventos: `{'valid': False, 'error_index': 1,
20
+ 'reason': 'broken_chain'}`. Na 0.4.0 o mesmo teste dá `valid: True` com os
21
+ 200 eventos.
22
+
23
+ ### Adicionado
24
+
25
+ - **Lock de arquivo entre processos** (`fs2`: `flock` no Unix, `LockFileEx` no
26
+ Windows) num `<file_path>.lock` ao lado do log — à parte, porque o
27
+ `flush_pending()` troca o JSONL por `rename` e um lock no próprio arquivo
28
+ ficaria preso ao inode antigo. Com o lock, o logger confere se o arquivo
29
+ cresceu desde o seu último append; se cresceu, relê o último hash **só do fim
30
+ do arquivo**. O handle do `.lock` fica aberto no logger, então o custo do
31
+ caminho comum não mudou (~29 µs por `log()`, medido antes e depois).
32
+ - **`fsync`** no construtor (padrão `False`). Com `True`, o `log()` só volta
33
+ depois de o evento estar no disco. Custa ~3,3 ms por evento no macOS
34
+ (`F_FULLFSYNC`), por isso é opt-in.
35
+ - A reescrita atômica do `flush_pending()` agora faz `sync_data` antes do
36
+ `rename`, para uma queda de energia não deixar o arquivo final truncado.
37
+
38
+ ### Compatibilidade
39
+
40
+ - Nenhum parâmetro existente mudou. Aparece um arquivo `<file_path>.lock` ao
41
+ lado do JSONL nos modos `local`/`hybrid`.
42
+ - Em `mode="hybrid"` com vários workers, a entrega ao ImmutableLog acontece
43
+ dentro do lock (o hash enviado depende do anterior), então os `log()` dos
44
+ workers passam a se serializar também no tempo de rede.
45
+ - Arquivos já gravados por vários workers em versões anteriores continuam
46
+ falhando no `verify()` a partir do primeiro entrelaçamento — o que eles têm
47
+ é o defeito antigo, não um efeito desta versão.
48
+
49
+ ## [0.3.3] — não publicada
50
+
51
+ Correção do passo de verificação pós-upload introduzido na 0.3.2, mais o
52
+ trabalho de licença e lint de 12/08/2026 (**P1-4** e **P1-6**).
53
+
54
+ ### Alterado
55
+
56
+ - **Publicação migrada para trusted publishing (OIDC).** O `release.yml` não
57
+ usa mais `secrets.PYPI_API_TOKEN`: o GitHub emite um JWT curto afirmando
58
+ repositório, workflow e ref, e o PyPI o troca por um token de upload válido
59
+ por poucos minutos e restrito a este projeto. Acaba o segredo de longa
60
+ duração que, se vazasse, publicaria até ser revogado à mão.
61
+
62
+ O passo de upload deixou de ser `maturin upload` dentro de um
63
+ `nick-fields/retry` e passou a ser dois `pypa/gh-action-pypi-publish`
64
+ explícitos (`continue-on-error` na primeira, `if: outcome == 'failure'` na
65
+ segunda) — a action oficial é a que faz a troca OIDC, e não dá para acioná-la
66
+ por linha de comando de dentro do wrapper de retry. `skip-existing: true`
67
+ mantém a segunda tentativa idempotente. É a mesma forma que `rust_py_cache` e
68
+ `rust_py_rate_limit` já rodam.
69
+
70
+ ⚠️ **Depende de configuração no PyPI**, e os valores têm que bater exatamente:
71
+ `robertolima-dev/rust-py-audit`, workflow `release.yml`, environment `pypi`.
72
+ O modo de falha é seguro — sem o publisher cadastrado, o job falha na troca
73
+ do token, depois de construir tudo e antes de qualquer arquivo sair do
74
+ runner. Nada é publicado pela metade.
75
+
76
+ 💡 A partir daqui a release passa a gerar **atestados PEP 740**, que o
77
+ `maturin upload` não gerava. A verificação pós-upload já estava preparada:
78
+ ela só conta `*.whl` e `*.tar.gz` desde a 0.3.2, recorte que nasceu do alarme
79
+ falso da `rate_limit` 0.2.1. Sem ele, este primeiro release por OIDC seria
80
+ reprovado por um motivo inventado.
81
+
82
+ - **Ensaio no TestPyPI por `workflow_dispatch`.** Job `publish-testpypi` novo,
83
+ na forma que o `rust_py_rate_limit` já tem: um disparo manual constrói a
84
+ matriz inteira e publica no índice de teste, para validar a troca de token
85
+ num lugar onde nada é irreversível.
86
+
87
+ Para isso, os cinco jobs de build passaram de
88
+ `if: startsWith(github.ref, 'refs/tags/')` para
89
+ `... || github.event_name == 'workflow_dispatch'` — sem essa mudança um
90
+ disparo manual não construiria wheel nenhuma e o ensaio ficaria sem o que
91
+ publicar.
92
+
93
+ ⚠️ **O job `release` deliberadamente NÃO ganhou a cláusula de dispatch.** A
94
+ assimetria é a proteção: disparo manual vai ao TestPyPI, só a tag chega ao
95
+ PyPI de produção.
96
+
97
+ ### Adicionado
98
+
99
+ - **Job `lint`** (`cargo fmt --check` + `cargo clippy -D warnings`) no
100
+ `release.yml`, com `needs: [test, lint]` em todos os jobs de build e no de
101
+ publicação — o lint barra a release, igual ao `test`. A toolchain é fixa
102
+ (`dtolnay/rust-toolchain@1.88.0`) e não `stable`: com `-D warnings`, um
103
+ clippy mais novo no runner reprovaria código que ninguém tocou.
104
+ - **`clippy.toml` e `Makefile`** compartilhados com os outros quatro
105
+ `rust_py_*`. O `clippy.toml` sobe `too-many-arguments-threshold` para 10:
106
+ os construtores PyO3 espelham a assinatura Python, e reagrupar em struct
107
+ para agradar o lint pioraria a API que o usuário vê.
108
+
109
+ ### Corrigido
110
+
111
+ - **Metadado de licença ausente no PyPI.** A 0.3.2 está publicada com
112
+ `license_expression: null` e `license: ""` — ou seja, sem metadado de
113
+ licença nenhum, só o classifier. A causa raiz não era a forma no
114
+ `pyproject.toml` (que já usava `license = "MIT"`), e sim a **maturin velha**:
115
+ versões anteriores à 1.14 não emitem `License-Expression`. O piso do
116
+ `[build-system]` subiu de `>=1.5` para `>=1.14`, entrou
117
+ `license-files = ["LICENSE"]` e o classifier `License :: OSI Approved` saiu,
118
+ por ser mutuamente exclusivo com `License-Expression` no Metadata 2.4.
119
+ Verificado: o `dist-info` gerado agora traz `License-Expression: MIT` e o
120
+ texto da licença em `licenses/LICENSE`.
121
+
122
+ - **Formatação.** `cargo fmt --all` aplicado — eram 38 blocos divergentes, de
123
+ longe o maior dos cinco. Mudança puramente de formatação, sem efeito em
124
+ comportamento; os 50 testes Rust seguem passando.
125
+
126
+ ### Conhecido
127
+
128
+ - `src/audit_logger.rs` ganhou um `#![allow(clippy::useless_conversion)]` de
129
+ módulo. **Não é código a corrigir:** o lint aponta o *tipo de retorno* de
130
+ `log`, `verify` e `flush_pending`, mas a conversão está no código que
131
+ `#[pymethods]` gera — a macro do pyo3 0.22. `#[allow]` no bloco `impl` e na
132
+ função foram testados e não silenciam, porque o código gerado fica fora do
133
+ `impl`. `cache` (0.24), `monitor` (0.25) e `scheduler` (0.29) não emitem o
134
+ aviso, o que confirma ser artefato da versão. O `allow` sai com o **P3-2**.
135
+
136
+ ### Corrigido — pipeline (0.3.2)
137
+
138
+ - **Falso negativo na verificação pós-publicação.** O passo comparava
139
+ `glob("dist/*")` com o que estava no PyPI. Nada quebrou aqui — o
140
+ `maturin upload` não deixa arquivo extra em `dist/` —, mas o glob amplo é
141
+ frágil: qualquer arquivo novo no diretório reprovaria uma release que deu
142
+ certo, que foi exatamente o que aconteceu no `rust_py_rate_limit`. O passo
143
+ agora só considera `*.whl` e `*.tar.gz`, os únicos tipos de arquivo que o
144
+ PyPI hospeda.
145
+
146
+ - **Os testes passam a rodar em push e PR.** O `release.yml` é o único workflow
147
+ destes repositórios e só disparava em tag, então o job `test` só rodava na
148
+ hora do release — protegia a publicação, mas só avisava do problema no pior
149
+ momento. Agora o workflow também dispara em `push` para `main` e em
150
+ `pull_request`, e os jobs de build e o de publicação ganharam
151
+ `if: startsWith(github.ref, 'refs/tags/')`. Essa guarda corrige um risco que
152
+ existia antes: o job de release **não tinha `if:`**, então um
153
+ `workflow_dispatch` manual a partir de um branch teria publicado no PyPI.
154
+
155
+ **Não vai ao PyPI sozinha:** a 0.3.2 está lá completa e esta versão só
156
+ mexe no pipeline. O número fica reservado e sai com a próxima mudança que
157
+ valha uma tag.
158
+
159
+ ## [0.3.2] - 2026-08-11
160
+
161
+ Release de infraestrutura: nada muda no comportamento da biblioteca.
162
+
163
+ ### Corrigido
164
+
165
+ - **A publicação não roda mais sem teste.** O `release.yml` era o único
166
+ workflow do repositório e só tinha jobs de build: `git push --tags`
167
+ publicava no PyPI sem nenhum `cargo test` nem `pytest`. Agora existe um job
168
+ `test` e todos os jobs de wheel, o de sdist e o de release declaram
169
+ `needs: test`. (BACKLOG2 P1-1)
170
+ - **O upload aguenta o 500 do PyPI.** O upload é arquivo por arquivo e sem
171
+ transação — uma falha no meio deixa a versão publicada pela metade, e o PyPI
172
+ nunca permite reusar um nome de arquivo. O passo de publicação agora tem
173
+ retry (4 tentativas), continua usando `--skip-existing` para ser idempotente,
174
+ confere o conjunto de artefatos **antes** de subir qualquer coisa (aborta se
175
+ faltar o sdist) e valida contra a API do PyPI **depois**, listando nome por
176
+ nome o que não chegou. (BACKLOG2 P1-9)
177
+
178
+ ### Adicionado
179
+
180
+ - Este `CHANGELOG.md`. (BACKLOG2 P1-5)
181
+
182
+ ## [0.3.1] e anteriores
183
+
184
+ Sem changelog retroativo. O histórico do que entrou em cada versão está no
185
+ `ROADMAP.md`, e o das mudanças no `git log`.
186
+
187
+ Resumo do que o projeto entrega hoje:
188
+
189
+ - `AuditLogger` com log append-only em JSONL e cadeia de hashes encadeada.
190
+ - `verify()` para detectar adulteração da cadeia.
191
+ - Integração com o ImmutableLog nos modos remoto e híbrido.
192
+ - Middlewares para FastAPI e Django.
193
+ - Wheels `abi3-py310` — uma wheel por plataforma, válida da 3.10 em diante.
194
+
195
+ ### Limitação conhecida (não corrigida nesta versão)
196
+
197
+ ⚠️ **Múltiplos processos escrevendo no mesmo arquivo quebram a cadeia.** Cada
198
+ processo mantém seu próprio `last_hash` em memória e não há `flock` no append,
199
+ então dois workers gunicorn apontando para o mesmo JSONL produzem uma cadeia
200
+ inconsistente — e `verify()` acusa corrupção. O modo de falha é silencioso até
201
+ alguém rodar `verify()`. Ver **P2-4** no `BACKLOG2.md`.
@@ -517,6 +517,16 @@ dependencies = [
517
517
  "percent-encoding",
518
518
  ]
519
519
 
520
+ [[package]]
521
+ name = "fs2"
522
+ version = "0.4.3"
523
+ source = "registry+https://github.com/rust-lang/crates.io-index"
524
+ checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
525
+ dependencies = [
526
+ "libc",
527
+ "winapi",
528
+ ]
529
+
520
530
  [[package]]
521
531
  name = "futures-channel"
522
532
  version = "0.3.32"
@@ -1597,8 +1607,9 @@ dependencies = [
1597
1607
 
1598
1608
  [[package]]
1599
1609
  name = "rust_py_audit"
1600
- version = "0.3.2"
1610
+ version = "0.4.0"
1601
1611
  dependencies = [
1612
+ "fs2",
1602
1613
  "httpmock",
1603
1614
  "pyo3",
1604
1615
  "reqwest",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "rust_py_audit"
3
- version = "0.3.2"
3
+ version = "0.4.0"
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`
@@ -0,0 +1,68 @@
1
+ # Makefile compartilhado dos `rust_py_*`.
2
+ #
3
+ # Existe para que a CI e a pessoa rodem exatamente o mesmo comando. Quando o
4
+ # lint da CI diverge do lint local, quem perde é sempre quem abriu o PR.
5
+ #
6
+ # A toolchain NÃO é fixada por `rust-toolchain.toml` de propósito: esse arquivo
7
+ # vale para toda invocação do cargo, inclusive a dos containers manylinux do
8
+ # release, e um download de toolchain no meio de uma publicação no PyPI é risco
9
+ # sem contrapartida. A fixação vive no job de lint da CI, que é onde
10
+ # `-D warnings` realmente morde. Localmente, use uma stable recente.
11
+
12
+ PYTHON ?= python3
13
+
14
+ # Fixa qual Python o pyo3 usa na compilação. Sem isto ele pega o primeiro do
15
+ # PATH, que pode não ser o mesmo do `make dev` — e aí o binário de teste linka
16
+ # contra uma libpython e o pytest roda contra outra.
17
+ export PYO3_PYTHON ?= $(PYTHON)
18
+
19
+ # No macOS o binário de `cargo test` carrega a libpython em runtime, e o dyld
20
+ # não a encontra sozinho: o erro é `Library not loaded: @rpath/libpython3.X.dylib`,
21
+ # em SIGABRT, depois de compilar com sucesso — parece falha de teste e não é.
22
+ # Os caminhos padrão entram junto porque definir DYLD_FALLBACK_LIBRARY_PATH
23
+ # *substitui* a lista default do dyld em vez de acrescentar a ela (senão o
24
+ # erro seguinte é libiconv.2.dylib).
25
+ PY_LIBDIR := $(shell $(PYTHON) -c 'import sysconfig; print(sysconfig.get_config_var("LIBDIR") or "")' 2>/dev/null)
26
+ export DYLD_FALLBACK_LIBRARY_PATH := $(PY_LIBDIR):/opt/homebrew/lib:/usr/local/lib:/usr/lib
27
+
28
+ .PHONY: help dev test lint fmt clippy test-rust test-python clean
29
+
30
+ help:
31
+ @echo "make dev instala o pacote em modo editável com os extras de dev"
32
+ @echo "make test cargo test + pytest"
33
+ @echo "make lint cargo fmt --check + cargo clippy -D warnings"
34
+ @echo "make fmt aplica a formatação (escreve nos arquivos)"
35
+
36
+ # `pip install` aciona o backend maturin, compila a extensão e instala.
37
+ # Ao contrário de `maturin develop`, não exige um virtualenv ativo — que é
38
+ # exatamente a armadilha que custou um release do monitor (ver P1-9).
39
+ dev:
40
+ $(PYTHON) -m pip install --upgrade pip
41
+ $(PYTHON) -m pip install -e ".[dev]"
42
+
43
+ test: test-rust test-python
44
+
45
+ # --no-default-features desliga `extension-module`, que impede o binário de
46
+ # teste de linkar contra a libpython no Linux. --lib porque os testes de
47
+ # integração precisam do módulo nativo, e esses ficam do lado Python.
48
+ test-rust:
49
+ cargo test --no-default-features --lib
50
+
51
+ test-python:
52
+ pytest -q
53
+
54
+ lint: fmt-check clippy
55
+
56
+ .PHONY: fmt-check
57
+ fmt-check:
58
+ cargo fmt --all -- --check
59
+
60
+ clippy:
61
+ cargo clippy --all-targets --all-features -- -D warnings
62
+
63
+ fmt:
64
+ cargo fmt --all
65
+
66
+ clean:
67
+ cargo clean
68
+ rm -rf dist build *.egg-info .pytest_cache