diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 058c12b..65f7ffa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,6 +132,15 @@ jobs: cargo +nightly fuzz run -s none apply_update -- -max_total_time=60 -rss_limit_mb=0 -max_len=8192 || echo "::warning title=fuzz informativo (R6)::apply_update halló un crash por amplificación de memoria del decoder de yrs — no bloquea M0; mitigación en M2 (ver AILOG R6)" + # Regresión R6 (CHARTER-08): ruta RESIDUAL state_vector::decode vía weft_doc_export_since — el + # único sitio de yrs que aún usa with_capacity sin acotar (id_set/state_vector; Update::decode ya + # usa try_reserve). Informativo hasta adoptar el fix upstream (FU-015); prueba el fix al bumpear. + - name: Fuzz weft_doc_export_since (60 s) + working-directory: native/weft-yrs-ffi + run: >- + cargo +nightly fuzz run -s none export_since -- + -max_total_time=60 -rss_limit_mb=0 -max_len=8192 + || echo "::warning title=fuzz informativo (R6)::export_since halló un crash por amplificación de memoria del decoder de yrs (state_vector::decode residual) — no bloquea; fix upstream vía try_reserve (FU-015)" # Shim Loro (localmente no amplifica memoria; mismo patrón informativo por robustez). - name: Fuzz loro_doc_load (60 s) working-directory: native/weft-loro-ffi diff --git a/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-14-002-charter-08-endurecer-decoder-yrs-r6.md b/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-14-002-charter-08-endurecer-decoder-yrs-r6.md new file mode 100644 index 0000000..b0fefd6 --- /dev/null +++ b/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-14-002-charter-08-endurecer-decoder-yrs-r6.md @@ -0,0 +1,124 @@ +--- +id: AILOG-2026-07-14-002 +title: "CHARTER-08: endurecer el decoder de yrs contra amplificación de memoria (R6) — PR upstream + doc + fuzz de regresión" +status: accepted +created: 2026-07-14 +agent: claude-opus-4-8 +confidence: high +review_required: true +reviewed_by: Jose Villaseñor Montfort +reviewed_at: 2026-07-14 +review_outcome: approved +risk_level: medium +eu_ai_act_risk: not_applicable +nist_genai_risks: [] +iso_42001_clause: [] +observability_scope: none +tags: [ffi-boundary, dos, yrs, upstream-pr, try-reserve, decoder, memory-amplification, fuzz, security-doc] +related: [AIDEC-2026-07-14-001, AILOG-2026-07-10-001] +originating_charter: CHARTER-08-endurecer-decoder-yrs-r6 +--- + +# AILOG: CHARTER-08 — endurecer el decoder de yrs contra amplificación de memoria (R6) + +## Summary + +Despacho de CHARTER-08 (FU-014): endurecer la ruta **directa del FFI** ante la amplificación de memoria del +decoder de yrs (**R6**), que FU-002 (cerrado) mitigó solo en la capa de relay. La corrección canónica vive +**upstream** — se envió un PR a `y-crdt/y-crdt` extendiendo el patrón `try_reserve` que yrs mismo estableció en +`Update::decode` (`b234ef4e`). Una **revisión de completitud** del crate durante la implementación amplió el +alcance del PR de los **2 sitios** del Charter a los **5 sitios reales** de la clase (ver AIDEC-2026-07-14-001, +decisión 2). Entregables de este repo: nota de seguridad del caveat de ingesta directa (ship-now) + fuzz target +de regresión de la ruta residual `state_vector::decode`. La adopción vía bump es **FU-015** (fuera de scope, no +bloquea el cierre). + +## Actions Performed + +1. **(a) PR upstream a `y-crdt/y-crdt` — [#639](https://github.com/y-crdt/y-crdt/pull/639)**. Fork + `StrangeDaysTech/y-crdt`, rama `harden/decode-try-reserve-idset-statevector` (base 0.27.3). Fix de la clase + completa de allocation-bomb por prefijo de longitud, en **5 sitios**: + - `state_vector.rs` (`StateVector::decode`), `any.rs` (`Any::Map` + `Any::Array`), `sync/awareness.rs` + (`AwarenessUpdate::decode`) → `try_reserve(len)?` alimentando la variante existente `Error::NotEnoughMemory` + (idéntico a `Update::decode`). + - `id_set.rs` (`IdRanges::decode`, delete sets) → grow-on-push (`SmallVec::try_reserve` da otro tipo de error; + ver AIDEC decisión 1). 5 tests de regresión upstream (uno por ruta), input adversarial `[255,255,255,122]`. + - Suite `cargo test -p yrs` verde en el fork: **377 + 34 tests, 0 fallos**. Commit sin trailer de coautoría + (autoría humana; disclosure honesto de uso de IA + responsabilidad humana en el cuerpo del PR). +2. **(b) Doc del caveat (ship-now)**: nota de seguridad en `GOVERNANCE.md §Seguridad` (subsección "Ingesta directa + de bytes CRDT no confiables (caveat R6)") + pointer breve en `README.md §Seguridad`: la ruta directa del FFI + (`weft_doc_load`/`apply_update`/`export_since`) ante bytes no confiables fuera del relay debe protegerse con un + cap de tamaño + límite de memoria del proceso, como hace el relay (FU-002). Calibrada honesto (glibc = error + limpio; `apply_update` ya endurecido upstream). +3. **(c) Fuzz de regresión**: nuevo target `export_since` (`native/weft-yrs-ffi/fuzz/fuzz_targets/export_since.rs` + + `[[bin]]` en `fuzz/Cargo.toml` + step informativo en `ci.yml`) que alimenta bytes arbitrarios como state + vector a `weft_doc_export_since` → ejercita la ruta residual `state_vector::decode`. Validado localmente: el + seed `[255,255,255,122]` dispara RSS de **~553 MB** (vs ~34 MB baseline) y completa con error limpio (exit 0, + sin abort) en glibc — confirma que el target alcanza el residual (mitiga R2 del Charter). +4. **(d) FU-015 registrado** (adopción vía bump R16 al mergear+publicar upstream) — ver §Follow-ups; no ejecutado + aquí. + +## Modified Files + +**Este repo** — `native/weft-yrs-ffi/fuzz/fuzz_targets/export_since.rs` (nuevo, T-c), +`native/weft-yrs-ffi/fuzz/Cargo.toml` (`[[bin]] export_since`, T-c), `.github/workflows/ci.yml` (step fuzz +`export_since`, T-c), `GOVERNANCE.md` (nota de seguridad, T-b), `README.md` (pointer, T-b), +`.straymark/follow-ups-backlog.md` (FU-014 → `closed`, FU-015 registrado), `.straymark/charters/08-*.md` +(status → in-progress; reconciliación §Context/§Scope 2→5 sitios), AIDEC-2026-07-14-001 (nuevo). + +**Externo (no en este repo)** — fork `StrangeDaysTech/y-crdt`, rama `harden/decode-try-reserve-idset-statevector`, +commit `2ee533e` (5 sitios + 5 tests) → PR `y-crdt/y-crdt#639`. + +## Risk + +- **R1 (medio) — el merge upstream no llega / tarda / se rechaza**: el fix canónico vive en y-crdt, timeline no + controlado. Mitigación: el cierre de CHARTER-08 **NO** depende del merge — cierra con nuestros entregables + (fuzz + doc + PR enviado); la adopción es **FU-015**. Nada regresiona mientras tanto (comportamiento actual = + error limpio en glibc). Plan B si se rechaza: guard de pre-validación en el shim, como riesgo emergente. +- **R2 (bajo) — asimetría del guard (grow-on-push en SmallVec vs try_reserve en el resto)**: decisión consciente + para no ampliar el enum público `Error` de yrs (AIDEC decisión 1); el PR ofrece la alternativa simétrica al + maintainer. Sin impacto de comportamiento. +- **R3 (bajo) — sobre-estimar la severidad en la doc**: en glibc es error limpio, no crash. Mitigación: la nota + calibra honesto (afecta hosts memory-constrained; `apply_update` ya endurecido; el relay ya capea). No es un CVE + nuestro. + +## Verification + +```bash +# (c) El fuzz de regresión compila y alcanza el residual state_vector::decode +cd native/weft-yrs-ffi && cargo +nightly fuzz build -s none export_since +cargo +nightly fuzz run -s none fuzz/corpus/export_since/seed_r6 -- -rss_limit_mb=0 +# → seed [255,255,255,122]: RSS ~553 MB, exit 0 (error de decode limpio, sin abort) en glibc + +# Suite .NET completa intacta (P-II ASan sobre la suite determinista) +cd ../.. && dotnet test Weft.sln -c Release + +# (a) PR upstream: la suite de yrs pasa con el fix + los 5 tests adversariales EN EL FORK +# (cd /y-crdt && cargo test -p yrs) → 377 + 34 tests, 0 fallos +``` + +## Follow-ups + +Derivado del entregable (d) de CHARTER-08. No bloquea el cierre: + +- **Follow-up (adopción, media)**: adoptar el fix de R6 vía **bump de yrs** (protocolo **R16**) cuando el PR + upstream `y-crdt/y-crdt#639` se mergee y publique en un release de crates.io. Actualizar `yrs = "=0.27.x"` en + `native/weft-yrs-ffi/Cargo.toml` (+ el fuzz) a la versión con el fix, re-correr el fuzz `export_since` (que + debe pasar a RSS acotado), y revertir el fork `StrangeDaysTech/y-crdt` (volver a consumir yrs de crates.io). + **Trigger**: merge + publish upstream. **Destination**: chore. **Cost**: S. + +## Additional Notes + +- La revisión de completitud (regla operativa: revisar más ancho que el cambio, para no enviar un fix que deja + gemelos idénticos vivos) encontró 3 sitios más allá de los 2 del Charter (`any.rs` Map+Array, `awareness.rs`). + Descartó correctamente los `with_capacity` de longitud **local** (Serializer `serde/ser.rs`, `self.len(txn)`, + `blocks.len()`, constantes). Ver AIDEC-2026-07-14-001 decisión 2. +- El fuzz `apply_update` (existente) ya cubre la ruta delete-set (`id_set`) vía update y el contenido `Any`; el + nuevo `export_since` cubre la ruta residual `state_vector`. La ruta `awareness` no tiene target de fuzz en Weft + (es .NET managed en el relay, ya capeada por FU-002); los 5 tests upstream la cubren en el lado de yrs. + +## Approval + +Trabajo de frontera nativa (`risk_level: medium`, `review_required: true`) con un entregable externo (PR upstream). +Revisión interactiva del operador (`Jose Villaseñor Montfort`, 2026-07-14): revisó el diff final de los 5 sitios + +tests, aprobó la expansión 2→5, la forma del guard, la identidad del commit y el envío del PR. Verificación local +citada. Compañero de AIDEC-2026-07-14-001. diff --git a/.straymark/07-ai-audit/decisions/AIDEC-2026-07-14-001-charter-08-forma-del-guard-y-alcance-del-pr-upstream.md b/.straymark/07-ai-audit/decisions/AIDEC-2026-07-14-001-charter-08-forma-del-guard-y-alcance-del-pr-upstream.md new file mode 100644 index 0000000..48501ce --- /dev/null +++ b/.straymark/07-ai-audit/decisions/AIDEC-2026-07-14-001-charter-08-forma-del-guard-y-alcance-del-pr-upstream.md @@ -0,0 +1,126 @@ +--- +id: AIDEC-2026-07-14-001 +title: "CHARTER-08: forma del guard anti-amplificación en el decoder de yrs y alcance del PR upstream (2 → 5 sitios)" +status: accepted +created: 2026-07-14 +agent: claude-opus-4-8 +confidence: high +review_required: true +reviewed_by: Jose Villaseñor Montfort +reviewed_at: 2026-07-14 +review_outcome: approved +risk_level: medium +eu_ai_act_risk: not_applicable +nist_genai_risks: [] +iso_42001_clause: [] +tags: [ffi-boundary, dos, yrs, upstream-pr, try-reserve, decoder, memory-amplification] +related: [AILOG-2026-07-14-002, yrs-decoder-r6-upstream] +originating_charter: CHARTER-08-endurecer-decoder-yrs-r6 +--- + +# AIDEC: forma del guard anti-amplificación (R6) y alcance del PR upstream + +> Registra las dos decisiones sustantivas de CHARTER-08 sobre el PR upstream a `y-crdt/y-crdt` +> ([#639](https://github.com/y-crdt/y-crdt/pull/639)). El Charter §Tasks anticipó la primera como +> candidata a AIDEC ("forma del guard: `try_reserve` vs bound-vs-remaining"); la segunda emergió de +> una revisión de completitud durante la implementación. + +## Context + +**R6** es una amplificación de memoria del decoder de yrs (allocation-bomb): un `Decode` que hace +`with_capacity(len)` con un `len` leído del prefijo de longitud —controlado por el atacante— reserva +cientos de MB con pocos bytes de input sin respaldo. Bajo glibc (overcommit) es una reserva virtual + +error de decode limpio; bajo un límite duro de memoria (cgroup/contenedor) o allocator eager, es un +`abort` (`handle_alloc_error`, no capturable por `catch_unwind`). yrs mismo ya estableció el patrón de +mitigación en `Update::decode` (`try_reserve`, commit `b234ef4e`). El Charter mandató extenderlo a los +sitios residuales. + +--- + +## Decisión 1 — Forma del guard en cada sitio (`try_reserve` vs alternativa) + +### Problem + +¿Cómo se acota la reserva sin cambiar el comportamiento para input válido ni el API público del enum +`read::Error`? El enum ya tiene `NotEnoughMemory(#[from] std::collections::TryReserveError)` — la +variante que `Update::decode` alimenta vía `try_reserve`. + +### Alternatives Considered + +- **A1 — `try_reserve` uniforme en los cinco sitios.** Encaja perfecto para los que usan `HashMap`/`Vec` + std (`std::collections::TryReserveError` → `?` → `NotEnoughMemory`). Pero `IdRanges::decode` usa un + `SmallVec`, y `SmallVec::try_reserve` devuelve el error propio de smallvec (`CollectionAllocErr`), **no** + el `std::TryReserveError`. Enchufarlo por `?` exigiría añadir una variante nueva al enum público `Error` + (o un `From`) — un cambio **breaking** para consumidores que hagan `match` exhaustivo. + Rechazada **para el sitio SmallVec** por ese blast radius; adoptada para los otros cuatro. +- **A2 — bound-vs-remaining (rechazar si `len` > bytes restantes del decoder).** El trait `Decoder` no + expone de forma barata/portátil los bytes restantes en todas sus implementaciones (V1/V2), así que el + guard no vive limpio en esta capa. Rechazada. +- **A3 (elegida para SmallVec) — no pre-asignar: crecer en `push`.** `SmallVec::new()` sin + `with_capacity`; cada `Range::decode` empujado consume bytes reales del decoder, así que el crecimiento + queda acotado por la longitud real del input. Elimina la bomba sin ampliar el enum y conserva el buffer + inline para el caso común pequeño (que es justo para lo que existe el SmallVec en `IdRanges`). + +### Rationale + +**Solución mixta, por tipo de colección:** `try_reserve` (variante `NotEnoughMemory` existente, idéntico +a `Update::decode`) en los cuatro sitios `HashMap`/`Vec` — `state_vector.rs`, `any.rs` (Map + Array), +`sync/awareness.rs` — y grow-on-push en el único sitio `SmallVec` (`id_set.rs`). El criterio: limitar el +alcance del cambio a corregir R6 respetando las convenciones locales, sin introducir un cambio breaking +del API por simetría cosmética. El PR **ofrece explícitamente** al maintainer cambiar el SmallVec a +`try_reserve` + `From` si prefiere la simetría (la decisión de ampliar su API es suya). + +### Consequences + +- Sin cambio de comportamiento para input válido; solo cambia el modo de fallo del prefijo mentiroso + (error de decode recuperable en vez de reserva ilimitada / abort). +- Cinco tests de regresión upstream (uno por ruta) con el input adversarial `[255,255,255,122]`. +- La adopción en Weft (bump de yrs) es **FU-015**, disparada al mergear+publicar upstream — no bloquea el + cierre de CHARTER-08. + +--- + +## Decisión 2 — Alcance del PR: los 5 sitios de la clase, no los 2 del Charter + +### Problem + +El Charter §Scope (basado en la investigación upstream previa, ver `[[yrs-decoder-r6-upstream]]`) nombró +**dos** sitios residuales: `id_set.rs:91` y `state_vector.rs:120`. Al implementar, una revisión de +completitud del crate `yrs` (motivada por la regla operativa: revisar más ancho que el cambio, para no +enviar un fix que deja hermanos idénticos vivos — analogía con un patch al kernel) barrió **todos** los +`with_capacity`/`reserve` del crate y los clasificó por origen del `len`. + +### Alternatives Considered + +- **B1 — Enviar solo los 2 sitios del Charter.** Disciplinado con el scope declarado, pero envía un fix + de clase **incompleto**: deja 3 gemelos idénticos vivos. Rechazada. +- **B2 (elegida) — Enviar los 5 sitios de la clase completa.** La barrida encontró 3 sitios más con el + patrón exacto (longitud no confiable → `with_capacity` eager): `any.rs:63` (`Any::Map`), `any.rs:73` + (`Any::Array`) y `sync/awareness.rs:560` (`AwarenessUpdate`, alcanzable desde mensajes de presencia del + relay). Descartados correctamente los `with_capacity` alimentados por longitud **local** (`self.len(txn)`, + `blocks.len()`, el Serializer `serde/ser.rs`, constantes, buffers de encoder). Es el mismo one-liner por + sitio; arreglar la clase de una es lo más honesto y mergeable. + +### Rationale + +"Limitar el cambio a lo que reportamos" aplica a la **clase** reportada (allocation-bomb por prefijo de +longitud), no a una enumeración incompleta de instancias. Fijar 2 de 5 y dejar 3 idénticos sería +precisamente el error que la revisión ancha busca evitar. Corroboración de alcanzabilidad: el fuzz +`apply_update` ya ejercita `any.rs` (los updates llevan contenido `Any`); la presencia es tráfico no +confiable del relay. + +### Consequences + +- **Expande el scope declarado de CHARTER-08** de 2 a 5 sitios. Reconciliado en el propio Charter (§Context/ + §Scope, actualización atómica format v4 en el mismo PR de cierre) y documentado aquí + en AILOG. +- El entregable (c) de Weft (fuzz `export_since` de regresión) sigue cubriendo la ruta `state_vector` + residual local; los 5 tests upstream cubren la clase completa en el lado de yrs. +- No cambia el resto del Charter: (b) doc de caveat y (d) FU-015 quedan igual. + +## Approval + +**Approved**: 2026-07-14 por `Jose Villaseñor Montfort`, en revisión interactiva. El operador revisó el +diff final de los 5 sitios + los 5 tests antes del envío, aprobó explícitamente la expansión 2→5 (decisión +2), la forma mixta del guard (decisión 1), la identidad del commit (sin trailer de coautoría; disclosure +honesto de uso de IA con responsabilidad humana en el cuerpo del PR) y dio el visto bueno para abrir el PR. +Compañero de AILOG-2026-07-14-002. PR upstream: y-crdt/y-crdt#639. diff --git a/.straymark/charters/08-endurecer-decoder-yrs-r6.md b/.straymark/charters/08-endurecer-decoder-yrs-r6.md new file mode 100644 index 0000000..f4014da --- /dev/null +++ b/.straymark/charters/08-endurecer-decoder-yrs-r6.md @@ -0,0 +1,173 @@ +--- +charter_id: CHARTER-08-endurecer-decoder-yrs-r6 +status: closed +closed_at: 2026-07-15 +effort_estimate: M +trigger: "FU-014 (registrado 2026-07-14): el job `fuzz` de CHARTER-07 confirmó que el decoder de yrs amplifica memoria (R6) en la ruta CRUDA del FFI, NO capeada por FU-002 (relay). Investigación upstream (2026-07-14, ver [[yrs-decoder-r6-upstream]]): `Update::decode` YA usa `try_reserve`, pero `id_set.rs:91` (delete sets) y `state_vector.rs:120` (state vectors) siguen con `with_capacity` sin acotar — presente en 0.27.2 Y en la última 0.27.3. Decisión del operador: FU-014 = charter propio + colaboración upstream (PR de `try_reserve`)." +originating_spec: specs/001-weft-crdt-versioning/spec.md +work_verb: implement +design_provenance: new +--- + +# Charter: Endurecer el decoder de yrs contra amplificación de memoria (R6) + +> **Status (mirrored from frontmatter — source of truth is above):** closed. Effort: M. +> +> **Origin:** Follow-up **FU-014**, sobre la spec 001 (constitución **P-I/P-II**: frontera nativa segura / +> memoria verificada). Endurece la ruta **directa del FFI** ante la amplificación R6 que FU-002 (cerrado) +> mitigó solo en la capa de relay. **Colaboración upstream decidida**: PR de `try_reserve` a `y-crdt`. + +## Context + +**R6** es una amplificación de memoria del decoder de yrs (allocation-bomb): un update/state-vector malformado +de pocos bytes declara una longitud gigante y yrs hace `with_capacity(N)` sin acotar N contra los bytes +restantes. No es un fallo del formato CRDT — es una brecha de robustez del decoder, clase conocida. Repro: +`Update::decode_v1(&[255,255,255,122])`. + +La **investigación upstream (2026-07-14, ver `[[yrs-decoder-r6-upstream]]` en memoria)** precisó el estado real: +- Las **primitivas de bajo nivel** (`read_exact`/`read_buf`) **ya acotan** (zero-copy, error si `len > restante`). +- **`Update::decode` YA usa `try_reserve`** (asignación falible → error recuperable, no abort) — `update.rs:830,844`, + introducido upstream en el commit `b234ef4e` (2023-12). **Confirmado en nuestra 0.27.2.** Por eso el fuzz ve + `WEFT_ERR_DECODE` limpio en glibc, no un crash. +- **Residual** (sigue con `with_capacity` sin acotar, en 0.27.2 **y** en la última 0.27.3): `id_set.rs:91` + (decode de **delete sets** — alcanzable desde `apply_update`, los updates llevan delete set) y + `state_vector.rs:120` (decode de **state vectors** — alcanzable desde `weft_doc_export_since`). + > **Actualización (2026-07-14, durante la implementación):** una **revisión de completitud** del crate `yrs` + > (regla operativa: revisar más ancho que el cambio, para no enviar un fix que deja gemelos idénticos vivos) + > encontró que la clase real son **5 sitios**, no 2: además de los dos de arriba, `any.rs:63` (`Any::Map`), + > `any.rs:73` (`Any::Array`) y `sync/awareness.rs:560` (`AwarenessUpdate`, alcanzable desde presencia del + > relay). El PR upstream (a) los cubre los cinco. Ver **AIDEC-2026-07-14-001** decisión 2. +- **Severidad práctica**: en glibc (overcommit) incluso el residual → reserva virtual, RSS acotado, error de + decode limpio; el **abort** (`handle_alloc_error`, no capturable) solo en entornos memory-constrained duros + (cgroup/contenedor con límite) o allocators eager (ASan). → robustez/calidad, no un hueco urgente. + +Trabajo de **implementación** que tensa **P-I/P-II** (frontera nativa / memoria verificada) y **P-VI** (portabilidad +del comportamiento). La corrección **canónica** es upstream: extender el patrón `try_reserve` que yrs mismo ya +estableció (`b234ef4e`) a los dos sitios residuales — **termina su propia migración**, alta probabilidad de merge, +y beneficia a todo el ecosistema. Nuestro repo aporta la **prueba de regresión** (fuzz) y la **documentación** del +caveat de ingesta directa. + +## Scope + +**In scope (4 partes):** + +1. **(a) PR upstream a `y-crdt`**: mantener el fork **`StrangeDaysTech/y-crdt`**; PR contra `y-crdt/y-crdt` que + endurece la clase completa de allocation-bomb por prefijo de longitud, con tests upstream que ejerciten el + input adversarial. Deliverable: **PR abierto** (link en el AILOG/telemetría). Ejecuta la colaboración upstream + decidida. **Entregado: [#639](https://github.com/y-crdt/y-crdt/pull/639)** — **5 sitios** (ampliado de 2 por la + revisión de completitud, ver §Context y AIDEC-2026-07-14-001): `try_reserve(len)?` en `state_vector.rs:120`, + `any.rs:63` (`Any::Map`), `any.rs:73` (`Any::Array`) y `sync/awareness.rs:560` (`AwarenessUpdate`) → + variante `Error::NotEnoughMemory` existente; grow-on-push en `id_set.rs:91` (`SmallVec`; su `try_reserve` da + otro tipo de error → evita ampliar el enum público). 5 tests de regresión, suite del fork verde (377+34). +2. **(b) Doc del caveat**: nota de seguridad en `GOVERNANCE.md` §Seguridad (+ pointer breve en `README.md`): + la ruta **directa** del FFI (`weft_doc_load`/`apply_update`/`export_since`) ante bytes CRDT **no confiables** + debe protegerse con un cap de tamaño + límite de memoria del proceso, como hace el relay (FU-002); la ruta + `apply_update` ya está `try_reserve`-endurecida upstream. Ship-now, no espera al merge. +3. **(c) Fuzz de regresión**: nuevo target `cargo-fuzz` `native/weft-yrs-ffi/fuzz/fuzz_targets/export_since.rs` + (+ `[[bin]]` en `fuzz/Cargo.toml`) que alimenta bytes arbitrarios como **state vector** a + `weft_doc_export_since` → ejercita la ruta residual `state_vector::decode`; cableado al job `fuzz` (informativo) + de `ci.yml`. Documenta/rastrea el residual y **prueba** el fix cuando se adopte. (`apply_update.rs` ya cubre la + ruta delete-set vía update.) +4. **(d) Adopción del fix**: bump de yrs a la versión que incluya el fix (protocolo **R16**) — **diferido a un + follow-up nuevo (FU-015)** que dispara cuando upstream mergee + publique. **NO bloquea el cierre de este Charter.** + +**Out of scope:** + +- **El merge upstream y el bump de adopción (d)**: fuera de nuestro control (timeline de revisión de y-crdt). El + cierre de este Charter depende SOLO de nuestros entregables — ver §Charter Closure. FU-015 cubre la adopción. +- Tocar las primitivas lib0 de bajo nivel (`read.rs`) — ya acotan; no son el problema. +- FU-012 (client-id determinista, CHARTER-09), FU-006 (Loro nativo, CHARTER-10), FU-010 (durabilidad relay) — + charters/follow-ups aparte. +- Reescribir el decoder de yrs de nuestro lado (fork mantenido, no vendorizado): el fix vive upstream + bump. + +## Files to modify + + + +| File | Change | +|---|---| +| `native/weft-yrs-ffi/fuzz/fuzz_targets/export_since.rs` | New — fuzz target de la ruta residual `state_vector::decode` vía `weft_doc_export_since` (T-c) | +| `native/weft-yrs-ffi/fuzz/Cargo.toml` | Change — registrar el `[[bin]] export_since` (T-c) | +| `.github/workflows/ci.yml` | Change — añadir el target `export_since` al job `fuzz` (informativo) (T-c) | +| `GOVERNANCE.md` | Change — nota de seguridad: ingesta directa no confiable → cap + límite de memoria (T-b) | +| `README.md` | Change — pointer breve al caveat de seguridad (T-b) | +| `.straymark/follow-ups-backlog.md` | Change — FU-014 → `closed`; registrar **FU-015** (adopción vía bump R16) | +| `.straymark/07-ai-audit/agent-logs/AILOG-*.md` | New, `risk_level: medium` (frontera nativa P-I/P-II; input adversarial; PR upstream) | +| `.straymark/07-ai-audit/decisions/AIDEC-*.md` | New si emerge decisión sustantiva (forma del guard: `try_reserve` vs bound-vs-remaining) | + +**Externo (no en este repo):** PR a `y-crdt/y-crdt` desde el fork `StrangeDaysTech/y-crdt` — los dos sitios +upstream de yrs (id_set y state_vector, líneas confirmadas en el §Context) + tests upstream. Link en el +AILOG/telemetría. + +## Verification + +### Local checks + +```bash +# El fuzz de regresión compila y corre; el shim NO hace panic-through / UB (mismo invariante que doc_load). +cd native/weft-yrs-ffi && cargo +nightly fuzz build export_since +cargo +nightly fuzz run -s none export_since -- -max_total_time=60 -rss_limit_mb=0 -max_len=8192 # informativo (R6) + +# Suite completa intacta + ASan sin fugas (P-II) sobre la suite determinista. +cd ../.. && dotnet test Weft.sln -c Release + +# El PR upstream: en el fork, la suite de yrs pasa con el fix + el nuevo test adversarial. +# (cd /y-crdt && cargo test -p yrs) # se corre en el fork, no en este repo. +``` + +### Production smoke (after deploy) + +No aplica — librería sin despliegue. Los auditores externos deben saltar esta sección. + +## Risks + +- **R1 — El merge upstream no llega (o tarda / se rechaza)**: severidad **media**. El fix canónico vive en y-crdt, + cuyo timeline no controlamos. Mitigación: el **cierre de este Charter NO depende del merge** — cierra con + nuestros entregables (fuzz + doc + PR **enviado**); la adopción es **FU-015** (dispara al publicarse). Si el PR + se rechaza: reevaluar un guard de nuestro lado (pre-validación en el shim) como plan B, registrado como riesgo + emergente. Nada regresiona mientras tanto (comportamiento actual = error limpio en glibc). +- **R2 — El fuzz de regresión no ejercita realmente el residual**: severidad **media**. Si el target no alcanza + `state_vector::decode`, no prueba nada. Mitigación: `weft_doc_export_since` decodifica el SV vía + `StateVector::decode` (verificado, CHARTER-01); el target alimenta el SV crudo. Validar que un input tipo + `[255,255,255,122]` dispara la ruta (RSS medido / error de decode). +- **R3 — Sobre-estimar la severidad y alarmar innecesariamente en la doc**: severidad **baja**. En glibc es error + limpio, no crash. Mitigación: la nota de doc calibra honesto (afecta hosts memory-constrained; `apply_update` + ya endurecido; el relay ya capea). No es un CVE nuestro. +- **R4 — Divergir del fork upstream**: severidad **baja**. Mantener `StrangeDaysTech/y-crdt` como fork implica + rebase periódico. Mitigación: el fork es solo para el PR (no vendorizamos); tras el merge, volvemos a consumir + yrs de crates.io vía el bump. Documentar el propósito del fork en su README. + +## Tasks + +1. Sync main, branch `charter/08-yrs-decoder-hardening` (**ya sobre `chore/fu-014-register`** → pliega el registro + de FU-014). Flip `declared` → `in-progress` al empezar. +2. Re-evaluar **Constitution Check**: **P-I/P-II** (frontera nativa / memoria — el fix es upstream + nuestra prueba + de regresión), **P-VI** (comportamiento portable). Sin violaciones esperadas. +3. **(c)** Escribir `export_since.rs` + registrar en `fuzz/Cargo.toml` + cablear a `ci.yml`; validar que ejercita + el residual localmente. +4. **(a)** Crear/actualizar el fork `StrangeDaysTech/y-crdt`; rama con el fix (`try_reserve` en `id_set.rs:91` + + `state_vector.rs:120`) + tests upstream; abrir PR contra `y-crdt/y-crdt`. Guardar el link. +5. **(b)** Nota de seguridad en `GOVERNANCE.md` + pointer en `README.md`. +6. **(d)** Registrar **FU-015** (adopción vía bump R16) en el backlog; NO ejecutarlo aquí. +7. **AILOG** (`risk_level: medium`, `review_required: true`) con el link del PR upstream. **AIDEC** si la forma del + guard amerita decisión. +8. Cerrar FU-014 en el backlog + `recount`. Verificación local completa. +9. `straymark charter drift CHARTER-08` (los `.rs`/`.toml`/`.yml`/`.md` pueden dar FP del parser #354 — documentar). + Commit + push + PR contra `main`; CI verde. + +## Charter Closure + +Charter con un **entregable externo** (PR upstream) cuyo merge NO controlamos → su cierre depende **solo de +nuestros entregables**. **No cierra hito** (no requiere auditoría externa multi-modelo). Al cerrar: + +1. Confirmar entregados: **(c)** fuzz de regresión verde/informativo, **(b)** doc, **(a)** PR upstream **abierto** + (link en telemetría), **(d)** FU-015 registrado. El merge + bump (adopción) es **FU-015**, fuera de scope. +2. Actualización atómica del Charter (format v4) si el drift reveló divergencias, mismo PR. +3. `straymark charter drift CHARTER-08 --range origin/main..HEAD` → limpio o documentado (incl. FP del parser #354). +4. `straymark charter close CHARTER-08` (telemetría con el link del PR upstream). No borrar este archivo. +5. Confirmar el backlog: **FU-014 `closed`**, **FU-015 `open`** (adopción). Siguiente en la secuencia: + **CHARTER-09 (FU-012, client-id determinista)** → **CHARTER-10 (FU-006, Loro nativo)**; FU-010 diferido. diff --git a/.straymark/charters/CHARTER-08.telemetry.yaml b/.straymark/charters/CHARTER-08.telemetry.yaml new file mode 100644 index 0000000..3a3598d --- /dev/null +++ b/.straymark/charters/CHARTER-08.telemetry.yaml @@ -0,0 +1,98 @@ +# StrayMark Charter telemetry — fill at Charter close. +# +# Schema: .straymark/schemas/charter-telemetry.schema.v0.json +# Conceptual: Propuesta/straymark-charter-telemetry.md (in the upstream repo) +# Storage path: .straymark/charters/CHARTER-08.telemetry.yaml +# +# Fill time target: 5-10 minutes per Charter. The point is signal, not exhaustiveness — +# leave a field blank or omit it if you don't have honest data for it. Honest gaps +# are more useful than invented numbers. + +charter_telemetry: + # ---------- Identification ---------- + charter_id: "CHARTER-08" + charter_title: "Endurecer el decoder de yrs contra amplificación de memoria (R6)" + closed_at: "2026-07-15" + + # ---------- Origin & activation ---------- + originating_ailogs: + - ailog_id: "AILOG-2026-07-14-002" + still_relevant_at_execution: true + relevance_notes: "AILOG de ejecución del Charter. El Charter se originó de FU-014 (annotations del job fuzz de CHARTER-07) + AILOG-2026-07-10-001 §R6; la investigación upstream en memoria ([[yrs-decoder-r6-upstream]]) fue el input de contexto principal — precisa pero incompleta (contó 2 sitios residuales, la revisión de completitud encontró 5)." + + trigger: + declared_kind: "event_trigger" + declared_description: "FU-014 (registrado 2026-07-14): el job fuzz de CHARTER-07 confirmó que el decoder de yrs amplifica memoria (R6) en la ruta CRUDA del FFI, NO capeada por FU-002 (relay). Decisión del operador: FU-014 = charter propio + colaboración upstream (PR de try_reserve)." + fired_at: "2026-07-14" + fire_clarity: "manually_decided" + fire_clarity_notes: "El operador decidió promover FU-014 a Charter propio con colaboración upstream (PR de try_reserve) en vez de un guard local — decisión registrada en el trigger del Charter." + + # ---------- Pre-work ---------- + pre_work: + items_declared: 1 + items_completed_before_planning: 1 + items_skipped: 0 + items_discovered_during_planning: 0 + pre_work_quality: "medium" + pre_work_notes: "Reconnaissance #210 confirmó la estructura de fuzz de weft-yrs-ffi, la existencia de GOVERNANCE.md/README.md, y los sitios upstream id_set.rs:91/state_vector.rs:120 vía GitHub API (0.27.2 y 0.27.3). Sólido en lo declarado, pero la investigación upstream previa UNDERCOUNTÓ la clase: contó 2 sitios residuales cuando la barrida de completitud durante la ejecución encontró 5 (faltaban any.rs Map/Array y awareness.rs). Ese gap es el datum: revisar solo la firma/línea de las funciones reportadas, sin barrer la clase completa en el crate, deja gemelos idénticos sin ver." + + # ---------- Planning session (if any) ---------- + planning_session: + occurred: false + duration_minutes: 0 + participants: 0 + decisions_made: 0 + decisions_deferred: 0 + notes: "" + + # ---------- Effort ---------- + effort: + started_at: "2026-07-14" + finished_at: "2026-07-15" + estimated_effort: "M (~1.5h)" + actual_effort: "L (~3h)" + estimation_drift_factor: 2.0 + estimation_drift_reason: "La revisión de completitud amplió el PR upstream de 2 a 5 sitios (más código + 3 tests extra + suite del fork re-corrida) y la revisión interactiva del operador (alcance del PR, forma del guard, identidad del commit / disclosure de IA, visibilidad del email proxy) añadió idas y vueltas legítimas antes del envío. Ninguna sorpresa técnica — el guard es un one-liner por sitio; el tiempo se fue en hacerlo completo y bien revisado." + + # ---------- Agent quality during execution ---------- + agent_quality: + sessions_count: 1 + hallucinations_caught: 0 + hallucination_categories: [] + decisions_contradicting_prior_adrs: 0 + contradiction_notes: "" + context_loaded_was_sufficient: true + additional_context_loaded_manually: 0 + r_n_plus_one_emergent_count: 1 + skill_prompts_used: [] + + # ---------- External audit ---------- + # No aplica: Charter que no cierra hito → sin auditoría externa multi-modelo obligatoria. + external_audit: [] + + # ---------- Outcome & follow-ups ---------- + outcome: + completed_as_planned: true + scope_changes: "menor" + scope_change_notes: "F1 (completeness): el PR upstream (a) se amplió de 2 a 5 sitios — una revisión de completitud del crate yrs (regla operativa del operador: revisar más ancho que el cambio para no dejar gemelos idénticos vivos) encontró 3 sitios más de la misma clase de allocation-bomb por prefijo de longitud (any.rs Map+Array, awareness.rs) además de los 2 declarados (id_set, state_vector). Misma clase, mismo deliverable; reconciliado en §Context/§Scope del Charter + AIDEC-2026-07-14-001 decisión 2. Los otros 3 entregables (b/c/d) entregados como se planeó." + new_followups_generated: 1 + new_charters_created: 0 + charters_invalidated: 0 + associated_stage_id: "M3" + + # ---------- Qualitative (where insight usually lives) ---------- + qualitative: + format_iteration: "v4" + friction_points: + - "SmallVec::try_reserve devuelve el error propio de smallvec (CollectionAllocErr), no std::TryReserveError → no enchufa por ? en la variante Error::NotEnoughMemory de yrs; forzó grow-on-push en id_set en vez de try_reserve uniforme (AIDEC decisión 1). Sin impacto de comportamiento, pero rompe la simetría del fix." + - "El push al fork externo se rechazó por la restricción de privacidad de email de GitHub (email real en el commit); resuelto con el email noreply del usuario tras --amend --reset-author." + - "El job test (macos-latest) hizo timeout a los 25 min en 'Test .NET' (hang de infraestructura del runner, no un test roto — Ubuntu/Windows verdes, sin cambios .NET); resuelto con rerun (pasó en 1m8s)." + - "El --force-with-lease al fork falló con 'stale info' (la ref de tracking no se refrescó con fetch a FETCH_HEAD); resuelto con --force directo (rama de autoría única)." + wins: + - "La revisión de completitud atrapó 3 sitios idénticos ANTES de enviar un fix de clase incompleto (2 de 5). El PR upstream arregla la clase entera de una — más honesto y mergeable." + - "El fuzz de regresión probó empíricamente que alcanza el residual: seed [255,255,255,122] → RSS ~553 MB (vs ~34 MB baseline), error limpio (exit 0, sin abort) en glibc. Mitiga R2 del Charter con evidencia medida, no argumentación." + - "El PR enmarca el fix como 'terminar la migración try_reserve que yrs mismo empezó en b234ef4e' → alta probabilidad de merge; ofrece explícitamente la alternativa simétrica (From) para dejar la decisión de API al maintainer." + - "Identidad del commit del PR externo calibrada al contexto: sin trailer de coautoría (rechazable en muchos repos) + disclosure honesto de uso de IA con responsabilidad humana; trailer conservado en el commit interno (convención de este repo)." + overall_satisfaction: 5 + would_repeat_format: true + proposed_format_changes: "La regla 'revisar más ancho que el cambio' (barrer la clase completa en el crate, no solo las funciones reportadas) probó su valor concreto aquí (2→5 sitios) y merecería ser un ítem explícito de §Pre-trabajo o §Verification para Charters que envían fixes upstream o tocan una clase de bug repetida." diff --git a/.straymark/follow-ups-backlog.md b/.straymark/follow-ups-backlog.md index 5aca3d3..3b6f7b1 100644 --- a/.straymark/follow-ups-backlog.md +++ b/.straymark/follow-ups-backlog.md @@ -1,9 +1,9 @@ --- -last_scan: 2026-07-10 +last_scan: 2026-07-14 schema_version: v1 -total_open: 3 +total_open: 4 total_promoted: 0 -total_closed_in_session: 10 +total_closed_in_session: 11 total_phase_blocked: 0 total_suspected_closed: 0 buckets: @@ -15,6 +15,7 @@ buckets: fully_extracted_ailogs: - AILOG-2026-07-10-001 - AILOG-2026-07-10-002 + - AILOG-2026-07-14-002 --- # Follow-ups Backlog @@ -94,6 +95,15 @@ fully_extracted_ailogs: ## Bucket: charter-triggered +### FU-015 — adopción del fix de R6 vía bump de yrs (protocolo R16) +- **Origin**: AILOG-2026-07-14-002 §Follow-ups · CHARTER-08 (d) · PR upstream y-crdt/y-crdt#639 +- **Source-hash**: 72ad54b51cf2 +- **Status**: open +- **Trigger**: when el PR upstream #639 se mergee y publique en un release de crates.io +- **Destination**: chore +- **Cost**: S +- **Notes**: Bump `yrs = "=0.27.x"` en `native/weft-yrs-ffi/Cargo.toml` (+ el crate de fuzz) a la versión con el fix de R6; re-correr el fuzz `export_since` (debe pasar a RSS acotado, probando el fix) y revertir el fork `StrangeDaysTech/y-crdt` (volver a consumir yrs de crates.io). No bloquea el cierre de CHARTER-08 (entregable diferido por diseño, fuera de nuestro control: timeline de revisión de y-crdt). + ### FU-002 — R6 (CHARTER-01): hardening del decoder ante amplificación de memoria (DoS) - **Origin**: AILOG-2026-07-10-001 §R6 (new, not in Charter) - **Source-hash**: 69e431c0f7d9 @@ -143,6 +153,14 @@ fully_extracted_ailogs: - **Cost**: XS - **Notes**: El dry-run anotó "Node.js 20 is deprecated" para `actions/checkout@v4`, `actions/setup-node@v4`, `actions/upload-artifact@v4`, `mlugg/setup-zig@v2` (forzados a Node 24). Bump a `@v5`/equivalentes en `.github/workflows/{ci.yml,release.yml,docs-validation.yml}` cuando toque. También aviso informativo: `macos-latest` migra a macOS 26 el 2026-06-15 (revisar el runner de `native (osx-arm64)` / pack-smoke). Cosmético hoy; ningún gate depende. **CERRADO 2026-07-14 (AILOG-2026-07-14-001)**: familia `actions/*` → `@v5` (checkout/setup-node/setup-dotnet/upload-artifact/download-artifact) + `setup-qemu-action@v4` en ci.yml + release.yml (cla.yml/docs-validation.yml ya estaban en v5). `mlugg/setup-zig@v2` y `rust-cache@v2` se dejan (última major). `macos-latest` se deja (migración ya pasada, dry-run verde en él). +### FU-014 — endurecer la ruta directa (no-relay) ante amplificación de memoria del decoder de yrs (R6) +- **Origin**: CHARTER-07 (annotations del job `fuzz`, run de PR #23) · AILOG-2026-07-10-001 §R6 · complementa FU-002 (cerrado, mitigación de capa de relay) — registro hand-add + recount (§13) +- **Status**: closed +- **Trigger**: antes de hacer el repo público, o cuando un consumidor ingiera bytes/updates CRDT no confiables fuera del relay +- **Destination**: mini-charter +- **Cost**: M +- **Notes**: FU-002 (cerrado) mitigó R6 en la **capa de relay** (cap de tamaño de mensaje + recursos antes de decodificar). El job `fuzz` (informativo, no-bloqueante) ejercita la ruta **cruda del FFI** (`weft_doc_load`/`weft_doc_apply_update`), que **NO** está capeada por FU-002: un consumidor que alimente bytes no confiables **directamente** a la API pública (fuera del relay) podría disparar la amplificación de memoria del decoder de yrs (`with_capacity(N)` sin cota → `handle_alloc_error`, no capturable por `catch_unwind`). El shim es memory-safe (ASan verde); la amplificación es **upstream yrs**. Dos partes: **(a) [XS]** nota de seguridad en `GOVERNANCE.md`/README — "si ingieres updates/blobs CRDT no confiables fuera del relay, aplica un cap de tamaño como hace el servidor"; **(b) [M]** evaluar bump de yrs con validación de longitud (protocolo R16) o un guard de tamaño a nivel FFI en `weft_doc_load`/`apply_update`. Ningún gate depende hoy; no urge (no se publica ya). Abordar junto con FU-006/FU-010/FU-012 (mini-charters). + ## Bucket: phase-blocked ## Bucket: operational diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 24d279f..96255d5 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -37,6 +37,22 @@ Reporta vulnerabilidades de forma privada (no en un issue público) al contacto [Strange Days Tech](https://strangedays.tech/es). La memoria nativa se verifica con ASan/LSan en CI (P-II) y la frontera FFI se fuzzea (`cargo-fuzz`); el input de red no confiable del relay tiene límites de tamaño/recursos. +### Ingesta directa de bytes CRDT no confiables (caveat R6) + +El **relay** (`Weft.Server`) ya protege la ingesta de red: cap configurable de tamaño de mensaje y límites de +recursos por conexión antes de decodificar (ver `WeftServerOptions`). Si en cambio alimentas bytes CRDT **no +confiables directamente** a la API pública fuera del relay — `weft_doc_load` / `apply_update` / `export_since`, +o sus envoltorios en `Weft.Core` — replica esa defensa: **impón un cap de tamaño de entrada y un límite de +memoria del proceso** (p. ej. cgroup/contenedor). + +Motivo: el decoder de `yrs` puede amplificar memoria (allocation-bomb) — pocos bytes que declaran una longitud +gigante disparan una reserva grande. `Update::decode` ya usa asignación falible (`try_reserve` → error +recuperable, no abort), por lo que `apply_update` está endurecido upstream; quedan dos sitios residuales con +`with_capacity` sin acotar (decode de *delete sets* y de *state vectors*, este último alcanzable vía +`export_since`). En `glibc` (overcommit) el efecto práctico es una reserva virtual y un **error de decode limpio**, +no un crash; el `abort` no capturable solo aparece en hosts memory-constrained duros o allocators eager. El fix +canónico vive upstream (PR de `try_reserve` a `y-crdt`); un target de fuzz de regresión rastrea el residual. + ## Licencia [Apache-2.0](./LICENSE) — permisiva, con concesión explícita de patentes. Recíproca con los motores MIT sobre diff --git a/README.md b/README.md index 0146490..3214393 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,13 @@ Spec-driven, con [GitHub Spec Kit](https://github.com/github/spec-kit): **Spec Toolchain: Rust (con `yrs` pinneado) · .NET SDK 10 (LTS) · empaquetado nativo por RID (Linux/Windows/macOS, x64/arm64). +## Seguridad + +El relay (`Weft.Server`) capea el input de red no confiable. Si ingieres bytes CRDT **no confiables directamente** +por la API (`weft_doc_load` / `apply_update` / `export_since`) fuera del relay, aplica un cap de tamaño y un límite +de memoria del proceso — el decoder de `yrs` puede amplificar memoria. Detalle y reporte de vulnerabilidades: +[GOVERNANCE.md § Seguridad](./GOVERNANCE.md#seguridad). + ## Licencia [Apache-2.0](./LICENSE) © 2026 [Strange Days Tech](https://strangedays.tech/es). Librería permisiva con concesión explícita de patentes; reciproca a los motores MIT sobre los que se apoya (`yrs`, Loro). diff --git a/native/weft-yrs-ffi/fuzz/Cargo.toml b/native/weft-yrs-ffi/fuzz/Cargo.toml index 6a348ce..a0633da 100644 --- a/native/weft-yrs-ffi/fuzz/Cargo.toml +++ b/native/weft-yrs-ffi/fuzz/Cargo.toml @@ -28,6 +28,13 @@ test = false doc = false bench = false +[[bin]] +name = "export_since" +path = "fuzz_targets/export_since.rs" +test = false +doc = false +bench = false + # panic=unwind (no el abort default de cargo-fuzz): el contrato del shim es que `catch_unwind` # convierte cualquier panic del motor en WEFT_ERR_PANIC en la frontera (P-I). El fuzz debe # ejercitar ese mismo modo que producción; con panic=abort, catch_unwind sería un no-op y un panic diff --git a/native/weft-yrs-ffi/fuzz/fuzz_targets/export_since.rs b/native/weft-yrs-ffi/fuzz/fuzz_targets/export_since.rs new file mode 100644 index 0000000..7f9613a --- /dev/null +++ b/native/weft-yrs-ffi/fuzz/fuzz_targets/export_since.rs @@ -0,0 +1,47 @@ +//! Fuzz target: `weft_doc_export_since` con un state vector arbitrario (regresión R6). +//! +//! Ejercita la ruta RESIDUAL `state_vector::decode` (`yrs/src/state_vector.rs:120`, +//! `HashMap::with_capacity(len)` sin acotar) — la única brecha de amplificación de memoria de +//! yrs que NO cubre el `try_reserve` ya presente en `Update::decode`. `weft_doc_export_since` +//! decodifica el SV crudo vía `StateVector::decode_v1` antes de calcular el delta, así que un SV +//! adversarial (`[255,255,255,122]`: 4 bytes que declaran una longitud gigante) llega directo al +//! sitio residual. +//! +//! Invariante: ningún input cruza la frontera como panic ni UB — el shim lo contiene como código +//! de error (`WEFT_ERR_DECODE` en glibc por overcommit; el `abort` de `handle_alloc_error` solo en +//! hosts memory-constrained duros / allocators eager). Informativo hasta que se adopte el fix +//! upstream (`try_reserve`, FU-015); prueba la regresión cuando el bump aterrice. +#![no_main] + +use std::ptr; +use std::sync::Once; + +use libfuzzer_sys::fuzz_target; +use weft_yrs_ffi::*; +use yrs::Doc; + +static INIT: Once = Once::new(); + +fuzz_target!(|data: &[u8]| { + // Ver doc_load.rs: silenciamos el hook de libfuzzer-sys que aborta en panic, para ejercitar + // el catch_unwind del shim como en producción. Un SIGSEGV/UB real sigue detectándose. + INIT.call_once(|| std::panic::set_hook(Box::new(|_| {}))); + + unsafe { + let mut doc: *mut Doc = ptr::null_mut(); + if weft_doc_new(&mut doc) != WEFT_OK || doc.is_null() { + return; + } + let mut out_ptr: *mut u8 = ptr::null_mut(); + let mut out_len: usize = 0; + // `data` es el state vector crudo → `StateVector::decode_v1` (ruta residual R6). El código + // de retorno es irrelevante para el fuzzer; lo que importa es que no haya UB. + let code = weft_doc_export_since(doc, data.as_ptr(), data.len(), &mut out_ptr, &mut out_len); + // En éxito el shim entregó un buffer nativo: liberarlo con weft_buf_free (ASan detectaría + // fugas si no; el GC jamás toca esta memoria). + if code == WEFT_OK && !out_ptr.is_null() { + weft_buf_free(out_ptr, out_len); + } + weft_doc_free(doc); + } +});