1use chrono::{DateTime, Utc};
2use sentinel_crypto::{hash_data, sign_hash, SigningKey};
3use serde_json::Value;
4use tracing::{debug, trace};
5
6use crate::Result;
7
8#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone, PartialEq, Eq)]
10#[allow(
11 clippy::field_scoped_visibility_modifiers,
12 reason = "fields need to be pub(crate) for internal access"
13)]
14pub struct Document {
15 pub(crate) id: String,
17 pub(crate) version: u32,
19 pub(crate) created_at: DateTime<Utc>,
21 pub(crate) updated_at: DateTime<Utc>,
23 pub(crate) hash: String,
25 pub(crate) signature: String,
27 pub(crate) data: Value,
29}
30
31impl Document {
32 pub fn new(id: String, data: Value, private_key: &SigningKey) -> Result<Self> {
35 trace!("Creating new signed document with id: {}", id);
36 let now = Utc::now();
37 let hash = hash_data(&data)?;
38 let signature = sign_hash(&hash, private_key)?;
39 debug!("Document {} created with hash: {}", id, hash);
40 Ok(Self {
41 id,
42 version: crate::META_SENTINEL_VERSION,
43 created_at: now,
44 updated_at: now,
45 hash,
46 signature,
47 data,
48 })
49 }
50
51 pub fn new_without_signature(id: String, data: Value) -> Result<Self> {
54 trace!("Creating new unsigned document with id: {}", id);
55 let now = Utc::now();
56 let hash = hash_data(&data)?;
57 debug!("Document {} created without signature, hash: {}", id, hash);
58 Ok(Self {
59 id,
60 version: crate::META_SENTINEL_VERSION,
61 created_at: now,
62 updated_at: now,
63 hash,
64 signature: String::new(),
65 data,
66 })
67 }
68
69 pub fn id(&self) -> &str { &self.id }
71
72 pub const fn version(&self) -> u32 { self.version }
74
75 pub const fn created_at(&self) -> DateTime<Utc> { self.created_at }
77
78 pub const fn updated_at(&self) -> DateTime<Utc> { self.updated_at }
80
81 pub fn hash(&self) -> &str { &self.hash }
83
84 pub fn signature(&self) -> &str { &self.signature }
86
87 pub const fn data(&self) -> &Value { &self.data }
89
90 pub fn set_data(&mut self, data: Value, private_key: &SigningKey) -> Result<()> {
93 trace!("Updating data for document: {}", self.id);
94 self.data = data;
95 self.updated_at = Utc::now();
96 self.hash = hash_data(&self.data)?;
97 self.signature = sign_hash(&self.hash, private_key)?;
98 debug!("Document {} data updated, new hash: {}", self.id, self.hash);
99 Ok(())
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use rand::{rngs::OsRng, RngCore};
106 use sentinel_crypto::SigningKey;
107
108 use super::*;
109
110 #[test]
111 fn test_document_creation() {
112 let mut rng = OsRng;
113 let mut key_bytes = [0u8; 32];
114 rng.fill_bytes(&mut key_bytes);
115 let private_key = SigningKey::from_bytes(&key_bytes);
116 let data = serde_json::json!({"name": "Test", "value": 42});
117 let doc = Document::new("test-id".to_string(), data.clone(), &private_key).unwrap();
118
119 assert_eq!(doc.id(), "test-id");
120 assert_eq!(doc.version(), crate::META_SENTINEL_VERSION);
121 assert_eq!(doc.data(), &data);
122 assert!(!doc.hash().is_empty());
123 assert!(!doc.signature().is_empty());
124 assert_eq!(doc.created_at(), doc.updated_at());
125 }
126
127 #[test]
128 fn test_document_with_empty_data() {
129 let mut rng = OsRng;
130 let mut key_bytes = [0u8; 32];
131 rng.fill_bytes(&mut key_bytes);
132 let private_key = SigningKey::from_bytes(&key_bytes);
133 let data = serde_json::json!({});
134 let doc = Document::new("empty".to_string(), data.clone(), &private_key).unwrap();
135
136 assert_eq!(doc.id(), "empty");
137 assert_eq!(doc.version(), crate::META_SENTINEL_VERSION);
138 assert!(doc.data().as_object().unwrap().is_empty());
139 }
140
141 #[test]
142 fn test_document_with_complex_data() {
143 let mut rng = OsRng;
144 let mut key_bytes = [0u8; 32];
145 rng.fill_bytes(&mut key_bytes);
146 let private_key = SigningKey::from_bytes(&key_bytes);
147 let data = serde_json::json!({
148 "string": "value",
149 "number": 123,
150 "boolean": true,
151 "array": [1, 2, 3],
152 "object": {"nested": "value"}
153 });
154 let doc = Document::new("complex".to_string(), data.clone(), &private_key).unwrap();
155
156 assert_eq!(doc.data()["string"], "value");
157 assert_eq!(doc.data()["number"], 123);
158 assert_eq!(doc.data()["boolean"], true);
159 assert_eq!(doc.data()["array"], serde_json::json!([1, 2, 3]));
160 assert_eq!(doc.data()["object"]["nested"], "value");
161 }
162
163 #[test]
164 fn test_document_with_valid_filename_safe_ids() {
165 let mut rng = OsRng;
166 let mut key_bytes = [0u8; 32];
167 rng.fill_bytes(&mut key_bytes);
168 let private_key = SigningKey::from_bytes(&key_bytes);
169 let valid_ids = vec![
171 "user-123",
172 "user_456",
173 "user123",
174 "123",
175 "a",
176 "user-123_test",
177 "CamelCaseID",
178 ];
179
180 for id in valid_ids {
181 let data = serde_json::json!({"data": "test"});
182 let doc = Document::new(id.to_owned(), data.clone(), &private_key).unwrap();
183
184 assert_eq!(doc.id(), id);
185 assert_eq!(doc.data(), &data);
186 }
187 }
188
189 #[test]
190 fn test_set_data_updates_hash_and_signature() {
191 let mut rng = OsRng;
192 let mut key_bytes = [0u8; 32];
193 rng.fill_bytes(&mut key_bytes);
194 let private_key = SigningKey::from_bytes(&key_bytes);
195 let initial_data = serde_json::json!({"initial": "data"});
196 let mut doc = Document::new("test".to_string(), initial_data, &private_key).unwrap();
197 let initial_hash = doc.hash().to_string();
198 let initial_signature = doc.signature().to_string();
199 let initial_updated_at = doc.updated_at();
200
201 let new_data = serde_json::json!({"new": "data"});
202 doc.set_data(new_data.clone(), &private_key).unwrap();
203
204 assert_eq!(doc.data(), &new_data);
205 assert_ne!(doc.hash(), initial_hash);
206 assert_ne!(doc.signature(), initial_signature);
207 assert!(doc.updated_at() > initial_updated_at);
208 }
209}