//! Checkpoints atômicos com prova de integridade criptográfica (v0.10.0). //! //! **Crítica Qwen3.8 lote 3, nº 3 ("Checkpointing Não-Atômico") — ACEITA //! COM CORREÇÕES (r-35 §4).** A proposta original tinha três falhas: //! //! 1. **Destruição do estado anterior:** `File::create(caminho_pesos)` //! trunca o checkpoint ANTERIOR antes de escrever o novo — uma queda //! de energia no meio da escrita perde AMBOS. A atomicidade real exige //! escrita em `.tmp` + `rename(2)` (o POSIX garante troca atômica). //! 2. **Manifesto sem barreira:** `fs::write(caminho_meta, ...)` sem //! `sync_all` — o manifesto pode não atingir o disco quando os pesos //! já atingiram (ou vice-versa), violando a própria ordenação alegada. //! 3. **Dependência nova (bincode) + `unwrap()`** em caminho de I/O. //! //! Esta implementação: SHA-256 **puro em Rust** (FIPS 180-4, sem crates //! externas — crates.io inacessível no ambiente), escrita tmp→fsync→ //! rename→fsync(dir) para AMBOS os arquivos, e manifesto lado-a-lado //! `{caminho}.manifest.json` com hash + tamanho. //! //! **Teo CP-1 (atomicidade da troca).** `rename(2)` é atômico em relação //! a leitores concorrentes e crashes: em qualquer instante o caminho //! resolve OU para o arquivo antigo OU para o novo, nunca uma mistura. //! Logo um crash em qualquer ponto deixa o caminho final = checkpoint //! antigo (crash antes do rename) ou novo (depois). ∎ (garantia POSIX) //! //! **Teo CP-2 (durabilidade ordenada).** Com a disciplina //! `write(W.tmp) → fsync(W.tmp) → rename(W) → write(M.tmp) → //! fsync(M.tmp) → rename(M) → fsync(dir)`, após reboot: se `M` existe, //! então o rename de `W` foi durável (executado e journalizado antes) e //! os blocos de dados de `W` foram forçados por `fsync(W.tmp)` — portanto //! `SHA-256(W) = M.hash` (a menos de colisão, CP-3). Se `M` não existe, //! o estado é tratado como INVÁLIDO (não silenciosamente errado). ∎ //! //! **Teo CP-3 (detecção de corrupção).** Para W' ≠ W com |W'| = |W|, //! `P[SHA-256(W') = SHA-256(W)] ≤ 2^-256` (ataque direto) e, para //! qualquer par distinto, ≤ 2^-128 (limite de aniversário). Truncamentos //! e gravações parciais são capturados DETERMINISTICAMENTE pelo campo //! `tamanho`. Nota honesta: a alegação original "matematicamente //! idêntico" é exagerada — o correto é indistinguibilidade computacional //! com probabilidade de colisão ≤ 2^-128. ∎ //! //! **Compatibilidade (regra da casa: nada é removido):** checkpoints da //! v0.9.0 (sem manifesto) continuam carregáveis pelos `carregar` legados; //! `carregar_verificado` exige manifesto e recusa o contrário. use candle_core::Result; use std::fs::File; use std::io::Write; use std::path::Path; pub const VERSAO_CHECKPOINT: &str = "0.10.0"; // --------------------------------------------------------------------------- // SHA-256 — FIPS 180-4, implementação autocontida (sem dependências) // --------------------------------------------------------------------------- const K_SHA256: [u32; 64] = [ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, ]; /// SHA-256 de `dados` — 32 bytes (FIPS 180-4 §6.2.2). pub fn sha256(dados: &[u8]) -> [u8; 32] { let mut h: [u32; 8] = [ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, ]; // padding: 0x80, zeros, comprimento de bit big-endian em 64 bits let bitlen = (dados.len() as u64).wrapping_mul(8); let mut msg = dados.to_vec(); msg.push(0x80); while msg.len() % 64 != 56 { msg.push(0); } msg.extend_from_slice(&bitlen.to_be_bytes()); for bloco in msg.chunks_exact(64) { let mut w = [0u32; 64]; for (i, j) in (0..64).step_by(4).enumerate() { w[i] = u32::from_be_bytes([bloco[j], bloco[j + 1], bloco[j + 2], bloco[j + 3]]); } for t in 16..64 { let s0 = w[t - 15].rotate_right(7) ^ w[t - 15].rotate_right(18) ^ (w[t - 15] >> 3); let s1 = w[t - 2].rotate_right(17) ^ w[t - 2].rotate_right(19) ^ (w[t - 2] >> 10); w[t] = w[t - 16] .wrapping_add(s0) .wrapping_add(w[t - 7]) .wrapping_add(s1); } let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) = (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]); for t in 0..64 { let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); let ch = (e & f) ^ ((!e) & g); let t1 = hh .wrapping_add(s1) .wrapping_add(ch) .wrapping_add(K_SHA256[t]) .wrapping_add(w[t]); let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); let maj = (a & b) ^ (a & c) ^ (b & c); let t2 = s0.wrapping_add(maj); hh = g; g = f; f = e; e = d.wrapping_add(t1); d = c; c = b; b = a; a = t1.wrapping_add(t2); } h[0] = h[0].wrapping_add(a); h[1] = h[1].wrapping_add(b); h[2] = h[2].wrapping_add(c); h[3] = h[3].wrapping_add(d); h[4] = h[4].wrapping_add(e); h[5] = h[5].wrapping_add(f); h[6] = h[6].wrapping_add(g); h[7] = h[7].wrapping_add(hh); } let mut saida = [0u8; 32]; for (i, x) in h.iter().enumerate() { saida[i * 4..i * 4 + 4].copy_from_slice(&x.to_be_bytes()); } saida } /// SHA-256 em hexadecimal minúsculo (64 caracteres). pub fn sha256_hex(dados: &[u8]) -> String { sha256(dados).iter().map(|b| format!("{b:02x}")).collect() } // --------------------------------------------------------------------------- // salva / carrega atômicos // --------------------------------------------------------------------------- /// Caminho do manifesto lado-a-lado de `caminho`. pub fn caminho_manifesto(caminho: &str) -> String { format!("{caminho}.manifest.json") } fn err_io(e: std::io::Error, ctx: &str) -> candle_core::Error { candle_core::Error::Msg(format!("{ctx}: {e}")) } /// fsync de um diretório (durabilidade do rename — POSIX). Unix apenas; /// em outras plataformas é no-op documentado. fn fsync_dir(dir: &Path) -> std::io::Result<()> { #[cfg(unix)] { let d = File::open(dir)?; d.sync_all()?; } #[cfg(not(unix))] { let _ = dir; } Ok(()) } /// Escrita atômica de `conteudo` em `caminho` com fsync + rename e /// manifesto `{caminho}.manifest.json` (hash SHA-256 + tamanho). /// /// Ordem (Teo CP-2): write(tmp) → fsync(tmp) → rename(final) para o /// conteúdo e para o manifesto; fsync(dir) ao final. pub fn salvar_atomico(caminho: &str, conteudo: &str) -> Result<()> { let dados = conteudo.as_bytes(); let hash = sha256_hex(dados); let tmp = format!("{caminho}.tmp"); // 1) conteúdo: tmp → fsync → rename (Teo CP-1: troca atômica) { let mut f = File::create(&tmp).map_err(|e| err_io(e, "checkpoint: criar tmp"))?; f.write_all(dados).map_err(|e| err_io(e, "checkpoint: escrever tmp"))?; f.sync_all().map_err(|e| err_io(e, "checkpoint: fsync tmp"))?; } std::fs::rename(&tmp, caminho).map_err(|e| err_io(e, "checkpoint: rename"))?; // 2) manifesto: mesma disciplina (a proposta original usava fs::write // simples — sem barreira, o manifesto pode perder-se sozinho) let manifest = serde_json::json!({ "versao": VERSAO_CHECKPOINT, "algoritmo": "sha256", "hash_sha256": hash, "tamanho": dados.len(), }); let mtxt = serde_json::to_string(&manifest) .map_err(|e| candle_core::Error::Msg(format!("serde: {e}")))?; let mtmp = format!("{}.manifest.json.tmp", caminho); { let mut f = File::create(&mtmp).map_err(|e| err_io(e, "manifesto: criar tmp"))?; f.write_all(mtxt.as_bytes()).map_err(|e| err_io(e, "manifesto: escrever tmp"))?; f.sync_all().map_err(|e| err_io(e, "manifesto: fsync tmp"))?; } std::fs::rename(&mtmp, caminho_manifesto(caminho)) .map_err(|e| err_io(e, "manifesto: rename"))?; // 3) durabilidade dos renames (POSIX: rename journalizado no dir) if let Some(pai) = Path::new(caminho).parent() { fsync_dir(pai).map_err(|e| err_io(e, "checkpoint: fsync dir"))?; } Ok(()) } /// Lê `caminho` verificando o manifesto (hash + tamanho). Recusa /// manifesto ausente ou divergente — Teo CP-3. pub fn carregar_verificado(caminho: &str) -> Result { let dados = std::fs::read(caminho).map_err(|e| err_io(e, "checkpoint: ler"))?; let mtxt = std::fs::read_to_string(caminho_manifesto(caminho)) .map_err(|_| candle_core::Error::Msg(format!( "checkpoint sem manifesto (legado v0.9.0?): {caminho}")))?; let m: serde_json::Value = serde_json::from_str(&mtxt) .map_err(|e| candle_core::Error::Msg(format!("manifesto ilegível: {e}")))?; let hash_esp = m["hash_sha256"].as_str().unwrap_or(""); let tam_esp = m["tamanho"].as_u64().unwrap_or(u64::MAX); let hash_rec = sha256_hex(&dados); if (dados.len() as u64) != tam_esp { candle_core::bail!( "checkpoint corrompido: tamanho {} ≠ manifesto {} ({caminho})", dados.len(), tam_esp); } if hash_rec != hash_esp { candle_core::bail!( "checkpoint corrompido: SHA-256 divergente ({caminho})"); } String::from_utf8(dados) .map_err(|e| candle_core::Error::Msg(format!("utf8: {e}"))) } /// Verificação sem exceção (para uso em Python/CLI antes de resume). pub fn validar(caminho: &str) -> bool { carregar_verificado(caminho).is_ok() } #[cfg(test)] mod testes_checkpoint { use super::*; fn tmp_dir(nome: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("khtst_cp_{nome}_{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d } /// CP-0a — SHA-256 contra os vetores oficiais FIPS 180-4. #[test] fn cp0a_sha256_vetores_fips() { assert_eq!( sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); assert_eq!( sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); assert_eq!( sha256_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"); // vetor de 4 blocos (256 bytes) — exercita o laço de blocos let longo = "abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmn".repeat(4); assert_eq!(sha256_hex(longo.as_bytes()).len(), 64); } /// CP-1a — salvar produz final + manifesto; tmp não permanece; /// carregar_verificado devolve o conteúdo bit-a-bit. #[test] fn cp1a_salvar_carregar_roundtrip() { let d = tmp_dir("rt"); let p = d.join("ck.json"); salvar_atomico(p.to_str().unwrap(), "{\"v0\":[1.0,2.0]}").unwrap(); assert!(p.exists()); assert!(d.join("ck.json.manifest.json").exists()); assert!(!d.join("ck.json.tmp").exists()); assert!(!d.join("ck.json.manifest.json.tmp").exists()); assert_eq!(carregar_verificado(p.to_str().unwrap()).unwrap(), "{\"v0\":[1.0,2.0]}"); let _ = std::fs::remove_dir_all(&d); } /// CP-3a — adulteração de 1 byte é detectada (hash divergente). #[test] fn cp3a_adulteracao_detectada() { let d = tmp_dir("t"); let p = d.join("ck.json"); salvar_atomico(p.to_str().unwrap(), "conteudo original").unwrap(); std::fs::write(&p, "conteudo originalX").unwrap(); assert!(carregar_verificado(p.to_str().unwrap()).is_err()); assert!(!validar(p.to_str().unwrap())); let _ = std::fs::remove_dir_all(&d); } /// CP-3b — truncamento detectado pelo tamanho (determinístico). #[test] fn cp3b_truncamento_detectado() { let d = tmp_dir("tr"); let p = d.join("ck.json"); salvar_atomico(p.to_str().unwrap(), "0123456789").unwrap(); std::fs::write(&p, "0123").unwrap(); assert!(carregar_verificado(p.to_str().unwrap()).is_err()); let _ = std::fs::remove_dir_all(&d); } /// CP-4a — manifesto ausente ⇒ recusa (estado legado é do `carregar`). #[test] fn cp4a_manifesto_ausente_recusado() { let d = tmp_dir("leg"); let p = d.join("ck.json"); salvar_atomico(p.to_str().unwrap(), "x").unwrap(); std::fs::remove_file(d.join("ck.json.manifest.json")).unwrap(); assert!(carregar_verificado(p.to_str().unwrap()).is_err()); let _ = std::fs::remove_dir_all(&d); } /// CP-1b — falha de escrita PRESERVA o checkpoint anterior /// (atomicidade prática: dir somente-leitura impede o create do tmp). #[test] fn cp1b_falha_preserva_anterior() { let d = tmp_dir("fa"); let p = d.join("ck.json"); salvar_atomico(p.to_str().unwrap(), "estado_bom").unwrap(); // dir somente-leitura: File::create(tmp) falha let mut perms = std::fs::metadata(&d).unwrap().permissions(); #[allow(clippy::unnecessary_cast)] { use std::os::unix::fs::PermissionsExt; perms.set_mode(0o555); } std::fs::set_permissions(&d, perms).unwrap(); let r = salvar_atomico(p.to_str().unwrap(), "estado_novo"); let mut perms2 = std::fs::metadata(&d).unwrap().permissions(); #[allow(clippy::unnecessary_cast)] { use std::os::unix::fs::PermissionsExt; perms2.set_mode(0o755); } std::fs::set_permissions(&d, perms2).unwrap(); assert!(r.is_err(), "escrita deveria falhar em dir somente-leitura"); // o anterior permanece ÍNTEGRO e válido (Teo CP-1) assert_eq!(carregar_verificado(p.to_str().unwrap()).unwrap(), "estado_bom"); let _ = std::fs::remove_dir_all(&d); } /// CP-5a — manifesto trocado entre arquivos (hash de outro) recusa. #[test] fn cp5a_manifesto_de_outro_recusado() { let d = tmp_dir("sw"); let a = d.join("a.json"); let b = d.join("b.json"); salvar_atomico(a.to_str().unwrap(), "AAAA").unwrap(); salvar_atomico(b.to_str().unwrap(), "BBBB").unwrap(); std::fs::copy(d.join("b.json.manifest.json"), d.join("a.json.manifest.json")).unwrap(); assert!(carregar_verificado(a.to_str().unwrap()).is_err()); let _ = std::fs::remove_dir_all(&d); } }