Skip to main content

bw/tools/
send.rs

1//! `bw send` command implementation.
2//!
3//! Dispatches the subcommands declared on [`SendArgs`] / [`SendCommands`] in
4//! [`super`] to the underlying [`bitwarden_send::SendClient`] methods. The arg
5//! parsing surface is intentionally defined in `super` so the legacy-CLI shape
6//! (which is part of the user contract) stays close to the rest of the
7//! `tools` family.
8
9use std::path::PathBuf;
10
11use bitwarden_pm::PasswordManagerClient;
12use bitwarden_send::{
13    AuthEdit, SendAddRequest, SendAuthType, SendEditRequest, SendFileView, SendId, SendTextView,
14    SendViewType,
15};
16use chrono::{Duration, Utc};
17use clap::{
18    Args, Subcommand,
19    builder::{PossibleValuesParser, TypedValueParser as _},
20};
21use color_eyre::eyre::{Context as _, eyre};
22use serde::Serialize;
23
24use crate::{
25    client_state::{AnyState, BwCommand, BwCommandExt as _, ClientContext, LoggedIn},
26    platform::read_config_json,
27    render::{CommandOutput, CommandResult},
28};
29
30/// Allowed values for `--deleteInDays`, matching the legacy CLI's enumerated set.
31/// Kept as strings so `PossibleValuesParser` can surface them in `--help` output.
32const DELETE_IN_DAYS_ALLOWED: &[&str] = &["1", "2", "3", "7", "14", "30"];
33
34/// Clap value parser for `--deleteInDays`. Restricts input to the legacy-CLI allowed
35/// set (also surfaced in `--help`) and maps it back to the `u64` field type.
36fn delete_in_days_parser() -> impl clap::builder::TypedValueParser<Value = u64> {
37    PossibleValuesParser::new(DELETE_IN_DAYS_ALLOWED)
38        .map(|s| s.parse::<u64>().expect("allowed values are valid u64"))
39}
40
41#[derive(Args, Clone)]
42pub struct SendArgs {
43    /// The data to Send
44    pub data: Option<String>,
45
46    #[arg(short = 'f', long, help = "Specifies that <data> is a filepath.")]
47    pub file: bool,
48
49    #[arg(
50        short = 'd',
51        long = "deleteInDays",
52        help = "The number of days in the future to set deletion date.",
53        default_value_t = 7,
54        value_parser = delete_in_days_parser(),
55    )]
56    pub delete_in_days: u64,
57
58    #[arg(
59        long,
60        conflicts_with = "emails",
61        help = "Optional password to access this Send."
62    )]
63    pub password: Option<String>,
64
65    #[arg(
66        long,
67        help = "Email addresses for OTP authentication (single, JSON array, comma- or space-separated)."
68    )]
69    pub emails: Option<String>,
70
71    #[arg(
72        short = 'a',
73        long = "maxAccessCount",
74        help = "The amount of max possible accesses."
75    )]
76    pub max_access_count: Option<u32>,
77
78    #[arg(long, help = "Hide <data> in web by default.")]
79    pub hidden: bool,
80
81    #[arg(short = 'n', long, help = "The name of the Send.")]
82    pub name: Option<String>,
83
84    #[arg(long, help = "Notes to add to the Send.")]
85    pub notes: Option<String>,
86
87    #[arg(
88        long = "fullObject",
89        help = "Specifies that the full Send object should be returned."
90    )]
91    pub full_object: bool,
92
93    #[command(subcommand)]
94    pub command: Option<SendCommands>,
95}
96
97#[derive(Subcommand, Clone, Debug)]
98pub enum SendCommands {
99    #[command(about = "List all the Sends owned by you.")]
100    List(SendListArgs),
101
102    #[command(about = "Get json templates for send objects.")]
103    Template(SendTemplateArgs),
104
105    #[command(about = "Get Sends owned by you.")]
106    Get(SendGetArgs),
107
108    #[command(about = "Access a Bitwarden Send from a url.")]
109    Receive(SendReceiveArgs),
110
111    #[command(about = "Create a Send.")]
112    Create(SendCreateArgs),
113
114    #[command(about = "Edit a Send.")]
115    Edit(SendEditArgs),
116
117    #[command(about = "Removes the saved password from a Send.")]
118    RemovePassword(SendRemovePasswordArgs),
119
120    #[command(about = "Delete a Send.")]
121    Delete(SendDeleteArgs),
122}
123
124#[derive(Args, Clone, Debug)]
125pub struct SendListArgs;
126
127#[derive(Args, Clone, Debug)]
128pub struct SendTemplateArgs {
129    pub object: String,
130}
131
132#[derive(Args, Clone, Debug)]
133pub struct SendGetArgs {
134    pub id: SendId,
135
136    // The internal field is `output_path` (not `output`) to avoid clashing with the
137    // top-level `Cli::output` (the `-o` rendered-output-format arg). User-facing long
138    // flag stays `--output` to match the legacy CLI.
139    #[arg(
140        long = "output",
141        help = "File path to save a file-type Send's decrypted contents to."
142    )]
143    pub output_path: Option<String>,
144
145    #[arg(long, help = "Only return the access url.")]
146    pub text: bool,
147}
148
149#[derive(Args, Clone, Debug)]
150pub struct SendReceiveArgs {
151    pub url: String,
152
153    #[arg(long, help = "Optional password for the Send.")]
154    pub password: Option<String>,
155
156    #[arg(long, help = "Specify a file path to save a File-type Send to.")]
157    pub obj: Option<String>,
158}
159
160#[derive(Args, Clone, Debug)]
161pub struct SendCreateArgs {
162    pub encoded_json: Option<String>,
163
164    #[arg(short = 'f', long, help = "Path to the file to Send.")]
165    pub file: Option<String>,
166
167    #[arg(long, help = "Text to Send.")]
168    pub text: Option<String>,
169
170    #[arg(
171        short = 'd',
172        long = "deleteInDays",
173        help = "The number of days in the future to set deletion date.",
174        default_value_t = 7,
175        value_parser = delete_in_days_parser(),
176    )]
177    pub delete_in_days: u64,
178
179    #[arg(
180        long = "maxAccessCount",
181        help = "The maximum number of times this Send can be accessed."
182    )]
183    pub max_access_count: Option<u32>,
184
185    #[arg(long, help = "Hide text.")]
186    pub hidden: bool,
187
188    #[arg(short = 'n', long, help = "The name of the Send.")]
189    pub name: Option<String>,
190
191    #[arg(long, help = "Notes to add to the Send.")]
192    pub notes: Option<String>,
193
194    #[arg(
195        long,
196        conflicts_with = "emails",
197        help = "Optional password to access this Send."
198    )]
199    pub password: Option<String>,
200
201    #[arg(
202        long,
203        help = "Email addresses for OTP authentication (single, JSON array, comma- or space-separated)."
204    )]
205    pub emails: Option<String>,
206
207    #[arg(
208        long = "fullObject",
209        help = "Return full Send object instead of access url."
210    )]
211    pub full_object: bool,
212}
213
214#[derive(Args, Clone, Debug)]
215pub struct SendEditArgs {
216    pub encoded_json: Option<String>,
217
218    #[arg(long, help = "Overrides the itemId provided in encodedJson.")]
219    pub itemid: Option<SendId>,
220
221    #[arg(
222        short = 'd',
223        long = "deleteInDays",
224        help = "The number of days in the future to set deletion date.",
225        value_parser = delete_in_days_parser(),
226    )]
227    pub delete_in_days: Option<u64>,
228
229    #[arg(
230        long = "maxAccessCount",
231        help = "The maximum number of times this Send can be accessed."
232    )]
233    pub max_access_count: Option<u32>,
234
235    #[arg(long, help = "Hide text.")]
236    pub hidden: bool,
237
238    #[arg(
239        long,
240        conflicts_with = "emails",
241        help = "Optional password to access this Send."
242    )]
243    pub password: Option<String>,
244
245    #[arg(
246        long,
247        help = "Email addresses for OTP authentication (single, JSON array, comma- or space-separated)."
248    )]
249    pub emails: Option<String>,
250}
251
252#[derive(Args, Clone, Debug)]
253pub struct SendRemovePasswordArgs {
254    pub id: SendId,
255}
256
257#[derive(Args, Clone, Debug)]
258pub struct SendDeleteArgs {
259    pub id: SendId,
260}
261
262impl BwCommand for SendArgs {
263    // `AnyState` because `bw send template` and `bw send receive` run without a session; the
264    // auth-required arms route to per-variant `BwCommand` impls below whose `type Client` is
265    // `LoggedIn`, so the auth check happens via the typestate extractor in each branch.
266    type Client = AnyState;
267
268    async fn run(self, state: AnyState) -> CommandResult {
269        // If no subcommand is supplied, the legacy CLI treats `bw send <data>` as a Create
270        // shortcut. Route that through the same builder path as `bw send create` so the two
271        // entry points share their happy path.
272        let ctx = ClientContext {
273            global: state.global,
274            user: state.user,
275        };
276        match self.command.clone() {
277            None => {
278                let LoggedIn { user, .. } = LoggedIn::try_from(ctx)?;
279                create_shortcut(&user, self).await
280            }
281            // `encoded_json` is intentionally pre-empted *before* `dispatch` extracts
282            // `LoggedIn`. The integration tests in `tests/send.rs` assert that supplying
283            // `encoded_json` to `create`/`edit` returns the "not yet implemented" error
284            // even when the caller is logged out — that signals the input is unsupported
285            // rather than burying it behind a confusing auth error.
286            Some(SendCommands::Create(args)) if args.encoded_json.is_some() => Err(eyre!(
287                "`encoded_json` input on `bw send create` is not yet implemented (tracked under PM-39240)."
288            )),
289            Some(SendCommands::Edit(args)) if args.encoded_json.is_some() => Err(eyre!(
290                "`encoded_json` input on `bw send edit` is not yet implemented (tracked under PM-39240)."
291            )),
292            // `--output` on `get` similarly fails before the auth check: silently
293            // emitting JSON to stdout while the requested file path goes uncreated would
294            // be a worse UX than an explicit "not implemented" error.
295            Some(SendCommands::Get(args)) if args.output_path.is_some() => Err(eyre!(
296                "`--output` on `bw send get` is not yet implemented (tracked under PM-39238)."
297            )),
298            Some(SendCommands::List(args)) => args.dispatch(ctx).await,
299            Some(SendCommands::Template(args)) => args.dispatch(ctx).await,
300            Some(SendCommands::Get(args)) => args.dispatch(ctx).await,
301            Some(SendCommands::Receive(args)) => args.dispatch(ctx).await,
302            Some(SendCommands::Create(args)) => args.dispatch(ctx).await,
303            Some(SendCommands::Edit(args)) => args.dispatch(ctx).await,
304            Some(SendCommands::RemovePassword(args)) => args.dispatch(ctx).await,
305            Some(SendCommands::Delete(args)) => args.dispatch(ctx).await,
306        }
307    }
308}
309
310impl BwCommand for SendListArgs {
311    type Client = LoggedIn;
312
313    async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
314        list_sends(&user).await
315    }
316}
317
318impl BwCommand for SendTemplateArgs {
319    // `template` doesn't talk to the server; route it through `AnyState` so users can
320    // generate JSON scaffolding without a session.
321    type Client = AnyState;
322
323    async fn run(self, _: AnyState) -> CommandResult {
324        render_template(&self.object)
325    }
326}
327
328impl BwCommand for SendGetArgs {
329    type Client = LoggedIn;
330
331    async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
332        // The `--output` early-error gate lives in [`SendArgs::run`] above so it can
333        // fire *before* the `LoggedIn` typestate extractor — a logged-out caller passing
334        // `--output` should see the precise "not yet implemented" error rather than
335        // a generic auth message. The file-decrypt pipeline follow-up is PM-39238.
336        get_send(&user, self.id, self.text).await
337    }
338}
339
340impl BwCommand for SendReceiveArgs {
341    // `bw send receive` is the legacy alias for the top-level `bw receive` command.
342    // Both are tracked under PM-34718 ("[SDK CLI] Receive Command"), a sibling to this
343    // ticket. Routed through `AnyState` so the not-yet-implemented error fires without
344    // an auth check.
345    type Client = AnyState;
346
347    async fn run(self, _: AnyState) -> CommandResult {
348        Err(eyre!(
349            "`bw send receive` is not yet implemented (tracked under PM-34718)."
350        ))
351    }
352}
353
354impl BwCommand for SendCreateArgs {
355    type Client = LoggedIn;
356
357    async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
358        // The `encoded_json` early-error gate lives in [`SendArgs::run`] above so it can
359        // fire *before* the `LoggedIn` typestate extractor. Encoded-JSON support is PM-39240.
360        let SendCreateArgs {
361            encoded_json: _,
362            file,
363            text,
364            delete_in_days,
365            max_access_count,
366            hidden,
367            name,
368            notes,
369            password,
370            emails,
371            full_object,
372        } = self;
373
374        let request = build_create_request(CreateInputs {
375            file,
376            text,
377            delete_in_days,
378            max_access_count,
379            hidden,
380            name,
381            notes,
382            password,
383            emails,
384        })?;
385
386        run_create(&user, request, full_object).await
387    }
388}
389
390impl BwCommand for SendEditArgs {
391    type Client = LoggedIn;
392
393    async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
394        // The `encoded_json` early-error gate lives in [`SendArgs::run`] above so it can
395        // fire *before* the `LoggedIn` typestate extractor. Encoded-JSON support is PM-39240.
396        let SendEditArgs {
397            encoded_json: _,
398            itemid,
399            delete_in_days,
400            max_access_count,
401            hidden,
402            password,
403            emails,
404        } = self;
405
406        let send_id = itemid.ok_or_else(|| {
407            eyre!("--itemid is required (or provide it via encoded JSON; see PM-39240).")
408        })?;
409
410        // PM-39240: support full-object JSON input and reject text↔file type changes on edit.
411        let existing = user.sends().get(send_id).await?;
412
413        let request = build_edit_request(
414            existing,
415            EditOverrides {
416                delete_in_days,
417                max_access_count,
418                hidden,
419                password,
420                emails,
421            },
422        )?;
423
424        let view = user.sends().edit(send_id, request).await?;
425        Ok(CommandOutput::Object(Box::new(view)))
426    }
427}
428
429impl BwCommand for SendRemovePasswordArgs {
430    type Client = LoggedIn;
431
432    async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
433        let view = user.sends().remove_password(self.id).await?;
434        Ok(CommandOutput::Object(Box::new(view)))
435    }
436}
437
438impl BwCommand for SendDeleteArgs {
439    type Client = LoggedIn;
440
441    async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
442        user.sends().delete(self.id).await?;
443        Ok("Send deleted.".into())
444    }
445}
446
447async fn list_sends(client: &PasswordManagerClient) -> CommandResult {
448    let views = client.sends().list().await?;
449    Ok(CommandOutput::Object(Box::new(views)))
450}
451
452async fn get_send(client: &PasswordManagerClient, id: SendId, text: bool) -> CommandResult {
453    let view = client.sends().get(id).await?;
454
455    if text {
456        // `--text` emits the shareable access URL (see the flag help). Recipients paste this
457        // into a browser or `bw receive` to fetch and decrypt the Send content client-side.
458        let url = build_access_url(client, &view)?;
459        return Ok(url.into());
460    }
461
462    Ok(CommandOutput::Object(Box::new(view)))
463}
464
465fn render_template(object: &str) -> CommandResult {
466    // The legacy CLI distinguishes `send.text` and `send.file` (the latter has a `file.fileName`
467    // field). Keep the shapes minimal but distinct so round-trips via `bw send create` are
468    // unambiguous.
469    match object {
470        "send.text" => Ok(CommandOutput::Object(Box::new(SendTextTemplate::default()))),
471        "send.file" => Ok(CommandOutput::Object(Box::new(SendFileTemplate::default()))),
472        other => Err(eyre!("Unknown template object: {other}")),
473    }
474}
475
476#[derive(Serialize, Default)]
477#[serde(rename_all = "camelCase")]
478struct SendTextTemplate {
479    name: String,
480    notes: String,
481    #[serde(rename = "type")]
482    send_type: u8, // 0 = text
483    text: SendTextTemplateBody,
484    deletion_date: String,
485}
486
487#[derive(Serialize, Default)]
488struct SendTextTemplateBody {
489    text: String,
490    hidden: bool,
491}
492
493#[derive(Serialize, Default)]
494#[serde(rename_all = "camelCase")]
495struct SendFileTemplate {
496    name: String,
497    notes: String,
498    #[serde(rename = "type")]
499    send_type: u8, // 1 = file
500    file: SendFileTemplateBody,
501    deletion_date: String,
502}
503
504#[derive(Serialize, Default)]
505#[serde(rename_all = "camelCase")]
506struct SendFileTemplateBody {
507    file_name: String,
508}
509
510struct CreateInputs {
511    file: Option<String>,
512    text: Option<String>,
513    delete_in_days: u64,
514    max_access_count: Option<u32>,
515    hidden: bool,
516    name: Option<String>,
517    notes: Option<String>,
518    password: Option<String>,
519    emails: Option<String>,
520}
521
522fn build_create_request(inputs: CreateInputs) -> color_eyre::eyre::Result<SendAddRequest> {
523    let CreateInputs {
524        file,
525        text,
526        delete_in_days,
527        max_access_count,
528        hidden,
529        name,
530        notes,
531        password,
532        emails,
533    } = inputs;
534
535    let deletion_date = compute_deletion_date(delete_in_days)?;
536
537    let view_type = match (file.as_deref(), text.as_deref()) {
538        (Some(path), None) => {
539            // File sends require a premium account; the precondition check is PM-39238.
540            let path = PathBuf::from(path);
541            let file_name = path
542                .file_name()
543                .and_then(|s| s.to_str())
544                .ok_or_else(|| eyre!("Could not derive a file name from --file path"))?
545                .to_string();
546            SendViewType::File(SendFileView {
547                id: None,
548                file_name,
549                size: None,
550                size_name: None,
551            })
552        }
553        (None, Some(t)) => SendViewType::Text(SendTextView {
554            text: Some(t.to_string()),
555            hidden,
556        }),
557        (Some(_), Some(_)) => {
558            return Err(eyre!("--file and --text are mutually exclusive."));
559        }
560        (None, None) => {
561            return Err(eyre!(
562                "Either --text <data> or --file <path> is required when creating a Send."
563            ));
564        }
565    };
566
567    // Derive a default name: file sends pick up the file name; text sends require an explicit
568    // name to match the legacy CLI's UX.
569    let resolved_name = match (name, &view_type) {
570        (Some(n), _) => n,
571        (None, SendViewType::File(f)) => f.file_name.clone(),
572        (None, SendViewType::Text(_)) => {
573            return Err(eyre!("--name is required for text Sends."));
574        }
575    };
576
577    let auth = build_auth(password, emails.as_deref())?;
578
579    Ok(SendAddRequest {
580        name: resolved_name,
581        notes,
582        view_type,
583        max_access_count,
584        disabled: false,
585        hide_email: false,
586        deletion_date,
587        expiration_date: None,
588        auth,
589    })
590}
591
592struct EditOverrides {
593    delete_in_days: Option<u64>,
594    max_access_count: Option<u32>,
595    hidden: bool,
596    password: Option<String>,
597    emails: Option<String>,
598}
599
600fn build_edit_request(
601    existing: bitwarden_send::SendView,
602    overrides: EditOverrides,
603) -> color_eyre::eyre::Result<SendEditRequest> {
604    let EditOverrides {
605        delete_in_days,
606        max_access_count,
607        hidden,
608        password,
609        emails,
610    } = overrides;
611
612    let deletion_date = match delete_in_days {
613        Some(d) => compute_deletion_date(d)?,
614        None => existing.deletion_date,
615    };
616
617    let view_type = match (existing.text, existing.file) {
618        (Some(t), None) => SendViewType::Text(SendTextView {
619            text: t.text,
620            hidden: if hidden { true } else { t.hidden },
621        }),
622        (None, Some(f)) => SendViewType::File(f),
623        // Sends should always carry exactly one of text/file; the API can in theory return
624        // both. The legacy CLI prefers text in that case, which is what we do here.
625        (Some(t), Some(_)) => SendViewType::Text(SendTextView {
626            text: t.text,
627            hidden: if hidden { true } else { t.hidden },
628        }),
629        (None, None) => {
630            return Err(eyre!(
631                "Cannot edit Send {:?}: server returned neither text nor file content.",
632                existing.id
633            ));
634        }
635    };
636
637    let auth = build_auth_for_edit(password, emails.as_deref())?;
638
639    Ok(SendEditRequest {
640        name: existing.name,
641        notes: existing.notes,
642        view_type,
643        max_access_count: max_access_count.or(existing.max_access_count),
644        disabled: existing.disabled,
645        hide_email: existing.hide_email,
646        deletion_date,
647        expiration_date: existing.expiration_date,
648        auth,
649    })
650}
651
652async fn create_shortcut(client: &PasswordManagerClient, args: SendArgs) -> CommandResult {
653    let data = args
654        .data
655        .clone()
656        .ok_or_else(|| eyre!("Missing <data> argument. Run `bw send --help` for usage."))?;
657
658    let inputs = if args.file {
659        CreateInputs {
660            file: Some(data),
661            text: None,
662            delete_in_days: args.delete_in_days,
663            max_access_count: args.max_access_count,
664            hidden: args.hidden,
665            name: args.name,
666            notes: args.notes,
667            password: args.password,
668            emails: args.emails,
669        }
670    } else {
671        CreateInputs {
672            file: None,
673            text: Some(data),
674            delete_in_days: args.delete_in_days,
675            max_access_count: args.max_access_count,
676            hidden: args.hidden,
677            // Text shortcut: default name to "Send" when not provided, matching the legacy CLI's
678            // permissive behavior when callers pipe data in.
679            name: args.name.or_else(|| Some("Send".to_string())),
680            notes: args.notes,
681            password: args.password,
682            emails: args.emails,
683        }
684    };
685
686    let request = build_create_request(inputs)?;
687    run_create(client, request, args.full_object).await
688}
689
690async fn run_create(
691    client: &PasswordManagerClient,
692    request: SendAddRequest,
693    full_object: bool,
694) -> CommandResult {
695    let is_file = matches!(request.view_type, SendViewType::File(_));
696
697    // For file sends, the create flow has to follow up with `upload_send_file` using the
698    // encrypted file bytes. The CLI doesn't have the encrypted bytes yet — the file-
699    // encryption step is tracked in PM-39238. For now we surface the gap explicitly.
700    if is_file {
701        return Err(eyre!(
702            "Creating file Sends from the Rust CLI is not yet implemented (tracked under PM-39238)."
703        ));
704    }
705
706    let view = client.sends().create(request).await?;
707
708    if full_object {
709        return Ok(CommandOutput::Object(Box::new(view)));
710    }
711
712    // The default output is the shareable access URL — the primary artifact a caller wants to
713    // hand to a recipient. `--fullObject` opts back into the full JSON view.
714    let url = build_access_url(client, &view)?;
715    Ok(url.into())
716}
717
718/// Build the shareable Send access URL from a decrypted [`bitwarden_send::SendView`].
719///
720/// Format: `<web-vault>/#/send/<access_id>/<url_b64_key>`, where `<web-vault>` is resolved by
721/// [`web_vault_url`].
722///
723/// This matches the legacy CLI (`SendResponse` in `apps/cli`, which appends
724/// `accessId + "/" + urlB64Key` to `env.getSendUrl()`, whose self-hosted form is
725/// `<web-vault>/#/send/`) and round-trips through the legacy `bw receive` parser, which reads the
726/// two trailing `#`-fragment segments (`url.hash.slice(1).split("/").slice(-2)`) and
727/// URL-safe-base64-decodes the key.
728///
729/// Note: we always emit the `<web-vault>/#/send/` form. The US-production vanity host
730/// (`https://send.bitwarden.com/#...`) is intentionally not reproduced — hitting the web-vault
731/// link directly works in every environment, and the CLI has no authoritative source for the
732/// vanity host (see [`web_vault_url`]).
733fn build_access_url(
734    client: &PasswordManagerClient,
735    view: &bitwarden_send::SendView,
736) -> color_eyre::eyre::Result<String> {
737    let access_id = view
738        .access_id
739        .as_deref()
740        .ok_or_else(|| eyre!("Send is missing an access id; cannot build a shareable URL."))?;
741    let key = view
742        .key
743        .as_deref()
744        .ok_or_else(|| eyre!("Send is missing a key; cannot build a shareable URL."))?;
745
746    let web_vault = web_vault_url(client);
747    let url_key = to_url_b64(key);
748
749    Ok(format!("{web_vault}/#/send/{access_id}/{url_key}"))
750}
751
752/// Resolve the web-vault base URL that `/#/send/<access_id>/<url_b64_key>` is appended to.
753///
754/// Precedence, mirroring the legacy CLI's per-service-then-base resolution:
755/// 1. `config.web_vault` — an explicit web-vault URL (`bw config server --web-vault <url>`).
756/// 2. `config.server` — the base server URL (`bw config server <url>`).
757/// 3. derive from the active client's `api_url` (see [`web_vault_from_api_url`]).
758///
759/// TODO: this derivation is interim. The CLI has no authoritative source for the web-vault/send
760/// host (confirmed with platform in the PM-39239 review), so we infer it. Replace this with a
761/// proper environment/config service in this repo (parity with the clients'
762/// `DefaultEnvironmentService`) once one exists, at which point this becomes a single lookup.
763fn web_vault_url(client: &PasswordManagerClient) -> String {
764    if let Ok(Some(config)) = read_config_json() {
765        if let Some(web_vault) = config.web_vault.as_deref() {
766            return web_vault.trim_end_matches('/').to_string();
767        }
768        if let Some(server) = config.server.as_deref() {
769            return server.trim_end_matches('/').to_string();
770        }
771    }
772
773    let api_url = client
774        .0
775        .internal
776        .get_api_configurations()
777        .api_config
778        .base_path
779        .clone();
780
781    web_vault_from_api_url(&api_url)
782}
783
784/// Derive the web-vault base from an API URL when no web-vault/server URL is configured (the
785/// `bw login --server` and cloud paths). Pure so it can be unit-tested without a live client.
786///
787/// - Single-domain deployment: the API lives at `<web-vault>/api` (the suffix `bw login --server`
788///   appends), so a trailing `/api` is stripped to recover the web vault.
789/// - Split-domain deployment (all Bitwarden cloud regions, and the standard self-host convention):
790///   the API is served from an `api.` host that does not serve the web-vault SPA, so the leading
791///   `api.` host label is rewritten to `vault.` (`https://api.bitwarden.com` ->
792///   `https://vault.bitwarden.com`, `https://api.bitwarden.eu` -> `https://vault.bitwarden.eu`).
793/// - Any other shape is treated as its own web vault.
794///
795/// This is a heuristic (see the `web_vault_url` TODO): a deployment whose API host neither ends in
796/// `/api` nor begins with `api.` cannot be mapped and will fall through to being used as-is. Such
797/// deployments should set `bw config server --web-vault <url>` for correct links.
798fn web_vault_from_api_url(api_url: &str) -> String {
799    let trimmed = api_url.trim_end_matches('/');
800
801    if let Some(base) = trimmed.strip_suffix("/api") {
802        return base.trim_end_matches('/').to_string();
803    }
804
805    if let Some(vault) = rewrite_api_host_to_vault(trimmed) {
806        return vault;
807    }
808
809    trimmed.to_string()
810}
811
812/// Rewrite a leading `api.` host label to `vault.` in a `scheme://host[/path]` URL, e.g.
813/// `https://api.bitwarden.com` -> `https://vault.bitwarden.com`. Returns `None` when the URL has no
814/// scheme or the host does not start with the `api.` label (so `apiary.example.com` is not
815/// rewritten).
816fn rewrite_api_host_to_vault(url: &str) -> Option<String> {
817    let (scheme, rest) = url.split_once("://")?;
818    let after_api = rest.strip_prefix("api.")?;
819    Some(format!("{scheme}://vault.{after_api}"))
820}
821
822/// Convert standard base64 to URL-safe base64 without padding.
823///
824/// Reproduces the legacy client's `Utils.fromB64toUrlB64`: `+` → `-`, `/` → `_`, and `=` padding
825/// stripped. The `SendView.key` is standard base64; the URL fragment must carry the URL-safe form
826/// so the `bw receive` parser (`Utils.fromUrlB64ToArray`) decodes it correctly.
827fn to_url_b64(b64: &str) -> String {
828    b64.replace('+', "-").replace('/', "_").replace('=', "")
829}
830
831fn compute_deletion_date(days: u64) -> color_eyre::eyre::Result<chrono::DateTime<Utc>> {
832    if days == 0 {
833        return Err(eyre!("--deleteInDays must be a positive integer"));
834    }
835    let signed =
836        i64::try_from(days).wrap_err_with(|| format!("--deleteInDays out of range: {days}"))?;
837    Ok(Utc::now() + Duration::days(signed))
838}
839
840fn build_auth(
841    password: Option<String>,
842    emails: Option<&str>,
843) -> color_eyre::eyre::Result<SendAuthType> {
844    match (password, emails) {
845        (None, None) => Ok(SendAuthType::None),
846        (Some(p), None) => Ok(SendAuthType::Password { password: p }),
847        (None, Some(e)) => Ok(SendAuthType::Emails {
848            emails: parse_emails(e)?,
849        }),
850        (Some(_), Some(_)) => Err(eyre!("--password and --emails are mutually exclusive.")),
851    }
852}
853
854/// Build the `auth` field for a [`SendEditRequest`].
855///
856/// Edit semantics differ from create:
857///   - `(None, None)` returns `AuthEdit::Preserve`, telling the SDK to keep the existing auth. The
858///     SDK reads the wire-format `password` hash and `emails` string off the repository row and
859///     forwards them verbatim, so a partial edit (e.g. just changing `--deleteInDays`) never
860///     silently strips a previously configured password or email-OTP gate. This is the fix for the
861///     auth-strip bug — the previous code emitted `SendAuthType::None` here, which the server
862///     treats as an overwrite.
863///   - `(Some(p), None)` / `(None, Some(e))` return `AuthEdit::Set { auth: _ }` to overwrite to
864///     Password / Email auth.
865///   - `(Some(_), Some(_))` is rejected (mutually exclusive).
866///
867/// Note: passing `--password ""` is not how callers strip auth on edit. To remove a
868/// previously configured password, use `bw send remove-password` (the legacy CLI's
869/// dedicated subcommand), or pass `AuthEdit::Set { auth: SendAuthType::None }` at the
870/// SDK boundary.
871fn build_auth_for_edit(
872    password: Option<String>,
873    emails: Option<&str>,
874) -> color_eyre::eyre::Result<AuthEdit> {
875    match (password, emails) {
876        (None, None) => Ok(AuthEdit::Preserve),
877        (Some(p), None) => Ok(AuthEdit::Set {
878            auth: SendAuthType::Password { password: p },
879        }),
880        (None, Some(e)) => Ok(AuthEdit::Set {
881            auth: SendAuthType::Emails {
882                emails: parse_emails(e)?,
883            },
884        }),
885        (Some(_), Some(_)) => Err(eyre!("--password and --emails are mutually exclusive.")),
886    }
887}
888
889/// Parse the `--emails` argument into a list of email addresses.
890///
891/// The legacy CLI accepts four shapes, in order of precedence:
892///   1. A JSON array: `["[email protected]", "[email protected]"]`
893///   2. A comma-separated list: `[email protected],[email protected]`
894///   3. A space-separated list: `[email protected] [email protected]`
895///   4. A single address: `[email protected]`
896///
897/// Returns an error if the parsed list is empty or any entry fails a basic shape check.
898pub(crate) fn parse_emails(raw: &str) -> color_eyre::eyre::Result<Vec<String>> {
899    let trimmed = raw.trim();
900    if trimmed.is_empty() {
901        return Err(eyre!("--emails cannot be empty"));
902    }
903
904    // 1. JSON array
905    if trimmed.starts_with('[') {
906        let arr: Vec<String> = serde_json::from_str(trimmed)
907            .wrap_err("--emails looked like a JSON array but failed to parse")?;
908        return finalize_emails(arr);
909    }
910
911    // 2./3. Delimiter-separated. Comma takes precedence; if no comma is present we fall back
912    // to whitespace splitting so a quoted-and-shell-escaped `--emails "[email protected] [email protected]"` works.
913    let parts: Vec<String> = if trimmed.contains(',') {
914        trimmed.split(',').map(|s| s.trim().to_string()).collect()
915    } else if trimmed.contains(char::is_whitespace) {
916        trimmed.split_whitespace().map(|s| s.to_string()).collect()
917    } else {
918        // 4. Single email
919        vec![trimmed.to_string()]
920    };
921
922    finalize_emails(parts)
923}
924
925fn finalize_emails(emails: Vec<String>) -> color_eyre::eyre::Result<Vec<String>> {
926    let cleaned: Vec<String> = emails
927        .into_iter()
928        .map(|e| e.trim().to_string())
929        .filter(|e| !e.is_empty())
930        .collect();
931    if cleaned.is_empty() {
932        return Err(eyre!("--emails must contain at least one address"));
933    }
934    for e in &cleaned {
935        // Minimal sanity check; the server is the source of truth for validity.
936        if !e.contains('@') {
937            return Err(eyre!("Invalid email address: {e}"));
938        }
939    }
940    Ok(cleaned)
941}
942
943#[cfg(test)]
944mod tests {
945    use super::*;
946
947    // ---- access URL construction ----
948
949    #[test]
950    fn to_url_b64_maps_standard_b64_to_url_safe() {
951        // `+` -> `-`, `/` -> `_`, padding stripped.
952        assert_eq!(to_url_b64("ab+/cd=="), "ab-_cd");
953        assert_eq!(
954            to_url_b64("Pgui0FK85cNhBGWHAlBHBw=="),
955            "Pgui0FK85cNhBGWHAlBHBw"
956        );
957        // No special chars: unchanged.
958        assert_eq!(to_url_b64("abcDEF123"), "abcDEF123");
959    }
960
961    #[test]
962    fn web_vault_from_api_url_strips_single_domain_api_suffix() {
963        // `bw login --server <base>` sets api_url to `<base>/api`; stripping it recovers the vault.
964        assert_eq!(
965            web_vault_from_api_url("https://vault.example.com/api"),
966            "https://vault.example.com"
967        );
968        // Trailing slash after /api.
969        assert_eq!(
970            web_vault_from_api_url("https://vault.example.com/api/"),
971            "https://vault.example.com"
972        );
973    }
974
975    #[test]
976    fn web_vault_from_api_url_rewrites_cloud_api_host_to_vault() {
977        // Cloud (all regions) serves the API from an `api.` host that does not serve the web-vault
978        // SPA, so it must be rewritten to the `vault.` host — not used as-is.
979        assert_eq!(
980            web_vault_from_api_url("https://api.bitwarden.com"),
981            "https://vault.bitwarden.com"
982        );
983        assert_eq!(
984            web_vault_from_api_url("https://api.bitwarden.eu"),
985            "https://vault.bitwarden.eu"
986        );
987        // Trailing slash is trimmed before the rewrite.
988        assert_eq!(
989            web_vault_from_api_url("https://api.bitwarden.com/"),
990            "https://vault.bitwarden.com"
991        );
992    }
993
994    #[test]
995    fn web_vault_from_api_url_rewrites_split_domain_self_host() {
996        // Standard self-host convention: `api.<domain>` -> `vault.<domain>`.
997        assert_eq!(
998            web_vault_from_api_url("https://api.example.com"),
999            "https://vault.example.com"
1000        );
1001    }
1002
1003    #[test]
1004    fn web_vault_from_api_url_leaves_unmappable_host_as_is() {
1005        // Neither `/api` suffix nor `api.` prefix: used as-is (documented limitation — such
1006        // deployments should configure the web vault explicitly). `apiary.` must NOT be rewritten.
1007        assert_eq!(
1008            web_vault_from_api_url("https://apiary.example.com"),
1009            "https://apiary.example.com"
1010        );
1011    }
1012
1013    /// The assembled URL must match the legacy `SendResponse` shape
1014    /// (`<web-vault>/#/send/<accessId>/<urlB64Key>`) so it round-trips through the `bw receive`
1015    /// fragment parser. Pins the exact string for the cloud (`api.`-rewrite) case.
1016    #[test]
1017    fn access_url_format_matches_legacy_and_round_trips() {
1018        let web_vault = web_vault_from_api_url("https://api.bitwarden.com");
1019        let access_id = "abcaccessid";
1020        // Standard-b64 key with chars that must be URL-encoded.
1021        let url_key = to_url_b64("Pgui0FK8+cNh/GWHAlBHBw==");
1022        let url = format!("{web_vault}/#/send/{access_id}/{url_key}");
1023
1024        assert_eq!(
1025            url,
1026            "https://vault.bitwarden.com/#/send/abcaccessid/Pgui0FK8-cNh_GWHAlBHBw"
1027        );
1028
1029        // Round-trip check: the legacy `bw receive` parser reads the last two `#`-fragment
1030        // segments. Reproduce that split and confirm we recover the access id + url-safe key.
1031        let (_, fragment) = url.split_once('#').expect("URL has a fragment");
1032        let segments: Vec<&str> = fragment.trim_start_matches('/').split('/').collect();
1033        let last_two = &segments[segments.len() - 2..];
1034        assert_eq!(last_two, ["abcaccessid", "Pgui0FK8-cNh_GWHAlBHBw"]);
1035    }
1036
1037    // ---- parse_emails ----
1038
1039    #[test]
1040    fn parse_emails_single() {
1041        let v = parse_emails("[email protected]").unwrap();
1042        assert_eq!(v, vec!["[email protected]".to_string()]);
1043    }
1044
1045    #[test]
1046    fn parse_emails_json_array() {
1047        let v = parse_emails(r#"["[email protected]","[email protected]"]"#).unwrap();
1048        assert_eq!(v, vec!["[email protected]".to_string(), "[email protected]".to_string()]);
1049    }
1050
1051    #[test]
1052    fn parse_emails_comma_separated() {
1053        let v = parse_emails("[email protected],[email protected] , [email protected]").unwrap();
1054        assert_eq!(
1055            v,
1056            vec![
1057                "[email protected]".to_string(),
1058                "[email protected]".to_string(),
1059                "[email protected]".to_string(),
1060            ]
1061        );
1062    }
1063
1064    #[test]
1065    fn parse_emails_space_separated() {
1066        let v = parse_emails("[email protected] [email protected]  [email protected]").unwrap();
1067        assert_eq!(
1068            v,
1069            vec![
1070                "[email protected]".to_string(),
1071                "[email protected]".to_string(),
1072                "[email protected]".to_string(),
1073            ]
1074        );
1075    }
1076
1077    #[test]
1078    fn parse_emails_rejects_empty() {
1079        assert!(parse_emails("").is_err());
1080        assert!(parse_emails("   ").is_err());
1081        assert!(parse_emails("[]").is_err());
1082    }
1083
1084    #[test]
1085    fn parse_emails_rejects_no_at_sign() {
1086        assert!(parse_emails("not-an-email").is_err());
1087    }
1088
1089    #[test]
1090    fn parse_emails_rejects_malformed_json_array() {
1091        // Looks like JSON but isn't a valid string array.
1092        assert!(parse_emails("[not, valid]").is_err());
1093    }
1094
1095    // ---- compute_deletion_date ----
1096
1097    #[test]
1098    fn compute_deletion_date_positive() {
1099        let d = compute_deletion_date(7).unwrap();
1100        let now = Utc::now();
1101        let diff = d - now;
1102        assert!(diff.num_days() >= 6 && diff.num_days() <= 7);
1103    }
1104
1105    #[test]
1106    fn compute_deletion_date_rejects_zero() {
1107        assert!(compute_deletion_date(0).is_err());
1108    }
1109
1110    // ---- build_auth ----
1111
1112    #[test]
1113    fn build_auth_none_when_neither_flag_given() {
1114        assert!(matches!(
1115            build_auth(None, None).unwrap(),
1116            SendAuthType::None
1117        ));
1118    }
1119
1120    #[test]
1121    fn build_auth_password_only() {
1122        let auth = build_auth(Some("secret".to_string()), None).unwrap();
1123        assert!(matches!(auth, SendAuthType::Password { password } if password == "secret"));
1124    }
1125
1126    #[test]
1127    fn build_auth_emails_only() {
1128        let auth = build_auth(None, Some("[email protected]")).unwrap();
1129        match auth {
1130            SendAuthType::Emails { emails } => assert_eq!(emails, vec!["[email protected]".to_string()]),
1131            other => panic!("expected Emails, got {other:?}"),
1132        }
1133    }
1134
1135    #[test]
1136    fn build_auth_rejects_both() {
1137        assert!(build_auth(Some("p".into()), Some("[email protected]")).is_err());
1138    }
1139
1140    // ---- build_create_request ----
1141
1142    #[test]
1143    fn build_create_request_text_send() {
1144        let req = build_create_request(CreateInputs {
1145            file: None,
1146            text: Some("hello".into()),
1147            delete_in_days: 7,
1148            max_access_count: Some(5),
1149            hidden: true,
1150            name: Some("My Send".into()),
1151            notes: Some("notes".into()),
1152            password: None,
1153            emails: None,
1154        })
1155        .unwrap();
1156
1157        assert_eq!(req.name, "My Send");
1158        assert_eq!(req.notes.as_deref(), Some("notes"));
1159        assert_eq!(req.max_access_count, Some(5));
1160        match req.view_type {
1161            SendViewType::Text(t) => {
1162                assert_eq!(t.text.as_deref(), Some("hello"));
1163                assert!(t.hidden);
1164            }
1165            other => panic!("expected Text, got {other:?}"),
1166        }
1167        assert!(matches!(req.auth, SendAuthType::None));
1168    }
1169
1170    #[test]
1171    fn build_create_request_text_requires_name() {
1172        let err = build_create_request(CreateInputs {
1173            file: None,
1174            text: Some("hello".into()),
1175            delete_in_days: 7,
1176            max_access_count: None,
1177            hidden: false,
1178            name: None,
1179            notes: None,
1180            password: None,
1181            emails: None,
1182        })
1183        .unwrap_err();
1184        assert!(err.to_string().contains("--name is required"));
1185    }
1186
1187    #[test]
1188    fn build_create_request_file_derives_name() {
1189        let req = build_create_request(CreateInputs {
1190            file: Some("/tmp/secrets.txt".into()),
1191            text: None,
1192            delete_in_days: 7,
1193            max_access_count: None,
1194            hidden: false,
1195            name: None,
1196            notes: None,
1197            password: None,
1198            emails: None,
1199        })
1200        .unwrap();
1201
1202        assert_eq!(req.name, "secrets.txt");
1203        match req.view_type {
1204            SendViewType::File(f) => assert_eq!(f.file_name, "secrets.txt"),
1205            other => panic!("expected File, got {other:?}"),
1206        }
1207    }
1208
1209    #[test]
1210    fn build_create_request_rejects_text_and_file_together() {
1211        let err = build_create_request(CreateInputs {
1212            file: Some("/tmp/x".into()),
1213            text: Some("hello".into()),
1214            delete_in_days: 7,
1215            max_access_count: None,
1216            hidden: false,
1217            name: Some("name".into()),
1218            notes: None,
1219            password: None,
1220            emails: None,
1221        })
1222        .unwrap_err();
1223        assert!(err.to_string().contains("mutually exclusive"));
1224    }
1225
1226    #[test]
1227    fn build_create_request_rejects_neither() {
1228        let err = build_create_request(CreateInputs {
1229            file: None,
1230            text: None,
1231            delete_in_days: 7,
1232            max_access_count: None,
1233            hidden: false,
1234            name: Some("name".into()),
1235            notes: None,
1236            password: None,
1237            emails: None,
1238        })
1239        .unwrap_err();
1240        assert!(err.to_string().contains("--text") || err.to_string().contains("--file"));
1241    }
1242
1243    #[test]
1244    fn build_create_request_password_auth() {
1245        let req = build_create_request(CreateInputs {
1246            file: None,
1247            text: Some("hello".into()),
1248            delete_in_days: 7,
1249            max_access_count: None,
1250            hidden: false,
1251            name: Some("name".into()),
1252            notes: None,
1253            password: Some("hunter2".into()),
1254            emails: None,
1255        })
1256        .unwrap();
1257        assert!(matches!(req.auth, SendAuthType::Password { .. }));
1258    }
1259
1260    #[test]
1261    fn build_create_request_email_auth() {
1262        let req = build_create_request(CreateInputs {
1263            file: None,
1264            text: Some("hello".into()),
1265            delete_in_days: 7,
1266            max_access_count: None,
1267            hidden: false,
1268            name: Some("name".into()),
1269            notes: None,
1270            password: None,
1271            emails: Some("[email protected],[email protected]".into()),
1272        })
1273        .unwrap();
1274        match req.auth {
1275            SendAuthType::Emails { emails } => assert_eq!(emails.len(), 2),
1276            other => panic!("expected Emails, got {other:?}"),
1277        }
1278    }
1279
1280    // ---- build_auth_for_edit ----
1281
1282    /// On edit, the `(None, None)` case must return `AuthEdit::Preserve`, not
1283    /// `AuthEdit::Set { auth: SendAuthType::None }` (overwrite to no-auth). This is the
1284    /// auth-strip regression boundary at the CLI helper level.
1285    #[test]
1286    fn build_auth_for_edit_no_flags_preserves() {
1287        let auth = build_auth_for_edit(None, None).unwrap();
1288        assert!(
1289            matches!(auth, AuthEdit::Preserve),
1290            "no flags must produce `AuthEdit::Preserve`, got {auth:?}"
1291        );
1292    }
1293
1294    #[test]
1295    fn build_auth_for_edit_password_overwrites() {
1296        let auth = build_auth_for_edit(Some("hunter2".into()), None).unwrap();
1297        assert!(matches!(
1298            auth,
1299            AuthEdit::Set { auth: SendAuthType::Password { ref password } } if password == "hunter2"
1300        ));
1301    }
1302
1303    #[test]
1304    fn build_auth_for_edit_emails_overwrites() {
1305        let auth = build_auth_for_edit(None, Some("[email protected],[email protected]")).unwrap();
1306        match auth {
1307            AuthEdit::Set {
1308                auth: SendAuthType::Emails { emails },
1309            } => assert_eq!(emails.len(), 2),
1310            other => panic!("expected AuthEdit::Set {{ auth: Emails }}, got {other:?}"),
1311        }
1312    }
1313
1314    #[test]
1315    fn build_auth_for_edit_rejects_both_flags() {
1316        assert!(build_auth_for_edit(Some("p".into()), Some("[email protected]")).is_err());
1317    }
1318
1319    // ---- build_edit_request ----
1320
1321    use bitwarden_send::{AuthType, SendType, SendView};
1322
1323    /// Helper producing a baseline `SendView` for edit fixtures. The relevant fields for
1324    /// these tests are `auth_type`, `has_password`, `emails`, and the text content.
1325    fn make_existing(auth_type: AuthType, has_password: bool, emails: Vec<String>) -> SendView {
1326        SendView {
1327            id: "25afb11c-9c95-4db5-8bac-c21cb204a3f1".parse().ok(),
1328            access_id: Some("access-id".to_string()),
1329            name: "existing".to_string(),
1330            notes: Some("notes".to_string()),
1331            key: Some("Pgui0FK85cNhBGWHAlBHBw".to_string()),
1332            new_password: None,
1333            has_password,
1334            r#type: SendType::Text,
1335            file: None,
1336            text: Some(SendTextView {
1337                text: Some("existing text".to_string()),
1338                hidden: false,
1339            }),
1340            max_access_count: Some(42),
1341            access_count: 0,
1342            disabled: false,
1343            hide_email: false,
1344            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
1345            deletion_date: "2030-01-01T00:00:00Z".parse().unwrap(),
1346            expiration_date: None,
1347            emails,
1348            auth_type,
1349        }
1350    }
1351
1352    fn no_override_edit() -> EditOverrides {
1353        EditOverrides {
1354            delete_in_days: None,
1355            max_access_count: None,
1356            hidden: false,
1357            password: None,
1358            emails: None,
1359        }
1360    }
1361
1362    /// This is the regression test for the auth-strip bug. Before the fix,
1363    /// `build_edit_request` for a Send with an existing Password gate (no auth flags
1364    /// provided) produced `auth: SendAuthType::None`, which the server treats as an
1365    /// overwrite that clears the password. After the fix, the request carries
1366    /// `auth: AuthEdit::Preserve` (the partial-update marker), and the SDK's
1367    /// `edit_send` consumes that to forward the existing password hash to the server
1368    /// verbatim.
1369    #[test]
1370    fn build_edit_request_preserves_existing_password_when_no_auth_flags() {
1371        let existing = make_existing(AuthType::Password, true, Vec::new());
1372        let req = build_edit_request(existing, no_override_edit()).unwrap();
1373        assert!(
1374            matches!(req.auth, AuthEdit::Preserve),
1375            "expected `AuthEdit::Preserve`, got {:?} — the previous behavior was \
1376             `AuthEdit::Set {{ auth: SendAuthType::None }}`, which silently strips the existing password",
1377            req.auth
1378        );
1379    }
1380
1381    #[test]
1382    fn build_edit_request_preserves_existing_emails_when_no_auth_flags() {
1383        let existing = make_existing(
1384            AuthType::Email,
1385            false,
1386            vec!["[email protected]".to_string(), "[email protected]".to_string()],
1387        );
1388        let req = build_edit_request(existing, no_override_edit()).unwrap();
1389        assert!(matches!(req.auth, AuthEdit::Preserve));
1390    }
1391
1392    /// Existing `AuthType::None` × no new auth flags → still preserve (i.e.
1393    /// `AuthEdit::Preserve`). The SDK will look at the existing repository row and
1394    /// emit `AuthType::None` on the wire; this CLI layer doesn't need to know that
1395    /// detail.
1396    #[test]
1397    fn build_edit_request_preserves_existing_none_auth_when_no_auth_flags() {
1398        let existing = make_existing(AuthType::None, false, Vec::new());
1399        let req = build_edit_request(existing, no_override_edit()).unwrap();
1400        assert!(matches!(req.auth, AuthEdit::Preserve));
1401    }
1402
1403    /// 4x4 matrix: existing { None, Password, Email } × override { None, Password,
1404    /// Emails }. The "preserve" cases all live above; the "overwrite" cases must
1405    /// produce a concrete `AuthEdit::Set { auth: _ }` regardless of the existing state.
1406    #[test]
1407    fn build_edit_request_password_flag_overwrites_regardless_of_existing() {
1408        for existing_auth in [AuthType::None, AuthType::Password, AuthType::Email] {
1409            let existing =
1410                make_existing(existing_auth, existing_auth == AuthType::Password, vec![]);
1411            let req = build_edit_request(
1412                existing,
1413                EditOverrides {
1414                    delete_in_days: None,
1415                    max_access_count: None,
1416                    hidden: false,
1417                    password: Some("hunter2".into()),
1418                    emails: None,
1419                },
1420            )
1421            .unwrap();
1422            assert!(
1423                matches!(
1424                    req.auth,
1425                    AuthEdit::Set { auth: SendAuthType::Password { ref password } } if password == "hunter2"
1426                ),
1427                "existing={existing_auth:?}, got auth={:?}",
1428                req.auth
1429            );
1430        }
1431    }
1432
1433    #[test]
1434    fn build_edit_request_emails_flag_overwrites_regardless_of_existing() {
1435        for existing_auth in [AuthType::None, AuthType::Password, AuthType::Email] {
1436            let existing =
1437                make_existing(existing_auth, existing_auth == AuthType::Password, vec![]);
1438            let req = build_edit_request(
1439                existing,
1440                EditOverrides {
1441                    delete_in_days: None,
1442                    max_access_count: None,
1443                    hidden: false,
1444                    password: None,
1445                    emails: Some("[email protected]".into()),
1446                },
1447            )
1448            .unwrap();
1449            match req.auth {
1450                AuthEdit::Set {
1451                    auth: SendAuthType::Emails { ref emails },
1452                } => {
1453                    assert_eq!(emails.len(), 1);
1454                }
1455                ref other => panic!(
1456                    "existing={existing_auth:?}, expected AuthEdit::Set {{ auth: Emails }}, got {other:?}"
1457                ),
1458            }
1459        }
1460    }
1461
1462    #[test]
1463    fn build_edit_request_rejects_both_auth_flags() {
1464        let existing = make_existing(AuthType::None, false, vec![]);
1465        let err = build_edit_request(
1466            existing,
1467            EditOverrides {
1468                delete_in_days: None,
1469                max_access_count: None,
1470                hidden: false,
1471                password: Some("p".into()),
1472                emails: Some("[email protected]".into()),
1473            },
1474        )
1475        .unwrap_err();
1476        assert!(err.to_string().contains("mutually exclusive"));
1477    }
1478}