Skip to main content

gradatum_core/
identity.rs

1//! Identity immutable : NoteId, ContentHash, NoteVersion, IntegritySignature.
2//!
3//! ## Invariants
4//!
5//! - `NoteId` : ULID, unique, sortable lexicographiquement (timestamp prefix).
6//!   Format : `01HQK0ABCDEF0123456789GHIJ` (26 chars Base32).
7//! - `ContentHash` : SHA-256 de `JCS(frontmatter) ++ "\n---\n" ++ body`.
8//!   Déterministe cross-langage via JCS RFC 8785. Permet drift detection.
9//! - `NoteVersion` : compteur monotone incrémenté à chaque écriture.
10//! - `IntegritySignature` : optionnel — HMAC-SHA256 ou Ed25519 via `gradatum-acl-auth`.
11//!
12//! ## Pourquoi JCS ?
13//!
14//! `serde_yml::to_string` non-déterministe entre versions de lib.
15//! `serde_json::to_string` non-canonique (ordre clés non garanti).
16//! JCS RFC 8785 = standard IETF : clés ordonnées, floats IEEE 754 canonical,
17//! strings escapées de façon normative. Hash bit-identique Rust/Python/Go/JS.
18//! Décision Q4 brainstorming the maintainer 2026-05-03.
19
20use serde::{Deserialize, Serialize};
21use ulid::Ulid;
22
23use crate::frontmatter::Frontmatter;
24
25/// Clé primaire relationnelle de tout dans Gradatum.
26///
27/// ULID (Universally Unique Lexicographically Sortable Identifier) :
28/// - 128 bits : 48 bits timestamp ms + 80 bits random.
29/// - Sortable lexicographiquement → les notes plus récentes sont "après" les anciennes.
30/// - Monotone dans le même milliseconde (pas de collision dans un process).
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[serde(transparent)]
33pub struct NoteId(pub Ulid);
34
35impl NoteId {
36    /// Génère un nouveau NoteId unique.
37    pub fn new() -> Self {
38        Self(Ulid::new())
39    }
40
41    /// Retourne le timestamp ULID en millisecondes Unix.
42    ///
43    /// Utile pour trier les notes par ordre d'insertion sans accéder au frontmatter.
44    pub fn timestamp_ms(&self) -> u64 {
45        self.0.timestamp_ms()
46    }
47}
48
49impl Default for NoteId {
50    fn default() -> Self {
51        Self::new()
52    }
53}
54
55impl std::fmt::Display for NoteId {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        write!(f, "{}", self.0)
58    }
59}
60
61/// Hash SHA-256 du contenu canonique d'une note.
62///
63/// **Canonicalisation = JCS RFC 8785** (`serde_jcs`) :
64/// ```text
65/// input  = JCS(frontmatter_as_json) ++ b"\n---\n" ++ body_utf8
66/// hash   = SHA-256(input)
67/// ```
68///
69/// Le hash est **indépendant du format on-disk** (YAML vs TOML pour le frontmatter) :
70/// le frontmatter est re-sérialisé en JSON canonique avant le hash → reproductible
71/// cross-langage (Python, Go, JavaScript, Rust) avec la même lib JCS.
72///
73/// ## Utilisation
74///
75/// - Drift detection : compare `ContentHash::compute(reparse(md))` vs `notes.content_hash` SQLite.
76/// - Cache key dans `gradatum-cache` : `(vault_id, content_hash)`.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
78#[serde(transparent)]
79pub struct ContentHash(pub [u8; 32]);
80
81impl ContentHash {
82    /// Calcule le hash depuis un frontmatter et un body.
83    ///
84    /// # Panics
85    ///
86    /// Ne panique jamais en production. `serde_jcs::to_string` retourne une erreur
87    /// uniquement si `Frontmatter` contient des types non-sérialisables en JSON (ex. `f32::NAN`).
88    /// `Frontmatter` ne contient aucun float → `expect` est justifié ici.
89    pub fn compute(frontmatter: &Frontmatter, body: &str) -> Self {
90        use sha2::{Digest, Sha256};
91
92        // JCS RFC 8785 : clés ordonnées, floats IEEE 754, strings normatives.
93        // Garantit un hash bit-identique quel que soit le producer ou la lib YAML.
94        let canonical = serde_jcs::to_string(frontmatter).expect(
95            "Frontmatter est toujours sérialisable en JCS (pas de f32::NAN ni de types non-JSON)",
96        );
97
98        let mut hasher = Sha256::new();
99        hasher.update(canonical.as_bytes());
100        hasher.update(b"\n---\n");
101        hasher.update(body.as_bytes());
102
103        ContentHash(hasher.finalize().into())
104    }
105
106    /// Retourne la représentation hexadécimale du hash (64 chars lowercase).
107    pub fn hex(&self) -> String {
108        self.0.iter().map(|b| format!("{b:02x}")).collect()
109    }
110}
111
112impl std::fmt::Display for ContentHash {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.write_str(&self.hex())
115    }
116}
117
118/// Compteur de version monotone par note.
119///
120/// Incrémenté à chaque écriture par `gradatum-worker`. Le couple `(NoteId, NoteVersion)`
121/// est unique dans le store (invariant d'unicité).
122#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
123#[serde(transparent)]
124pub struct NoteVersion(pub u32);
125
126impl NoteVersion {
127    /// Version initiale à la création d'une note.
128    pub fn initial() -> Self {
129        Self(1)
130    }
131
132    /// Retourne la version suivante (version courante + 1).
133    pub fn next(&self) -> Self {
134        Self(self.0 + 1)
135    }
136}
137
138/// Signature cryptographique optionnelle.
139///
140/// Absente par défaut : la détection de drift via `ContentHash` suffit pour les usages courants.
141/// HMAC-SHA256 ou Ed25519 vault-scoped disponible via `gradatum-acl-auth` avec la couche bearer auth.
142///
143/// Sépare drift accidentel (`ContentHash`) de tamper malveillant (`IntegritySignature`)
144/// — décision Q5/B13.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(transparent)]
147pub struct IntegritySignature(pub Vec<u8>);