1use serde::{Deserialize, Serialize};
17use uuid::Uuid;
18use zeroize::Zeroize;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum VaultTool {
26 #[serde(rename = "vault__search")]
27 Search,
28 #[serde(rename = "vault__get")]
29 Get,
30 #[serde(rename = "vault__create")]
31 Create,
32 #[serde(rename = "vault__update")]
33 Update,
34 #[serde(rename = "vault__trash")]
35 Trash,
36 #[serde(rename = "vault__restore")]
37 Restore,
38 #[serde(rename = "vault__totp_code")]
39 TotpCode,
40 #[serde(rename = "vault__attachment_get")]
41 AttachmentGet,
42 #[serde(rename = "vault__attachment_put")]
43 AttachmentPut,
44 #[serde(rename = "vault__folder_list")]
45 FolderList,
46 #[serde(rename = "vault__folder_create")]
47 FolderCreate,
48 #[serde(rename = "vault__folder_update")]
49 FolderUpdate,
50 #[serde(rename = "vault__folder_delete")]
51 FolderDelete,
52}
53
54impl VaultTool {
55 pub const ALL: [Self; 13] = [
57 Self::Search,
58 Self::Get,
59 Self::Create,
60 Self::Update,
61 Self::Trash,
62 Self::Restore,
63 Self::TotpCode,
64 Self::AttachmentGet,
65 Self::AttachmentPut,
66 Self::FolderList,
67 Self::FolderCreate,
68 Self::FolderUpdate,
69 Self::FolderDelete,
70 ];
71
72 #[must_use]
74 pub const fn as_str(self) -> &'static str {
75 match self {
76 Self::Search => "vault__search",
77 Self::Get => "vault__get",
78 Self::Create => "vault__create",
79 Self::Update => "vault__update",
80 Self::Trash => "vault__trash",
81 Self::Restore => "vault__restore",
82 Self::TotpCode => "vault__totp_code",
83 Self::AttachmentGet => "vault__attachment_get",
84 Self::AttachmentPut => "vault__attachment_put",
85 Self::FolderList => "vault__folder_list",
86 Self::FolderCreate => "vault__folder_create",
87 Self::FolderUpdate => "vault__folder_update",
88 Self::FolderDelete => "vault__folder_delete",
89 }
90 }
91
92 #[must_use]
95 pub fn parse(name: &str) -> Option<Self> {
96 Self::ALL.into_iter().find(|tool| tool.as_str() == name)
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct VaultAttachmentDraft {
105 pub name: String,
107 pub size: u64,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub replaces: Option<String>,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct VaultDraft {
121 pub id: Uuid,
123 pub tool: VaultTool,
125 pub summary: String,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub item_id: Option<String>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub item_name: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub field: Option<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub masked_diff: Option<String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub attachment: Option<VaultAttachmentDraft>,
144 #[serde(default)]
149 pub needs_unlock: bool,
150}
151
152impl VaultDraft {
153 #[must_use]
156 pub fn new(tool: VaultTool, summary: impl Into<String>) -> Self {
157 Self {
158 id: Uuid::new_v4(),
159 tool,
160 summary: summary.into(),
161 item_id: None,
162 item_name: None,
163 field: None,
164 masked_diff: None,
165 attachment: None,
166 needs_unlock: false,
167 }
168 }
169
170 #[must_use]
172 pub fn with_unlock_state(mut self, needs_unlock: bool) -> Self {
173 self.needs_unlock = needs_unlock;
174 self
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(tag = "decision", rename_all = "snake_case")]
181pub enum VaultAskDecision {
182 Approve,
184 SendBack { note: String },
186 Decline,
188}
189
190#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub struct VaultReleased {
198 pub refresh_token: String,
200 pub user_key: String,
202}
203
204impl Drop for VaultReleased {
205 fn drop(&mut self) {
206 self.refresh_token.zeroize();
207 self.user_key.zeroize();
208 }
209}
210
211impl std::fmt::Debug for VaultReleased {
212 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213 f.debug_struct("VaultReleased")
214 .field("refresh_token", &"[redacted]")
215 .field("user_key", &"[redacted]")
216 .finish()
217 }
218}
219
220#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
229pub struct VaultAskAnswer {
230 #[serde(flatten)]
236 pub decision: VaultAskDecision,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub master_password: Option<String>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub two_factor: Option<String>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub released: Option<VaultReleased>,
250}
251
252impl Drop for VaultAskAnswer {
253 fn drop(&mut self) {
254 if let Some(password) = &mut self.master_password {
255 password.zeroize();
256 }
257 if let Some(code) = &mut self.two_factor {
258 code.zeroize();
259 }
260 }
261}
262
263impl std::fmt::Debug for VaultAskAnswer {
264 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265 f.debug_struct("VaultAskAnswer")
266 .field("decision", &self.decision)
267 .field("master_password", &self.master_password.as_ref().map(|_| "[redacted]"))
268 .field("two_factor", &self.two_factor.as_ref().map(|_| "[redacted]"))
269 .field("released", &self.released)
270 .finish()
271 }
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum VaultAskEnding {
280 Approved,
283 SentBack,
285 Declined,
287 Abandoned,
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
298 fn every_tool_name_round_trips() {
299 for tool in VaultTool::ALL {
300 assert_eq!(VaultTool::parse(tool.as_str()), Some(tool), "{}", tool.as_str());
301 }
302 assert_eq!(VaultTool::parse("vault__hard_delete"), None);
303 }
304
305 #[test]
308 fn a_draft_serialises_only_what_a_card_draws() {
309 let mut draft = VaultDraft::new(VaultTool::Get, "read the password for 'bank'");
310 draft.item_id = Some("cipher-id".to_owned());
311 draft.item_name = Some("bank".to_owned());
312 draft.field = Some("password".to_owned());
313 draft.masked_diff = Some("password changed".to_owned());
314 draft.attachment =
315 Some(VaultAttachmentDraft { name: "notes.txt".to_owned(), size: 97, replaces: None });
316 let value = serde_json::to_value(&draft).expect("a draft serialises");
317 let object = value.as_object().expect("a draft serialises as an object");
318 let mut keys: Vec<&str> = object.keys().map(String::as_str).collect();
319 keys.sort_unstable();
320 assert_eq!(
321 keys,
322 [
323 "attachment",
324 "field",
325 "id",
326 "item_id",
327 "item_name",
328 "masked_diff",
329 "needs_unlock",
330 "summary",
331 "tool"
332 ],
333 );
334 }
335
336 #[test]
340 fn every_tool_serde_name_is_its_as_str() {
341 for tool in VaultTool::ALL {
342 let value = serde_json::to_value(tool).expect("a tool encodes");
343 assert_eq!(value.as_str(), Some(tool.as_str()), "{}", tool.as_str());
344 }
345 }
346
347 #[test]
350 fn the_ask_shapes_round_trip() {
351 let mut draft = VaultDraft::new(VaultTool::AttachmentPut, "upload 'notes.txt' (97 bytes)");
352 draft.item_id = Some("cipher-id".to_owned());
353 draft.item_name = Some("bank".to_owned());
354 draft.attachment = Some(VaultAttachmentDraft {
355 name: "notes.txt".to_owned(),
356 size: 97,
357 replaces: Some("old-attachment".to_owned()),
358 });
359 let json = serde_json::to_string(&draft).expect("serialise");
360 let back: VaultDraft = serde_json::from_str(&json).expect("deserialise");
361 assert_eq!(draft, back);
362
363 let answer = VaultAskAnswer {
364 decision: VaultAskDecision::SendBack { note: "trim the summary".to_owned() },
365 master_password: Some("never printed".to_owned()),
366 two_factor: Some("123456".to_owned()),
367 released: Some(VaultReleased {
368 refresh_token: "never printed either".to_owned(),
369 user_key: "nor this".to_owned(),
370 }),
371 };
372 let json = serde_json::to_string(&answer).expect("serialise");
373 let back: VaultAskAnswer = serde_json::from_str(&json).expect("deserialise");
374 assert_eq!(answer, back);
375 assert!(!format!("{answer:?}").contains("never printed"));
376 assert!(!format!("{answer:?}").contains("123456"));
377 assert!(!format!("{answer:?}").contains("nor this"), "the released user key is redacted");
378 }
379
380 #[test]
383 fn the_unlock_state_is_stamped_not_frozen() {
384 let draft = VaultDraft::new(VaultTool::Search, "find 'bank'").with_unlock_state(true);
385 assert!(draft.needs_unlock);
386 let rebased = draft.with_unlock_state(false);
387 assert!(!rebased.needs_unlock);
388 }
389}