Skip to main content

forge_workspace/mcp/vault/
facade.rs

1//! The vault family's facade: the thirteen operations the tools call, every
2//! one held for the reader's approval.
3//!
4//! The facade owns no session state: the process-wide `VaultSession` does.
5//! It parks a `VaultDraft` on the workspace's registry, awaits one answer,
6//! and only then unlocks (when the card asked for the master password) and
7//! runs the operation. The card draws only what the draft carries; no secret
8//! value is put on it.
9
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Weak};
12
13use async_trait::async_trait;
14use forge_connectors::vault::VaultError;
15use forge_connectors::vault::crypto::Unlocked;
16use forge_connectors::vault::models::{Profile, SyncData};
17use forge_primitives::vault::{
18    VaultAskDecision, VaultAskEnding, VaultAttachmentDraft, VaultDraft, VaultTool,
19};
20use serde::Deserialize;
21use serde_json::{Value, json};
22use zeroize::Zeroize;
23
24use crate::SessionSlot;
25use crate::vault::VaultSession;
26use crate::workspace::Workspace;
27
28/// The server's attachment ceiling, mirrored so forge refuses before a read
29/// or write buffers more than the vault accepts.
30const MAX_ATTACHMENT_BYTES: u64 = 525 * 1024 * 1024;
31
32/// What a call settles with when the `[vault]` section reloaded while it was
33/// parked or reading: the account it was drawn against is gone.
34const SESSION_CHANGED: &str =
35    "the vault configuration changed while the call was parked; nothing ran";
36
37/// Why a vault tool call did not return a result.
38#[derive(Debug, thiserror::Error)]
39pub enum VaultToolError {
40    #[error("{0}")]
41    Message(String),
42    #[error("the vault call was declined, so nothing ran")]
43    Rejected,
44    #[error("the vault call was sent back: {note}")]
45    SentBack { note: String },
46    #[error("the vault session is locked; the next call asks for the master password")]
47    Locked,
48    #[error("the vault is not configured: add a [vault] section to forge.toml")]
49    NotConfigured,
50    #[error("no client is attached to answer the vault call")]
51    NoClient,
52    #[error("the answerer went away before the vault call was settled")]
53    Abandoned,
54    #[error("vault request failed: {0}")]
55    Vault(#[from] VaultError),
56    #[error("vault crypto failed: {0}")]
57    Crypto(#[from] anyhow::Error),
58    #[error("{0}")]
59    Io(String),
60}
61
62impl From<std::io::Error> for VaultToolError {
63    fn from(error: std::io::Error) -> Self {
64        Self::Io(error.to_string())
65    }
66}
67
68/// A custom field as `vault_create` / `vault_update` take it.
69#[derive(Clone, Deserialize)]
70pub struct CustomFieldInput {
71    pub name: String,
72    pub value: String,
73    #[serde(default, rename = "type")]
74    pub kind: Option<String>,
75}
76
77/// Hand-written because a custom field's value is a secret.
78impl std::fmt::Debug for CustomFieldInput {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.debug_struct("CustomFieldInput")
81            .field("name", &self.name)
82            .field("value", &"[redacted]")
83            .field("kind", &self.kind)
84            .finish()
85    }
86}
87
88/// The arguments `vault_create` accepts.
89#[derive(Clone, Default, Deserialize)]
90pub struct CreateInput {
91    #[serde(default, rename = "type")]
92    pub kind: Option<String>,
93    pub name: String,
94    #[serde(default)]
95    pub username: Option<String>,
96    #[serde(default)]
97    pub password: Option<String>,
98    #[serde(default)]
99    pub uri: Option<String>,
100    #[serde(default)]
101    pub totp: Option<String>,
102    #[serde(default)]
103    pub notes: Option<String>,
104    #[serde(default)]
105    pub cardholder_name: Option<String>,
106    #[serde(default)]
107    pub number: Option<String>,
108    #[serde(default)]
109    pub exp_month: Option<String>,
110    #[serde(default)]
111    pub exp_year: Option<String>,
112    #[serde(default)]
113    pub code: Option<String>,
114    #[serde(default)]
115    pub brand: Option<String>,
116    #[serde(default)]
117    pub folder_id: Option<String>,
118    #[serde(default)]
119    pub custom_fields: Option<Vec<CustomFieldInput>>,
120}
121
122/// Hand-written because its password, card number, CVV and custom values are
123/// secrets.
124impl std::fmt::Debug for CreateInput {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.debug_struct("CreateInput")
127            .field("kind", &self.kind)
128            .field("name", &self.name)
129            .field("username", &self.username)
130            .field("password", &self.password.as_ref().map(|_| "[redacted]"))
131            .field("uri", &self.uri)
132            .field("totp", &self.totp.as_ref().map(|_| "[redacted]"))
133            .field("notes", &self.notes.as_ref().map(|_| "[redacted]"))
134            .field("cardholder_name", &self.cardholder_name)
135            .field("number", &self.number.as_ref().map(|_| "[redacted]"))
136            .field("exp_month", &self.exp_month)
137            .field("exp_year", &self.exp_year)
138            .field("code", &self.code.as_ref().map(|_| "[redacted]"))
139            .field("brand", &self.brand)
140            .field("folder_id", &self.folder_id)
141            .field("custom_fields", &self.custom_fields)
142            .finish()
143    }
144}
145
146/// The fields `vault_update` may change.
147#[derive(Clone, Default, Deserialize)]
148pub struct PatchInput {
149    #[serde(default)]
150    pub name: Option<String>,
151    #[serde(default)]
152    pub username: Option<String>,
153    #[serde(default)]
154    pub password: Option<String>,
155    #[serde(default)]
156    pub uri: Option<String>,
157    #[serde(default)]
158    pub totp: Option<String>,
159    #[serde(default)]
160    pub notes: Option<String>,
161    #[serde(default)]
162    pub cardholder_name: Option<String>,
163    #[serde(default)]
164    pub number: Option<String>,
165    #[serde(default)]
166    pub exp_month: Option<String>,
167    #[serde(default)]
168    pub exp_year: Option<String>,
169    #[serde(default)]
170    pub code: Option<String>,
171    #[serde(default)]
172    pub brand: Option<String>,
173    #[serde(default)]
174    pub folder_id: Option<String>,
175    #[serde(default)]
176    pub custom_fields: Option<Vec<CustomFieldInput>>,
177}
178
179/// Hand-written because its password, card number, CVV and custom values are
180/// secrets.
181impl std::fmt::Debug for PatchInput {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.debug_struct("PatchInput")
184            .field("name", &self.name)
185            .field("username", &self.username)
186            .field("password", &self.password.as_ref().map(|_| "[redacted]"))
187            .field("uri", &self.uri)
188            .field("totp", &self.totp.as_ref().map(|_| "[redacted]"))
189            .field("notes", &self.notes.as_ref().map(|_| "[redacted]"))
190            .field("cardholder_name", &self.cardholder_name)
191            .field("number", &self.number.as_ref().map(|_| "[redacted]"))
192            .field("exp_month", &self.exp_month)
193            .field("exp_year", &self.exp_year)
194            .field("code", &self.code.as_ref().map(|_| "[redacted]"))
195            .field("brand", &self.brand)
196            .field("folder_id", &self.folder_id)
197            .field("custom_fields", &self.custom_fields)
198            .finish()
199    }
200}
201
202/// The thirteen vault operations. Every method parks a hold and awaits the
203/// reader's answer; the mock in tests implements the same contract.
204#[async_trait]
205pub trait VaultFacade: Send + Sync {
206    async fn search(
207        &self,
208        caller: &SessionSlot,
209        query: &str,
210        limit: Option<usize>,
211    ) -> Result<Value, VaultToolError>;
212    async fn get(
213        &self,
214        caller: &SessionSlot,
215        id: &str,
216        reveal: Option<&str>,
217    ) -> Result<Value, VaultToolError>;
218    async fn create(
219        &self,
220        caller: &SessionSlot,
221        input: &CreateInput,
222    ) -> Result<Value, VaultToolError>;
223    async fn update(
224        &self,
225        caller: &SessionSlot,
226        id: &str,
227        patch: &PatchInput,
228    ) -> Result<Value, VaultToolError>;
229    async fn trash(&self, caller: &SessionSlot, id: &str) -> Result<Value, VaultToolError>;
230    async fn restore(&self, caller: &SessionSlot, id: &str) -> Result<Value, VaultToolError>;
231    async fn totp_code(&self, caller: &SessionSlot, id: &str) -> Result<Value, VaultToolError>;
232    async fn attachment_get(
233        &self,
234        caller: &SessionSlot,
235        item_id: &str,
236        attachment_id: &str,
237        destination: &str,
238    ) -> Result<Value, VaultToolError>;
239    async fn attachment_put(
240        &self,
241        caller: &SessionSlot,
242        item_id: &str,
243        path: &str,
244        name: Option<&str>,
245        replace_attachment_id: Option<&str>,
246    ) -> Result<Value, VaultToolError>;
247    async fn folder_list(&self, caller: &SessionSlot) -> Result<Value, VaultToolError>;
248    async fn folder_create(
249        &self,
250        caller: &SessionSlot,
251        name: &str,
252    ) -> Result<Value, VaultToolError>;
253    async fn folder_update(
254        &self,
255        caller: &SessionSlot,
256        id: &str,
257        name: &str,
258    ) -> Result<Value, VaultToolError>;
259    async fn folder_delete(&self, caller: &SessionSlot, id: &str) -> Result<Value, VaultToolError>;
260}
261
262/// The production facade: a weak workspace, so it can read the current
263/// `[vault]` session after a reload rather than holding a stale one.
264pub struct ProdVaultFacade {
265    workspace: Weak<Workspace>,
266}
267
268impl ProdVaultFacade {
269    pub fn from_arc(workspace: &Arc<Workspace>) -> Self {
270        Self { workspace: Arc::downgrade(workspace) }
271    }
272
273    pub fn into_arc(self) -> Arc<dyn VaultFacade> {
274        Arc::new(self)
275    }
276
277    fn workspace(&self) -> Result<Arc<Workspace>, VaultToolError> {
278        self.workspace.upgrade().ok_or(VaultToolError::NotConfigured)
279    }
280
281    fn session(&self) -> Result<Arc<VaultSession>, VaultToolError> {
282        self.workspace()?.vault.read().clone().ok_or(VaultToolError::NotConfigured)
283    }
284
285    /// A decryption failure under a known key means the account rotated its
286    /// keys: drop them and restamp every other parked card as needing the
287    /// password again. Other error kinds pass through untouched.
288    fn decrypt_failed(
289        &self,
290        session: &VaultSession,
291        caller: &SessionSlot,
292        item: Option<&str>,
293        error: VaultToolError,
294    ) -> VaultToolError {
295        if matches!(
296            &error,
297            VaultToolError::Vault(VaultError::Crypto(_)) | VaultToolError::Crypto(_)
298        ) {
299            session.note_decrypt_failure(caller, item);
300            if let Ok(workspace) = self.workspace() {
301                workspace.rebase_vault_asks(true);
302            }
303        }
304        error
305    }
306
307    /// A card may draw `(unreadable)` for a field it has no key for, but a
308    /// crypto failure means the keys are stale and must lock the session.
309    fn fallback_or_lock<T>(
310        &self,
311        session: &VaultSession,
312        caller: &SessionSlot,
313        item: Option<&str>,
314        result: Result<T, VaultToolError>,
315        fallback: T,
316    ) -> Result<T, VaultToolError> {
317        match result {
318            Ok(value) => Ok(value),
319            Err(VaultToolError::Vault(VaultError::MissingKey(_))) => Ok(fallback),
320            Err(error) => Err(self.decrypt_failed(session, caller, item, error)),
321        }
322    }
323
324    /// A write re-checks the account key immediately before it runs: a
325    /// rotation during the park would store ciphertext the next unlock cannot
326    /// read. The sync comes back for the write's `encryptedFor` stamp.
327    async fn ensure_keys_current(
328        &self,
329        approved: &Approved,
330        item: Option<&str>,
331    ) -> Result<SyncData, VaultToolError> {
332        match approved.session.ensure_keys_current(&approved.caller, item).await {
333            Ok(sync) => Ok(sync),
334            // No eager rebase: a transient HTTP failure on the check must not
335            // ask for a password. `done` restamps when the session is locked,
336            // which is the rotation case.
337            Err(error) => approved.fail(VaultToolError::Vault(error)),
338        }
339    }
340
341    /// A call that read the account before its gate must not run against a
342    /// session a `[vault]` reload swapped in: settle it as changed instead.
343    fn guard_reload(
344        approved: &Approved,
345        session: &Arc<VaultSession>,
346    ) -> Result<(), VaultToolError> {
347        if Arc::ptr_eq(&approved.session, session) {
348            return Ok(());
349        }
350        approved.fail(VaultToolError::Message(SESSION_CHANGED.to_owned()))
351    }
352
353    /// Park the draft, await one answer, settle it, and unlock the shared
354    /// session when the card asked for the password. Decline and send-back
355    /// are audited here and returned as the tool's refusal; an approval
356    /// returns the session and its keys, and the caller audits the outcome.
357    async fn gate(
358        &self,
359        caller: &SessionSlot,
360        draft: VaultDraft,
361        tool: VaultTool,
362        field: Option<&str>,
363    ) -> Result<Approved, VaultToolError> {
364        let workspace = self.workspace()?;
365        let session = self.session()?;
366        let (id, receiver, answerable) =
367            workspace.register_vault_ask(caller, draft.clone(), session.is_locked());
368        if !answerable {
369            return Err(VaultToolError::NoClient);
370        }
371        let mut guard = SettleOnDrop {
372            workspace: Arc::clone(&workspace),
373            id,
374            caller: caller.clone(),
375            armed: true,
376        };
377        let Ok(mut answer) = receiver.await else {
378            return Err(VaultToolError::Abandoned);
379        };
380        let ending = match &answer.decision {
381            VaultAskDecision::Approve => VaultAskEnding::Approved,
382            VaultAskDecision::SendBack { .. } => VaultAskEnding::SentBack,
383            VaultAskDecision::Decline => VaultAskEnding::Declined,
384        };
385        guard.armed = false;
386        workspace.settle_vault_ask(id, caller, ending);
387
388        let item_id = draft.item_id.as_deref();
389        let item_name = draft.item_name.as_deref();
390        // A call that names a field on its draft (a reveal) must audit it even
391        // when the gate's own argument is empty.
392        let field = field.or(draft.field.as_deref());
393        match &answer.decision {
394            VaultAskDecision::Decline => {
395                session.audit(caller, tool, item_id, item_name, field, "decline", "not run");
396                return Err(VaultToolError::Rejected);
397            }
398            VaultAskDecision::SendBack { note } => {
399                session.audit(caller, tool, item_id, item_name, field, "send_back", "not run");
400                return Err(VaultToolError::SentBack { note: note.clone() });
401            }
402            VaultAskDecision::Approve => {}
403        }
404
405        // A `[vault]` reload builds a new session. The answer was drawn against
406        // the old account, so running it would send the old account's password
407        // hash to the old URL - or spend a password on a session that is gone.
408        let current = self.session();
409        let reloaded = match current {
410            Ok(current) => !Arc::ptr_eq(&current, &session),
411            Err(_) => true,
412        };
413        if reloaded {
414            if let Some(mut password) = answer.master_password.take() {
415                password.zeroize();
416            }
417            if let Some(mut code) = answer.two_factor.take() {
418                code.zeroize();
419            }
420            session.audit(
421                caller,
422                tool,
423                item_id,
424                item_name,
425                field,
426                "approve",
427                "failed: the vault configuration changed",
428            );
429            return Err(VaultToolError::Message(SESSION_CHANGED.to_owned()));
430        }
431
432        if session.is_locked() {
433            // The card offers both paths: the material a platform
434            // authenticator released, or the master password. Either one
435            // reaches the same session; the material is what a start uses
436            // once the device is enrolled.
437            let unlocked = if let Some(released) = answer.released.as_ref() {
438                session.unlock_released(&released.refresh_token, &released.user_key).await
439            } else {
440                let Some(password) =
441                    answer.master_password.as_deref().filter(|password| !password.is_empty())
442                else {
443                    session.audit(
444                        caller,
445                        tool,
446                        item_id,
447                        item_name,
448                        field,
449                        "approve",
450                        "failed: a locked session was approved without the master password",
451                    );
452                    return Err(VaultToolError::Locked);
453                };
454                let two_factor = answer.two_factor.as_deref().filter(|code| !code.is_empty());
455                session.unlock(password, two_factor).await
456            };
457            if let Some(mut password) = answer.master_password.take() {
458                password.zeroize();
459            }
460            if let Some(mut code) = answer.two_factor.take() {
461                code.zeroize();
462            }
463            if let Some(mut released) = answer.released.take() {
464                released.refresh_token.zeroize();
465                released.user_key.zeroize();
466            }
467            if let Err(error) = unlocked {
468                session.audit(
469                    caller,
470                    tool,
471                    item_id,
472                    item_name,
473                    field,
474                    "approve",
475                    &format!("failed: {error}"),
476                );
477                return Err(VaultToolError::Vault(error));
478            }
479            // One unlock serves every card parked behind this one: each is an
480            // ordinary approve card now, and a client that already drew it
481            // merges the restamped frame by id.
482            workspace.rebase_vault_asks(false);
483        } else {
484            if let Some(mut password) = answer.master_password.take() {
485                password.zeroize();
486            }
487            if let Some(mut code) = answer.two_factor.take() {
488                code.zeroize();
489            }
490        }
491
492        let keys = session.keys().map_err(|error| {
493            session.audit(
494                caller,
495                tool,
496                item_id,
497                item_name,
498                field,
499                "approve",
500                &format!("failed: {error}"),
501            );
502            VaultToolError::Vault(error)
503        })?;
504        Ok(Approved {
505            workspace,
506            session,
507            keys,
508            caller: caller.clone(),
509            tool,
510            item_id: item_id.map(str::to_owned),
511            item_name: item_name.map(str::to_owned),
512            field: field.map(str::to_owned),
513        })
514    }
515
516    /// Gate one item operation. When the session is unlocked the item is
517    /// fetched first so the card can name it; when it is locked the card is
518    /// parked with the id alone, because a token call would fail before the
519    /// reader ever saw the password field. The item is fetched after the
520    /// unlock either way, so the returned cipher is always current.
521    async fn item_gate<F>(
522        &self,
523        caller: &SessionSlot,
524        tool: VaultTool,
525        id: &str,
526        summary: F,
527    ) -> Result<(Approved, Value, VaultDraft), VaultToolError>
528    where
529        F: FnOnce(Option<&str>, &mut VaultDraft) -> String,
530    {
531        let session = self.session()?;
532        let (cipher, name) = if session.is_locked() {
533            (None, None)
534        } else {
535            let cipher = session.get_cipher(id).await?;
536            let keys = session.keys()?;
537            let name = self.fallback_or_lock(
538                &session,
539                caller,
540                Some(id),
541                decrypt_name(&keys, &cipher).map(Some),
542                None,
543            )?;
544            (Some(cipher), name)
545        };
546        let mut draft = VaultDraft::new(tool, String::new());
547        draft.item_id = Some(id.to_owned());
548        draft.item_name = name;
549        let item_name = draft.item_name.clone();
550        draft.summary = summary(item_name.as_deref(), &mut draft);
551        let approved = self.gate(caller, draft.clone(), tool, None).await?;
552        // A reload between the pre-gate read and the gate's own read leaves
553        // `cipher` from the old account; the approval names it, so it must not
554        // be read by the new session.
555        Self::guard_reload(&approved, &session)?;
556        let cipher = match cipher {
557            Some(cipher) => cipher,
558            None => match approved.session.get_cipher(id).await {
559                Ok(cipher) => cipher,
560                Err(error) => return approved.fail(VaultToolError::Vault(error)),
561            },
562        };
563        Ok((approved, cipher, draft))
564    }
565}
566
567/// Settle a held call as abandoned when the blocked tool future is dropped
568/// before it answered.
569struct SettleOnDrop {
570    workspace: Arc<Workspace>,
571    id: uuid::Uuid,
572    caller: SessionSlot,
573    armed: bool,
574}
575
576impl Drop for SettleOnDrop {
577    fn drop(&mut self) {
578        if self.armed {
579            self.workspace.settle_vault_ask(self.id, &self.caller, VaultAskEnding::Abandoned);
580        }
581    }
582}
583
584/// An approved call: the session and keys it runs with, plus the facts its
585/// audit line records. [`Approved::done`] writes that line from the
586/// operation's own result, never before it has run.
587struct Approved {
588    workspace: Arc<Workspace>,
589    session: Arc<VaultSession>,
590    keys: Arc<Unlocked>,
591    caller: SessionSlot,
592    tool: VaultTool,
593    item_id: Option<String>,
594    item_name: Option<String>,
595    field: Option<String>,
596}
597
598impl Approved {
599    fn done<T>(&self, result: Result<T, VaultToolError>) -> Result<T, VaultToolError> {
600        // A decryption failure under a key that has not changed means the
601        // account rotated its keys; the stale ones are dropped so the next
602        // held call asks for the master password.
603        if matches!(
604            &result,
605            Err(VaultToolError::Vault(VaultError::Crypto(_)) | VaultToolError::Crypto(_))
606        ) {
607            self.session.note_decrypt_failure(&self.caller, self.item_id.as_deref());
608        }
609        // A call's own 401 path may have locked the session (a failed refresh),
610        // so every card still parked asks for the password again.
611        if self.session.is_locked() {
612            self.workspace.rebase_vault_asks(true);
613        }
614        let outcome = match &result {
615            Ok(_) => "ok".to_owned(),
616            Err(error) => format!("failed: {error}"),
617        };
618        self.session.audit(
619            &self.caller,
620            self.tool,
621            self.item_id.as_deref(),
622            self.item_name.as_deref(),
623            self.field.as_deref(),
624            "approve",
625            &outcome,
626        );
627        result
628    }
629
630    /// Settle a failure that happened after the gate without running the
631    /// operation: the audit line is written from here, never skipped.
632    fn fail<T>(&self, error: VaultToolError) -> Result<T, VaultToolError> {
633        self.done(Err(error))
634    }
635}
636
637#[async_trait]
638impl VaultFacade for ProdVaultFacade {
639    async fn search(
640        &self,
641        caller: &SessionSlot,
642        query: &str,
643        limit: Option<usize>,
644    ) -> Result<Value, VaultToolError> {
645        let session = self.session()?;
646        let limit = limit.unwrap_or(20).clamp(1, 100);
647        let (items, hits) = if session.is_locked() {
648            (None, None)
649        } else {
650            let sync = session.sync().await?;
651            let keys = session.keys()?;
652            let items = self.search_items(&session, caller, &keys, &sync, query, limit)?;
653            let hits = items.len();
654            (Some(items), Some(hits))
655        };
656        let draft = VaultDraft::new(
657            VaultTool::Search,
658            match hits {
659                Some(hits) => {
660                    format!("search for '{query}' ({hits} hit{})", if hits == 1 { "" } else { "s" })
661                }
662                None => format!("search for '{query}'"),
663            },
664        );
665        let approved = self.gate(caller, draft.clone(), VaultTool::Search, None).await?;
666        Self::guard_reload(&approved, &session)?;
667        let session = Arc::clone(&approved.session);
668        let result = async move {
669            let items = if let Some(items) = items {
670                items
671            } else {
672                let sync = session.sync().await?;
673                let keys = session.keys()?;
674                self.search_items(&session, caller, &keys, &sync, query, limit)?
675            };
676            Ok(json!({ "items": items }))
677        }
678        .await;
679        approved.done(result)
680    }
681
682    async fn get(
683        &self,
684        caller: &SessionSlot,
685        id: &str,
686        reveal: Option<&str>,
687    ) -> Result<Value, VaultToolError> {
688        validate_reveal(reveal)?;
689        let (approved, cipher, _draft) = self
690            .item_gate(caller, VaultTool::Get, id, |name, draft| {
691                draft.field = reveal.map(str::to_owned);
692                match (name, reveal) {
693                    (Some(name), Some(field)) => format!("read {field} for '{name}'"),
694                    (Some(name), None) => format!("read metadata for '{name}'"),
695                    (None, Some(field)) => format!("read {field} for item {id}"),
696                    (None, None) => format!("read metadata for item {id}"),
697                }
698            })
699            .await?;
700        let keys = Arc::clone(&approved.keys);
701        let result = async {
702            let mut result = self.item_metadata(&approved, &keys, &cipher)?;
703            if let Some(reveal) = reveal {
704                let value = reveal_value(&keys, &cipher, reveal)?;
705                result["revealed"] = json!({ "field": reveal, "value": value });
706            }
707            Ok(result)
708        }
709        .await;
710        approved.done(result)
711    }
712
713    async fn create(
714        &self,
715        caller: &SessionSlot,
716        input: &CreateInput,
717    ) -> Result<Value, VaultToolError> {
718        let kind = input.kind.as_deref().unwrap_or("login");
719        if !matches!(kind, "login" | "note" | "card") {
720            return Err(VaultToolError::Message(format!(
721                "unknown item type '{kind}'; the types are login, note, card"
722            )));
723        }
724        let mut draft = VaultDraft::new(
725            VaultTool::Create,
726            match kind {
727                "card" => "create a new card".to_owned(),
728                "note" => format!("create a secure note '{}'", input.name),
729                _ => format!("create a login '{}'", input.name),
730            },
731        );
732        draft.item_name = Some(input.name.clone());
733        draft.masked_diff = Some(masked_create_diff(input, kind));
734        let approved = self.gate(caller, draft.clone(), VaultTool::Create, None).await?;
735        let sync = self.ensure_keys_current(&approved, None).await?;
736        let user_id = match account_user_id(&sync.profile) {
737            Ok(user_id) => user_id,
738            Err(error) => return approved.done(Err(error)),
739        };
740        let body = match build_new_cipher(&approved.keys, &user_id, input, kind) {
741            Ok(body) => body,
742            Err(error) => return approved.done(Err(error)),
743        };
744        let created = match approved.session.create_cipher(&body).await {
745            Ok(created) => created,
746            Err(error) => return approved.done(Err(VaultToolError::Vault(error))),
747        };
748        let Some(id) = created.get("id").and_then(Value::as_str) else {
749            return approved.done(Err(VaultToolError::Message(
750                "the vault did not return the new item's id".to_owned(),
751            )));
752        };
753        // A missing name field falls back to what the caller asked for. A
754        // decrypt failure means the item already exists, so the one audit
755        // line is the one that names the id it landed under.
756        let name = match created.get("name").and_then(Value::as_str) {
757            Some(enc) => match approved.keys.decrypt_item_str(&created, enc) {
758                Ok(name) => name,
759                Err(error) => {
760                    let mut approved = approved;
761                    approved.item_id = Some(id.to_owned());
762                    return approved.done(Err(VaultToolError::Vault(error)));
763                }
764            },
765            None => input.name.clone(),
766        };
767        approved.done(Ok(json!({ "id": id, "name": name, "type": kind })))
768    }
769
770    async fn update(
771        &self,
772        caller: &SessionSlot,
773        id: &str,
774        patch: &PatchInput,
775    ) -> Result<Value, VaultToolError> {
776        let changed = patch_fields(patch);
777        if changed.is_empty() {
778            return Err(VaultToolError::Message("the patch changes no fields".to_owned()));
779        }
780        let (approved, cipher, draft) = self
781            .item_gate(caller, VaultTool::Update, id, |name, draft| {
782                draft.masked_diff = Some(masked_update_diff(&changed));
783                format!("change {} on '{}'", changed.join(", "), name.unwrap_or(id))
784            })
785            .await?;
786        let sync = self.ensure_keys_current(&approved, Some(id)).await?;
787        let user_id = match account_user_id(&sync.profile) {
788            Ok(user_id) => user_id,
789            Err(error) => return approved.done(Err(error)),
790        };
791        let session = Arc::clone(&approved.session);
792        let keys = Arc::clone(&approved.keys);
793        let result = async move {
794            let name = draft.item_name.clone().unwrap_or_else(|| id.to_owned());
795            let body = apply_patch(&keys, &user_id, cipher, patch)?;
796            session.update_cipher(id, &body).await?;
797            Ok(json!({ "id": id, "name": name, "updated": true }))
798        }
799        .await;
800        approved.done(result)
801    }
802
803    async fn trash(&self, caller: &SessionSlot, id: &str) -> Result<Value, VaultToolError> {
804        let (approved, _cipher, draft) = self
805            .item_gate(caller, VaultTool::Trash, id, |name, _draft| {
806                format!("move '{}' to the trash", name.unwrap_or(id))
807            })
808            .await?;
809        let session = Arc::clone(&approved.session);
810        let result = async move {
811            session.trash_cipher(id).await?;
812            let name = draft.item_name.clone().unwrap_or_else(|| id.to_owned());
813            Ok(json!({ "id": id, "name": name, "trashed": true }))
814        }
815        .await;
816        approved.done(result)
817    }
818
819    async fn restore(&self, caller: &SessionSlot, id: &str) -> Result<Value, VaultToolError> {
820        let (approved, _cipher, draft) = self
821            .item_gate(caller, VaultTool::Restore, id, |name, _draft| {
822                format!("restore '{}' from the trash", name.unwrap_or(id))
823            })
824            .await?;
825        let session = Arc::clone(&approved.session);
826        let result = async move {
827            session.restore_cipher(id).await?;
828            let name = draft.item_name.clone().unwrap_or_else(|| id.to_owned());
829            Ok(json!({ "id": id, "name": name, "restored": true }))
830        }
831        .await;
832        approved.done(result)
833    }
834
835    async fn totp_code(&self, caller: &SessionSlot, id: &str) -> Result<Value, VaultToolError> {
836        let (approved, cipher, draft) = self
837            .item_gate(caller, VaultTool::TotpCode, id, |name, _draft| {
838                format!("compute a TOTP code for '{}'", name.unwrap_or(id))
839            })
840            .await?;
841        let keys = Arc::clone(&approved.keys);
842        let result = async move {
843            let name = draft.item_name.clone().unwrap_or_else(|| id.to_owned());
844            let seed = cipher
845                .pointer("/login/totp")
846                .and_then(Value::as_str)
847                .ok_or_else(|| VaultToolError::Message(format!("'{name}' has no TOTP seed")))?;
848            let seed = keys.decrypt_item_str(&cipher, seed)?;
849            let totp = forge_connectors::vault::crypto::totp_from_uri(
850                &seed,
851                std::time::SystemTime::now(),
852            )?;
853            Ok(json!({ "code": totp.code, "period": totp.period, "remaining": totp.remaining }))
854        }
855        .await;
856        approved.done(result)
857    }
858
859    async fn attachment_get(
860        &self,
861        caller: &SessionSlot,
862        item_id: &str,
863        attachment_id: &str,
864        destination: &str,
865    ) -> Result<Value, VaultToolError> {
866        let session = self.session()?;
867        let workspace = self.workspace()?;
868        // The destination is resolved before the card parks: it needs no keys,
869        // and the reader must see where the file would land even on a locked
870        // card that cannot name the item.
871        let path = resolve_destination(&workspace, caller, destination)?;
872        let (cipher, name, file_name, size) = if session.is_locked() {
873            (None, None, None, None)
874        } else {
875            let cipher = session.get_cipher(item_id).await?;
876            let keys = session.keys()?;
877            let name = decrypt_name(&keys, &cipher)
878                .map_err(|error| self.decrypt_failed(&session, caller, Some(item_id), error))?;
879            let (file_name, size) = attachment_named(&keys, &cipher, attachment_id)
880                .map_err(|error| self.decrypt_failed(&session, caller, Some(item_id), error))?;
881            (Some(cipher), Some(name), Some(file_name), Some(size))
882        };
883        let destination_shown = path.display().to_string();
884        let mut draft = VaultDraft::new(
885            VaultTool::AttachmentGet,
886            match (&name, &file_name) {
887                (Some(name), Some(file_name)) => {
888                    format!("download '{file_name}' from '{name}' to {destination_shown}")
889                }
890                _ => format!(
891                    "download attachment {attachment_id} from item {item_id} to {destination_shown}"
892                ),
893            },
894        );
895        draft.item_id = Some(item_id.to_owned());
896        draft.item_name = name;
897        if let (Some(file_name), Some(size)) = (&file_name, size) {
898            draft.attachment =
899                Some(VaultAttachmentDraft { name: file_name.clone(), size, replaces: None });
900        }
901        let approved = self.gate(caller, draft.clone(), VaultTool::AttachmentGet, None).await?;
902        Self::guard_reload(&approved, &session)?;
903        let result = async {
904            let cipher = match cipher {
905                Some(cipher) => cipher,
906                None => approved.session.get_cipher(item_id).await?,
907            };
908            let meta = approved.session.get_attachment_meta(item_id, attachment_id).await?;
909            let url = meta.url.clone().ok_or_else(|| {
910                VaultToolError::Message("the vault did not return a download URL".to_owned())
911            })?;
912            let wrapped = meta.key.clone().ok_or_else(|| {
913                VaultToolError::Message("the vault did not return the attachment key".to_owned())
914            })?;
915            let blob = approved.session.download_attachment(&url, MAX_ATTACHMENT_BYTES).await?;
916            let plain = approved.keys.open_attachment(&cipher, &wrapped, &blob)?;
917            let size = plain.len();
918            let sha256 = forge_connectors::vault::crypto::sha256_hex(&plain);
919            // The parent can be swapped at any point up to the write - the
920            // gate wait, the metadata read, the download - so the path is
921            // resolved and containment-checked inside the blocking closure
922            // that does the write, never before it.
923            let write_workspace = Arc::clone(&workspace);
924            let write_caller = caller.clone();
925            let expected = path.clone();
926            let destination = destination.to_owned();
927            tokio::task::spawn_blocking(move || {
928                let write_path =
929                    recheck_destination(&write_workspace, &write_caller, &destination, &expected)?;
930                write_private(&write_path, &plain)
931            })
932            .await
933            .map_err(|error| VaultToolError::Io(format!("the attachment write: {error}")))??;
934            Ok(json!({
935                "path": path.display().to_string(),
936                "size": size,
937                "sha256": sha256,
938            }))
939        }
940        .await;
941        approved.done(result)
942    }
943
944    async fn attachment_put(
945        &self,
946        caller: &SessionSlot,
947        item_id: &str,
948        path: &str,
949        name: Option<&str>,
950        replace_attachment_id: Option<&str>,
951    ) -> Result<Value, VaultToolError> {
952        let workspace = self.workspace()?;
953        let session = self.session()?;
954        let source = resolve_source(&workspace, caller, path)?;
955        // The size is checked from metadata before a byte is read, and the
956        // read itself runs off the async thread.
957        let read_path = source.clone();
958        let data =
959            tokio::task::spawn_blocking(move || read_attachment(&read_path, MAX_ATTACHMENT_BYTES))
960                .await
961                .map_err(|error| VaultToolError::Io(format!("the attachment read: {error}")))??;
962        let file_name = name
963            .map(str::to_owned)
964            .or_else(|| source.file_name().map(|name| name.to_string_lossy().into_owned()))
965            .ok_or_else(|| VaultToolError::Message("the path has no file name".to_owned()))?;
966        let size = data.len() as u64;
967        // The card names the file being replaced, which needs the keys. A locked
968        // session parks with the id instead, and says only that it replaces one.
969        let replaced_name = if let Some(old) = replace_attachment_id {
970            if session.is_locked() {
971                None
972            } else {
973                let cipher = session.get_cipher(item_id).await?;
974                let keys = session.keys()?;
975                let (name, _) = attachment_named(&keys, &cipher, old)
976                    .map_err(|error| self.decrypt_failed(&session, caller, Some(item_id), error))?;
977                Some(name)
978            }
979        } else {
980            None
981        };
982        let (approved, cipher, _draft) = self
983            .item_gate(caller, VaultTool::AttachmentPut, item_id, |name, draft| {
984                let replaced = replacement_label(replace_attachment_id, replaced_name);
985                draft.attachment = Some(VaultAttachmentDraft {
986                    name: file_name.clone(),
987                    size,
988                    replaces: replaced.clone(),
989                });
990                match &replaced {
991                    Some(old) => format!(
992                        "replace '{old}' on '{}' with '{file_name}'",
993                        name.unwrap_or(item_id)
994                    ),
995                    None => {
996                        format!("upload '{file_name}' to '{}'", name.unwrap_or(item_id))
997                    }
998                }
999            })
1000            .await?;
1001        Self::guard_reload(&approved, &session)?;
1002        self.ensure_keys_current(&approved, Some(item_id)).await?;
1003        let result = async {
1004            let (wrapped, encrypted_name, blob) =
1005                approved.keys.prepare_attachment(&cipher, &file_name, &data)?;
1006            if blob.len() as u64 > MAX_ATTACHMENT_BYTES {
1007                return Err(VaultToolError::Message(format!(
1008                    "the encrypted attachment is {} bytes, over the {} byte cap",
1009                    blob.len(),
1010                    MAX_ATTACHMENT_BYTES
1011                )));
1012            }
1013            let slot_body = json!({
1014                "key": wrapped,
1015                "fileName": encrypted_name,
1016                "fileSize": blob.len() as u64,
1017                "adminRequest": false,
1018            });
1019            let slot = approved.session.create_attachment_slot(item_id, &slot_body).await?;
1020            let attachment_id =
1021                slot.get("attachmentId").and_then(Value::as_str).ok_or_else(|| {
1022                    VaultToolError::Message(
1023                        "the vault did not return the new attachment id".to_owned(),
1024                    )
1025                })?;
1026            let blob_len = blob.len();
1027            approved.session.upload_attachment(item_id, attachment_id, &blob).await?;
1028            let meta = approved.session.get_attachment_meta(item_id, attachment_id).await?;
1029            check_stored_size(meta.size, blob_len)?;
1030            // The replace's old attachment is deleted only after the new one
1031            // is stored and its size checked; a failed upload leaves the old
1032            // there.
1033            if let Some(old) = replace_attachment_id {
1034                approved.session.delete_attachment(item_id, old).await?;
1035            }
1036            Ok(json!({
1037                "id": attachment_id,
1038                "name": file_name,
1039                "size": blob_len,
1040                "sha256": forge_connectors::vault::crypto::sha256_hex(&data),
1041                "replaced": replace_attachment_id,
1042            }))
1043        }
1044        .await;
1045        approved.done(result)
1046    }
1047
1048    async fn folder_list(&self, caller: &SessionSlot) -> Result<Value, VaultToolError> {
1049        let draft = VaultDraft::new(VaultTool::FolderList, "list folders");
1050        let approved = self.gate(caller, draft.clone(), VaultTool::FolderList, None).await?;
1051        self.ensure_keys_current(&approved, None).await?;
1052        let result = async {
1053            let folders = approved.session.list_folders().await?;
1054            let mut rows = Vec::new();
1055            for folder in &folders {
1056                let name = self.fallback_or_lock(
1057                    &approved.session,
1058                    &approved.caller,
1059                    Some(&folder.id),
1060                    approved.keys.decrypt_user_str(&folder.name).map_err(VaultToolError::Crypto),
1061                    "(unreadable)".to_owned(),
1062                )?;
1063                rows.push(json!({ "id": folder.id, "name": name }));
1064            }
1065            Ok(json!({ "folders": rows }))
1066        }
1067        .await;
1068        approved.done(result)
1069    }
1070
1071    async fn folder_create(
1072        &self,
1073        caller: &SessionSlot,
1074        name: &str,
1075    ) -> Result<Value, VaultToolError> {
1076        let mut draft = VaultDraft::new(VaultTool::FolderCreate, format!("create folder '{name}'"));
1077        draft.item_name = Some(name.to_owned());
1078        let approved = self.gate(caller, draft.clone(), VaultTool::FolderCreate, None).await?;
1079        self.ensure_keys_current(&approved, None).await?;
1080        let result = async {
1081            let encrypted = approved.keys.encrypt_user_str(name)?;
1082            let created = approved.session.create_folder(&encrypted).await?;
1083            let id = created.get("id").and_then(Value::as_str).ok_or_else(|| {
1084                VaultToolError::Message("the vault did not return the new folder's id".to_owned())
1085            })?;
1086            Ok(json!({ "id": id, "name": name }))
1087        }
1088        .await;
1089        approved.done(result)
1090    }
1091
1092    async fn folder_update(
1093        &self,
1094        caller: &SessionSlot,
1095        id: &str,
1096        name: &str,
1097    ) -> Result<Value, VaultToolError> {
1098        let mut draft =
1099            VaultDraft::new(VaultTool::FolderUpdate, format!("rename a folder to '{name}'"));
1100        draft.item_id = Some(id.to_owned());
1101        let approved = self.gate(caller, draft.clone(), VaultTool::FolderUpdate, None).await?;
1102        self.ensure_keys_current(&approved, Some(id)).await?;
1103        let result = async {
1104            let encrypted = approved.keys.encrypt_user_str(name)?;
1105            approved.session.update_folder(id, &encrypted).await?;
1106            Ok(json!({ "id": id, "name": name }))
1107        }
1108        .await;
1109        approved.done(result)
1110    }
1111
1112    async fn folder_delete(&self, caller: &SessionSlot, id: &str) -> Result<Value, VaultToolError> {
1113        let session = self.session()?;
1114        let name = if session.is_locked() {
1115            None
1116        } else {
1117            let keys = session.keys()?;
1118            let folders = session.list_folders().await?;
1119            match folders.iter().find(|folder| folder.id == id) {
1120                Some(folder) => Some(self.fallback_or_lock(
1121                    &session,
1122                    caller,
1123                    Some(id),
1124                    keys.decrypt_user_str(&folder.name).map_err(VaultToolError::Crypto),
1125                    "(unreadable)".to_owned(),
1126                )?),
1127                None => None,
1128            }
1129        };
1130        let mut draft = VaultDraft::new(
1131            VaultTool::FolderDelete,
1132            match &name {
1133                Some(name) => format!("delete folder '{name}' (its items move to No Folder)"),
1134                None => format!("delete folder {id} (its items move to No Folder)"),
1135            },
1136        );
1137        draft.item_id = Some(id.to_owned());
1138        draft.item_name = name;
1139        let approved = self.gate(caller, draft.clone(), VaultTool::FolderDelete, None).await?;
1140        Self::guard_reload(&approved, &session)?;
1141        let result = async {
1142            approved.session.delete_folder(id).await?;
1143            let name = draft.item_name.clone().unwrap_or_else(|| id.to_owned());
1144            Ok(json!({ "id": id, "name": name, "deleted": true }))
1145        }
1146        .await;
1147        approved.done(result)
1148    }
1149}
1150
1151impl ProdVaultFacade {
1152    /// The metadata hits for one query: name, username and URIs are decrypted,
1153    /// and nothing else is. Trashed items are skipped, as a vault search does.
1154    /// A field the item's key cannot decrypt draws `(unreadable)`; a stale
1155    /// key lock the session instead.
1156    fn search_items(
1157        &self,
1158        session: &VaultSession,
1159        caller: &SessionSlot,
1160        keys: &Unlocked,
1161        sync: &SyncData,
1162        query: &str,
1163        limit: usize,
1164    ) -> Result<Vec<Value>, VaultToolError> {
1165        let needle = query.trim().to_lowercase();
1166        let mut items = Vec::new();
1167        for cipher in &sync.ciphers {
1168            if cipher.get("deletedDate").and_then(Value::as_str).is_some() {
1169                continue;
1170            }
1171            let item = cipher.get("id").and_then(Value::as_str);
1172            let name = self.fallback_or_lock(
1173                session,
1174                caller,
1175                item,
1176                decrypt_name(keys, cipher),
1177                "(unreadable)".to_owned(),
1178            )?;
1179            let username = self.decrypt_username(session, caller, item, keys, cipher)?;
1180            let uris = self.decrypt_uris(session, caller, item, keys, cipher)?;
1181            let hit = needle.is_empty()
1182                || name.to_lowercase().contains(&needle)
1183                || username.as_deref().is_some_and(|u| u.to_lowercase().contains(&needle))
1184                || uris.iter().any(|uri| uri.to_lowercase().contains(&needle));
1185            if !hit {
1186                continue;
1187            }
1188            let folder = self.folder_name(session, caller, item, keys, sync, cipher)?;
1189            items.push(json!({
1190                "id": cipher.get("id").and_then(Value::as_str),
1191                "name": name,
1192                "username": username,
1193                "uris": uris,
1194                "folder": folder,
1195                "collection": Value::Null,
1196                "updated_at": cipher.get("revisionDate").and_then(Value::as_str),
1197            }));
1198            if items.len() >= limit {
1199                break;
1200            }
1201        }
1202        Ok(items)
1203    }
1204
1205    fn decrypt_username(
1206        &self,
1207        session: &VaultSession,
1208        caller: &SessionSlot,
1209        item: Option<&str>,
1210        keys: &Unlocked,
1211        cipher: &Value,
1212    ) -> Result<Option<String>, VaultToolError> {
1213        let Some(enc) = cipher.pointer("/login/username").and_then(Value::as_str) else {
1214            return Ok(None);
1215        };
1216        let value = self.fallback_or_lock(
1217            session,
1218            caller,
1219            item,
1220            keys.decrypt_item_str(cipher, enc).map_err(VaultToolError::Vault),
1221            String::new(),
1222        )?;
1223        Ok((!value.is_empty()).then_some(value))
1224    }
1225
1226    fn decrypt_uris(
1227        &self,
1228        session: &VaultSession,
1229        caller: &SessionSlot,
1230        item: Option<&str>,
1231        keys: &Unlocked,
1232        cipher: &Value,
1233    ) -> Result<Vec<String>, VaultToolError> {
1234        let mut uris = Vec::new();
1235        if let Some(list) = cipher.pointer("/login/uris").and_then(Value::as_array) {
1236            for entry in list {
1237                if let Some(enc) = entry.get("uri").and_then(Value::as_str) {
1238                    let uri = self.fallback_or_lock(
1239                        session,
1240                        caller,
1241                        item,
1242                        keys.decrypt_item_str(cipher, enc).map_err(VaultToolError::Vault),
1243                        String::new(),
1244                    )?;
1245                    if !uri.is_empty() {
1246                        uris.push(uri);
1247                    }
1248                }
1249            }
1250        }
1251        if uris.is_empty()
1252            && let Some(enc) = cipher.pointer("/login/uri").and_then(Value::as_str)
1253        {
1254            let uri = self.fallback_or_lock(
1255                session,
1256                caller,
1257                item,
1258                keys.decrypt_item_str(cipher, enc).map_err(VaultToolError::Vault),
1259                String::new(),
1260            )?;
1261            if !uri.is_empty() {
1262                uris.push(uri);
1263            }
1264        }
1265        Ok(uris)
1266    }
1267
1268    fn folder_name(
1269        &self,
1270        session: &VaultSession,
1271        caller: &SessionSlot,
1272        item: Option<&str>,
1273        keys: &Unlocked,
1274        sync: &SyncData,
1275        cipher: &Value,
1276    ) -> Result<Option<String>, VaultToolError> {
1277        let Some(id) = cipher.get("folderId").and_then(Value::as_str) else {
1278            return Ok(None);
1279        };
1280        let Some(folder) = sync.folders.iter().find(|folder| folder.id == id) else {
1281            return Ok(None);
1282        };
1283        let name = self.fallback_or_lock(
1284            session,
1285            caller,
1286            item,
1287            keys.decrypt_user_str(&folder.name).map_err(VaultToolError::Crypto),
1288            String::new(),
1289        )?;
1290        Ok((!name.is_empty()).then_some(name))
1291    }
1292
1293    fn item_metadata(
1294        &self,
1295        approved: &Approved,
1296        keys: &Unlocked,
1297        cipher: &Value,
1298    ) -> Result<Value, VaultToolError> {
1299        let session = approved.session.as_ref();
1300        let caller = &approved.caller;
1301        let item = cipher.get("id").and_then(Value::as_str);
1302        let mut metadata = json!({
1303            "id": item,
1304            "name": decrypt_name(keys, cipher)?,
1305            "type": cipher.get("type").and_then(Value::as_u64),
1306            "folder_id": cipher.get("folderId").and_then(Value::as_str),
1307            "collection_ids": cipher.get("collectionIds").cloned().unwrap_or(Value::Null),
1308            "favorite": cipher.get("favorite").and_then(Value::as_bool).unwrap_or(false),
1309            "revision_date": cipher.get("revisionDate").and_then(Value::as_str),
1310            "deleted_date": cipher.get("deletedDate").and_then(Value::as_str),
1311            "attachments": self.attachment_rows(session, caller, item, keys, cipher)?,
1312        });
1313        if let Some(username) = self.decrypt_username(session, caller, item, keys, cipher)? {
1314            metadata["username"] = json!(username);
1315        }
1316        let uris = self.decrypt_uris(session, caller, item, keys, cipher)?;
1317        if !uris.is_empty() {
1318            metadata["uris"] = json!(uris);
1319        }
1320        if let Some(brand) = cipher.pointer("/card/brand").and_then(Value::as_str) {
1321            let brand = self.fallback_or_lock(
1322                session,
1323                caller,
1324                item,
1325                keys.decrypt_item_str(cipher, brand).map_err(VaultToolError::Vault),
1326                String::new(),
1327            )?;
1328            if !brand.is_empty() {
1329                metadata["card_brand"] = json!(brand);
1330            }
1331        }
1332        Ok(metadata)
1333    }
1334
1335    fn attachment_rows(
1336        &self,
1337        session: &VaultSession,
1338        caller: &SessionSlot,
1339        item: Option<&str>,
1340        keys: &Unlocked,
1341        cipher: &Value,
1342    ) -> Result<Value, VaultToolError> {
1343        let Some(list) = cipher.get("attachments").and_then(Value::as_array) else {
1344            return Ok(Value::Array(Vec::new()));
1345        };
1346        let mut rows = Vec::new();
1347        for attachment in list {
1348            let name = match attachment.get("fileName").and_then(Value::as_str) {
1349                Some(enc) => self.fallback_or_lock(
1350                    session,
1351                    caller,
1352                    item,
1353                    keys.decrypt_item_str(cipher, enc).map_err(VaultToolError::Vault),
1354                    "(unreadable)".to_owned(),
1355                )?,
1356                None => "(unreadable)".to_owned(),
1357            };
1358            rows.push(json!({
1359                "id": attachment.get("id").and_then(Value::as_str),
1360                "file_name": name,
1361                "size": attachment.get("size").and_then(Value::as_u64),
1362            }));
1363        }
1364        Ok(Value::Array(rows))
1365    }
1366}
1367
1368fn decrypt_name(keys: &Unlocked, cipher: &Value) -> Result<String, VaultToolError> {
1369    let enc = cipher
1370        .get("name")
1371        .and_then(Value::as_str)
1372        .ok_or_else(|| VaultToolError::Message("the item has no name".to_owned()))?;
1373    Ok(keys.decrypt_item_str(cipher, enc)?)
1374}
1375
1376fn attachment_named(
1377    keys: &Unlocked,
1378    cipher: &Value,
1379    attachment_id: &str,
1380) -> Result<(String, u64), VaultToolError> {
1381    let list = cipher
1382        .get("attachments")
1383        .and_then(Value::as_array)
1384        .ok_or_else(|| VaultToolError::Message("the item has no attachments".to_owned()))?;
1385    let attachment = list
1386        .iter()
1387        .find(|attachment| attachment.get("id").and_then(Value::as_str) == Some(attachment_id))
1388        .ok_or_else(|| {
1389            VaultToolError::Message(format!("the item has no attachment '{attachment_id}'"))
1390        })?;
1391    let name = match attachment.get("fileName").and_then(Value::as_str) {
1392        Some(enc) => match keys.decrypt_item_str(cipher, enc) {
1393            Ok(name) => name,
1394            // A card can still draw the size for a file whose key did not
1395            // unlock; that is a display gap, not a stale-key failure.
1396            Err(VaultError::MissingKey(_)) => "(unreadable)".to_owned(),
1397            Err(error) => return Err(VaultToolError::Vault(error)),
1398        },
1399        None => "(unreadable)".to_owned(),
1400    };
1401    let size = attachment.get("size").and_then(Value::as_u64).unwrap_or(0);
1402    Ok((name, size))
1403}
1404
1405fn validate_reveal(reveal: Option<&str>) -> Result<(), VaultToolError> {
1406    let Some(reveal) = reveal else { return Ok(()) };
1407    let accepted = matches!(
1408        reveal,
1409        "password" | "notes" | "card.cardholder" | "card.number" | "card.expiry" | "card.code"
1410    ) || reveal.starts_with("custom:");
1411    if accepted {
1412        Ok(())
1413    } else {
1414        Err(VaultToolError::Message(format!(
1415            "unknown reveal '{reveal}'; use password, notes, custom:<name>, card.cardholder, \
1416             card.number, card.expiry or card.code"
1417        )))
1418    }
1419}
1420
1421fn reveal_value(keys: &Unlocked, cipher: &Value, reveal: &str) -> Result<Value, VaultToolError> {
1422    match reveal {
1423        "password" => {
1424            let enc = cipher
1425                .pointer("/login/password")
1426                .and_then(Value::as_str)
1427                .ok_or_else(|| VaultToolError::Message("the item has no password".to_owned()))?;
1428            Ok(json!(keys.decrypt_item_str(cipher, enc)?))
1429        }
1430        "notes" => {
1431            let enc = cipher
1432                .get("notes")
1433                .and_then(Value::as_str)
1434                .ok_or_else(|| VaultToolError::Message("the item has no notes".to_owned()))?;
1435            Ok(json!(keys.decrypt_item_str(cipher, enc)?))
1436        }
1437        "card.cardholder" => card_field(keys, cipher, "cardholderName"),
1438        "card.number" => card_field(keys, cipher, "number"),
1439        "card.code" => card_field(keys, cipher, "code"),
1440        "card.expiry" => {
1441            let month = card_field(keys, cipher, "expMonth")?;
1442            let year = card_field(keys, cipher, "expYear")?;
1443            Ok(json!({ "expMonth": month, "expYear": year }))
1444        }
1445        custom if custom.starts_with("custom:") => {
1446            let wanted = &custom["custom:".len()..];
1447            let fields = cipher.get("fields").and_then(Value::as_array).ok_or_else(|| {
1448                VaultToolError::Message("the item has no custom fields".to_owned())
1449            })?;
1450            for field in fields {
1451                let name = field
1452                    .get("name")
1453                    .and_then(Value::as_str)
1454                    .map(|enc| keys.decrypt_item_str(cipher, enc))
1455                    .transpose()?;
1456                if name.as_deref() == Some(wanted)
1457                    && let Some(enc) = field.get("value").and_then(Value::as_str)
1458                {
1459                    return Ok(json!(keys.decrypt_item_str(cipher, enc)?));
1460                }
1461            }
1462            Err(VaultToolError::Message(format!("the item has no custom field '{wanted}'")))
1463        }
1464        other => Err(VaultToolError::Message(format!("unknown reveal '{other}'"))),
1465    }
1466}
1467
1468fn card_field(keys: &Unlocked, cipher: &Value, field: &str) -> Result<Value, VaultToolError> {
1469    let enc = cipher
1470        .pointer(&format!("/card/{field}"))
1471        .and_then(Value::as_str)
1472        .ok_or_else(|| VaultToolError::Message(format!("the item has no card {field}")))?;
1473    Ok(json!(keys.decrypt_item_str(cipher, enc)?))
1474}
1475
1476fn masked_create_diff(input: &CreateInput, kind: &str) -> String {
1477    match kind {
1478        "card" => "new card: number, expiry and CVV are set and never drawn".to_owned(),
1479        "note" => "secure note".to_owned(),
1480        _ => {
1481            let mut fields = vec!["name"];
1482            if input.username.is_some() {
1483                fields.push("username");
1484            }
1485            if input.password.is_some() {
1486                fields.push("password");
1487            }
1488            if input.uri.is_some() {
1489                fields.push("uri");
1490            }
1491            if input.totp.is_some() {
1492                fields.push("totp");
1493            }
1494            if input.notes.is_some() {
1495                fields.push("notes");
1496            }
1497            if input.custom_fields.is_some() {
1498                fields.push("custom fields");
1499            }
1500            format!("new login: {}", fields.join(", "))
1501        }
1502    }
1503}
1504
1505fn patch_fields(patch: &PatchInput) -> Vec<&'static str> {
1506    let mut fields = Vec::new();
1507    if patch.name.is_some() {
1508        fields.push("name");
1509    }
1510    if patch.username.is_some() {
1511        fields.push("username");
1512    }
1513    if patch.password.is_some() {
1514        fields.push("password");
1515    }
1516    if patch.uri.is_some() {
1517        fields.push("uri");
1518    }
1519    if patch.totp.is_some() {
1520        fields.push("totp");
1521    }
1522    if patch.notes.is_some() {
1523        fields.push("notes");
1524    }
1525    if patch.cardholder_name.is_some()
1526        || patch.number.is_some()
1527        || patch.exp_month.is_some()
1528        || patch.exp_year.is_some()
1529        || patch.code.is_some()
1530        || patch.brand.is_some()
1531    {
1532        fields.push("card details");
1533    }
1534    if patch.folder_id.is_some() {
1535        fields.push("folder");
1536    }
1537    if patch.custom_fields.is_some() {
1538        fields.push("custom fields");
1539    }
1540    fields
1541}
1542
1543fn masked_update_diff(changed: &[&str]) -> String {
1544    if changed.contains(&"password") {
1545        let mut parts: Vec<&str> =
1546            changed.iter().copied().filter(|field| *field != "password").collect();
1547        parts.push("password changed");
1548        return parts.join(", ");
1549    }
1550    if changed.contains(&"card details") {
1551        let mut parts: Vec<&str> =
1552            changed.iter().copied().filter(|field| *field != "card details").collect();
1553        parts.push("card details changed");
1554        return parts.join(", ");
1555    }
1556    changed.join(", ")
1557}
1558
1559/// The account id a write stamps on `encryptedFor`. Vaultwarden 1.37
1560/// deserializes it as a required field and compares it with the token's own
1561/// user, so a sync that omits it cannot produce a valid write.
1562fn account_user_id(profile: &Profile) -> Result<String, VaultToolError> {
1563    profile.id.as_deref().filter(|id| !id.is_empty()).map(str::to_owned).ok_or_else(|| {
1564        VaultToolError::Message(
1565            "the vault sync carried no account id, so the item cannot be stamped with encryptedFor"
1566                .to_owned(),
1567        )
1568    })
1569}
1570
1571/// A seed that cannot produce a code would store a permanent broken second
1572/// factor with the card masking the typo, so it is refused before encrypting.
1573fn check_totp_seed(seed: &str) -> Result<(), VaultToolError> {
1574    if seed.trim().is_empty() {
1575        return Err(VaultToolError::Message("the TOTP seed is empty".to_owned()));
1576    }
1577    forge_connectors::vault::crypto::totp_from_uri(seed, std::time::SystemTime::now())
1578        .map(|_| ())
1579        .map_err(|error| {
1580            VaultToolError::Message(format!("the TOTP seed cannot produce a code: {error}"))
1581        })
1582}
1583
1584fn build_new_cipher(
1585    keys: &Unlocked,
1586    user_id: &str,
1587    input: &CreateInput,
1588    kind: &str,
1589) -> Result<Value, VaultToolError> {
1590    if input.totp.is_some() && kind != "login" {
1591        return Err(VaultToolError::Message(format!(
1592            "a TOTP seed applies to a login item, not a {kind}"
1593        )));
1594    }
1595    let base = Value::Null;
1596    let mut cipher = json!({
1597        "type": match kind { "card" => 3, "note" => 2, _ => 1 },
1598        "name": keys.encrypt_item_str(&base, &input.name)?,
1599        "favorite": false,
1600        "reprompt": 0,
1601        "encryptedFor": user_id,
1602    });
1603    if let Some(notes) = &input.notes {
1604        cipher["notes"] = json!(keys.encrypt_item_str(&base, notes)?);
1605    }
1606    if let Some(folder) = &input.folder_id {
1607        cipher["folderId"] = json!(folder);
1608    }
1609    match kind {
1610        "card" => {
1611            let mut card = serde_json::Map::new();
1612            for (field, value) in [
1613                ("cardholderName", input.cardholder_name.as_ref()),
1614                ("brand", input.brand.as_ref()),
1615                ("number", input.number.as_ref()),
1616                ("expMonth", input.exp_month.as_ref()),
1617                ("expYear", input.exp_year.as_ref()),
1618                ("code", input.code.as_ref()),
1619            ] {
1620                if let Some(value) = value {
1621                    card.insert(field.to_owned(), json!(keys.encrypt_item_str(&base, value)?));
1622                }
1623            }
1624            cipher["card"] = Value::Object(card);
1625        }
1626        "note" => {
1627            cipher["secureNote"] = json!({ "type": 0 });
1628        }
1629        _ => {
1630            let mut login = serde_json::Map::new();
1631            if let Some(username) = &input.username {
1632                login.insert("username".to_owned(), json!(keys.encrypt_item_str(&base, username)?));
1633            }
1634            if let Some(password) = &input.password {
1635                login.insert("password".to_owned(), json!(keys.encrypt_item_str(&base, password)?));
1636            }
1637            if let Some(uri) = &input.uri {
1638                let encrypted = keys.encrypt_item_str(&base, uri)?;
1639                login.insert("uri".to_owned(), json!(encrypted));
1640                login
1641                    .insert("uris".to_owned(), json!([{ "uri": encrypted, "match": Value::Null }]));
1642            }
1643            if let Some(totp) = &input.totp {
1644                check_totp_seed(totp)?;
1645                login.insert("totp".to_owned(), json!(keys.encrypt_item_str(&base, totp)?));
1646            }
1647            cipher["login"] = Value::Object(login);
1648        }
1649    }
1650    if let Some(fields) = &input.custom_fields {
1651        cipher["fields"] = custom_fields(keys, &base, fields)?;
1652    }
1653    Ok(cipher)
1654}
1655
1656fn custom_fields(
1657    keys: &Unlocked,
1658    cipher: &Value,
1659    fields: &[CustomFieldInput],
1660) -> Result<Value, VaultToolError> {
1661    let mut rows = Vec::new();
1662    for field in fields {
1663        let kind = match field.kind.as_deref() {
1664            Some("hidden") => 1,
1665            Some("boolean") => 2,
1666            _ => 0,
1667        };
1668        rows.push(json!({
1669            "name": keys.encrypt_item_str(cipher, &field.name)?,
1670            "value": keys.encrypt_item_str(cipher, &field.value)?,
1671            "type": kind,
1672        }));
1673    }
1674    Ok(Value::Array(rows))
1675}
1676
1677fn apply_patch(
1678    keys: &Unlocked,
1679    user_id: &str,
1680    mut cipher: Value,
1681    patch: &PatchInput,
1682) -> Result<Value, VaultToolError> {
1683    // A PUT deserializes the same CipherData a create does, and the fetched
1684    // cipher does not carry this field, so the update stamps it here. A body
1685    // that already names its owner keeps it: an organization cipher's value
1686    // describes the ciphertext, and rewriting it to the personal id would
1687    // contradict what was encrypted.
1688    let carried =
1689        cipher.get("encryptedFor").and_then(Value::as_str).is_some_and(|owner| !owner.is_empty());
1690    if !carried {
1691        cipher["encryptedFor"] = json!(user_id);
1692    }
1693    if let Some(totp) = &patch.totp {
1694        if cipher.get("type").and_then(Value::as_u64) != Some(1) {
1695            return Err(VaultToolError::Message("a TOTP seed applies to a login item".to_owned()));
1696        }
1697        check_totp_seed(totp)?;
1698    }
1699    if let Some(name) = &patch.name {
1700        cipher["name"] = json!(keys.encrypt_item_str(&cipher, name)?);
1701    }
1702    if let Some(notes) = &patch.notes {
1703        cipher["notes"] = json!(keys.encrypt_item_str(&cipher, notes)?);
1704    }
1705    if let Some(folder) = &patch.folder_id {
1706        cipher["folderId"] = json!(folder);
1707    }
1708    if patch.username.is_some()
1709        || patch.password.is_some()
1710        || patch.uri.is_some()
1711        || patch.totp.is_some()
1712    {
1713        if cipher.get("login").is_none() {
1714            cipher["login"] = json!({});
1715        }
1716        if let Some(username) = &patch.username {
1717            cipher["login"]["username"] = json!(keys.encrypt_item_str(&cipher, username)?);
1718        }
1719        if let Some(password) = &patch.password {
1720            cipher["login"]["password"] = json!(keys.encrypt_item_str(&cipher, password)?);
1721        }
1722        if let Some(uri) = &patch.uri {
1723            let encrypted = keys.encrypt_item_str(&cipher, uri)?;
1724            cipher["login"]["uri"] = json!(encrypted);
1725            cipher["login"]["uris"] = json!([{ "uri": encrypted, "match": Value::Null }]);
1726        }
1727        if let Some(totp) = &patch.totp {
1728            cipher["login"]["totp"] = json!(keys.encrypt_item_str(&cipher, totp)?);
1729        }
1730    }
1731    let card_fields = [
1732        ("cardholderName", patch.cardholder_name.as_ref()),
1733        ("brand", patch.brand.as_ref()),
1734        ("number", patch.number.as_ref()),
1735        ("expMonth", patch.exp_month.as_ref()),
1736        ("expYear", patch.exp_year.as_ref()),
1737        ("code", patch.code.as_ref()),
1738    ];
1739    if card_fields.iter().any(|(_, value)| value.is_some()) {
1740        if cipher.get("card").is_none() {
1741            cipher["card"] = json!({});
1742        }
1743        for (field, value) in card_fields {
1744            if let Some(value) = value {
1745                cipher["card"][field] = json!(keys.encrypt_item_str(&cipher, value)?);
1746            }
1747        }
1748    }
1749    if let Some(fields) = &patch.custom_fields {
1750        cipher["fields"] = custom_fields(keys, &cipher, fields)?;
1751    }
1752    Ok(cipher)
1753}
1754
1755fn resolve_destination(
1756    workspace: &Workspace,
1757    caller: &SessionSlot,
1758    destination: &str,
1759) -> Result<PathBuf, VaultToolError> {
1760    let cwd = workspace.cwd_for_session(caller).ok_or_else(|| {
1761        VaultToolError::Message("the session has no working directory".to_owned())
1762    })?;
1763    let cwd = std::fs::canonicalize(cwd)
1764        .map_err(|error| VaultToolError::Io(format!("the working directory: {error}")))?;
1765    let target = Path::new(destination);
1766    let absolute = if target.is_absolute() { target.to_path_buf() } else { cwd.join(target) };
1767    let parent = absolute.parent().ok_or_else(|| {
1768        VaultToolError::Message("the destination has no parent directory".to_owned())
1769    })?;
1770    let parent = std::fs::canonicalize(parent)
1771        .map_err(|error| VaultToolError::Io(format!("the destination directory: {error}")))?;
1772    if !parent.starts_with(&cwd) {
1773        return Err(VaultToolError::Message(
1774            "the destination is outside this session's working directory".to_owned(),
1775        ));
1776    }
1777    let name = absolute
1778        .file_name()
1779        .ok_or_else(|| VaultToolError::Message("the destination has no file name".to_owned()))?;
1780    Ok(parent.join(name))
1781}
1782
1783fn resolve_source(
1784    workspace: &Workspace,
1785    caller: &SessionSlot,
1786    path: &str,
1787) -> Result<PathBuf, VaultToolError> {
1788    let cwd = workspace.cwd_for_session(caller).ok_or_else(|| {
1789        VaultToolError::Message("the session has no working directory".to_owned())
1790    })?;
1791    let cwd = std::fs::canonicalize(cwd)
1792        .map_err(|error| VaultToolError::Io(format!("the working directory: {error}")))?;
1793    let target = Path::new(path);
1794    let absolute = if target.is_absolute() { target.to_path_buf() } else { cwd.join(target) };
1795    let source = std::fs::canonicalize(&absolute)
1796        .map_err(|error| VaultToolError::Io(format!("the attachment file: {error}")))?;
1797    if !source.starts_with(&cwd) {
1798        return Err(VaultToolError::Message(
1799            "the attachment file is outside this session's working directory".to_owned(),
1800        ));
1801    }
1802    Ok(source)
1803}
1804
1805/// The server records the ciphertext's size at upload; a missing size fails
1806/// closed rather than reading as a match.
1807fn check_stored_size(stored: Option<u64>, blob_len: usize) -> Result<(), VaultToolError> {
1808    let Some(stored) = stored else {
1809        return Err(VaultToolError::Message(
1810            "the server did not report the stored attachment size".to_owned(),
1811        ));
1812    };
1813    if stored != blob_len as u64 {
1814        return Err(VaultToolError::Message(format!(
1815            "the server stored {stored} bytes for a {blob_len} byte blob"
1816        )));
1817    }
1818    Ok(())
1819}
1820
1821/// Re-resolve the destination immediately before a write: a parent swapped to
1822/// a symlink while the card waited must not redirect the write.
1823fn recheck_destination(
1824    workspace: &Workspace,
1825    caller: &SessionSlot,
1826    destination: &str,
1827    expected: &Path,
1828) -> Result<PathBuf, VaultToolError> {
1829    let current = resolve_destination(workspace, caller, destination)?;
1830    if current != expected {
1831        return Err(VaultToolError::Message(
1832            "the destination changed while the call was parked; nothing was written".to_owned(),
1833        ));
1834    }
1835    Ok(current)
1836}
1837
1838/// The name a replace card draws for the attachment it would remove: the
1839/// decrypted file name when the session could read it, else the id.
1840fn replacement_label(id: Option<&str>, name: Option<String>) -> Option<String> {
1841    id.map(|id| name.unwrap_or_else(|| id.to_owned()))
1842}
1843
1844/// Read an attachment source: the size is checked from metadata before the
1845/// read, and again after it, because the file can grow between the two.
1846fn read_attachment(path: &Path, cap: u64) -> Result<Vec<u8>, VaultToolError> {
1847    let declared = std::fs::metadata(path)?.len();
1848    if declared > cap {
1849        return Err(VaultToolError::Message(format!(
1850            "the file is {declared} bytes, over the {cap} byte cap"
1851        )));
1852    }
1853    let data = std::fs::read(path)?;
1854    if data.len() as u64 > cap {
1855        return Err(VaultToolError::Message(format!(
1856            "the file is {} bytes, over the {cap} byte cap",
1857            data.len()
1858        )));
1859    }
1860    Ok(data)
1861}
1862
1863fn write_private(path: &Path, bytes: &[u8]) -> Result<(), VaultToolError> {
1864    use rustix::fs::{Mode, OFlags};
1865    use std::io::Write;
1866    use std::os::unix::fs::{MetadataExt, PermissionsExt};
1867
1868    let parent = path
1869        .parent()
1870        .ok_or_else(|| VaultToolError::Message("the destination has no parent".to_owned()))?;
1871    let name = path
1872        .file_name()
1873        .ok_or_else(|| VaultToolError::Message("the destination has no file name".to_owned()))?;
1874    // The parent is held as a directory handle and the file is opened
1875    // relative to it, so the path checked for containment is the one written.
1876    // The handle is then proven to be the directory the containment check
1877    // looked at by reading the fd's own path back: a parent swapped between
1878    // the canonicalize and the open makes the two disagree.
1879    let expected = std::fs::canonicalize(parent)?;
1880    let dir = rustix::fs::open(&expected, OFlags::RDONLY | OFlags::CLOEXEC, Mode::empty())
1881        .map_err(errno_io)?;
1882    let dir = std::fs::File::from(dir);
1883    verify_dir_path(&dir, &expected)?;
1884    let file = rustix::fs::openat(
1885        &dir,
1886        name,
1887        OFlags::WRONLY | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
1888        Mode::from_bits_truncate(0o600),
1889    )
1890    .map_err(errno_io)?;
1891    let mut file = std::fs::File::from(file);
1892    let metadata = file.metadata()?;
1893    // A FIFO would block the blocking thread; a device or socket is not an
1894    // attachment destination either.
1895    if !metadata.file_type().is_file() {
1896        return Err(VaultToolError::Message(
1897            "the destination is not a regular file; refusing to write".to_owned(),
1898        ));
1899    }
1900    // A hard link names bytes outside this tree; truncating through one would
1901    // write another file's contents. The truncate waits until the link count
1902    // has been checked.
1903    if metadata.nlink() > 1 {
1904        return Err(VaultToolError::Message(
1905            "the destination is a hard link; refusing to write through it".to_owned(),
1906        ));
1907    }
1908    file.set_len(0)?;
1909    file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
1910    file.write_all(bytes)?;
1911    file.flush()?;
1912    Ok(())
1913}
1914
1915fn errno_io(error: rustix::io::Errno) -> std::io::Error {
1916    std::io::Error::from_raw_os_error(error.raw_os_error())
1917}
1918
1919/// The canonical path of an opened directory, read from the descriptor
1920/// itself. A platform that cannot answer fails closed.
1921fn dir_path(dir: &std::fs::File) -> std::io::Result<PathBuf> {
1922    #[cfg(target_os = "linux")]
1923    use std::os::fd::AsRawFd;
1924    #[cfg(target_os = "macos")]
1925    use std::os::unix::ffi::OsStrExt;
1926
1927    #[cfg(target_os = "linux")]
1928    {
1929        std::fs::read_link(format!("/proc/self/fd/{}", dir.as_raw_fd()))
1930    }
1931    #[cfg(target_os = "macos")]
1932    {
1933        let path = rustix::fs::getpath(dir).map_err(errno_io)?;
1934        Ok(PathBuf::from(std::ffi::OsStr::from_bytes(path.as_bytes())))
1935    }
1936    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1937    {
1938        let _ = dir;
1939        Err(std::io::Error::new(
1940            std::io::ErrorKind::Unsupported,
1941            "this platform cannot read a directory handle's path",
1942        ))
1943    }
1944}
1945
1946/// The opened directory must be the canonical directory the containment check
1947/// approved, not merely a path that resolved to it a moment later.
1948fn verify_dir_path(dir: &std::fs::File, expected: &Path) -> Result<(), VaultToolError> {
1949    if dir_path(dir)? != expected {
1950        return Err(VaultToolError::Message(
1951            "the destination directory changed while the call was parked; nothing was written"
1952                .to_owned(),
1953        ));
1954    }
1955    Ok(())
1956}
1957
1958/// A mock facade for the tool tests: records each call and returns a fixed
1959/// JSON acknowledgment. The error path can be armed for one call.
1960#[cfg(test)]
1961#[derive(Default)]
1962pub(crate) struct MockVaultFacade {
1963    calls: parking_lot::Mutex<Vec<String>>,
1964    failure: parking_lot::Mutex<Option<String>>,
1965}
1966
1967#[cfg(test)]
1968impl MockVaultFacade {
1969    pub(crate) fn new() -> Self {
1970        Self::default()
1971    }
1972
1973    pub(crate) fn into_arc(self) -> Arc<dyn VaultFacade> {
1974        Arc::new(self)
1975    }
1976
1977    pub(crate) fn fail_next(&self, message: impl Into<String>) {
1978        *self.failure.lock() = Some(message.into());
1979    }
1980
1981    pub(crate) fn calls(&self) -> Vec<String> {
1982        self.calls.lock().clone()
1983    }
1984
1985    fn record(&self, tool: VaultTool) -> Result<Value, VaultToolError> {
1986        self.calls.lock().push(tool.as_str().to_owned());
1987        if let Some(message) = self.failure.lock().take() {
1988            return Err(VaultToolError::Message(message));
1989        }
1990        Ok(json!({ "tool": tool.as_str() }))
1991    }
1992}
1993
1994#[cfg(test)]
1995#[async_trait]
1996impl VaultFacade for MockVaultFacade {
1997    async fn search(
1998        &self,
1999        _caller: &SessionSlot,
2000        _query: &str,
2001        _limit: Option<usize>,
2002    ) -> Result<Value, VaultToolError> {
2003        self.record(VaultTool::Search)
2004    }
2005    async fn get(
2006        &self,
2007        _caller: &SessionSlot,
2008        _id: &str,
2009        _reveal: Option<&str>,
2010    ) -> Result<Value, VaultToolError> {
2011        self.record(VaultTool::Get)
2012    }
2013    async fn create(
2014        &self,
2015        _caller: &SessionSlot,
2016        _input: &CreateInput,
2017    ) -> Result<Value, VaultToolError> {
2018        self.record(VaultTool::Create)
2019    }
2020    async fn update(
2021        &self,
2022        _caller: &SessionSlot,
2023        _id: &str,
2024        _patch: &PatchInput,
2025    ) -> Result<Value, VaultToolError> {
2026        self.record(VaultTool::Update)
2027    }
2028    async fn trash(&self, _caller: &SessionSlot, _id: &str) -> Result<Value, VaultToolError> {
2029        self.record(VaultTool::Trash)
2030    }
2031    async fn restore(&self, _caller: &SessionSlot, _id: &str) -> Result<Value, VaultToolError> {
2032        self.record(VaultTool::Restore)
2033    }
2034    async fn totp_code(&self, _caller: &SessionSlot, _id: &str) -> Result<Value, VaultToolError> {
2035        self.record(VaultTool::TotpCode)
2036    }
2037    async fn attachment_get(
2038        &self,
2039        _caller: &SessionSlot,
2040        _item_id: &str,
2041        _attachment_id: &str,
2042        _destination: &str,
2043    ) -> Result<Value, VaultToolError> {
2044        self.record(VaultTool::AttachmentGet)
2045    }
2046    async fn attachment_put(
2047        &self,
2048        _caller: &SessionSlot,
2049        _item_id: &str,
2050        _path: &str,
2051        _name: Option<&str>,
2052        _replace_attachment_id: Option<&str>,
2053    ) -> Result<Value, VaultToolError> {
2054        self.record(VaultTool::AttachmentPut)
2055    }
2056    async fn folder_list(&self, _caller: &SessionSlot) -> Result<Value, VaultToolError> {
2057        self.record(VaultTool::FolderList)
2058    }
2059    async fn folder_create(
2060        &self,
2061        _caller: &SessionSlot,
2062        _name: &str,
2063    ) -> Result<Value, VaultToolError> {
2064        self.record(VaultTool::FolderCreate)
2065    }
2066    async fn folder_update(
2067        &self,
2068        _caller: &SessionSlot,
2069        _id: &str,
2070        _name: &str,
2071    ) -> Result<Value, VaultToolError> {
2072        self.record(VaultTool::FolderUpdate)
2073    }
2074    async fn folder_delete(
2075        &self,
2076        _caller: &SessionSlot,
2077        _id: &str,
2078    ) -> Result<Value, VaultToolError> {
2079        self.record(VaultTool::FolderDelete)
2080    }
2081}
2082
2083#[cfg(test)]
2084mod tests {
2085    use super::*;
2086    use crate::config::VaultConfig;
2087    use crate::protocol::Command;
2088    use crate::vault::{test_install_unlocked, test_session};
2089    use forge_primitives::vault::{VaultAskAnswer, VaultAskDecision, VaultReleased};
2090
2091    fn audit_text(dir: &tempfile::TempDir) -> String {
2092        std::fs::read_to_string(
2093            dir.path()
2094                .join("vault")
2095                .join(format!("audit-{}.jsonl", forge_sdk::config_dir_store_key(dir.path()))),
2096        )
2097        .unwrap_or_default()
2098    }
2099
2100    async fn read_test_request(socket: &mut tokio::net::TcpStream) -> String {
2101        use tokio::io::AsyncReadExt;
2102        let mut buf = Vec::new();
2103        let mut chunk = [0u8; 8192];
2104        loop {
2105            let read = socket.read(&mut chunk).await.unwrap_or(0);
2106            if read == 0 {
2107                break;
2108            }
2109            buf.extend_from_slice(&chunk[..read]);
2110            if let Some(end) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
2111                let headers = String::from_utf8_lossy(&buf[..end]).to_lowercase();
2112                let length: usize = headers
2113                    .lines()
2114                    .find_map(|line| line.strip_prefix("content-length:"))
2115                    .and_then(|value| value.trim().parse().ok())
2116                    .unwrap_or(0);
2117                if buf.len() >= end + 4 + length {
2118                    break;
2119                }
2120            }
2121        }
2122        String::from_utf8_lossy(&buf).into_owned()
2123    }
2124
2125    /// One cipher read that pauses after the request arrives, so a test can
2126    /// swap the session while the pre-gate read is held.
2127    async fn serve_paused_cipher(
2128        name: String,
2129    ) -> (std::net::SocketAddr, tokio::sync::oneshot::Receiver<()>, tokio::sync::oneshot::Sender<()>)
2130    {
2131        use tokio::io::AsyncWriteExt;
2132        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
2133        let address = listener.local_addr().expect("addr");
2134        let (seen_tx, seen_rx) = tokio::sync::oneshot::channel();
2135        let (release_tx, release_rx) = tokio::sync::oneshot::channel();
2136        tokio::spawn(async move {
2137            let (mut socket, _) = listener.accept().await.expect("accept");
2138            let request = read_test_request(&mut socket).await;
2139            assert!(
2140                request.split_whitespace().nth(1) == Some("/api/ciphers/item-1"),
2141                "unexpected request: {}",
2142                request.lines().next().unwrap_or_default(),
2143            );
2144            let _ = seen_tx.send(());
2145            let _ = release_rx.await;
2146            let body = serde_json::json!({
2147                "id": "item-1",
2148                "type": 1,
2149                "name": name,
2150                "login": {},
2151            })
2152            .to_string();
2153            let response = format!(
2154                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
2155                body.len()
2156            );
2157            let _ = socket.write_all(response.as_bytes()).await;
2158        });
2159        (address, seen_rx, release_tx)
2160    }
2161
2162    enum Reply {
2163        Json(String),
2164        Bytes(Vec<u8>),
2165        Status { code: u16, body: String },
2166    }
2167
2168    struct Step {
2169        path: &'static str,
2170        reply: Reply,
2171        pause: Option<(tokio::sync::oneshot::Sender<()>, tokio::sync::oneshot::Receiver<()>)>,
2172    }
2173
2174    /// Serve one scripted response per request in order, optionally pausing
2175    /// after the request arrives so a test can swap state under the call.
2176    async fn serve_steps(steps: Vec<Step>) -> std::net::SocketAddr {
2177        use tokio::io::AsyncWriteExt;
2178        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
2179        let address = listener.local_addr().expect("addr");
2180        tokio::spawn(async move {
2181            for step in steps {
2182                let (mut socket, _) = listener.accept().await.expect("accept");
2183                let request = read_test_request(&mut socket).await;
2184                assert_eq!(
2185                    request.split_whitespace().nth(1),
2186                    Some(step.path),
2187                    "unexpected request: {}",
2188                    request.lines().next().unwrap_or_default(),
2189                );
2190                if let Some((seen, release)) = step.pause {
2191                    let _ = seen.send(());
2192                    let _ = release.await;
2193                }
2194                let (status, kind, body) = match step.reply {
2195                    Reply::Json(body) => {
2196                        ("200 OK".to_owned(), "application/json", body.into_bytes())
2197                    }
2198                    Reply::Bytes(body) => ("200 OK".to_owned(), "application/octet-stream", body),
2199                    Reply::Status { code, body } => {
2200                        (format!("{code} Error"), "application/json", body.into_bytes())
2201                    }
2202                };
2203                let head = format!(
2204                    "HTTP/1.1 {status}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2205                    body.len()
2206                );
2207                let _ = socket.write_all(head.as_bytes()).await;
2208                let _ = socket.write_all(&body).await;
2209            }
2210        });
2211        address
2212    }
2213
2214    /// An unlocked worker session with a cwd, pointed at `address`.
2215    fn unlocked_worker_workspace(
2216        address: std::net::SocketAddr,
2217        user_key: forge_connectors::vault::crypto::SymmetricCryptoKey,
2218        user_key_fingerprint: String,
2219    ) -> (
2220        Arc<Workspace>,
2221        Arc<ProdVaultFacade>,
2222        SessionSlot,
2223        tempfile::TempDir,
2224        tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
2225        Arc<VaultSession>,
2226    ) {
2227        use crate::mcp::workers::types::WorkerEntry;
2228        use forge_primitives::WorkerLiveness;
2229        use forge_primitives::project_key::ProjectKey;
2230
2231        let dir = tempfile::tempdir().expect("tempdir");
2232        let (workspace, rx) = Workspace::testing_stub();
2233        let caller = SessionSlot::worker("TestOrg", "proj", "vault-test");
2234        workspace.insert_live_worker(
2235            &ProjectKey::new("TestOrg/proj"),
2236            WorkerEntry {
2237                label: "vault-test".to_owned(),
2238                charter: "test".to_owned(),
2239                slot: caller.clone(),
2240                session_id: None,
2241                status: WorkerLiveness::Running,
2242                spawned_at: std::time::SystemTime::UNIX_EPOCH,
2243                spawned_by: SessionSlot::lead("TestOrg", "proj"),
2244                needs_tag: false,
2245                is_git_repo_at_spawn: false,
2246                cwd: Some(dir.path().to_path_buf()),
2247                diagnostic: None,
2248                kick: None,
2249            },
2250        );
2251        let config = VaultConfig {
2252            url: format!("http://{address}"),
2253            email: "hi@vedhavyas.dev".to_owned(),
2254            idle_minutes: None,
2255        };
2256        let session =
2257            Arc::new(test_session(&config, Some(dir.path()), dir.path()).expect("a session"));
2258        test_install_unlocked(
2259            &session,
2260            Unlocked {
2261                user_key,
2262                private_key: None,
2263                org_keys: std::collections::HashMap::new(),
2264                failed_orgs: Vec::new(),
2265                user_key_fingerprint,
2266            },
2267        );
2268        *workspace.vault.write() = Some(Arc::clone(&session));
2269        let facade = Arc::new(ProdVaultFacade::from_arc(&workspace));
2270        (workspace, facade, caller, dir, rx, session)
2271    }
2272
2273    fn approve(workspace: &Arc<Workspace>, caller: &SessionSlot, id: uuid::Uuid) {
2274        workspace
2275            .dispatch(Command::RespondVaultAsk {
2276                key: caller.clone(),
2277                id,
2278                answer: VaultAskAnswer {
2279                    decision: VaultAskDecision::Approve,
2280                    master_password: None,
2281                    two_factor: None,
2282                    released: None,
2283                },
2284            })
2285            .expect("the answer routes");
2286    }
2287
2288    /// Serve one response per expected `(path, body)` in order, asserting the
2289    /// request line names the path the test expected.
2290    async fn serve_vault(responses: Vec<(&'static str, String)>) -> std::net::SocketAddr {
2291        use tokio::io::AsyncWriteExt;
2292        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
2293        let address = listener.local_addr().expect("addr");
2294        tokio::spawn(async move {
2295            for (path, body) in responses {
2296                let (mut socket, _) = listener.accept().await.expect("accept");
2297                let request = read_test_request(&mut socket).await;
2298                assert!(
2299                    request.split_whitespace().nth(1) == Some(path),
2300                    "the server expected {path}, got {}",
2301                    request.lines().next().unwrap_or_default(),
2302                );
2303                let response = format!(
2304                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
2305                    body.len()
2306                );
2307                let _ = socket.write_all(response.as_bytes()).await;
2308            }
2309        });
2310        address
2311    }
2312
2313    fn locked_workspace() -> (
2314        Arc<Workspace>,
2315        Arc<ProdVaultFacade>,
2316        SessionSlot,
2317        tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
2318    ) {
2319        let (workspace, rx) = Workspace::testing_stub();
2320        let config = VaultConfig {
2321            url: "http://127.0.0.1:1".to_owned(),
2322            email: "hi@vedhavyas.dev".to_owned(),
2323            idle_minutes: None,
2324        };
2325        let session = Arc::new(test_session(&config, None, Path::new("/tmp")).expect("a session"));
2326        *workspace.vault.write() = Some(session);
2327        let facade = Arc::new(ProdVaultFacade::from_arc(&workspace));
2328        (workspace, facade, SessionSlot::lead("Default", "forge"), rx)
2329    }
2330
2331    async fn parked_draft(
2332        rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
2333    ) -> VaultDraft {
2334        loop {
2335            // Generous: PBKDF2 unlock runs on the blocking pool, and a loaded
2336            // machine can keep this off the requester longer than a few seconds.
2337            let frame = tokio::time::timeout(std::time::Duration::from_secs(30), rx.recv())
2338                .await
2339                .expect("the locked call parks a card")
2340                .expect("the seat stream stays open");
2341            if let crate::protocol::SessionUpdate::VaultAskPending { draft, .. } =
2342                std::sync::Arc::unwrap_or_clone(frame.update)
2343            {
2344                return draft;
2345            }
2346        }
2347    }
2348
2349    fn decline(workspace: &Arc<Workspace>, caller: &SessionSlot, id: uuid::Uuid) {
2350        workspace
2351            .dispatch(Command::RespondVaultAsk {
2352                key: caller.clone(),
2353                id,
2354                answer: VaultAskAnswer {
2355                    decision: VaultAskDecision::Decline,
2356                    master_password: None,
2357                    two_factor: None,
2358                    released: None,
2359                },
2360            })
2361            .expect("the answer routes");
2362    }
2363
2364    fn locked_worker_workspace() -> (
2365        Arc<Workspace>,
2366        Arc<ProdVaultFacade>,
2367        SessionSlot,
2368        tempfile::TempDir,
2369        tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
2370    ) {
2371        use crate::mcp::workers::types::WorkerEntry;
2372        use forge_primitives::WorkerLiveness;
2373        use forge_primitives::project_key::ProjectKey;
2374
2375        let dir = tempfile::tempdir().expect("tempdir");
2376        let (workspace, rx) = Workspace::testing_stub();
2377        let caller = SessionSlot::worker("TestOrg", "proj", "vault-test");
2378        workspace.insert_live_worker(
2379            &ProjectKey::new("TestOrg/proj"),
2380            WorkerEntry {
2381                label: "vault-test".to_owned(),
2382                charter: "test".to_owned(),
2383                slot: caller.clone(),
2384                session_id: None,
2385                status: WorkerLiveness::Running,
2386                spawned_at: std::time::SystemTime::UNIX_EPOCH,
2387                spawned_by: SessionSlot::lead("TestOrg", "proj"),
2388                needs_tag: false,
2389                is_git_repo_at_spawn: false,
2390                cwd: Some(dir.path().to_path_buf()),
2391                diagnostic: None,
2392                kick: None,
2393            },
2394        );
2395        let config = VaultConfig {
2396            url: "http://127.0.0.1:1".to_owned(),
2397            email: "hi@vedhavyas.dev".to_owned(),
2398            idle_minutes: None,
2399        };
2400        let session =
2401            Arc::new(test_session(&config, Some(dir.path()), dir.path()).expect("a session"));
2402        *workspace.vault.write() = Some(session);
2403        let facade = Arc::new(ProdVaultFacade::from_arc(&workspace));
2404        (workspace, facade, caller, dir, rx)
2405    }
2406
2407    async fn held<T, F>(
2408        workspace: &Arc<Workspace>,
2409        caller: &SessionSlot,
2410        rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
2411        tool: VaultTool,
2412        call: F,
2413    ) where
2414        T: Send + 'static,
2415        F: std::future::Future<Output = Result<T, VaultToolError>> + Send + 'static,
2416    {
2417        let task = tokio::spawn(call);
2418        let draft = parked_draft(rx).await;
2419        assert_eq!(draft.tool, tool, "the card names the tool that parked");
2420        decline(workspace, caller, draft.id);
2421        assert!(
2422            matches!(task.await.expect("the call finishes"), Err(VaultToolError::Rejected)),
2423            "{} must not run",
2424            tool.as_str(),
2425        );
2426    }
2427
2428    /// Every tool parks its own card on a locked session and runs nothing
2429    /// until the reader answers.
2430    #[tokio::test]
2431    async fn every_tool_parks_its_own_card_on_a_locked_session() {
2432        let (workspace, facade, caller, dir, mut rx) = locked_worker_workspace();
2433        std::fs::write(dir.path().join("note.txt"), b"hello").expect("the file");
2434
2435        {
2436            let f = Arc::clone(&facade);
2437            let c = caller.clone();
2438            held(&workspace, &caller, &mut rx, VaultTool::Search, async move {
2439                f.search(&c, "bank", None).await
2440            })
2441            .await;
2442        }
2443        {
2444            let f = Arc::clone(&facade);
2445            let c = caller.clone();
2446            held(&workspace, &caller, &mut rx, VaultTool::Get, async move {
2447                f.get(&c, "item-1", Some("password")).await
2448            })
2449            .await;
2450        }
2451        {
2452            let f = Arc::clone(&facade);
2453            let c = caller.clone();
2454            let input = CreateInput { name: "bank".to_owned(), ..Default::default() };
2455            held(&workspace, &caller, &mut rx, VaultTool::Create, async move {
2456                f.create(&c, &input).await
2457            })
2458            .await;
2459        }
2460        {
2461            let f = Arc::clone(&facade);
2462            let c = caller.clone();
2463            let patch = PatchInput { password: Some("x".to_owned()), ..Default::default() };
2464            held(&workspace, &caller, &mut rx, VaultTool::Update, async move {
2465                f.update(&c, "item-1", &patch).await
2466            })
2467            .await;
2468        }
2469        for (tool, id) in [
2470            (VaultTool::Trash, "item-1"),
2471            (VaultTool::Restore, "item-1"),
2472            (VaultTool::TotpCode, "item-1"),
2473        ] {
2474            let f = Arc::clone(&facade);
2475            let c = caller.clone();
2476            held(&workspace, &caller, &mut rx, tool, async move {
2477                match tool {
2478                    VaultTool::Trash => f.trash(&c, id).await,
2479                    VaultTool::Restore => f.restore(&c, id).await,
2480                    _ => f.totp_code(&c, id).await,
2481                }
2482            })
2483            .await;
2484        }
2485        {
2486            let f = Arc::clone(&facade);
2487            let c = caller.clone();
2488            held(&workspace, &caller, &mut rx, VaultTool::AttachmentGet, async move {
2489                f.attachment_get(&c, "item-1", "att-1", "out.bin").await
2490            })
2491            .await;
2492        }
2493        {
2494            let f = Arc::clone(&facade);
2495            let c = caller.clone();
2496            held(&workspace, &caller, &mut rx, VaultTool::AttachmentPut, async move {
2497                f.attachment_put(&c, "item-1", "note.txt", None, None).await
2498            })
2499            .await;
2500        }
2501        {
2502            let f = Arc::clone(&facade);
2503            let c = caller.clone();
2504            held(&workspace, &caller, &mut rx, VaultTool::FolderList, async move {
2505                f.folder_list(&c).await
2506            })
2507            .await;
2508        }
2509        {
2510            let f = Arc::clone(&facade);
2511            let c = caller.clone();
2512            held(&workspace, &caller, &mut rx, VaultTool::FolderCreate, async move {
2513                f.folder_create(&c, "folder").await
2514            })
2515            .await;
2516        }
2517        {
2518            let f = Arc::clone(&facade);
2519            let c = caller.clone();
2520            held(&workspace, &caller, &mut rx, VaultTool::FolderUpdate, async move {
2521                f.folder_update(&c, "folder-id", "renamed").await
2522            })
2523            .await;
2524        }
2525        {
2526            let f = Arc::clone(&facade);
2527            let c = caller.clone();
2528            held(&workspace, &caller, &mut rx, VaultTool::FolderDelete, async move {
2529                f.folder_delete(&c, "folder-id").await
2530            })
2531            .await;
2532        }
2533
2534        let audit = std::fs::read_to_string(
2535            dir.path()
2536                .join("vault")
2537                .join(format!("audit-{}.jsonl", forge_sdk::config_dir_store_key(dir.path()))),
2538        )
2539        .expect("every decline wrote an audit line");
2540        assert_eq!(audit.lines().count(), 13, "one line per decision");
2541        assert!(
2542            audit.contains("\"field\":\"password\""),
2543            "the get's reveal field reaches the audit through the gate",
2544        );
2545    }
2546
2547    /// The line an approval leaves records the operation's own outcome, not
2548    /// a claim made before it ran, and the field the gate carried reaches it.
2549    #[test]
2550    fn an_approved_call_audits_its_own_outcome() {
2551        let dir = tempfile::tempdir().expect("tempdir");
2552        let config = VaultConfig {
2553            url: "http://127.0.0.1:1".to_owned(),
2554            email: "hi@vedhavyas.dev".to_owned(),
2555            idle_minutes: None,
2556        };
2557        let session =
2558            Arc::new(test_session(&config, Some(dir.path()), dir.path()).expect("a session"));
2559        let approved = Approved {
2560            workspace: Arc::clone(&Workspace::testing_stub().0),
2561            session,
2562            keys: Arc::new(Unlocked {
2563                user_key: forge_connectors::vault::crypto::make_symmetric_key(),
2564                private_key: None,
2565                org_keys: std::collections::HashMap::new(),
2566                failed_orgs: Vec::new(),
2567                user_key_fingerprint: String::new(),
2568            }),
2569            caller: SessionSlot::lead("Default", "forge"),
2570            tool: VaultTool::Get,
2571            item_id: Some("cipher-id".to_owned()),
2572            item_name: Some("bank".to_owned()),
2573            field: Some("password".to_owned()),
2574        };
2575
2576        let failed: Result<(), VaultToolError> = Err(VaultToolError::Message("boom".to_owned()));
2577        let _ = approved.done(failed);
2578        let _ = approved.done(Ok(()));
2579
2580        let path = dir
2581            .path()
2582            .join("vault")
2583            .join(format!("audit-{}.jsonl", forge_sdk::config_dir_store_key(dir.path())));
2584        let audit = std::fs::read_to_string(path).expect("the outcome lines");
2585        let lines: Vec<serde_json::Value> =
2586            audit.lines().map(|line| serde_json::from_str(line).expect("json")).collect();
2587        assert_eq!(lines.len(), 2);
2588        assert_eq!(lines[0]["tool"], "vault__get");
2589        assert_eq!(lines[0]["item_id"], "cipher-id");
2590        assert_eq!(lines[0]["item_name"], "bank");
2591        assert_eq!(lines[0]["field"], "password");
2592        assert_eq!(lines[0]["decision"], "approve");
2593        assert_eq!(lines[0]["result"], "failed: boom");
2594        assert_eq!(lines[1]["result"], "ok", "a success is recorded after the call runs");
2595    }
2596
2597    /// A locked session must park the card asking for the password before it
2598    /// makes any token call. The first call after a forge start is the case:
2599    /// an error returned before the hold would leave the reader no way to
2600    /// unlock.
2601    #[tokio::test]
2602    async fn a_locked_get_parks_the_unlock_card_before_any_token_call() {
2603        let (workspace, facade, caller, mut rx) = locked_workspace();
2604        let asked = caller.clone();
2605        let task = tokio::spawn(async move { facade.get(&asked, "item-1", None).await });
2606
2607        let draft = parked_draft(&mut rx).await;
2608        assert!(draft.needs_unlock, "the card asks for the master password");
2609        assert_eq!(draft.item_id.as_deref(), Some("item-1"));
2610        assert_eq!(draft.summary, "read metadata for item item-1");
2611
2612        decline(&workspace, &caller, draft.id);
2613        assert!(
2614            matches!(task.await.expect("the call finishes"), Err(VaultToolError::Rejected)),
2615            "a decline runs nothing"
2616        );
2617    }
2618
2619    /// A send-back is not an approval: the note reaches the agent as the
2620    /// call's own error and no operation runs in the vault.
2621    #[tokio::test]
2622    async fn a_send_back_returns_the_note_and_runs_nothing() {
2623        let (workspace, facade, caller, mut rx) = locked_workspace();
2624        let asked = caller.clone();
2625        let task = tokio::spawn(async move { facade.search(&asked, "bank", None).await });
2626
2627        let draft = parked_draft(&mut rx).await;
2628        workspace
2629            .dispatch(Command::RespondVaultAsk {
2630                key: caller.clone(),
2631                id: draft.id,
2632                answer: VaultAskAnswer {
2633                    decision: VaultAskDecision::SendBack { note: "use the bank folder".to_owned() },
2634                    master_password: None,
2635                    two_factor: None,
2636                    released: None,
2637                },
2638            })
2639            .expect("the answer routes");
2640
2641        match task.await.expect("the call finishes") {
2642            Err(VaultToolError::SentBack { note }) => assert_eq!(note, "use the bank folder"),
2643            other => panic!("a send-back must not run the search: {other:?}"),
2644        }
2645    }
2646
2647    /// The attachment writer creates a private file and repairs an existing
2648    /// file's mode, which `mode(0o600)` alone does not do.
2649    #[test]
2650    fn write_private_creates_and_repairs_0600() {
2651        use std::os::unix::fs::PermissionsExt;
2652        let dir = tempfile::tempdir().expect("tempdir");
2653        let path = dir.path().join("note");
2654        write_private(&path, b"first").expect("write");
2655        assert_eq!(
2656            std::fs::metadata(&path).expect("metadata").permissions().mode() & 0o777,
2657            0o600,
2658            "a new attachment is private",
2659        );
2660        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod");
2661        write_private(&path, b"second").expect("write");
2662        assert_eq!(
2663            std::fs::metadata(&path).expect("metadata").permissions().mode() & 0o777,
2664            0o600,
2665            "an existing attachment is repaired to private",
2666        );
2667    }
2668
2669    /// A final-component symlink is refused rather than followed out of the
2670    /// session's tree.
2671    #[test]
2672    fn write_private_refuses_a_final_symlink() {
2673        use std::os::unix::fs::symlink;
2674        let dir = tempfile::tempdir().expect("tempdir");
2675        let target = dir.path().join("target");
2676        std::fs::write(&target, b"x").expect("target");
2677        let link = dir.path().join("link");
2678        symlink(&target, &link).expect("symlink");
2679
2680        assert!(write_private(&link, b"y").is_err(), "the symlink is not followed");
2681        assert_eq!(std::fs::read(&target).expect("target read"), b"x", "the target is untouched");
2682    }
2683
2684    /// The upload size check is a comparison only when the server reported a
2685    /// size; a missing size is an error, not a match.
2686    #[test]
2687    fn a_missing_upload_size_fails_closed() {
2688        assert!(check_stored_size(Some(97), 97).is_ok());
2689        assert!(check_stored_size(Some(96), 97).is_err());
2690        assert!(check_stored_size(None, 97).is_err(), "a missing size is not a match");
2691    }
2692
2693    /// A name patch leaves the fields this version does not model intact and
2694    /// encrypts the new name with the cipher's own key.
2695    #[test]
2696    fn a_name_patch_keeps_the_unmodelled_fields() {
2697        let unlocked = Unlocked {
2698            user_key: forge_connectors::vault::crypto::make_symmetric_key(),
2699            private_key: None,
2700            org_keys: std::collections::HashMap::new(),
2701            failed_orgs: Vec::new(),
2702            user_key_fingerprint: String::new(),
2703        };
2704        let cipher = serde_json::json!({
2705            "id": "c",
2706            "type": 1,
2707            "name": "old",
2708            "login": { "totp": "seed", "password": "pw" },
2709            "fields": [{ "name": "n", "value": "v" }],
2710            "passwordHistory": [{ "password": "old" }],
2711            "key": null,
2712        });
2713        let patch = PatchInput { name: Some("renamed".to_owned()), ..Default::default() };
2714
2715        let out = apply_patch(&unlocked, "user-1", cipher.clone(), &patch).expect("the patch");
2716        assert_eq!(out["encryptedFor"], "user-1", "a PUT deserializes the same CipherData");
2717
2718        // An organization-owned cipher's body names the org; the update must
2719        // not contradict the ciphertext by rewriting it to the personal id.
2720        let org = serde_json::json!({
2721            "id": "c",
2722            "type": 1,
2723            "organizationId": "org-1",
2724            "encryptedFor": "org-1",
2725            "name": "old",
2726            "login": {},
2727        });
2728        // A folder move needs no crypto, so the owner guard is what is under
2729        // test rather than the org key this fixture does not hold.
2730        let move_patch =
2731            PatchInput { folder_id: Some("folder-1".to_owned()), ..Default::default() };
2732        let org_out = apply_patch(&unlocked, "user-1", org, &move_patch).expect("the patch");
2733        assert_eq!(org_out["encryptedFor"], "org-1", "the fetched owner survives");
2734
2735        assert_eq!(out["login"]["totp"], cipher["login"]["totp"]);
2736        assert_eq!(out["login"]["password"], cipher["login"]["password"]);
2737        assert_eq!(out["fields"], cipher["fields"]);
2738        assert_eq!(out["passwordHistory"], cipher["passwordHistory"]);
2739        assert_eq!(out["key"], cipher["key"]);
2740        let name = out["name"].as_str().expect("the name is an EncString");
2741        assert_eq!(unlocked.decrypt_item_str(&out, name).expect("decrypt"), "renamed");
2742    }
2743
2744    /// A new login encrypts its fields with the user key and keeps the type
2745    /// the caller asked for.
2746    #[test]
2747    fn a_new_login_encrypts_its_fields() {
2748        let unlocked = Unlocked {
2749            user_key: forge_connectors::vault::crypto::make_symmetric_key(),
2750            private_key: None,
2751            org_keys: std::collections::HashMap::new(),
2752            failed_orgs: Vec::new(),
2753            user_key_fingerprint: String::new(),
2754        };
2755        let input = CreateInput {
2756            name: "bank".to_owned(),
2757            username: Some("u".to_owned()),
2758            password: Some("p".to_owned()),
2759            ..Default::default()
2760        };
2761
2762        let body = build_new_cipher(&unlocked, "user-1", &input, "login").expect("the body");
2763
2764        assert_eq!(body["type"], 1);
2765        assert_eq!(body["encryptedFor"], "user-1");
2766        let name = body["name"].as_str().expect("the name is an EncString");
2767        assert_eq!(unlocked.decrypt_item_str(&body, name).expect("decrypt"), "bank");
2768        let username = body.pointer("/login/username").and_then(Value::as_str).expect("username");
2769        assert_eq!(unlocked.decrypt_item_str(&body, username).expect("decrypt"), "u");
2770    }
2771
2772    /// A TOTP seed is storable only once the inputs carry it; it encrypts like
2773    /// any login field, and a totp-only patch is a change rather than an
2774    /// empty one.
2775    #[test]
2776    fn a_totp_seed_is_stored_and_patched_on_a_login() {
2777        let unlocked = Unlocked {
2778            user_key: forge_connectors::vault::crypto::make_symmetric_key(),
2779            private_key: None,
2780            org_keys: std::collections::HashMap::new(),
2781            failed_orgs: Vec::new(),
2782            user_key_fingerprint: String::new(),
2783        };
2784        let seed = "otpauth://totp/bank?secret=JBSWY3DPEHPK3PXP";
2785        let input = CreateInput {
2786            name: "bank".to_owned(),
2787            totp: Some(seed.to_owned()),
2788            ..Default::default()
2789        };
2790
2791        let body = build_new_cipher(&unlocked, "user-1", &input, "login").expect("the body");
2792        let stored = body.pointer("/login/totp").and_then(Value::as_str).expect("the seed");
2793        assert_eq!(unlocked.decrypt_item_str(&body, stored).expect("decrypt"), seed);
2794
2795        let cipher = serde_json::json!({ "id": "c", "type": 1, "name": "old", "login": {} });
2796        let patch = PatchInput { totp: Some("JBSWY3DPEHPK3PXP".to_owned()), ..Default::default() };
2797        assert_eq!(patch_fields(&patch), vec!["totp"], "a totp-only patch changes a field");
2798        let out = apply_patch(&unlocked, "user-1", cipher, &patch).expect("the patch");
2799        let stored = out.pointer("/login/totp").and_then(Value::as_str).expect("the seed");
2800        assert_eq!(unlocked.decrypt_item_str(&out, stored).expect("decrypt"), "JBSWY3DPEHPK3PXP");
2801    }
2802
2803    /// A seed that cannot produce a code would store a permanent broken second
2804    /// factor with the card masking the typo, so it is refused before it is
2805    /// encrypted. An empty seed decodes as valid base32, so it is named here.
2806    #[test]
2807    fn a_totp_seed_that_cannot_produce_a_code_is_refused() {
2808        let unlocked = Unlocked {
2809            user_key: forge_connectors::vault::crypto::make_symmetric_key(),
2810            private_key: None,
2811            org_keys: std::collections::HashMap::new(),
2812            failed_orgs: Vec::new(),
2813            user_key_fingerprint: String::new(),
2814        };
2815
2816        for seed in ["", "not!base32"] {
2817            let input = CreateInput {
2818                name: "bank".to_owned(),
2819                totp: Some(seed.to_owned()),
2820                ..Default::default()
2821            };
2822            let error = build_new_cipher(&unlocked, "user-1", &input, "login")
2823                .map(|_| ())
2824                .expect_err(&format!("the create must refuse '{seed}'"));
2825            assert!(error.to_string().contains("TOTP"), "'{seed}': {error}");
2826
2827            let cipher = serde_json::json!({ "id": "c", "type": 1, "name": "old", "login": {} });
2828            let patch = PatchInput { totp: Some(seed.to_owned()), ..Default::default() };
2829            let error = apply_patch(&unlocked, "user-1", cipher, &patch)
2830                .map(|_| ())
2831                .expect_err(&format!("the patch must refuse '{seed}'"));
2832            assert!(error.to_string().contains("TOTP"), "'{seed}': {error}");
2833        }
2834    }
2835
2836    /// `login.totp` belongs to a login; accepting it on a note or a card
2837    /// would either vanish or grow a login object on the wrong item.
2838    #[test]
2839    fn a_totp_seed_on_a_note_or_card_is_refused() {
2840        let unlocked = Unlocked {
2841            user_key: forge_connectors::vault::crypto::make_symmetric_key(),
2842            private_key: None,
2843            org_keys: std::collections::HashMap::new(),
2844            failed_orgs: Vec::new(),
2845            user_key_fingerprint: String::new(),
2846        };
2847        let seed = "JBSWY3DPEHPK3PXP";
2848
2849        for kind in ["note", "card"] {
2850            let input = CreateInput {
2851                name: "x".to_owned(),
2852                totp: Some(seed.to_owned()),
2853                ..Default::default()
2854            };
2855            assert!(
2856                build_new_cipher(&unlocked, "user-1", &input, kind).is_err(),
2857                "a {kind} does not take a TOTP seed"
2858            );
2859        }
2860        let card = serde_json::json!({ "id": "c", "type": 3, "name": "old" });
2861        let patch = PatchInput { totp: Some(seed.to_owned()), ..Default::default() };
2862        assert!(
2863            apply_patch(&unlocked, "user-1", card, &patch).is_err(),
2864            "a card does not take a TOTP seed",
2865        );
2866    }
2867
2868    /// Removing the containment checks would let a prompt-injected call read
2869    /// `~/.ssh/id_rsa` or write outside the session's tree.
2870    #[test]
2871    fn the_attachment_paths_must_stay_inside_the_session_tree() {
2872        let (workspace, _facade, caller, dir, _rx) = locked_worker_workspace();
2873        std::fs::write(dir.path().join("ok.txt"), b"x").expect("the file");
2874        let outside = tempfile::tempdir().expect("outside");
2875        std::fs::write(outside.path().join("secret"), b"x").expect("the outside file");
2876        std::os::unix::fs::symlink(outside.path(), dir.path().join("link")).expect("symlink");
2877
2878        assert!(resolve_source(&workspace, &caller, "ok.txt").is_ok());
2879        assert!(resolve_destination(&workspace, &caller, "out.txt").is_ok());
2880
2881        for escape in ["../escape.txt", "/etc/hosts", "link/secret"] {
2882            assert!(
2883                resolve_source(&workspace, &caller, escape).is_err(),
2884                "the source {escape} escapes the tree",
2885            );
2886        }
2887        for escape in ["../escape.txt", "/tmp/escape.txt", "link/out.txt"] {
2888            assert!(
2889                resolve_destination(&workspace, &caller, escape).is_err(),
2890                "the destination {escape} escapes the tree",
2891            );
2892        }
2893        assert!(!outside.path().join("out.txt").exists(), "nothing was written outside");
2894    }
2895
2896    /// A replace card names the file it removes when the session could read
2897    /// it, and falls back to the id only when it could not.
2898    #[test]
2899    fn a_replace_card_names_the_file_it_removes() {
2900        assert_eq!(
2901            replacement_label(Some("att-1"), Some("old.txt".to_owned())).as_deref(),
2902            Some("old.txt"),
2903        );
2904        assert_eq!(replacement_label(Some("att-1"), None).as_deref(), Some("att-1"));
2905        assert_eq!(replacement_label(None, Some("old.txt".to_owned())), None);
2906    }
2907
2908    /// The source size is checked from metadata before the read, and a file
2909    /// that grew past the cap between the check and the read is refused too.
2910    #[test]
2911    fn an_attachment_source_over_the_cap_is_refused() {
2912        let dir = tempfile::tempdir().expect("tempdir");
2913        let path = dir.path().join("file");
2914        std::fs::write(&path, b"five!").expect("the file");
2915
2916        let error = read_attachment(&path, 4).expect_err("the cap refuses the file");
2917        assert!(error.to_string().contains("over the 4 byte cap"), "{error}");
2918        assert_eq!(read_attachment(&path, 5).expect("the exact size passes"), b"five!");
2919    }
2920
2921    /// A locked attachment-get card cannot name the item, but it must name
2922    /// where the file would land.
2923    #[tokio::test]
2924    async fn a_locked_attachment_get_card_names_its_destination() {
2925        let (workspace, facade, caller, _dir, mut rx) = locked_worker_workspace();
2926        let asked = caller.clone();
2927        let task = tokio::spawn(async move {
2928            facade.attachment_get(&asked, "item-1", "att-1", "out.bin").await
2929        });
2930
2931        let draft = parked_draft(&mut rx).await;
2932        assert!(draft.needs_unlock);
2933        assert!(draft.item_name.is_none(), "a locked card cannot name the item");
2934        assert!(
2935            draft.summary.contains("out.bin"),
2936            "the card names the destination: {}",
2937            draft.summary,
2938        );
2939
2940        decline(&workspace, &caller, draft.id);
2941        assert!(matches!(task.await.expect("the call finishes"), Err(VaultToolError::Rejected)));
2942    }
2943
2944    /// A `[vault]` reload builds a new session; an approval drawn against the
2945    /// old account must not run against it.
2946    #[tokio::test]
2947    async fn an_approval_does_not_run_after_a_config_reload() {
2948        let (workspace, facade, caller, dir, mut rx) = locked_worker_workspace();
2949        let asked = caller.clone();
2950        let task = tokio::spawn(async move { facade.search(&asked, "bank", None).await });
2951        let draft = parked_draft(&mut rx).await;
2952
2953        let next = VaultConfig {
2954            url: "http://127.0.0.1:1".to_owned(),
2955            email: "other@example.com".to_owned(),
2956            idle_minutes: None,
2957        };
2958        *workspace.vault.write() = Some(Arc::new(
2959            test_session(&next, Some(dir.path()), dir.path()).expect("the new session"),
2960        ));
2961        workspace
2962            .dispatch(Command::RespondVaultAsk {
2963                key: caller.clone(),
2964                id: draft.id,
2965                answer: VaultAskAnswer {
2966                    decision: VaultAskDecision::Approve,
2967                    master_password: Some("old password".to_owned()),
2968                    two_factor: None,
2969                    released: None,
2970                },
2971            })
2972            .expect("the answer routes");
2973
2974        let result = task.await.expect("the call finishes");
2975        assert!(
2976            matches!(&result, Err(VaultToolError::Message(message)) if message.contains("configuration changed")),
2977            "{result:?}",
2978        );
2979        let audit = audit_text(&dir);
2980        assert!(
2981            audit.contains("failed: the vault configuration changed"),
2982            "the mismatch is audited: {audit}",
2983        );
2984    }
2985
2986    /// An approval that carries no password cannot unlock, and the refusal is
2987    /// audited rather than settling the card silently.
2988    #[tokio::test]
2989    async fn a_locked_approval_without_a_password_is_audited() {
2990        let (workspace, facade, caller, dir, mut rx) = locked_worker_workspace();
2991        let asked = caller.clone();
2992        let task = tokio::spawn(async move { facade.search(&asked, "bank", None).await });
2993        let draft = parked_draft(&mut rx).await;
2994
2995        workspace
2996            .dispatch(Command::RespondVaultAsk {
2997                key: caller.clone(),
2998                id: draft.id,
2999                answer: VaultAskAnswer {
3000                    decision: VaultAskDecision::Approve,
3001                    master_password: None,
3002                    two_factor: None,
3003                    released: None,
3004                },
3005            })
3006            .expect("the answer routes");
3007
3008        assert!(matches!(task.await.expect("the call finishes"), Err(VaultToolError::Locked)));
3009        let audit = audit_text(&dir);
3010        assert!(
3011            audit.contains("failed: a locked session was approved without the master password"),
3012            "{audit}",
3013        );
3014    }
3015
3016    /// Approve -> unlock -> rebase, end to end: the first card carries the
3017    /// password, the second is restamped by the unlock and runs without one.
3018    #[tokio::test]
3019    async fn an_unlock_rebases_the_other_parked_card_end_to_end() {
3020        use forge_connectors::vault::crypto::{derive_master_key, encrypt_str};
3021
3022        let pre = forge_connectors::vault::models::Prelogin {
3023            kdf: 0,
3024            kdf_iterations: 100_000,
3025            kdf_memory: None,
3026            kdf_parallelism: None,
3027        };
3028        let master_key =
3029            derive_master_key("open sesame", "hi@vedhavyas.dev", &pre).expect("the master key");
3030        let (user_key, wrapped_user_key) = master_key.make_user_key().expect("the user key");
3031        let cipher = serde_json::json!({
3032            "id": "item-1",
3033            "type": 1,
3034            "name": encrypt_str("bank", &user_key.0).expect("encrypting the name"),
3035            "login": {},
3036        });
3037        let prelogin = serde_json::json!({
3038            "kdf": 0,
3039            "kdfIterations": 100_000,
3040        })
3041        .to_string();
3042        let token = serde_json::json!({
3043            "access_token": "access",
3044            "refresh_token": "refresh",
3045            "Key": wrapped_user_key.to_string(),
3046        })
3047        .to_string();
3048        let sync = r#"{"profile":{},"folders":[],"ciphers":[]}"#.to_owned();
3049        let address = serve_vault(vec![
3050            ("/identity/accounts/prelogin", prelogin),
3051            ("/identity/connect/token", token),
3052            ("/api/sync", sync),
3053            ("/api/ciphers/item-1", cipher.to_string()),
3054            ("/api/ciphers/item-1", cipher.to_string()),
3055        ])
3056        .await;
3057        let (workspace, mut rx) = Workspace::testing_stub();
3058        let config = VaultConfig {
3059            url: format!("http://{address}"),
3060            email: "hi@vedhavyas.dev".to_owned(),
3061            idle_minutes: None,
3062        };
3063        let session = Arc::new(test_session(&config, None, Path::new("/tmp")).expect("session"));
3064        *workspace.vault.write() = Some(session);
3065        let facade = Arc::new(ProdVaultFacade::from_arc(&workspace));
3066        let caller = SessionSlot::lead("Default", "forge");
3067
3068        let first_facade = Arc::clone(&facade);
3069        let first_caller = caller.clone();
3070        let first =
3071            tokio::spawn(async move { first_facade.get(&first_caller, "item-1", None).await });
3072        let first_draft = parked_draft(&mut rx).await;
3073        assert!(first_draft.needs_unlock, "the first card asks for the password");
3074
3075        let second_facade = Arc::clone(&facade);
3076        let second_caller = caller.clone();
3077        let second =
3078            tokio::spawn(async move { second_facade.get(&second_caller, "item-1", None).await });
3079        let second_draft = parked_draft(&mut rx).await;
3080        assert!(second_draft.needs_unlock, "the second card parks while locked too");
3081
3082        workspace
3083            .dispatch(Command::RespondVaultAsk {
3084                key: caller.clone(),
3085                id: first_draft.id,
3086                answer: VaultAskAnswer {
3087                    decision: VaultAskDecision::Approve,
3088                    master_password: Some("open sesame".to_owned()),
3089                    two_factor: None,
3090                    released: None,
3091                },
3092            })
3093            .expect("the answer routes");
3094
3095        let mut restamped = None;
3096        while restamped.is_none() {
3097            let frame = tokio::time::timeout(std::time::Duration::from_secs(30), rx.recv())
3098                .await
3099                .expect("the unlock pushes a restamp")
3100                .expect("the seat stream stays open");
3101            if let crate::protocol::SessionUpdate::VaultAskPending { draft, .. } =
3102                std::sync::Arc::unwrap_or_clone(frame.update)
3103                && draft.id == second_draft.id
3104            {
3105                restamped = Some(draft);
3106            }
3107        }
3108        let restamped = restamped.expect("the second card was restamped");
3109        assert!(!restamped.needs_unlock, "the unlock restamps the other card");
3110
3111        let decoded = first.await.expect("the first call finishes").expect("the first read");
3112        assert_eq!(decoded["name"], "bank");
3113
3114        workspace
3115            .dispatch(Command::RespondVaultAsk {
3116                key: caller.clone(),
3117                id: second_draft.id,
3118                answer: VaultAskAnswer {
3119                    decision: VaultAskDecision::Approve,
3120                    master_password: None,
3121                    two_factor: None,
3122                    released: None,
3123                },
3124            })
3125            .expect("the answer routes");
3126        let decoded = second.await.expect("the second call finishes").expect("the second read");
3127        assert_eq!(decoded["name"], "bank");
3128    }
3129
3130    /// A start from released material: the card carries the enrolment's
3131    /// refresh token and the user key instead of the password, and the call
3132    /// runs exactly as a password unlock leaves it.
3133    #[tokio::test]
3134    async fn an_unlock_from_released_material_reaches_the_same_session() {
3135        use forge_connectors::vault::crypto::{derive_master_key, encrypt_str};
3136
3137        let pre = forge_connectors::vault::models::Prelogin {
3138            kdf: 0,
3139            kdf_iterations: 100_000,
3140            kdf_memory: None,
3141            kdf_parallelism: None,
3142        };
3143        let master_key =
3144            derive_master_key("open sesame", "hi@vedhavyas.dev", &pre).expect("the master key");
3145        let (user_key, wrapped_user_key) = master_key.make_user_key().expect("the user key");
3146        let cipher = serde_json::json!({
3147            "id": "item-1",
3148            "type": 1,
3149            "name": encrypt_str("bank", &user_key.0).expect("encrypting the name"),
3150            "login": {},
3151        });
3152        // The redemption answers a rotated refresh token, which the session
3153        // keeps for its own refreshes.
3154        let token = serde_json::json!({
3155            "access_token": "access",
3156            "refresh_token": "rotated",
3157            "Key": wrapped_user_key.to_string(),
3158        })
3159        .to_string();
3160        let sync = r#"{"profile":{},"folders":[],"ciphers":[]}"#.to_owned();
3161        let address = serve_vault(vec![
3162            ("/identity/connect/token", token),
3163            ("/api/sync", sync),
3164            ("/api/ciphers/item-1", cipher.to_string()),
3165        ])
3166        .await;
3167        let (workspace, mut rx) = Workspace::testing_stub();
3168        let config = VaultConfig {
3169            url: format!("http://{address}"),
3170            email: "hi@vedhavyas.dev".to_owned(),
3171            idle_minutes: None,
3172        };
3173        let session = Arc::new(test_session(&config, None, Path::new("/tmp")).expect("session"));
3174        *workspace.vault.write() = Some(Arc::clone(&session));
3175        let facade = Arc::new(ProdVaultFacade::from_arc(&workspace));
3176        let caller = SessionSlot::lead("Default", "forge");
3177
3178        let reader = Arc::clone(&facade);
3179        let reader_caller = caller.clone();
3180        let call = tokio::spawn(async move { reader.get(&reader_caller, "item-1", None).await });
3181        let draft = parked_draft(&mut rx).await;
3182        assert!(draft.needs_unlock, "the locked session asks for an unlock");
3183
3184        workspace
3185            .dispatch(Command::RespondVaultAsk {
3186                key: caller.clone(),
3187                id: draft.id,
3188                answer: VaultAskAnswer {
3189                    decision: VaultAskDecision::Approve,
3190                    master_password: None,
3191                    two_factor: None,
3192                    released: Some(VaultReleased {
3193                        refresh_token: "refresh".to_owned(),
3194                        user_key: user_key.0.to_base64().to_string(),
3195                    }),
3196                },
3197            })
3198            .expect("the answer routes");
3199
3200        let decoded = call.await.expect("the call finishes").expect("the item is read");
3201        assert_eq!(decoded["name"], "bank");
3202        assert!(!session.is_locked(), "the session unlocked from the released material");
3203    }
3204
3205    /// A release the account refuses leaves the session locked, so the card
3206    /// can offer the password rather than pretending the gesture worked.
3207    #[tokio::test]
3208    async fn a_refused_release_leaves_the_session_locked() {
3209        // A token answer with no access token: the redemption is refused.
3210        let address = serve_vault(vec![("/identity/connect/token", "{}".to_owned())]).await;
3211        let (workspace, mut rx) = Workspace::testing_stub();
3212        let config = VaultConfig {
3213            url: format!("http://{address}"),
3214            email: "hi@vedhavyas.dev".to_owned(),
3215            idle_minutes: None,
3216        };
3217        let session = Arc::new(test_session(&config, None, Path::new("/tmp")).expect("session"));
3218        *workspace.vault.write() = Some(Arc::clone(&session));
3219        let facade = Arc::new(ProdVaultFacade::from_arc(&workspace));
3220        let caller = SessionSlot::lead("Default", "forge");
3221
3222        let reader = Arc::clone(&facade);
3223        let reader_caller = caller.clone();
3224        let call = tokio::spawn(async move { reader.get(&reader_caller, "item-1", None).await });
3225        let draft = parked_draft(&mut rx).await;
3226
3227        workspace
3228            .dispatch(Command::RespondVaultAsk {
3229                key: caller.clone(),
3230                id: draft.id,
3231                answer: VaultAskAnswer {
3232                    decision: VaultAskDecision::Approve,
3233                    master_password: None,
3234                    two_factor: None,
3235                    released: Some(VaultReleased {
3236                        refresh_token: "spent".to_owned(),
3237                        user_key: "not-a-key".to_owned(),
3238                    }),
3239                },
3240            })
3241            .expect("the answer routes");
3242
3243        assert!(call.await.expect("the call finishes").is_err(), "the call is refused");
3244        assert!(session.is_locked(), "a refused release leaves the session locked");
3245    }
3246
3247    /// The command inputs carry secret values, so their Debug lines must not.
3248    #[test]
3249    fn the_vault_inputs_redact_their_secret_values() {
3250        let field = CustomFieldInput {
3251            name: "pin".to_owned(),
3252            value: "1234-secret".to_owned(),
3253            kind: None,
3254        };
3255        let create = CreateInput {
3256            name: "bank".to_owned(),
3257            password: Some("password-secret".to_owned()),
3258            number: Some("4111111111111111".to_owned()),
3259            code: Some("999".to_owned()),
3260            totp: Some("totp-create-seed".to_owned()),
3261            custom_fields: Some(vec![field.clone()]),
3262            ..Default::default()
3263        };
3264        let patch = PatchInput {
3265            password: Some("patch-secret".to_owned()),
3266            totp: Some("totp-patch-seed".to_owned()),
3267            ..Default::default()
3268        };
3269
3270        for text in [format!("{field:?}"), format!("{create:?}"), format!("{patch:?}")] {
3271            for secret in [
3272                "1234-secret",
3273                "password-secret",
3274                "4111111111111111",
3275                "patch-secret",
3276                "totp-create-seed",
3277                "totp-patch-seed",
3278            ] {
3279                assert!(!text.contains(secret), "a secret reached Debug: {text}");
3280            }
3281        }
3282    }
3283
3284    /// A file whose organization key did not unlock draws `(unreadable)` on
3285    /// the card; it is not a stale-key failure.
3286    #[test]
3287    fn an_attachment_name_without_its_org_key_is_unreadable() {
3288        use forge_connectors::vault::crypto::make_symmetric_key;
3289
3290        let unlocked = Unlocked {
3291            user_key: make_symmetric_key(),
3292            private_key: None,
3293            org_keys: std::collections::HashMap::new(),
3294            failed_orgs: vec!["org-1".to_owned()],
3295            user_key_fingerprint: String::new(),
3296        };
3297        let cipher = serde_json::json!({
3298            "organizationId": "org-1",
3299            "attachments": [{ "id": "att-1", "fileName": "2.x|y|z", "size": 7 }],
3300        });
3301
3302        let (name, size) = attachment_named(&unlocked, &cipher, "att-1").expect("the fallback");
3303
3304        assert_eq!(name, "(unreadable)");
3305        assert_eq!(size, 7);
3306    }
3307
3308    /// A FIFO destination is refused by the regular-file guard, not merely by
3309    /// the non-blocking open: a reader is held so the writer's open succeeds.
3310    #[test]
3311    fn write_private_refuses_a_fifo() {
3312        use std::os::unix::fs::OpenOptionsExt;
3313
3314        let dir = tempfile::tempdir().expect("tempdir");
3315        let fifo = dir.path().join("fifo");
3316        let created = std::process::Command::new("mkfifo")
3317            .arg(fifo.as_os_str())
3318            .status()
3319            .expect("mkfifo runs");
3320        assert!(created.success(), "mkfifo failed");
3321        let _reader = std::fs::OpenOptions::new()
3322            .read(true)
3323            .custom_flags(libc::O_NONBLOCK)
3324            .open(&fifo)
3325            .expect("the FIFO reader");
3326
3327        let error = write_private(&fifo, b"x").expect_err("the FIFO is refused");
3328
3329        assert!(error.to_string().contains("not a regular file"), "{error}");
3330    }
3331
3332    /// The directory handle must be the directory the containment check
3333    /// approved: a handle whose own path is another directory is refused.
3334    #[test]
3335    fn write_private_refuses_a_directory_whose_fd_path_differs() {
3336        let first = tempfile::tempdir().expect("first");
3337        let second = tempfile::tempdir().expect("second");
3338        let expected = std::fs::canonicalize(first.path()).expect("canonical first");
3339        let dir = std::fs::File::open(first.path()).expect("the directory handle");
3340
3341        verify_dir_path(&dir, &expected).expect("the same directory passes");
3342
3343        let other = std::fs::canonicalize(second.path()).expect("canonical second");
3344        let error = verify_dir_path(&dir, &other).expect_err("a different directory is refused");
3345        assert!(error.to_string().contains("changed"), "{error}");
3346    }
3347
3348    /// A hard link names bytes outside this tree; writing through one would
3349    /// truncate another file.
3350    #[test]
3351    fn write_private_refuses_a_hard_link() {
3352        let dir = tempfile::tempdir().expect("tempdir");
3353        let target = dir.path().join("target");
3354        std::fs::write(&target, b"keep").expect("target");
3355        let link = dir.path().join("link");
3356        std::fs::hard_link(&target, &link).expect("hard link");
3357
3358        assert!(write_private(&link, b"overwrite").is_err(), "the hard link is refused");
3359        assert_eq!(std::fs::read(&target).expect("target read"), b"keep");
3360    }
3361
3362    /// A destination whose parent is swapped while the card waits is refused
3363    /// before the write.
3364    #[test]
3365    fn a_swapped_destination_parent_is_refused_at_write_time() {
3366        let (workspace, _facade, caller, dir, _rx) = locked_worker_workspace();
3367        std::fs::create_dir(dir.path().join("sub")).expect("sub");
3368        let expected = resolve_destination(&workspace, &caller, "sub/out.txt").expect("the path");
3369        let outside = tempfile::tempdir().expect("outside");
3370        std::fs::remove_dir(dir.path().join("sub")).expect("remove sub");
3371        std::os::unix::fs::symlink(outside.path(), dir.path().join("sub")).expect("symlink");
3372
3373        let error = recheck_destination(&workspace, &caller, "sub/out.txt", &expected)
3374            .expect_err("the swapped parent is refused");
3375
3376        assert!(error.to_string().contains("outside"), "{error}");
3377        assert!(!outside.path().join("out.txt").exists());
3378    }
3379
3380    /// The metadata pre-check sees an empty FIFO; the post-read check is what
3381    /// refuses a stream that grows past the cap.
3382    #[test]
3383    fn an_attachment_that_grows_past_the_cap_is_refused_after_the_read() {
3384        use std::io::Write;
3385
3386        let dir = tempfile::tempdir().expect("tempdir");
3387        let fifo = dir.path().join("fifo");
3388        let created = std::process::Command::new("mkfifo")
3389            .arg(fifo.as_os_str())
3390            .status()
3391            .expect("mkfifo runs");
3392        assert!(created.success(), "mkfifo failed");
3393        let writer_path = fifo.clone();
3394        let writer = std::thread::spawn(move || {
3395            let mut file =
3396                std::fs::OpenOptions::new().write(true).open(&writer_path).expect("fifo write");
3397            file.write_all(&[b'x'; 8]).expect("write");
3398        });
3399
3400        let error = read_attachment(&fifo, 4).expect_err("the post-read cap refuses it");
3401        writer.join().expect("writer");
3402
3403        assert!(error.to_string().contains("over the 4 byte cap"), "{error}");
3404    }
3405
3406    /// A `[vault]` reload while the pre-gate read is in flight makes the
3407    /// approved call fail rather than run against the new account.
3408    #[tokio::test]
3409    async fn a_reload_during_the_pre_gate_read_fails_the_call() {
3410        use forge_connectors::vault::crypto::{encrypt_str, make_symmetric_key};
3411
3412        let user_key = make_symmetric_key();
3413        let name = encrypt_str("bank", &user_key).expect("encrypt the name");
3414        let (address, seen, release) = serve_paused_cipher(name).await;
3415        let dir = tempfile::tempdir().expect("tempdir");
3416        let config = VaultConfig {
3417            url: format!("http://{address}"),
3418            email: "hi@vedhavyas.dev".to_owned(),
3419            idle_minutes: None,
3420        };
3421        let old =
3422            Arc::new(test_session(&config, Some(dir.path()), dir.path()).expect("the old session"));
3423        test_install_unlocked(
3424            &old,
3425            Unlocked {
3426                user_key,
3427                private_key: None,
3428                org_keys: std::collections::HashMap::new(),
3429                failed_orgs: Vec::new(),
3430                user_key_fingerprint: String::new(),
3431            },
3432        );
3433        let (workspace, mut rx) = Workspace::testing_stub();
3434        *workspace.vault.write() = Some(Arc::clone(&old));
3435        let facade = Arc::new(ProdVaultFacade::from_arc(&workspace));
3436        let caller = SessionSlot::lead("Default", "forge");
3437
3438        let asked = caller.clone();
3439        let call = {
3440            let facade = Arc::clone(&facade);
3441            tokio::spawn(async move { facade.get(&asked, "item-1", None).await })
3442        };
3443        seen.await.expect("the pre-gate read reached the server");
3444
3445        let next =
3446            Arc::new(test_session(&config, Some(dir.path()), dir.path()).expect("the new session"));
3447        test_install_unlocked(
3448            &next,
3449            Unlocked {
3450                user_key: make_symmetric_key(),
3451                private_key: None,
3452                org_keys: std::collections::HashMap::new(),
3453                failed_orgs: Vec::new(),
3454                user_key_fingerprint: String::new(),
3455            },
3456        );
3457        *workspace.vault.write() = Some(Arc::clone(&next));
3458        release.send(()).expect("the server responds");
3459
3460        let draft = parked_draft(&mut rx).await;
3461        workspace
3462            .dispatch(Command::RespondVaultAsk {
3463                key: caller.clone(),
3464                id: draft.id,
3465                answer: VaultAskAnswer {
3466                    decision: VaultAskDecision::Approve,
3467                    master_password: None,
3468                    two_factor: None,
3469                    released: None,
3470                },
3471            })
3472            .expect("the answer routes");
3473
3474        let result = call.await.expect("the call finishes");
3475        assert!(
3476            matches!(&result, Err(VaultToolError::Message(message)) if message.contains("configuration changed")),
3477            "{result:?}",
3478        );
3479        assert!(audit_text(&dir).contains("failed: the vault configuration changed"));
3480    }
3481
3482    /// A reload during the pre-gate sync makes the approved search settle as
3483    /// changed instead of running old-account items against the new account.
3484    #[tokio::test]
3485    async fn a_reload_during_the_pre_gate_sync_fails_the_search() {
3486        use forge_connectors::vault::crypto::make_symmetric_key;
3487
3488        let (seen, seen_rx) = tokio::sync::oneshot::channel();
3489        let (release, release_rx) = tokio::sync::oneshot::channel();
3490        let address = serve_steps(vec![Step {
3491            path: "/api/sync",
3492            reply: Reply::Json(r#"{"profile":{},"folders":[],"ciphers":[]}"#.to_owned()),
3493            pause: Some((seen, release_rx)),
3494        }])
3495        .await;
3496        let (workspace, facade, caller, dir, mut rx, old) =
3497            unlocked_worker_workspace(address, make_symmetric_key(), String::new());
3498        let asked = caller.clone();
3499        let call = {
3500            let facade = Arc::clone(&facade);
3501            tokio::spawn(async move { facade.search(&asked, "bank", None).await })
3502        };
3503        seen_rx.await.expect("the pre-gate sync reached the server");
3504        let user_key = old.keys().expect("keys").user_key.clone();
3505        let next = Arc::new(
3506            test_session(
3507                &VaultConfig {
3508                    url: format!("http://{address}"),
3509                    email: "hi@vedhavyas.dev".to_owned(),
3510                    idle_minutes: None,
3511                },
3512                Some(dir.path()),
3513                dir.path(),
3514            )
3515            .expect("the new session"),
3516        );
3517        test_install_unlocked(
3518            &next,
3519            Unlocked {
3520                user_key,
3521                private_key: None,
3522                org_keys: std::collections::HashMap::new(),
3523                failed_orgs: Vec::new(),
3524                user_key_fingerprint: String::new(),
3525            },
3526        );
3527        *workspace.vault.write() = Some(Arc::clone(&next));
3528        release.send(()).expect("the server responds");
3529
3530        let draft = parked_draft(&mut rx).await;
3531        approve(&workspace, &caller, draft.id);
3532        let result = call.await.expect("the call finishes");
3533        assert!(
3534            matches!(&result, Err(VaultToolError::Message(message)) if message.contains("configuration changed")),
3535            "{result:?}",
3536        );
3537    }
3538
3539    /// A reload during the pre-gate cipher read fails an attachment get.
3540    #[tokio::test]
3541    async fn a_reload_during_the_pre_gate_cipher_read_fails_attachment_get() {
3542        use forge_connectors::vault::crypto::{encrypt_str, make_symmetric_key};
3543
3544        let user_key = make_symmetric_key();
3545        let name = encrypt_str("bank", &user_key).expect("the name");
3546        let file = encrypt_str("file", &user_key).expect("the file name");
3547        let cipher = serde_json::json!({
3548            "id": "item-1",
3549            "type": 1,
3550            "name": name,
3551            "login": {},
3552            "attachments": [{ "id": "att-1", "fileName": file, "size": 4 }],
3553        })
3554        .to_string();
3555        let (seen, seen_rx) = tokio::sync::oneshot::channel();
3556        let (release, release_rx) = tokio::sync::oneshot::channel();
3557        let address = serve_steps(vec![Step {
3558            path: "/api/ciphers/item-1",
3559            reply: Reply::Json(cipher),
3560            pause: Some((seen, release_rx)),
3561        }])
3562        .await;
3563        let (workspace, facade, caller, dir, mut rx, old) =
3564            unlocked_worker_workspace(address, user_key.clone(), String::new());
3565        let asked = caller.clone();
3566        let call = {
3567            let facade = Arc::clone(&facade);
3568            tokio::spawn(async move {
3569                facade.attachment_get(&asked, "item-1", "att-1", "out.bin").await
3570            })
3571        };
3572        seen_rx.await.expect("the pre-gate read reached the server");
3573        let next = Arc::new(
3574            test_session(
3575                &VaultConfig {
3576                    url: format!("http://{address}"),
3577                    email: "hi@vedhavyas.dev".to_owned(),
3578                    idle_minutes: None,
3579                },
3580                Some(dir.path()),
3581                dir.path(),
3582            )
3583            .expect("the new session"),
3584        );
3585        test_install_unlocked(
3586            &next,
3587            Unlocked {
3588                user_key: old.keys().expect("keys").user_key.clone(),
3589                private_key: None,
3590                org_keys: std::collections::HashMap::new(),
3591                failed_orgs: Vec::new(),
3592                user_key_fingerprint: String::new(),
3593            },
3594        );
3595        *workspace.vault.write() = Some(Arc::clone(&next));
3596        release.send(()).expect("the server responds");
3597
3598        let draft = parked_draft(&mut rx).await;
3599        approve(&workspace, &caller, draft.id);
3600        let result = call.await.expect("the call finishes");
3601        assert!(
3602            matches!(&result, Err(VaultToolError::Message(message)) if message.contains("configuration changed")),
3603            "{result:?}",
3604        );
3605    }
3606
3607    /// A reload during the pre-gate replace-name read fails an attachment put.
3608    #[tokio::test]
3609    async fn a_reload_during_the_pre_gate_replace_read_fails_attachment_put() {
3610        use forge_connectors::vault::crypto::{encrypt_str, make_symmetric_key};
3611
3612        let user_key = make_symmetric_key();
3613        let name = encrypt_str("bank", &user_key).expect("the name");
3614        let file = encrypt_str("old", &user_key).expect("the file name");
3615        let cipher = serde_json::json!({
3616            "id": "item-1",
3617            "type": 1,
3618            "name": name,
3619            "login": {},
3620            "attachments": [{ "id": "att-1", "fileName": file, "size": 4 }],
3621        })
3622        .to_string();
3623        let (seen, seen_rx) = tokio::sync::oneshot::channel();
3624        let (release, release_rx) = tokio::sync::oneshot::channel();
3625        let address = serve_steps(vec![
3626            Step {
3627                path: "/api/ciphers/item-1",
3628                reply: Reply::Json(cipher.clone()),
3629                pause: Some((seen, release_rx)),
3630            },
3631            Step { path: "/api/ciphers/item-1", reply: Reply::Json(cipher), pause: None },
3632        ])
3633        .await;
3634        let (workspace, facade, caller, dir, mut rx, old) =
3635            unlocked_worker_workspace(address, user_key.clone(), String::new());
3636        std::fs::write(dir.path().join("upload.txt"), b"content").expect("the upload file");
3637        let asked = caller.clone();
3638        let call = {
3639            let facade = Arc::clone(&facade);
3640            tokio::spawn(async move {
3641                facade.attachment_put(&asked, "item-1", "upload.txt", None, Some("att-1")).await
3642            })
3643        };
3644        seen_rx.await.expect("the pre-gate read reached the server");
3645        let next = Arc::new(
3646            test_session(
3647                &VaultConfig {
3648                    url: format!("http://{address}"),
3649                    email: "hi@vedhavyas.dev".to_owned(),
3650                    idle_minutes: None,
3651                },
3652                Some(dir.path()),
3653                dir.path(),
3654            )
3655            .expect("the new session"),
3656        );
3657        test_install_unlocked(
3658            &next,
3659            Unlocked {
3660                user_key: old.keys().expect("keys").user_key.clone(),
3661                private_key: None,
3662                org_keys: std::collections::HashMap::new(),
3663                failed_orgs: Vec::new(),
3664                user_key_fingerprint: String::new(),
3665            },
3666        );
3667        *workspace.vault.write() = Some(Arc::clone(&next));
3668        release.send(()).expect("the server responds");
3669
3670        let draft = parked_draft(&mut rx).await;
3671        approve(&workspace, &caller, draft.id);
3672        let result = call.await.expect("the call finishes");
3673        assert!(
3674            matches!(&result, Err(VaultToolError::Message(message)) if message.contains("configuration changed")),
3675            "{result:?}",
3676        );
3677    }
3678
3679    /// A reload during the pre-gate folder read fails a folder delete.
3680    #[tokio::test]
3681    async fn a_reload_during_the_pre_gate_folder_read_fails_folder_delete() {
3682        use forge_connectors::vault::crypto::{encrypt_str, make_symmetric_key};
3683
3684        let user_key = make_symmetric_key();
3685        let name = encrypt_str("bank", &user_key).expect("the folder name");
3686        let folders = serde_json::json!({
3687            "data": [{ "id": "folder-1", "name": name }],
3688            "object": "list",
3689            "continuationToken": null,
3690        })
3691        .to_string();
3692        let (seen, seen_rx) = tokio::sync::oneshot::channel();
3693        let (release, release_rx) = tokio::sync::oneshot::channel();
3694        let address = serve_steps(vec![Step {
3695            path: "/api/folders",
3696            reply: Reply::Json(folders),
3697            pause: Some((seen, release_rx)),
3698        }])
3699        .await;
3700        let (workspace, facade, caller, dir, mut rx, old) =
3701            unlocked_worker_workspace(address, user_key, String::new());
3702        let asked = caller.clone();
3703        let call = {
3704            let facade = Arc::clone(&facade);
3705            tokio::spawn(async move { facade.folder_delete(&asked, "folder-1").await })
3706        };
3707        seen_rx.await.expect("the pre-gate read reached the server");
3708        let next = Arc::new(
3709            test_session(
3710                &VaultConfig {
3711                    url: format!("http://{address}"),
3712                    email: "hi@vedhavyas.dev".to_owned(),
3713                    idle_minutes: None,
3714                },
3715                Some(dir.path()),
3716                dir.path(),
3717            )
3718            .expect("the new session"),
3719        );
3720        test_install_unlocked(
3721            &next,
3722            Unlocked {
3723                user_key: old.keys().expect("keys").user_key.clone(),
3724                private_key: None,
3725                org_keys: std::collections::HashMap::new(),
3726                failed_orgs: Vec::new(),
3727                user_key_fingerprint: String::new(),
3728            },
3729        );
3730        *workspace.vault.write() = Some(Arc::clone(&next));
3731        release.send(()).expect("the server responds");
3732
3733        let draft = parked_draft(&mut rx).await;
3734        approve(&workspace, &caller, draft.id);
3735        let result = call.await.expect("the call finishes");
3736        assert!(
3737            matches!(&result, Err(VaultToolError::Message(message)) if message.contains("configuration changed")),
3738            "{result:?}",
3739        );
3740    }
3741
3742    /// The write re-checks containment inside the blocking closure: a parent
3743    /// swapped during the download is refused and nothing lands outside.
3744    #[tokio::test]
3745    async fn a_swap_during_the_download_is_refused_at_the_write() {
3746        use forge_connectors::vault::crypto::{
3747            SymmetricCryptoKey, encrypt_str, make_symmetric_key,
3748        };
3749
3750        let user_key: SymmetricCryptoKey = make_symmetric_key();
3751        let preparer = Unlocked {
3752            user_key: user_key.clone(),
3753            private_key: None,
3754            org_keys: std::collections::HashMap::new(),
3755            failed_orgs: Vec::new(),
3756            user_key_fingerprint: String::new(),
3757        };
3758        let base = serde_json::json!({ "id": "item-1", "type": 1, "login": {} });
3759        let (wrapped, encrypted_name, blob) =
3760            preparer.prepare_attachment(&base, "file", b"contents").expect("the attachment");
3761        let name = encrypt_str("bank", &user_key).expect("the name");
3762        let cipher = serde_json::json!({
3763            "id": "item-1",
3764            "type": 1,
3765            "name": name,
3766            "login": {},
3767            "attachments": [{
3768                "id": "att-1",
3769                "fileName": encrypted_name.clone(),
3770                "size": blob.len(),
3771            }],
3772        })
3773        .to_string();
3774        let meta = serde_json::json!({
3775            "id": "att-1",
3776            "fileName": encrypted_name,
3777            "size": blob.len(),
3778            "url": "/dl",
3779            "key": wrapped,
3780        })
3781        .to_string();
3782        let (seen, seen_rx) = tokio::sync::oneshot::channel();
3783        let (release, release_rx) = tokio::sync::oneshot::channel();
3784        let address = serve_steps(vec![
3785            Step { path: "/api/ciphers/item-1", reply: Reply::Json(cipher), pause: None },
3786            Step {
3787                path: "/api/ciphers/item-1/attachment/att-1",
3788                reply: Reply::Json(meta),
3789                pause: Some((seen, release_rx)),
3790            },
3791            Step { path: "/dl", reply: Reply::Bytes(blob), pause: None },
3792        ])
3793        .await;
3794        let (workspace, facade, caller, dir, mut rx, _session) =
3795            unlocked_worker_workspace(address, user_key, String::new());
3796        std::fs::create_dir(dir.path().join("sub")).expect("sub");
3797        let outside = tempfile::tempdir().expect("outside");
3798        let asked = caller.clone();
3799        let call = {
3800            let facade = Arc::clone(&facade);
3801            tokio::spawn(async move {
3802                facade.attachment_get(&asked, "item-1", "att-1", "sub/out.bin").await
3803            })
3804        };
3805        let draft = parked_draft(&mut rx).await;
3806        approve(&workspace, &caller, draft.id);
3807        seen_rx.await.expect("the metadata read reached the server");
3808        std::fs::remove_dir(dir.path().join("sub")).expect("remove sub");
3809        std::os::unix::fs::symlink(outside.path(), dir.path().join("sub")).expect("symlink");
3810        release.send(()).expect("the server responds");
3811
3812        let result = call.await.expect("the call finishes");
3813        assert!(result.is_err(), "the swapped destination is refused: {result:?}");
3814        assert!(!outside.path().join("out.bin").exists(), "nothing was written outside");
3815    }
3816
3817    /// The key comparison locks the session when the account's wrapped user
3818    /// key changed under the unlock.
3819    #[tokio::test]
3820    async fn a_rotated_user_key_locks_the_session_on_a_write() {
3821        use forge_connectors::vault::crypto::{make_symmetric_key, sha256_hex};
3822
3823        let sync = serde_json::json!({ "profile": { "key": "wrapped-key-two" } }).to_string();
3824        let address =
3825            serve_steps(vec![Step { path: "/api/sync", reply: Reply::Json(sync), pause: None }])
3826                .await;
3827        let (_workspace, _facade, caller, _dir, _rx, session) = unlocked_worker_workspace(
3828            address,
3829            make_symmetric_key(),
3830            sha256_hex(b"wrapped-key-one"),
3831        );
3832
3833        let error = session.ensure_keys_current(&caller, None).await.expect_err("rotation");
3834
3835        assert!(matches!(error, VaultError::KeyRotated), "{error}");
3836        assert!(session.is_locked(), "the rotated keys are dropped");
3837    }
3838
3839    /// The same wrapped key leaves the session unlocked.
3840    #[tokio::test]
3841    async fn an_unchanged_user_key_passes_the_write_check() {
3842        use forge_connectors::vault::crypto::{make_symmetric_key, sha256_hex};
3843
3844        let sync = serde_json::json!({ "profile": { "key": "wrapped-key-one" } }).to_string();
3845        let address =
3846            serve_steps(vec![Step { path: "/api/sync", reply: Reply::Json(sync), pause: None }])
3847                .await;
3848        let (_workspace, _facade, caller, _dir, _rx, session) = unlocked_worker_workspace(
3849            address,
3850            make_symmetric_key(),
3851            sha256_hex(b"wrapped-key-one"),
3852        );
3853
3854        session.ensure_keys_current(&caller, None).await.expect("the key is unchanged");
3855
3856        assert!(!session.is_locked());
3857    }
3858
3859    /// A session whose fingerprint is not the synced key, for write tests.
3860    fn rotated_worker_workspace(
3861        address: std::net::SocketAddr,
3862        user_key: forge_connectors::vault::crypto::SymmetricCryptoKey,
3863    ) -> (
3864        Arc<Workspace>,
3865        Arc<ProdVaultFacade>,
3866        SessionSlot,
3867        tempfile::TempDir,
3868        tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
3869        Arc<VaultSession>,
3870    ) {
3871        use forge_connectors::vault::crypto::sha256_hex;
3872        unlocked_worker_workspace(address, user_key, sha256_hex(b"unlocked-key"))
3873    }
3874
3875    /// A sync carrying the wrapped key the session unlocked with.
3876    fn synced_steps(extra: Vec<Step>) -> Vec<Step> {
3877        let sync =
3878            serde_json::json!({ "profile": { "key": "unlocked-key", "id": "user-1" } }).to_string();
3879        let mut steps = vec![Step { path: "/api/sync", reply: Reply::Json(sync), pause: None }];
3880        steps.extend(extra);
3881        steps
3882    }
3883
3884    /// A sync carrying a different wrapped key.
3885    fn rotated_steps(extra: Vec<Step>) -> Vec<Step> {
3886        let sync = serde_json::json!({ "profile": { "key": "sync-key" } }).to_string();
3887        let mut steps = vec![Step { path: "/api/sync", reply: Reply::Json(sync), pause: None }];
3888        steps.extend(extra);
3889        steps
3890    }
3891
3892    /// Requests that happen before the gate, then the rotated-key sync.
3893    fn rotated_steps_after(first: Vec<Step>) -> Vec<Step> {
3894        let mut steps = first;
3895        steps.extend(rotated_steps(Vec::new()));
3896        steps
3897    }
3898
3899    /// Serve the write-path sync and one write, handing the write's own body
3900    /// back to the test.
3901    async fn serve_write_capture(
3902        sync: String,
3903        path: &'static str,
3904    ) -> (std::net::SocketAddr, tokio::sync::oneshot::Receiver<String>) {
3905        serve_capture(vec![("/api/sync", sync), (path, r#"{"id":"new-item"}"#.to_owned())]).await
3906    }
3907
3908    /// Serve the given `(path, reply)` steps in order, handing the LAST
3909    /// step's request body back to the test.
3910    async fn serve_capture(
3911        steps: Vec<(&'static str, String)>,
3912    ) -> (std::net::SocketAddr, tokio::sync::oneshot::Receiver<String>) {
3913        use tokio::io::AsyncWriteExt;
3914        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
3915        let address = listener.local_addr().expect("addr");
3916        let (body_tx, body_rx) = tokio::sync::oneshot::channel();
3917        tokio::spawn(async move {
3918            let mut body_tx = Some(body_tx);
3919            let last = steps.len().saturating_sub(1);
3920            for (index, (expected, reply)) in steps.into_iter().enumerate() {
3921                let (mut socket, _) = listener.accept().await.expect("accept");
3922                let request = read_test_request(&mut socket).await;
3923                assert_eq!(
3924                    request.split_whitespace().nth(1),
3925                    Some(expected),
3926                    "unexpected request: {}",
3927                    request.lines().next().unwrap_or_default(),
3928                );
3929                if index == last {
3930                    let body = request.split("\r\n\r\n").nth(1).unwrap_or_default().to_owned();
3931                    if let Some(body_tx) = body_tx.take() {
3932                        let _ = body_tx.send(body);
3933                    }
3934                }
3935                let response = format!(
3936                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}",
3937                    reply.len()
3938                );
3939                let _ = socket.write_all(response.as_bytes()).await;
3940            }
3941        });
3942        (address, body_rx)
3943    }
3944
3945    /// A sync with no wrapped key at all.
3946    #[tokio::test]
3947    async fn a_sync_without_the_user_key_locks_the_session() {
3948        use forge_connectors::vault::crypto::{make_symmetric_key, sha256_hex};
3949
3950        let sync = serde_json::json!({ "profile": {} }).to_string();
3951        let address =
3952            serve_steps(vec![Step { path: "/api/sync", reply: Reply::Json(sync), pause: None }])
3953                .await;
3954        let (_workspace, _facade, caller, _dir, _rx, session) =
3955            unlocked_worker_workspace(address, make_symmetric_key(), sha256_hex(b"unlocked-key"));
3956
3957        let error = session.ensure_keys_current(&caller, None).await.expect_err("no key");
3958
3959        assert!(matches!(error, VaultError::KeyUnverifiable), "{error}");
3960        assert!(session.is_locked(), "an unverifiable key locks the session");
3961    }
3962
3963    /// A transient error from the key check must not restamp parked cards as
3964    /// needing a password the gate would then ignore.
3965    #[tokio::test]
3966    async fn a_transient_sync_error_does_not_restamp_parked_cards() {
3967        use forge_connectors::vault::crypto::make_symmetric_key;
3968
3969        let address = serve_steps(vec![Step {
3970            path: "/api/sync",
3971            reply: Reply::Status { code: 500, body: "{}".to_owned() },
3972            pause: None,
3973        }])
3974        .await;
3975        let (workspace, facade, caller, _dir, mut rx, session) =
3976            unlocked_worker_workspace(address, make_symmetric_key(), String::new());
3977        let (other, _held, _) = workspace.register_vault_ask(
3978            &caller,
3979            VaultDraft::new(VaultTool::Search, "another call"),
3980            false,
3981        );
3982        while rx.try_recv().is_ok() {}
3983
3984        let asked = caller.clone();
3985        let call = {
3986            let facade = Arc::clone(&facade);
3987            tokio::spawn(async move {
3988                facade
3989                    .create(&asked, &CreateInput { name: "bank".to_owned(), ..Default::default() })
3990                    .await
3991            })
3992        };
3993        let draft = parked_draft(&mut rx).await;
3994        approve(&workspace, &caller, draft.id);
3995        let result = call.await.expect("the call finishes");
3996
3997        assert!(
3998            matches!(result, Err(VaultToolError::Vault(VaultError::Api { status: 500, .. }))),
3999            "{result:?}",
4000        );
4001        assert!(!session.is_locked(), "a transient error does not lock");
4002        let mut restamped = Vec::new();
4003        while let Ok(frame) = rx.try_recv() {
4004            if let crate::protocol::SessionUpdate::VaultAskPending { draft, .. } =
4005                std::sync::Arc::unwrap_or_clone(frame.update)
4006            {
4007                restamped.push(draft);
4008            }
4009        }
4010        assert!(
4011            !restamped.iter().any(|draft| draft.id == other),
4012            "the other card is not restamped: {restamped:?}",
4013        );
4014    }
4015
4016    /// Each write path refuses after the account key rotated during the park.
4017    #[tokio::test]
4018    async fn a_rotated_key_refuses_create() {
4019        use forge_connectors::vault::crypto::make_symmetric_key;
4020
4021        let address = serve_steps(rotated_steps(Vec::new())).await;
4022        let (workspace, facade, caller, _dir, mut rx, session) =
4023            rotated_worker_workspace(address, make_symmetric_key());
4024        let asked = caller.clone();
4025        let call = {
4026            let facade = Arc::clone(&facade);
4027            tokio::spawn(async move {
4028                facade
4029                    .create(&asked, &CreateInput { name: "bank".to_owned(), ..Default::default() })
4030                    .await
4031            })
4032        };
4033        let draft = parked_draft(&mut rx).await;
4034        approve(&workspace, &caller, draft.id);
4035        let result = call.await.expect("the call finishes");
4036        assert!(matches!(result, Err(VaultToolError::Vault(VaultError::KeyRotated))), "{result:?}");
4037        assert!(session.is_locked());
4038    }
4039
4040    #[tokio::test]
4041    async fn a_rotated_key_refuses_folder_list() {
4042        use forge_connectors::vault::crypto::make_symmetric_key;
4043
4044        let address = serve_steps(rotated_steps(Vec::new())).await;
4045        let (workspace, facade, caller, _dir, mut rx, session) =
4046            rotated_worker_workspace(address, make_symmetric_key());
4047        let asked = caller.clone();
4048        let call = {
4049            let facade = Arc::clone(&facade);
4050            tokio::spawn(async move { facade.folder_list(&asked).await })
4051        };
4052        let draft = parked_draft(&mut rx).await;
4053        approve(&workspace, &caller, draft.id);
4054        let result = call.await.expect("the call finishes");
4055        assert!(matches!(result, Err(VaultToolError::Vault(VaultError::KeyRotated))), "{result:?}");
4056        assert!(session.is_locked());
4057    }
4058
4059    #[tokio::test]
4060    async fn a_rotated_key_refuses_folder_create() {
4061        use forge_connectors::vault::crypto::make_symmetric_key;
4062
4063        let address = serve_steps(rotated_steps(Vec::new())).await;
4064        let (workspace, facade, caller, _dir, mut rx, session) =
4065            rotated_worker_workspace(address, make_symmetric_key());
4066        let asked = caller.clone();
4067        let call = {
4068            let facade = Arc::clone(&facade);
4069            tokio::spawn(async move { facade.folder_create(&asked, "bank").await })
4070        };
4071        let draft = parked_draft(&mut rx).await;
4072        approve(&workspace, &caller, draft.id);
4073        let result = call.await.expect("the call finishes");
4074        assert!(matches!(result, Err(VaultToolError::Vault(VaultError::KeyRotated))), "{result:?}");
4075        assert!(session.is_locked());
4076    }
4077
4078    #[tokio::test]
4079    async fn a_rotated_key_refuses_folder_update() {
4080        use forge_connectors::vault::crypto::make_symmetric_key;
4081
4082        let address = serve_steps(rotated_steps(Vec::new())).await;
4083        let (workspace, facade, caller, _dir, mut rx, session) =
4084            rotated_worker_workspace(address, make_symmetric_key());
4085        let asked = caller.clone();
4086        let call = {
4087            let facade = Arc::clone(&facade);
4088            tokio::spawn(async move { facade.folder_update(&asked, "folder-1", "renamed").await })
4089        };
4090        let draft = parked_draft(&mut rx).await;
4091        approve(&workspace, &caller, draft.id);
4092        let result = call.await.expect("the call finishes");
4093        assert!(matches!(result, Err(VaultToolError::Vault(VaultError::KeyRotated))), "{result:?}");
4094        assert!(session.is_locked());
4095    }
4096
4097    #[tokio::test]
4098    async fn a_rotated_key_refuses_update() {
4099        use forge_connectors::vault::crypto::{encrypt_str, make_symmetric_key};
4100
4101        let user_key = make_symmetric_key();
4102        let name = encrypt_str("bank", &user_key).expect("the name");
4103        let cipher = serde_json::json!({
4104            "id": "item-1",
4105            "type": 1,
4106            "name": name,
4107            "login": {},
4108        })
4109        .to_string();
4110        let address = serve_steps(rotated_steps_after(vec![Step {
4111            path: "/api/ciphers/item-1",
4112            reply: Reply::Json(cipher),
4113            pause: None,
4114        }]))
4115        .await;
4116        let (workspace, facade, caller, _dir, mut rx, session) =
4117            rotated_worker_workspace(address, user_key);
4118        let asked = caller.clone();
4119        let call = {
4120            let facade = Arc::clone(&facade);
4121            tokio::spawn(async move {
4122                let patch = PatchInput { password: Some("new".to_owned()), ..Default::default() };
4123                facade.update(&asked, "item-1", &patch).await
4124            })
4125        };
4126        let draft = parked_draft(&mut rx).await;
4127        approve(&workspace, &caller, draft.id);
4128        let result = call.await.expect("the call finishes");
4129        assert!(matches!(result, Err(VaultToolError::Vault(VaultError::KeyRotated))), "{result:?}");
4130        assert!(session.is_locked());
4131    }
4132
4133    #[tokio::test]
4134    async fn a_rotated_key_refuses_attachment_put() {
4135        use forge_connectors::vault::crypto::{encrypt_str, make_symmetric_key};
4136
4137        let user_key = make_symmetric_key();
4138        let name = encrypt_str("bank", &user_key).expect("the name");
4139        let cipher = serde_json::json!({
4140            "id": "item-1",
4141            "type": 1,
4142            "name": name,
4143            "login": {},
4144            "attachments": [],
4145        })
4146        .to_string();
4147        let address = serve_steps(rotated_steps_after(vec![Step {
4148            path: "/api/ciphers/item-1",
4149            reply: Reply::Json(cipher),
4150            pause: None,
4151        }]))
4152        .await;
4153        let (workspace, facade, caller, dir, mut rx, session) =
4154            rotated_worker_workspace(address, user_key);
4155        std::fs::write(dir.path().join("upload.txt"), b"content").expect("the upload file");
4156        let asked = caller.clone();
4157        let call = {
4158            let facade = Arc::clone(&facade);
4159            tokio::spawn(async move {
4160                facade.attachment_put(&asked, "item-1", "upload.txt", None, None).await
4161            })
4162        };
4163        let draft = parked_draft(&mut rx).await;
4164        approve(&workspace, &caller, draft.id);
4165        let result = call.await.expect("the call finishes");
4166        assert!(matches!(result, Err(VaultToolError::Vault(VaultError::KeyRotated))), "{result:?}");
4167        assert!(session.is_locked());
4168    }
4169
4170    /// `create` returns a Crypto failure through `done` and audits the id the
4171    /// item landed under, rather than swallowing it.
4172    #[tokio::test]
4173    async fn create_propagates_a_name_decrypt_failure_and_names_the_id() {
4174        use forge_connectors::vault::crypto::{make_symmetric_key, sha256_hex};
4175
4176        let created = serde_json::json!({
4177            "id": "new-item",
4178            "name": "2.bad|bad|bad",
4179        })
4180        .to_string();
4181        let address = serve_steps(synced_steps(vec![Step {
4182            path: "/api/ciphers",
4183            reply: Reply::Json(created),
4184            pause: None,
4185        }]))
4186        .await;
4187        let (workspace, facade, caller, dir, mut rx, session) =
4188            unlocked_worker_workspace(address, make_symmetric_key(), sha256_hex(b"unlocked-key"));
4189        let asked = caller.clone();
4190        let call = {
4191            let facade = Arc::clone(&facade);
4192            tokio::spawn(async move {
4193                facade
4194                    .create(&asked, &CreateInput { name: "bank".to_owned(), ..Default::default() })
4195                    .await
4196            })
4197        };
4198        let draft = parked_draft(&mut rx).await;
4199        approve(&workspace, &caller, draft.id);
4200        let result = call.await.expect("the call finishes");
4201
4202        assert!(matches!(result, Err(VaultToolError::Vault(VaultError::Crypto(_)))), "{result:?}");
4203        assert!(session.is_locked(), "the crypto failure drops the keys");
4204        let audit = audit_text(&dir);
4205        assert!(
4206            audit.contains("\"item_id\":\"new-item\""),
4207            "the one audit line names the id that landed: {audit}",
4208        );
4209        assert_eq!(audit.lines().count(), 1, "one audit line, not two: {audit}");
4210    }
4211
4212    /// Vaultwarden 1.37 refuses a `CipherData` without `encryptedFor`; the
4213    /// create must stamp the account id the write-path sync carries.
4214    #[tokio::test]
4215    async fn a_create_sends_the_synced_user_id_in_encrypted_for() {
4216        use forge_connectors::vault::crypto::{make_symmetric_key, sha256_hex};
4217
4218        let sync =
4219            serde_json::json!({ "profile": { "key": "unlocked-key", "id": "user-1" } }).to_string();
4220        let (address, body) = serve_write_capture(sync, "/api/ciphers").await;
4221        let (workspace, facade, caller, _dir, mut rx, _session) =
4222            unlocked_worker_workspace(address, make_symmetric_key(), sha256_hex(b"unlocked-key"));
4223        let asked = caller.clone();
4224        let call = {
4225            let facade = Arc::clone(&facade);
4226            tokio::spawn(async move {
4227                facade
4228                    .create(&asked, &CreateInput { name: "bank".to_owned(), ..Default::default() })
4229                    .await
4230            })
4231        };
4232        let draft = parked_draft(&mut rx).await;
4233        approve(&workspace, &caller, draft.id);
4234        let created = call.await.expect("the call finishes").expect("the create");
4235
4236        assert_eq!(created["id"], "new-item");
4237        let body = body.await.expect("the create body");
4238        let body: Value = serde_json::from_str(&body).expect("the body is JSON");
4239        assert_eq!(body["encryptedFor"], "user-1", "{body}");
4240    }
4241
4242    /// The PUT deserializes the same `CipherData`; Vaultwarden's cipher
4243    /// response carries no `encryptedFor`, so the update stamps it too.
4244    #[tokio::test]
4245    async fn an_update_sends_the_synced_user_id_in_encrypted_for() {
4246        use forge_connectors::vault::crypto::{encrypt_str, make_symmetric_key, sha256_hex};
4247
4248        let user_key = make_symmetric_key();
4249        let name = encrypt_str("bank", &user_key).expect("the name");
4250        let cipher =
4251            serde_json::json!({ "id": "item-1", "type": 1, "name": name, "login": {} }).to_string();
4252        let sync =
4253            serde_json::json!({ "profile": { "key": "unlocked-key", "id": "user-1" } }).to_string();
4254        let (address, body) = serve_capture(vec![
4255            ("/api/ciphers/item-1", cipher),
4256            ("/api/sync", sync),
4257            ("/api/ciphers/item-1", "{}".to_owned()),
4258        ])
4259        .await;
4260        let (workspace, facade, caller, _dir, mut rx, _session) =
4261            unlocked_worker_workspace(address, user_key, sha256_hex(b"unlocked-key"));
4262        let asked = caller.clone();
4263        let call = {
4264            let facade = Arc::clone(&facade);
4265            tokio::spawn(async move {
4266                let patch = PatchInput { password: Some("new".to_owned()), ..Default::default() };
4267                facade.update(&asked, "item-1", &patch).await
4268            })
4269        };
4270        let draft = parked_draft(&mut rx).await;
4271        approve(&workspace, &caller, draft.id);
4272        let updated = call.await.expect("the call finishes").expect("the update");
4273
4274        assert_eq!(updated["updated"], true);
4275        let body = body.await.expect("the update body");
4276        let body: Value = serde_json::from_str(&body).expect("the body is JSON");
4277        assert_eq!(body["encryptedFor"], "user-1", "{body}");
4278    }
4279
4280    /// A sync that carries no user id cannot stamp the cipher, so the create
4281    /// fails by name instead of sending a body the server rejects. An empty
4282    /// id is as unusable as an absent one.
4283    #[tokio::test]
4284    async fn a_create_without_a_synced_user_id_is_refused() {
4285        use forge_connectors::vault::crypto::{make_symmetric_key, sha256_hex};
4286
4287        for profile in [
4288            serde_json::json!({ "key": "unlocked-key" }),
4289            serde_json::json!({ "key": "unlocked-key", "id": "" }),
4290        ] {
4291            let sync = serde_json::json!({ "profile": profile }).to_string();
4292            let address = serve_steps(vec![Step {
4293                path: "/api/sync",
4294                reply: Reply::Json(sync),
4295                pause: None,
4296            }])
4297            .await;
4298            let (workspace, facade, caller, _dir, mut rx, _session) = unlocked_worker_workspace(
4299                address,
4300                make_symmetric_key(),
4301                sha256_hex(b"unlocked-key"),
4302            );
4303            let asked = caller.clone();
4304            let call = {
4305                let facade = Arc::clone(&facade);
4306                tokio::spawn(async move {
4307                    facade
4308                        .create(
4309                            &asked,
4310                            &CreateInput { name: "bank".to_owned(), ..Default::default() },
4311                        )
4312                        .await
4313                })
4314            };
4315            let draft = parked_draft(&mut rx).await;
4316            approve(&workspace, &caller, draft.id);
4317            let result = call.await.expect("the call finishes");
4318
4319            assert!(
4320                matches!(&result, Err(VaultToolError::Message(message)) if message.contains("account id")),
4321                "{result:?}",
4322            );
4323        }
4324    }
4325
4326    /// A missing organization key falls back; any other decrypt failure locks
4327    /// and restamps the cards still parked.
4328    #[tokio::test]
4329    async fn missing_key_falls_back_and_a_crypto_failure_locks_and_restamps() {
4330        use forge_connectors::vault::crypto::make_symmetric_key;
4331
4332        let address = serve_steps(Vec::new()).await;
4333        let (workspace, facade, caller, _dir, mut rx, session) =
4334            unlocked_worker_workspace(address, make_symmetric_key(), String::new());
4335        let (other, _held, _) = workspace.register_vault_ask(
4336            &caller,
4337            VaultDraft::new(VaultTool::Search, "another call"),
4338            false,
4339        );
4340        while rx.try_recv().is_ok() {}
4341
4342        let missing = facade
4343            .fallback_or_lock(
4344                &session,
4345                &caller,
4346                Some("item-1"),
4347                Err(VaultToolError::Vault(VaultError::MissingKey("org-1".to_owned()))),
4348                "fallback".to_owned(),
4349            )
4350            .expect("MissingKey falls back");
4351        assert_eq!(missing, "fallback");
4352        assert!(!session.is_locked(), "a missing org key does not lock the account");
4353
4354        let error = facade
4355            .fallback_or_lock(
4356                &session,
4357                &caller,
4358                Some("item-1"),
4359                Err(VaultToolError::Vault(VaultError::Crypto(anyhow::anyhow!("rotated")))),
4360                "fallback".to_owned(),
4361            )
4362            .expect_err("a crypto failure surfaces");
4363        assert!(matches!(error, VaultToolError::Vault(VaultError::Crypto(_))), "{error}");
4364        assert!(session.is_locked(), "the stale keys are dropped");
4365
4366        let mut restamped = None;
4367        while let Ok(frame) = rx.try_recv() {
4368            if let crate::protocol::SessionUpdate::VaultAskPending { draft, .. } =
4369                std::sync::Arc::unwrap_or_clone(frame.update)
4370                && draft.id == other
4371            {
4372                restamped = Some(draft);
4373            }
4374        }
4375        assert!(
4376            restamped.is_some_and(|draft| draft.needs_unlock),
4377            "the other card asks for the password again",
4378        );
4379    }
4380
4381    /// A decryption failure inside an approved call drops the keys and
4382    /// restamps every other parked card as needing the password.
4383    #[tokio::test]
4384    async fn a_decryption_failure_locks_and_restamps_the_parked_cards() {
4385        use forge_connectors::vault::crypto::make_symmetric_key;
4386
4387        let dir = tempfile::tempdir().expect("tempdir");
4388        let config = VaultConfig {
4389            url: "http://127.0.0.1:1".to_owned(),
4390            email: "hi@vedhavyas.dev".to_owned(),
4391            idle_minutes: None,
4392        };
4393        let session =
4394            Arc::new(test_session(&config, Some(dir.path()), dir.path()).expect("a session"));
4395        test_install_unlocked(
4396            &session,
4397            Unlocked {
4398                user_key: make_symmetric_key(),
4399                private_key: None,
4400                org_keys: std::collections::HashMap::new(),
4401                failed_orgs: Vec::new(),
4402                user_key_fingerprint: String::new(),
4403            },
4404        );
4405        let (workspace, mut rx) = Workspace::testing_stub();
4406        *workspace.vault.write() = Some(Arc::clone(&session));
4407        let caller = SessionSlot::lead("Default", "forge");
4408        let (other, _held, _) = workspace.register_vault_ask(
4409            &caller,
4410            VaultDraft::new(VaultTool::Search, "another call"),
4411            false,
4412        );
4413        while rx.try_recv().is_ok() {}
4414
4415        let approved = Approved {
4416            workspace: Arc::clone(&workspace),
4417            session: Arc::clone(&session),
4418            keys: session.keys().expect("the installed keys"),
4419            caller: caller.clone(),
4420            tool: VaultTool::Get,
4421            item_id: Some("item-1".to_owned()),
4422            item_name: Some("bank".to_owned()),
4423            field: None,
4424        };
4425        let error = approved
4426            .done::<()>(Err(VaultToolError::Vault(VaultError::Crypto(anyhow::anyhow!(
4427                "keys rotated"
4428            )))))
4429            .expect_err("the crypto failure is returned");
4430
4431        assert!(session.is_locked(), "the stale keys are dropped: {error}");
4432        let mut restamped = None;
4433        while let Ok(frame) = rx.try_recv() {
4434            if let crate::protocol::SessionUpdate::VaultAskPending { draft, .. } =
4435                std::sync::Arc::unwrap_or_clone(frame.update)
4436                && draft.id == other
4437            {
4438                restamped = Some(draft);
4439            }
4440        }
4441        let restamped = restamped.expect("the other card is restamped");
4442        assert!(restamped.needs_unlock, "the other card asks for the password again");
4443        assert!(
4444            audit_text(&dir).contains("failed: vault request failed: crypto error: keys rotated"),
4445            "{}",
4446            audit_text(&dir),
4447        );
4448    }
4449
4450    /// A locked search parks the query rather than failing on the sync.
4451    #[tokio::test]
4452    async fn a_locked_search_parks_the_query() {
4453        let (workspace, facade, caller, mut rx) = locked_workspace();
4454        let asked = caller.clone();
4455        let task = tokio::spawn(async move { facade.search(&asked, "bank", None).await });
4456
4457        let draft = parked_draft(&mut rx).await;
4458        assert!(draft.needs_unlock);
4459        assert!(draft.summary.contains("search for 'bank'"), "{}", draft.summary);
4460
4461        decline(&workspace, &caller, draft.id);
4462        assert!(matches!(task.await.expect("the call finishes"), Err(VaultToolError::Rejected)));
4463    }
4464}