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::{
10    io::{IsTerminal as _, Read as _},
11    path::PathBuf,
12};
13
14use base64::{Engine as _, engine::general_purpose::STANDARD};
15use bitwarden_core::{auth::JwtToken, client::persisted_state::AUTHENTICATION_TOKENS};
16use bitwarden_pm::PasswordManagerClient;
17use bitwarden_send::{
18    AuthEdit, AuthType, SendAddRequest, SendAuthType, SendEditRequest, SendFileView, SendId,
19    SendTextView, SendType, SendView, SendViewType,
20};
21use chrono::{DateTime, Duration, Utc};
22use clap::{
23    Args, Subcommand,
24    builder::{PossibleValuesParser, TypedValueParser as _},
25};
26use color_eyre::eyre::{Context as _, eyre};
27use serde::{Deserialize, Serialize};
28
29use crate::{
30    client_state::{AnyState, BwCommand, BwCommandExt as _, ClientContext, LoggedIn},
31    platform::read_config_json,
32    render::{CommandOutput, CommandResult},
33    tools::{
34        file_output::reject_path_traversal,
35        receive::{ReceiveInputs, run_receive},
36    },
37};
38
39/// Allowed values for `--deleteInDays`, matching the legacy CLI's enumerated set.
40/// Kept as strings so `PossibleValuesParser` can surface them in `--help` output.
41const DELETE_IN_DAYS_ALLOWED: &[&str] = &["1", "2", "3", "7", "14", "30"];
42
43/// Clap value parser for `--deleteInDays`. Restricts input to the legacy-CLI allowed
44/// set (also surfaced in `--help`) and maps it back to the `u64` field type.
45fn delete_in_days_parser() -> impl clap::builder::TypedValueParser<Value = u64> {
46    PossibleValuesParser::new(DELETE_IN_DAYS_ALLOWED)
47        .map(|s| s.parse::<u64>().expect("allowed values are valid u64"))
48}
49
50#[derive(Args, Clone)]
51pub struct SendArgs {
52    /// The data to Send
53    pub data: Option<String>,
54
55    #[arg(short = 'f', long, help = "Specifies that <data> is a filepath.")]
56    pub file: bool,
57
58    #[arg(
59        short = 'd',
60        long = "deleteInDays",
61        help = "The number of days in the future to set deletion date.",
62        default_value_t = 7,
63        value_parser = delete_in_days_parser(),
64    )]
65    pub delete_in_days: u64,
66
67    #[arg(
68        long,
69        conflicts_with = "emails",
70        help = "Optional password to access this Send."
71    )]
72    pub password: Option<String>,
73
74    #[arg(
75        long,
76        help = "Email addresses for OTP authentication (single, JSON array, comma- or space-separated)."
77    )]
78    pub emails: Option<String>,
79
80    #[arg(
81        short = 'a',
82        long = "maxAccessCount",
83        help = "The amount of max possible accesses."
84    )]
85    pub max_access_count: Option<u32>,
86
87    #[arg(long, help = "Hide <data> in web by default.")]
88    pub hidden: bool,
89
90    #[arg(short = 'n', long, help = "The name of the Send.")]
91    pub name: Option<String>,
92
93    #[arg(long, help = "Notes to add to the Send.")]
94    pub notes: Option<String>,
95
96    #[arg(
97        long = "fullObject",
98        help = "Specifies that the full Send object should be returned."
99    )]
100    pub full_object: bool,
101
102    #[command(subcommand)]
103    pub command: Option<SendCommands>,
104}
105
106#[derive(Subcommand, Clone, Debug)]
107pub enum SendCommands {
108    #[command(about = "List all the Sends owned by you.")]
109    List(SendListArgs),
110
111    #[command(about = "Get json templates for send objects.")]
112    Template(SendTemplateArgs),
113
114    #[command(about = "Get Sends owned by you.")]
115    Get(SendGetArgs),
116
117    #[command(about = "Access a Bitwarden Send from a url.")]
118    Receive(SendReceiveArgs),
119
120    #[command(about = "Create a Send.")]
121    Create(SendCreateArgs),
122
123    #[command(about = "Edit a Send.")]
124    Edit(SendEditArgs),
125
126    #[command(about = "Removes the saved password from a Send.")]
127    RemovePassword(SendRemovePasswordArgs),
128
129    #[command(about = "Delete a Send.")]
130    Delete(SendDeleteArgs),
131}
132
133#[derive(Args, Clone, Debug)]
134pub struct SendListArgs;
135
136#[derive(Args, Clone, Debug)]
137pub struct SendTemplateArgs {
138    pub object: String,
139}
140
141#[derive(Args, Clone, Debug)]
142pub struct SendGetArgs {
143    pub id: SendId,
144
145    // The internal field is `output_path` (not `output`) to avoid clashing with the
146    // top-level `Cli::output` (the `-o` rendered-output-format arg). User-facing long
147    // flag stays `--output` to match the legacy CLI.
148    #[arg(
149        long = "output",
150        help = "File path to save a file-type Send's decrypted contents to."
151    )]
152    pub output_path: Option<String>,
153
154    #[arg(long, help = "Only return the access url.")]
155    pub text: bool,
156}
157
158/// `bw send receive <url>`. Must stay field-identical to [`super::ReceiveArgs`]
159/// (`bw receive`) — they are the same command under two names, and both delegate to
160/// [`super::receive::run_receive`].
161#[derive(Args, Clone, Debug)]
162#[command(after_help = "Notes:
163    If a password is required, the provided password is used or the user is prompted.")]
164pub struct SendReceiveArgs {
165    pub url: String,
166
167    #[arg(long, help = "Optional password for the Send.")]
168    pub password: Option<String>,
169
170    #[arg(long, help = "Environment variable storing the Send's password.")]
171    pub passwordenv: Option<String>,
172
173    #[arg(
174        long,
175        help = "Path to a file containing the Send's password as its first line."
176    )]
177    pub passwordfile: Option<String>,
178
179    // The internal field is `output_path` (not `output`) to avoid clashing with the top-level
180    // `Cli::output` (the `-o` rendered-output-format arg) — same convention as
181    // `SendGetArgs::output_path`. User-facing long flag stays `--output` to match both that
182    // sibling command and the legacy CLI.
183    #[arg(
184        long = "output",
185        help = "Specify a file path to save a File-type Send to."
186    )]
187    pub output_path: Option<String>,
188
189    #[arg(
190        long = "fullObject",
191        alias = "full-object",
192        help = "Return the Send's json object rather than its content."
193    )]
194    pub full_object: bool,
195}
196
197#[derive(Args, Clone, Debug)]
198pub struct SendCreateArgs {
199    pub encoded_json: Option<String>,
200
201    #[arg(short = 'f', long, help = "Path to the file to Send.")]
202    pub file: Option<String>,
203
204    #[arg(long, help = "Text to Send.")]
205    pub text: Option<String>,
206
207    #[arg(
208        short = 'd',
209        long = "deleteInDays",
210        help = "The number of days in the future to set deletion date.",
211        default_value_t = 7,
212        value_parser = delete_in_days_parser(),
213    )]
214    pub delete_in_days: u64,
215
216    #[arg(
217        long = "maxAccessCount",
218        help = "The maximum number of times this Send can be accessed."
219    )]
220    pub max_access_count: Option<u32>,
221
222    #[arg(long, help = "Hide text.")]
223    pub hidden: bool,
224
225    #[arg(short = 'n', long, help = "The name of the Send.")]
226    pub name: Option<String>,
227
228    #[arg(long, help = "Notes to add to the Send.")]
229    pub notes: Option<String>,
230
231    #[arg(
232        long,
233        conflicts_with = "emails",
234        help = "Optional password to access this Send."
235    )]
236    pub password: Option<String>,
237
238    #[arg(
239        long,
240        help = "Email addresses for OTP authentication (single, JSON array, comma- or space-separated)."
241    )]
242    pub emails: Option<String>,
243
244    #[arg(
245        long = "fullObject",
246        help = "Return full Send object instead of access url."
247    )]
248    pub full_object: bool,
249}
250
251#[derive(Args, Clone, Debug)]
252pub struct SendEditArgs {
253    pub encoded_json: Option<String>,
254
255    #[arg(long, help = "Overrides the itemId provided in encodedJson.")]
256    pub itemid: Option<SendId>,
257
258    #[arg(
259        short = 'd',
260        long = "deleteInDays",
261        help = "The number of days in the future to set deletion date.",
262        value_parser = delete_in_days_parser(),
263    )]
264    pub delete_in_days: Option<u64>,
265
266    #[arg(
267        long = "maxAccessCount",
268        help = "The maximum number of times this Send can be accessed."
269    )]
270    pub max_access_count: Option<u32>,
271
272    #[arg(long, help = "Hide text.")]
273    pub hidden: bool,
274
275    #[arg(
276        long,
277        conflicts_with = "emails",
278        help = "Optional password to access this Send."
279    )]
280    pub password: Option<String>,
281
282    #[arg(
283        long,
284        help = "Email addresses for OTP authentication (single, JSON array, comma- or space-separated)."
285    )]
286    pub emails: Option<String>,
287}
288
289#[derive(Args, Clone, Debug)]
290pub struct SendRemovePasswordArgs {
291    pub id: SendId,
292}
293
294#[derive(Args, Clone, Debug)]
295pub struct SendDeleteArgs {
296    pub id: SendId,
297}
298
299impl BwCommand for SendArgs {
300    // `AnyState` because `bw send template` and `bw send receive` run without a session; the
301    // auth-required arms route to per-variant `BwCommand` impls below whose `type Client` is
302    // `LoggedIn`, so the auth check happens via the typestate extractor in each branch.
303    type Client = AnyState;
304
305    async fn run(self, state: AnyState) -> CommandResult {
306        // If no subcommand is supplied, the legacy CLI treats `bw send <data>` as a Create
307        // shortcut. Route that through the same builder path as `bw send create` so the two
308        // entry points share their happy path.
309        let ctx = ClientContext {
310            global: state.global,
311            user: state.user,
312        };
313        match self.command.clone() {
314            None => {
315                let LoggedIn { user, .. } = LoggedIn::try_from(ctx)?;
316                create_shortcut(&user, self).await
317            }
318            // `--output` on `get` fails before the auth check: silently emitting JSON to
319            // stdout while the requested file path goes uncreated would be a worse UX than
320            // an explicit "not implemented" error.
321            Some(SendCommands::Get(args)) if args.output_path.is_some() => {
322                Err(eyre!("`--output` on `bw send get` is not yet implemented"))
323            }
324            // `create`/`edit` resolve and parse their full-object JSON input *before*
325            // extracting `LoggedIn`, so malformed input surfaces a clear parse error rather
326            // than a confusing "not logged in" message (the integration tests assert this
327            // ordering). Input comes from the positional `encoded_json` or, when absent and
328            // stdin is piped, from stdin. Stdin is only consulted when no other input source
329            // was given: a fully specified flag-only invocation must not block on (or
330            // consume) a caller's stdin pipe.
331            Some(SendCommands::Create(args)) => {
332                let stdin_eligible = args.text.is_none() && args.file.is_none();
333                let json = read_encoded_json_input(args.encoded_json.clone(), stdin_eligible)?
334                    .map(|raw| parse_encoded_send_view(&raw))
335                    .transpose()?;
336                let LoggedIn { user, .. } = LoggedIn::try_from(ctx)?;
337                dispatch_create(&user, args, json).await
338            }
339            Some(SendCommands::Edit(args)) => {
340                let stdin_eligible = args.delete_in_days.is_none()
341                    && args.max_access_count.is_none()
342                    && !args.hidden
343                    && args.password.is_none()
344                    && args.emails.is_none();
345                let json = read_encoded_json_input(args.encoded_json.clone(), stdin_eligible)?
346                    .map(|raw| parse_encoded_send_view(&raw))
347                    .transpose()?;
348                let LoggedIn { user, .. } = LoggedIn::try_from(ctx)?;
349                dispatch_edit(&user, args, json).await
350            }
351            Some(SendCommands::List(args)) => args.dispatch(ctx).await,
352            Some(SendCommands::Template(args)) => args.dispatch(ctx).await,
353            Some(SendCommands::Get(args)) => args.dispatch(ctx).await,
354            Some(SendCommands::Receive(args)) => args.dispatch(ctx).await,
355            Some(SendCommands::RemovePassword(args)) => args.dispatch(ctx).await,
356            Some(SendCommands::Delete(args)) => args.dispatch(ctx).await,
357        }
358    }
359}
360
361impl BwCommand for SendListArgs {
362    type Client = LoggedIn;
363
364    async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
365        list_sends(&user).await
366    }
367}
368
369impl BwCommand for SendTemplateArgs {
370    // `template` doesn't talk to the server; route it through `AnyState` so users can
371    // generate JSON scaffolding without a session.
372    type Client = AnyState;
373
374    async fn run(self, _: AnyState) -> CommandResult {
375        render_template(&self.object)
376    }
377}
378
379impl BwCommand for SendGetArgs {
380    type Client = LoggedIn;
381
382    async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
383        // The `--output` early-error gate lives in [`SendArgs::run`] above so it can
384        // fire *before* the `LoggedIn` typestate extractor — a logged-out caller passing
385        // `--output` should see the precise "not yet implemented" error rather than
386        // a generic auth message. See that gate for why it stays unimplemented.
387        get_send(&user, self.id, self.text).await
388    }
389}
390
391impl BwCommand for SendReceiveArgs {
392    // `bw send receive` is the legacy alias for the top-level `bw receive` command; both route
393    // into the same implementation. `AnyState` because receiving a Send needs no session — the
394    // decryption key comes from the url fragment, not the account key store.
395    type Client = AnyState;
396
397    async fn run(self, _: AnyState) -> CommandResult {
398        run_receive(ReceiveInputs {
399            url: self.url,
400            password: self.password,
401            passwordenv: self.passwordenv,
402            passwordfile: self.passwordfile,
403            output_path: self.output_path,
404            full_object: self.full_object,
405        })
406        .await
407    }
408}
409
410/// Run `bw send create`, either from a full-object `SendView` (`json`) or from the
411/// individual CLI flags. The JSON input, when present, has already been resolved and parsed
412/// in [`SendArgs::run`] so parse errors surface before the auth check.
413async fn dispatch_create(
414    user: &PasswordManagerClient,
415    args: SendCreateArgs,
416    json: Option<SendView>,
417) -> CommandResult {
418    let SendCreateArgs {
419        encoded_json: _,
420        file,
421        text,
422        delete_in_days,
423        max_access_count,
424        hidden,
425        name,
426        notes,
427        password,
428        emails,
429        full_object,
430    } = args;
431
432    let request = match json {
433        Some(view) => build_create_request_from_view(
434            view,
435            CreateOverrides {
436                name,
437                notes,
438                max_access_count,
439                hidden,
440                password,
441                emails,
442            },
443        )?,
444        None => build_create_request(CreateInputs {
445            file: file.clone(),
446            text,
447            delete_in_days,
448            max_access_count,
449            hidden,
450            name,
451            notes,
452            password,
453            emails,
454        })?,
455    };
456
457    run_create(user, request, file, full_object).await
458}
459
460/// Run `bw send edit`, either merging a full-object `SendView` (`json`) into the existing
461/// server row or applying the individual CLI-flag overrides. The JSON input, when present,
462/// has already been resolved and parsed in [`SendArgs::run`].
463async fn dispatch_edit(
464    user: &PasswordManagerClient,
465    args: SendEditArgs,
466    json: Option<SendView>,
467) -> CommandResult {
468    let SendEditArgs {
469        encoded_json: _,
470        itemid,
471        delete_in_days,
472        max_access_count,
473        hidden,
474        password,
475        emails,
476    } = args;
477
478    // `--itemid` overrides the `id` carried in the JSON object (legacy precedence); fall back
479    // to the JSON object's own `id` when the flag is absent.
480    let send_id = itemid
481        .or_else(|| json.as_ref().and_then(|v| v.id))
482        .ok_or_else(|| eyre!("--itemid is required (or provide `id` in the encoded JSON)."))?;
483
484    let existing = user.sends().get(send_id).await?;
485
486    let overrides = EditOverrides {
487        delete_in_days,
488        max_access_count,
489        hidden,
490        password,
491        emails,
492    };
493    let request = match json {
494        Some(req) => build_edit_request_from_json(existing, req, overrides)?,
495        None => build_edit_request(existing, overrides)?,
496    };
497
498    let view = user.sends().edit(send_id, request).await?;
499    Ok(CommandOutput::Object(Box::new(view)))
500}
501
502impl BwCommand for SendRemovePasswordArgs {
503    type Client = LoggedIn;
504
505    async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
506        let view = user.sends().remove_password(self.id).await?;
507        Ok(CommandOutput::Object(Box::new(view)))
508    }
509}
510
511impl BwCommand for SendDeleteArgs {
512    type Client = LoggedIn;
513
514    async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
515        user.sends().delete(self.id).await?;
516        Ok("Send deleted.".into())
517    }
518}
519
520async fn list_sends(client: &PasswordManagerClient) -> CommandResult {
521    let views = client.sends().list().await?;
522    Ok(CommandOutput::Object(Box::new(views)))
523}
524
525async fn get_send(client: &PasswordManagerClient, id: SendId, text: bool) -> CommandResult {
526    let view = client.sends().get(id).await?;
527
528    if text {
529        // `--text` emits the shareable access URL (see the flag help). Recipients paste this
530        // into a browser or `bw receive` to fetch and decrypt the Send content client-side.
531        let url = build_access_url(client, &view)?;
532        return Ok(url.into());
533    }
534
535    Ok(CommandOutput::Object(Box::new(view)))
536}
537
538fn render_template(object: &str) -> CommandResult {
539    // The legacy CLI distinguishes `send.text` and `send.file` (the latter has a `file.fileName`
540    // field). Keep the shapes minimal but distinct so round-trips via `bw send create` are
541    // unambiguous.
542    match object {
543        "send.text" => Ok(CommandOutput::Object(Box::new(SendTextTemplate::default()))),
544        "send.file" => Ok(CommandOutput::Object(Box::new(SendFileTemplate::default()))),
545        other => Err(eyre!("Unknown template object: {other}")),
546    }
547}
548
549#[derive(Serialize, Default)]
550#[serde(rename_all = "camelCase")]
551struct SendTextTemplate {
552    name: String,
553    notes: String,
554    #[serde(rename = "type")]
555    send_type: u8, // 0 = text
556    text: SendTextTemplateBody,
557    deletion_date: String,
558}
559
560#[derive(Serialize, Default)]
561struct SendTextTemplateBody {
562    text: String,
563    hidden: bool,
564}
565
566#[derive(Serialize)]
567#[serde(rename_all = "camelCase")]
568struct SendFileTemplate {
569    name: String,
570    notes: String,
571    #[serde(rename = "type")]
572    send_type: u8, // 1 = file
573    file: SendFileTemplateBody,
574    deletion_date: String,
575}
576
577impl Default for SendFileTemplate {
578    fn default() -> Self {
579        // `#[derive(Default)]` would give `send_type: 0`, the text-Send discriminant, since
580        // `u8::default()` is 0 — this struct needs the non-zero file discriminant instead.
581        Self {
582            name: String::new(),
583            notes: String::new(),
584            send_type: 1,
585            file: SendFileTemplateBody::default(),
586            deletion_date: String::new(),
587        }
588    }
589}
590
591#[derive(Serialize, Default)]
592#[serde(rename_all = "camelCase")]
593struct SendFileTemplateBody {
594    file_name: String,
595}
596
597/// Resolve the raw full-object JSON input for `create`/`edit`.
598///
599/// Precedence mirrors the legacy CLI: an explicit positional argument wins; otherwise, when
600/// `stdin_eligible` (the caller supplied no other flag that already fully specifies the
601/// command) and stdin is piped (not an interactive terminal), read it. When stdin is a TTY, no
602/// positional was given, or `stdin_eligible` is false, there is no JSON input and the
603/// flag-only path runs.
604///
605/// `stdin_eligible` exists so a fully flag-specified `create`/`edit` never touches stdin: an
606/// unconditional read would block (or silently swallow bytes) when stdin is a non-TTY pipe
607/// that stays open, e.g. `ssh host 'bw send edit --itemid <id> --deleteInDays 3'` or
608/// `docker run -i`. The `is_terminal` guard separately keeps an interactive shell (and the
609/// test harness, which doesn't pipe stdin) from blocking on a read.
610fn read_encoded_json_input(
611    positional: Option<String>,
612    stdin_eligible: bool,
613) -> color_eyre::eyre::Result<Option<String>> {
614    if let Some(raw) = positional {
615        return Ok(Some(raw));
616    }
617    if !stdin_eligible || std::io::stdin().is_terminal() {
618        return Ok(None);
619    }
620    let mut buf = String::new();
621    std::io::stdin().read_to_string(&mut buf)?;
622    if buf.trim().is_empty() {
623        return Ok(None);
624    }
625    Ok(Some(buf))
626}
627
628/// CLI-local shape for the full-object JSON accepted by `create`/`edit`.
629///
630/// Deliberately distinct from [`SendView`] (`deny_unknown_fields`, no field defaults — the
631/// wire contract other SDK consumers rely on): the JSON this command accepts comes from three
632/// sources with different completeness —
633///   - `bw send template` output, which only carries the fields relevant to creation (`name`,
634///     `notes`, `type`, `text`/`file`, `deletionDate`)
635///   - `bw send get`/`--fullObject` output, a full `SendView` that also carries fields this CLI
636///     doesn't model (`object`, `accessUrl`, ...)
637///   - a hand-authored object supplying just the fields the caller cares about
638///
639/// Server-owned/read-only fields default when absent so all three shapes parse, and unknown
640/// keys are silently ignored (no `deny_unknown_fields`) so `bw send get` output round-trips.
641#[derive(Deserialize)]
642#[serde(rename_all = "camelCase")]
643struct SendJsonInput {
644    id: Option<SendId>,
645    access_id: Option<String>,
646    #[serde(default)]
647    name: String,
648    notes: Option<String>,
649    key: Option<String>,
650    new_password: Option<String>,
651    #[serde(default)]
652    has_password: bool,
653    r#type: SendType,
654    file: Option<SendFileView>,
655    text: Option<SendTextView>,
656    max_access_count: Option<u32>,
657    #[serde(default)]
658    access_count: u32,
659    #[serde(default)]
660    disabled: bool,
661    #[serde(default)]
662    hide_email: bool,
663    #[serde(default = "Utc::now")]
664    revision_date: DateTime<Utc>,
665    deletion_date: DateTime<Utc>,
666    expiration_date: Option<DateTime<Utc>>,
667    #[serde(default)]
668    emails: Vec<String>,
669    #[serde(default = "default_auth_type")]
670    auth_type: AuthType,
671}
672
673fn default_auth_type() -> AuthType {
674    AuthType::None
675}
676
677impl From<SendJsonInput> for SendView {
678    fn from(input: SendJsonInput) -> Self {
679        SendView {
680            id: input.id,
681            access_id: input.access_id,
682            name: input.name,
683            notes: input.notes,
684            key: input.key,
685            new_password: input.new_password,
686            has_password: input.has_password,
687            r#type: input.r#type,
688            file: input.file,
689            text: input.text,
690            max_access_count: input.max_access_count,
691            access_count: input.access_count,
692            disabled: input.disabled,
693            hide_email: input.hide_email,
694            revision_date: input.revision_date,
695            deletion_date: input.deletion_date,
696            expiration_date: input.expiration_date,
697            emails: input.emails,
698            auth_type: input.auth_type,
699        }
700    }
701}
702
703/// Decode and parse the full-object JSON input into a [`SendView`].
704///
705/// The input may be base64-encoded JSON (legacy CLI behavior) or raw JSON (this CLI's
706/// convenience). Real JSON text starts with `{` and contains characters outside the base64
707/// alphabet, so decoding as base64 fails fast on raw JSON; when decoding fails (or the decoded
708/// bytes aren't valid UTF-8) we fall back to treating the original string as the JSON text
709/// directly. Either way, JSON is parsed exactly once, so a deserialize failure (e.g. a missing
710/// required field) is always the *real* error — we never re-parse the encoded string as JSON
711/// and mask it behind a generic "expected value" message.
712fn parse_encoded_send_view(raw: &str) -> color_eyre::eyre::Result<SendView> {
713    let trimmed = raw.trim();
714    let json_text = STANDARD
715        .decode(trimmed)
716        .ok()
717        .and_then(|decoded| String::from_utf8(decoded).ok())
718        .unwrap_or_else(|| trimmed.to_string());
719
720    let input: SendJsonInput =
721        serde_json::from_str(&json_text).wrap_err("Error parsing the encoded request data.")?;
722    Ok(input.into())
723}
724
725struct CreateInputs {
726    file: Option<String>,
727    text: Option<String>,
728    delete_in_days: u64,
729    max_access_count: Option<u32>,
730    hidden: bool,
731    name: Option<String>,
732    notes: Option<String>,
733    password: Option<String>,
734    emails: Option<String>,
735}
736
737fn build_create_request(inputs: CreateInputs) -> color_eyre::eyre::Result<SendAddRequest> {
738    let CreateInputs {
739        file,
740        text,
741        delete_in_days,
742        max_access_count,
743        hidden,
744        name,
745        notes,
746        password,
747        emails,
748    } = inputs;
749
750    let deletion_date = compute_deletion_date(delete_in_days)?;
751
752    let view_type = match (file.as_deref(), text.as_deref()) {
753        (Some(path), None) => {
754            // File sends require a premium account; the precondition is checked in `run_create`
755            // (against the access-token JWT) before the send is created on the server.
756            let path = PathBuf::from(path);
757            let file_name = path
758                .file_name()
759                .and_then(|s| s.to_str())
760                .ok_or_else(|| eyre!("Could not derive a file name from --file path"))?
761                .to_string();
762            // `size` is intentionally left `None` on create: the legacy client does not set
763            // `file.size` on the create request (a plaintext byte count would not match the
764            // uploaded ciphertext blob). The server derives the size from the uploaded blob; the
765            // ciphertext length is instead sent as `file_length` inside `create_file_send`.
766            SendViewType::File(SendFileView {
767                id: None,
768                file_name,
769                size: None,
770                size_name: None,
771            })
772        }
773        (None, Some(t)) => SendViewType::Text(SendTextView {
774            text: Some(t.to_string()),
775            hidden,
776        }),
777        (Some(_), Some(_)) => {
778            return Err(eyre!("--file and --text are mutually exclusive."));
779        }
780        (None, None) => {
781            return Err(eyre!(
782                "Either --text <data> or --file <path> is required when creating a Send."
783            ));
784        }
785    };
786
787    // Derive a default name: file sends pick up the file name; text sends require an explicit
788    // name to match the legacy CLI's UX.
789    let resolved_name = match (name, &view_type) {
790        (Some(n), _) => n,
791        (None, SendViewType::File(f)) => f.file_name.clone(),
792        (None, SendViewType::Text(_)) => {
793            return Err(eyre!("--name is required for text Sends."));
794        }
795    };
796
797    let auth = build_auth(password, emails.as_deref())?;
798
799    Ok(SendAddRequest {
800        name: resolved_name,
801        notes,
802        view_type,
803        max_access_count,
804        disabled: false,
805        hide_email: false,
806        deletion_date,
807        expiration_date: None,
808        auth,
809    })
810}
811
812/// CLI-flag overrides applied on top of a full-object JSON `create`.
813struct CreateOverrides {
814    name: Option<String>,
815    notes: Option<String>,
816    max_access_count: Option<u32>,
817    hidden: bool,
818    password: Option<String>,
819    emails: Option<String>,
820}
821
822/// Build a create request from a full-object [`SendView`] (the `encoded_json` path).
823///
824/// This builds the [`SendAddRequest`] directly from the parsed view rather than routing
825/// through [`CreateInputs`]/[`build_create_request`]: `CreateInputs` is flag-shaped (a
826/// relative `--deleteInDays`, a file *path*) and cannot represent the absolute `deletionDate`,
827/// `expirationDate`, `disabled`, or `hideEmail` a full JSON object carries. The JSON object is
828/// authoritative; a CLI flag, when explicitly provided, overrides the corresponding field.
829fn build_create_request_from_view(
830    view: SendView,
831    overrides: CreateOverrides,
832) -> color_eyre::eyre::Result<SendAddRequest> {
833    let CreateOverrides {
834        name,
835        notes,
836        max_access_count,
837        hidden,
838        password,
839        emails,
840    } = overrides;
841
842    let view_type = match view.r#type {
843        SendType::Text => {
844            let text = view.text.unwrap_or(SendTextView {
845                text: None,
846                hidden: false,
847            });
848            SendViewType::Text(SendTextView {
849                text: text.text,
850                hidden: text.hidden || hidden,
851            })
852        }
853        SendType::File => {
854            // Creating a file Send needs the local file bytes to encrypt and upload; a JSON
855            // object only carries the file name, so this can't be supported without a local
856            // `--file <path>`. (File-send creation over the Rust CLI is tracked under PM-39238
857            // regardless of input source.)
858            return Err(eyre!(
859                "Creating file Sends from JSON is not supported: the CLI needs a local file path \
860                 to read and encrypt the contents. Use `--file <path>` instead."
861            ));
862        }
863        SendType::Item => {
864            return Err(eyre!("Creating item Sends is not supported by the CLI."));
865        }
866    };
867
868    let resolved_name = name.unwrap_or(view.name);
869    if resolved_name.is_empty() {
870        return Err(eyre!("--name is required."));
871    }
872
873    // Auth precedence: CLI `--password`/`--emails` win as a unit; otherwise fall back to the
874    // auth carried in the JSON object (`newPassword` / `emails`).
875    let (password, emails) = if password.is_some() || emails.is_some() {
876        (password, emails)
877    } else {
878        let json_emails = (!view.emails.is_empty()).then(|| view.emails.join(","));
879        (view.new_password, json_emails)
880    };
881    let auth = build_auth(password, emails.as_deref())?;
882
883    Ok(SendAddRequest {
884        name: resolved_name,
885        notes: notes.or(view.notes),
886        view_type,
887        max_access_count: max_access_count.or(view.max_access_count),
888        disabled: view.disabled,
889        hide_email: view.hide_email,
890        deletion_date: view.deletion_date,
891        expiration_date: view.expiration_date,
892        auth,
893    })
894}
895
896struct EditOverrides {
897    delete_in_days: Option<u64>,
898    max_access_count: Option<u32>,
899    hidden: bool,
900    password: Option<String>,
901    emails: Option<String>,
902}
903
904fn build_edit_request(
905    existing: bitwarden_send::SendView,
906    overrides: EditOverrides,
907) -> color_eyre::eyre::Result<SendEditRequest> {
908    let EditOverrides {
909        delete_in_days,
910        max_access_count,
911        hidden,
912        password,
913        emails,
914    } = overrides;
915
916    let deletion_date = match delete_in_days {
917        Some(d) => compute_deletion_date(d)?,
918        None => existing.deletion_date,
919    };
920
921    let view_type = match (existing.text, existing.file) {
922        (Some(t), None) => SendViewType::Text(SendTextView {
923            text: t.text,
924            hidden: if hidden { true } else { t.hidden },
925        }),
926        (None, Some(f)) => SendViewType::File(f),
927        // Sends should always carry exactly one of text/file; the API can in theory return both.
928        // PM-39238 disambiguation finding (item #4): there is NO deviation from legacy to fix here.
929        // `get` returns the full [`SendView`] (both `text` and `file` preserved), so a caller
930        // reading a mixed-shape response loses nothing. `create` is built from the typed
931        // [`SendViewType`] enum and so is unambiguous by construction. The only place a choice is
932        // forced is `edit`, where a single variant must be reconstructed from the existing row —
933        // preferring text matches the legacy CLI (`SendView.text ?? SendView.file`).
934        (Some(t), Some(_)) => SendViewType::Text(SendTextView {
935            text: t.text,
936            hidden: if hidden { true } else { t.hidden },
937        }),
938        (None, None) => {
939            return Err(eyre!(
940                "Cannot edit Send {:?}: server returned neither text nor file content.",
941                existing.id
942            ));
943        }
944    };
945
946    let auth = build_auth_for_edit(password, emails.as_deref())?;
947
948    Ok(SendEditRequest {
949        name: existing.name,
950        notes: existing.notes,
951        view_type,
952        max_access_count: max_access_count.or(existing.max_access_count),
953        disabled: existing.disabled,
954        hide_email: existing.hide_email,
955        deletion_date,
956        expiration_date: existing.expiration_date,
957        auth,
958    })
959}
960
961/// Build an edit request by merging a full-object [`SendView`] (`req`) over the existing
962/// server row, replicating the legacy CLI's precedence.
963///
964/// Unlike the flag-only [`build_edit_request`], the JSON object is a *full replace*, not a
965/// sparse patch: every JSON-owned field (`name`, `notes`, `disabled`, `hideEmail`,
966/// `expirationDate`, `text`/`file`, ...) unconditionally overwrites the existing value, so a
967/// field absent from the JSON is cleared — matching legacy's documented "fetch the full
968/// object, edit it, resubmit the whole thing" workflow.
969///
970/// Precedence exceptions: `--deleteInDays`, `--maxAccessCount`, `--password`, and `--emails`
971/// CLI flags win over the JSON field when explicitly provided (`flag > JSON > existing`), and
972/// auth falls back to `AuthEdit::Preserve` when neither a flag nor the JSON supplies one — so
973/// a resubmit never silently strips a previously configured password/email gate.
974fn build_edit_request_from_json(
975    existing: SendView,
976    req: SendView,
977    overrides: EditOverrides,
978) -> color_eyre::eyre::Result<SendEditRequest> {
979    let EditOverrides {
980        delete_in_days,
981        max_access_count,
982        hidden,
983        password,
984        emails,
985    } = overrides;
986
987    // A Send's type is immutable. Legacy rejects a type change before any encryption/API call;
988    // do the same so the user gets a clear error rather than a server rejection.
989    if req.r#type != existing.r#type {
990        return Err(eyre!("Cannot change a Send's type."));
991    }
992
993    let view_type = match req.r#type {
994        SendType::Text => {
995            let text = req.text.unwrap_or(SendTextView {
996                text: None,
997                hidden: false,
998            });
999            SendViewType::Text(SendTextView {
1000                text: text.text,
1001                hidden: text.hidden || hidden,
1002            })
1003        }
1004        SendType::File => SendViewType::File(req.file.ok_or_else(|| {
1005            eyre!("JSON declares a file Send (type 1) but is missing the `file` object.")
1006        })?),
1007        SendType::Item => {
1008            return Err(eyre!("Editing item Sends is not supported by the CLI."));
1009        }
1010    };
1011
1012    let deletion_date = match delete_in_days {
1013        Some(d) => compute_deletion_date(d)?,
1014        None => req.deletion_date,
1015    };
1016
1017    // Auth precedence: CLI flag > JSON field > preserve existing. Routing through
1018    // `build_auth_for_edit` keeps the preserve-by-default fix — `(None, None)` resolves to
1019    // `AuthEdit::Preserve`, never a silent auth strip.
1020    let (password, emails) = if password.is_some() || emails.is_some() {
1021        (password, emails)
1022    } else {
1023        let json_emails = (!req.emails.is_empty()).then(|| req.emails.join(","));
1024        (req.new_password, json_emails)
1025    };
1026    let auth = build_auth_for_edit(password, emails.as_deref())?;
1027
1028    Ok(SendEditRequest {
1029        name: req.name,
1030        notes: req.notes,
1031        view_type,
1032        max_access_count: max_access_count.or(req.max_access_count),
1033        disabled: req.disabled,
1034        hide_email: req.hide_email,
1035        deletion_date,
1036        expiration_date: req.expiration_date,
1037        auth,
1038    })
1039}
1040
1041async fn create_shortcut(client: &PasswordManagerClient, args: SendArgs) -> CommandResult {
1042    let data = args
1043        .data
1044        .clone()
1045        .ok_or_else(|| eyre!("Missing <data> argument. Run `bw send --help` for usage."))?;
1046
1047    let file_path = if args.file { Some(data.clone()) } else { None };
1048
1049    let inputs = if args.file {
1050        CreateInputs {
1051            file: Some(data),
1052            text: None,
1053            delete_in_days: args.delete_in_days,
1054            max_access_count: args.max_access_count,
1055            hidden: args.hidden,
1056            name: args.name,
1057            notes: args.notes,
1058            password: args.password,
1059            emails: args.emails,
1060        }
1061    } else {
1062        CreateInputs {
1063            file: None,
1064            text: Some(data),
1065            delete_in_days: args.delete_in_days,
1066            max_access_count: args.max_access_count,
1067            hidden: args.hidden,
1068            // Text shortcut: default name to "Send" when not provided, matching the legacy CLI's
1069            // permissive behavior when callers pipe data in.
1070            name: args.name.or_else(|| Some("Send".to_string())),
1071            notes: args.notes,
1072            password: args.password,
1073            emails: args.emails,
1074        }
1075    };
1076
1077    let request = build_create_request(inputs)?;
1078    run_create(client, request, file_path, args.full_object).await
1079}
1080
1081async fn run_create(
1082    client: &PasswordManagerClient,
1083    request: SendAddRequest,
1084    file_path: Option<String>,
1085    full_object: bool,
1086) -> CommandResult {
1087    let is_file = matches!(request.view_type, SendViewType::File(_));
1088
1089    let view = if is_file {
1090        // File sends require a premium membership. Match the legacy CLI's pre-check so the user
1091        // gets a clear error before any file is read or any request is sent to the server, rather
1092        // than a generic server-side rejection mid-upload.
1093        require_premium(client).await?;
1094
1095        let path = file_path.ok_or_else(|| {
1096            eyre!("Internal error: file Send created without a source file path.")
1097        })?;
1098        run_create_file(client, request, &path).await?
1099    } else {
1100        client.sends().create(request).await?
1101    };
1102
1103    if full_object {
1104        return Ok(CommandOutput::Object(Box::new(view)));
1105    }
1106
1107    // The default output is the shareable access URL — the primary artifact a caller wants to
1108    // hand to a recipient. `--fullObject` opts back into the full JSON view.
1109    let url = build_access_url(client, &view)?;
1110    Ok(url.into())
1111}
1112
1113/// Full file-send create pipeline:
1114/// 1. Read the plaintext file bytes.
1115/// 2. `create_file_send` encrypts them under the send key it derives, sends the ciphertext length
1116///    as `file_length`, registers the send, and returns the encrypted bytes plus upload metadata
1117///    (URL + backend).
1118/// 3. The ciphertext is uploaded via `upload_send_file`, which dispatches to the Direct or Azure
1119///    backend based on the `file_upload_type` from step 2.
1120async fn run_create_file(
1121    client: &PasswordManagerClient,
1122    request: SendAddRequest,
1123    path: &str,
1124) -> color_eyre::eyre::Result<bitwarden_send::SendView> {
1125    reject_path_traversal("--file", path)?;
1126
1127    // Read the plaintext before creating the send so a read failure aborts before we register a
1128    // send that would then have no content.
1129    let bytes = std::fs::read(path).wrap_err_with(|| format!("Could not read file {path}"))?;
1130
1131    // `create_file_send` performs the encryption internally (so `file_length` on the create request
1132    // reflects the true ciphertext length) and hands back the encrypted bytes for the upload.
1133    let resp = client.sends().create_file_send(request, bytes).await?;
1134
1135    let send_id = resp
1136        .send
1137        .id
1138        .ok_or_else(|| eyre!("Server did not return an id for the created file Send."))?;
1139
1140    client
1141        .sends()
1142        .upload_send_file(
1143            send_id,
1144            resp.file_id,
1145            resp.encrypted_file_name,
1146            resp.file_upload_type,
1147            resp.url,
1148            resp.encrypted_file_buffer,
1149        )
1150        .await?;
1151
1152    Ok(resp.send)
1153}
1154
1155/// Enforce the premium-membership precondition for file Sends by inspecting the `premium` claim on
1156/// the current user's access-token JWT (option a from PM-39238). Reads the persisted
1157/// [`AUTHENTICATION_TOKENS`] state — the same source the auth middleware attaches to requests — so
1158/// no additional token accessor is needed on the client.
1159async fn require_premium(client: &PasswordManagerClient) -> color_eyre::eyre::Result<()> {
1160    let tokens = client
1161        .platform()
1162        .state()
1163        .setting(AUTHENTICATION_TOKENS)?
1164        .get()
1165        .await?
1166        .ok_or_else(|| eyre!("You must be logged in to create a file Send."))?;
1167
1168    let claims: JwtToken = tokens
1169        .access_token
1170        .parse()
1171        .wrap_err("Could not parse the current access token.")?;
1172
1173    if claims.premium == Some(true) {
1174        Ok(())
1175    } else {
1176        Err(eyre!(
1177            "A premium membership is required to create file Sends."
1178        ))
1179    }
1180}
1181
1182/// Build the shareable Send access URL from a decrypted [`bitwarden_send::SendView`].
1183///
1184/// Format: `<web-vault>/#/send/<access_id>/<url_b64_key>`, where `<web-vault>` is resolved by
1185/// [`web_vault_url`].
1186///
1187/// This matches the legacy CLI (`SendResponse` in `apps/cli`, which appends
1188/// `accessId + "/" + urlB64Key` to `env.getSendUrl()`, whose self-hosted form is
1189/// `<web-vault>/#/send/`) and round-trips through the legacy `bw receive` parser, which reads the
1190/// two trailing `#`-fragment segments (`url.hash.slice(1).split("/").slice(-2)`) and
1191/// URL-safe-base64-decodes the key.
1192///
1193/// Note: we always emit the `<web-vault>/#/send/` form. The US-production vanity host
1194/// (`https://send.bitwarden.com/#...`) is intentionally not reproduced — hitting the web-vault
1195/// link directly works in every environment, and the CLI has no authoritative source for the
1196/// vanity host (see [`web_vault_url`]).
1197fn build_access_url(
1198    client: &PasswordManagerClient,
1199    view: &bitwarden_send::SendView,
1200) -> color_eyre::eyre::Result<String> {
1201    let access_id = view
1202        .access_id
1203        .as_deref()
1204        .ok_or_else(|| eyre!("Send is missing an access id; cannot build a shareable URL."))?;
1205    let key = view
1206        .key
1207        .as_deref()
1208        .ok_or_else(|| eyre!("Send is missing a key; cannot build a shareable URL."))?;
1209
1210    let web_vault = web_vault_url(client);
1211    let url_key = to_url_b64(key);
1212
1213    Ok(format!("{web_vault}/#/send/{access_id}/{url_key}"))
1214}
1215
1216/// Resolve the web-vault base URL that `/#/send/<access_id>/<url_b64_key>` is appended to.
1217///
1218/// Precedence, mirroring the legacy CLI's per-service-then-base resolution:
1219/// 1. `config.web_vault` — an explicit web-vault URL (`bw config server --web-vault <url>`).
1220/// 2. `config.server` — the base server URL (`bw config server <url>`).
1221/// 3. derive from the active client's `api_url` (see [`web_vault_from_api_url`]).
1222///
1223/// TODO: this derivation is interim. The CLI has no authoritative source for the web-vault/send
1224/// host (confirmed with platform in the PM-39239 review), so we infer it. Replace this with a
1225/// proper environment/config service in this repo (parity with the clients'
1226/// `DefaultEnvironmentService`) once one exists, at which point this becomes a single lookup.
1227fn web_vault_url(client: &PasswordManagerClient) -> String {
1228    if let Ok(Some(config)) = read_config_json() {
1229        if let Some(web_vault) = config.web_vault.as_deref() {
1230            return web_vault.trim_end_matches('/').to_string();
1231        }
1232        if let Some(server) = config.server.as_deref() {
1233            return server.trim_end_matches('/').to_string();
1234        }
1235    }
1236
1237    let api_url = client
1238        .0
1239        .internal
1240        .get_api_configurations()
1241        .api_config
1242        .base_path
1243        .clone();
1244
1245    web_vault_from_api_url(&api_url)
1246}
1247
1248/// Derive the web-vault base from an API URL when no web-vault/server URL is configured (the
1249/// `bw login --server` and cloud paths). Pure so it can be unit-tested without a live client.
1250///
1251/// - Single-domain deployment: the API lives at `<web-vault>/api` (the suffix `bw login --server`
1252///   appends), so a trailing `/api` is stripped to recover the web vault.
1253/// - Split-domain deployment (all Bitwarden cloud regions, and the standard self-host convention):
1254///   the API is served from an `api.` host that does not serve the web-vault SPA, so the leading
1255///   `api.` host label is rewritten to `vault.` (`https://api.bitwarden.com` ->
1256///   `https://vault.bitwarden.com`, `https://api.bitwarden.eu` -> `https://vault.bitwarden.eu`).
1257/// - Any other shape is treated as its own web vault.
1258///
1259/// This is a heuristic (see the `web_vault_url` TODO): a deployment whose API host neither ends in
1260/// `/api` nor begins with `api.` cannot be mapped and will fall through to being used as-is. Such
1261/// deployments should set `bw config server --web-vault <url>` for correct links.
1262fn web_vault_from_api_url(api_url: &str) -> String {
1263    let trimmed = api_url.trim_end_matches('/');
1264
1265    if let Some(base) = trimmed.strip_suffix("/api") {
1266        return base.trim_end_matches('/').to_string();
1267    }
1268
1269    if let Some(vault) = rewrite_api_host_to_vault(trimmed) {
1270        return vault;
1271    }
1272
1273    trimmed.to_string()
1274}
1275
1276/// Rewrite a leading `api.` host label to `vault.` in a `scheme://host[/path]` URL, e.g.
1277/// `https://api.bitwarden.com` -> `https://vault.bitwarden.com`. Returns `None` when the URL has no
1278/// scheme or the host does not start with the `api.` label (so `apiary.example.com` is not
1279/// rewritten).
1280fn rewrite_api_host_to_vault(url: &str) -> Option<String> {
1281    let (scheme, rest) = url.split_once("://")?;
1282    let after_api = rest.strip_prefix("api.")?;
1283    Some(format!("{scheme}://vault.{after_api}"))
1284}
1285
1286/// Convert standard base64 to URL-safe base64 without padding.
1287///
1288/// Reproduces the legacy client's `Utils.fromB64toUrlB64`: `+` → `-`, `/` → `_`, and `=` padding
1289/// stripped. The `SendView.key` is standard base64; the URL fragment must carry the URL-safe form
1290/// so the `bw receive` parser (`Utils.fromUrlB64ToArray`) decodes it correctly.
1291fn to_url_b64(b64: &str) -> String {
1292    b64.replace('+', "-").replace('/', "_").replace('=', "")
1293}
1294
1295fn compute_deletion_date(days: u64) -> color_eyre::eyre::Result<chrono::DateTime<Utc>> {
1296    if days == 0 {
1297        return Err(eyre!("--deleteInDays must be a positive integer"));
1298    }
1299    let signed =
1300        i64::try_from(days).wrap_err_with(|| format!("--deleteInDays out of range: {days}"))?;
1301    Ok(Utc::now() + Duration::days(signed))
1302}
1303
1304fn build_auth(
1305    password: Option<String>,
1306    emails: Option<&str>,
1307) -> color_eyre::eyre::Result<SendAuthType> {
1308    match (password, emails) {
1309        (None, None) => Ok(SendAuthType::None),
1310        (Some(p), None) => Ok(SendAuthType::Password { password: p }),
1311        (None, Some(e)) => Ok(SendAuthType::Emails {
1312            emails: parse_emails(e)?,
1313        }),
1314        (Some(_), Some(_)) => Err(eyre!("--password and --emails are mutually exclusive.")),
1315    }
1316}
1317
1318/// Build the `auth` field for a [`SendEditRequest`].
1319///
1320/// Edit semantics differ from create:
1321///   - `(None, None)` returns `AuthEdit::Preserve`, telling the SDK to keep the existing auth. The
1322///     SDK reads the wire-format `password` hash and `emails` string off the repository row and
1323///     forwards them verbatim, so a partial edit (e.g. just changing `--deleteInDays`) never
1324///     silently strips a previously configured password or email-OTP gate. This is the fix for the
1325///     auth-strip bug — the previous code emitted `SendAuthType::None` here, which the server
1326///     treats as an overwrite.
1327///   - `(Some(p), None)` / `(None, Some(e))` return `AuthEdit::Set { auth: _ }` to overwrite to
1328///     Password / Email auth.
1329///   - `(Some(_), Some(_))` is rejected (mutually exclusive).
1330///
1331/// Note: passing `--password ""` is not how callers strip auth on edit. To remove a
1332/// previously configured password, use `bw send remove-password` (the legacy CLI's
1333/// dedicated subcommand), or pass `AuthEdit::Set { auth: SendAuthType::None }` at the
1334/// SDK boundary.
1335fn build_auth_for_edit(
1336    password: Option<String>,
1337    emails: Option<&str>,
1338) -> color_eyre::eyre::Result<AuthEdit> {
1339    match (password, emails) {
1340        (None, None) => Ok(AuthEdit::Preserve),
1341        (Some(p), None) => Ok(AuthEdit::Set {
1342            auth: SendAuthType::Password { password: p },
1343        }),
1344        (None, Some(e)) => Ok(AuthEdit::Set {
1345            auth: SendAuthType::Emails {
1346                emails: parse_emails(e)?,
1347            },
1348        }),
1349        (Some(_), Some(_)) => Err(eyre!("--password and --emails are mutually exclusive.")),
1350    }
1351}
1352
1353/// Parse the `--emails` argument into a list of email addresses.
1354///
1355/// The legacy CLI accepts four shapes, in order of precedence:
1356///   1. A JSON array: `["[email protected]", "[email protected]"]`
1357///   2. A comma-separated list: `[email protected],[email protected]`
1358///   3. A space-separated list: `[email protected] [email protected]`
1359///   4. A single address: `[email protected]`
1360///
1361/// Returns an error if the parsed list is empty or any entry fails a basic shape check.
1362pub(crate) fn parse_emails(raw: &str) -> color_eyre::eyre::Result<Vec<String>> {
1363    let trimmed = raw.trim();
1364    if trimmed.is_empty() {
1365        return Err(eyre!("--emails cannot be empty"));
1366    }
1367
1368    // 1. JSON array
1369    if trimmed.starts_with('[') {
1370        let arr: Vec<String> = serde_json::from_str(trimmed)
1371            .wrap_err("--emails looked like a JSON array but failed to parse")?;
1372        return finalize_emails(arr);
1373    }
1374
1375    // 2./3. Delimiter-separated. Comma takes precedence; if no comma is present we fall back
1376    // to whitespace splitting so a quoted-and-shell-escaped `--emails "[email protected] [email protected]"` works.
1377    let parts: Vec<String> = if trimmed.contains(',') {
1378        trimmed.split(',').map(|s| s.trim().to_string()).collect()
1379    } else if trimmed.contains(char::is_whitespace) {
1380        trimmed.split_whitespace().map(|s| s.to_string()).collect()
1381    } else {
1382        // 4. Single email
1383        vec![trimmed.to_string()]
1384    };
1385
1386    finalize_emails(parts)
1387}
1388
1389fn finalize_emails(emails: Vec<String>) -> color_eyre::eyre::Result<Vec<String>> {
1390    let cleaned: Vec<String> = emails
1391        .into_iter()
1392        .map(|e| e.trim().to_string())
1393        .filter(|e| !e.is_empty())
1394        .collect();
1395    if cleaned.is_empty() {
1396        return Err(eyre!("--emails must contain at least one address"));
1397    }
1398    for e in &cleaned {
1399        // Minimal sanity check; the server is the source of truth for validity.
1400        if !e.contains('@') {
1401            return Err(eyre!("Invalid email address: {e}"));
1402        }
1403    }
1404    Ok(cleaned)
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409    use super::*;
1410
1411    // ---- access URL construction ----
1412
1413    #[test]
1414    fn to_url_b64_maps_standard_b64_to_url_safe() {
1415        // `+` -> `-`, `/` -> `_`, padding stripped.
1416        assert_eq!(to_url_b64("ab+/cd=="), "ab-_cd");
1417        assert_eq!(
1418            to_url_b64("Pgui0FK85cNhBGWHAlBHBw=="),
1419            "Pgui0FK85cNhBGWHAlBHBw"
1420        );
1421        // No special chars: unchanged.
1422        assert_eq!(to_url_b64("abcDEF123"), "abcDEF123");
1423    }
1424
1425    #[test]
1426    fn web_vault_from_api_url_strips_single_domain_api_suffix() {
1427        // `bw login --server <base>` sets api_url to `<base>/api`; stripping it recovers the vault.
1428        assert_eq!(
1429            web_vault_from_api_url("https://vault.example.com/api"),
1430            "https://vault.example.com"
1431        );
1432        // Trailing slash after /api.
1433        assert_eq!(
1434            web_vault_from_api_url("https://vault.example.com/api/"),
1435            "https://vault.example.com"
1436        );
1437    }
1438
1439    #[test]
1440    fn web_vault_from_api_url_rewrites_cloud_api_host_to_vault() {
1441        // Cloud (all regions) serves the API from an `api.` host that does not serve the web-vault
1442        // SPA, so it must be rewritten to the `vault.` host — not used as-is.
1443        assert_eq!(
1444            web_vault_from_api_url("https://api.bitwarden.com"),
1445            "https://vault.bitwarden.com"
1446        );
1447        assert_eq!(
1448            web_vault_from_api_url("https://api.bitwarden.eu"),
1449            "https://vault.bitwarden.eu"
1450        );
1451        // Trailing slash is trimmed before the rewrite.
1452        assert_eq!(
1453            web_vault_from_api_url("https://api.bitwarden.com/"),
1454            "https://vault.bitwarden.com"
1455        );
1456    }
1457
1458    #[test]
1459    fn web_vault_from_api_url_rewrites_split_domain_self_host() {
1460        // Standard self-host convention: `api.<domain>` -> `vault.<domain>`.
1461        assert_eq!(
1462            web_vault_from_api_url("https://api.example.com"),
1463            "https://vault.example.com"
1464        );
1465    }
1466
1467    #[test]
1468    fn web_vault_from_api_url_leaves_unmappable_host_as_is() {
1469        // Neither `/api` suffix nor `api.` prefix: used as-is (documented limitation — such
1470        // deployments should configure the web vault explicitly). `apiary.` must NOT be rewritten.
1471        assert_eq!(
1472            web_vault_from_api_url("https://apiary.example.com"),
1473            "https://apiary.example.com"
1474        );
1475    }
1476
1477    /// The assembled URL must match the legacy `SendResponse` shape
1478    /// (`<web-vault>/#/send/<accessId>/<urlB64Key>`) so it round-trips through the `bw receive`
1479    /// fragment parser. Pins the exact string for the cloud (`api.`-rewrite) case.
1480    #[test]
1481    fn access_url_format_matches_legacy_and_round_trips() {
1482        let web_vault = web_vault_from_api_url("https://api.bitwarden.com");
1483        let access_id = "abcaccessid";
1484        // Standard-b64 key with chars that must be URL-encoded.
1485        let url_key = to_url_b64("Pgui0FK8+cNh/GWHAlBHBw==");
1486        let url = format!("{web_vault}/#/send/{access_id}/{url_key}");
1487
1488        assert_eq!(
1489            url,
1490            "https://vault.bitwarden.com/#/send/abcaccessid/Pgui0FK8-cNh_GWHAlBHBw"
1491        );
1492
1493        // Round-trip check: the legacy `bw receive` parser reads the last two `#`-fragment
1494        // segments. Reproduce that split and confirm we recover the access id + url-safe key.
1495        let (_, fragment) = url.split_once('#').expect("URL has a fragment");
1496        let segments: Vec<&str> = fragment.trim_start_matches('/').split('/').collect();
1497        let last_two = &segments[segments.len() - 2..];
1498        assert_eq!(last_two, ["abcaccessid", "Pgui0FK8-cNh_GWHAlBHBw"]);
1499    }
1500
1501    // ---- parse_emails ----
1502
1503    #[test]
1504    fn parse_emails_single() {
1505        let v = parse_emails("[email protected]").unwrap();
1506        assert_eq!(v, vec!["[email protected]".to_string()]);
1507    }
1508
1509    #[test]
1510    fn parse_emails_json_array() {
1511        let v = parse_emails(r#"["[email protected]","[email protected]"]"#).unwrap();
1512        assert_eq!(v, vec!["[email protected]".to_string(), "[email protected]".to_string()]);
1513    }
1514
1515    #[test]
1516    fn parse_emails_comma_separated() {
1517        let v = parse_emails("[email protected],[email protected] , [email protected]").unwrap();
1518        assert_eq!(
1519            v,
1520            vec![
1521                "[email protected]".to_string(),
1522                "[email protected]".to_string(),
1523                "[email protected]".to_string(),
1524            ]
1525        );
1526    }
1527
1528    #[test]
1529    fn parse_emails_space_separated() {
1530        let v = parse_emails("[email protected] [email protected]  [email protected]").unwrap();
1531        assert_eq!(
1532            v,
1533            vec![
1534                "[email protected]".to_string(),
1535                "[email protected]".to_string(),
1536                "[email protected]".to_string(),
1537            ]
1538        );
1539    }
1540
1541    #[test]
1542    fn parse_emails_rejects_empty() {
1543        assert!(parse_emails("").is_err());
1544        assert!(parse_emails("   ").is_err());
1545        assert!(parse_emails("[]").is_err());
1546    }
1547
1548    #[test]
1549    fn parse_emails_rejects_no_at_sign() {
1550        assert!(parse_emails("not-an-email").is_err());
1551    }
1552
1553    #[test]
1554    fn parse_emails_rejects_malformed_json_array() {
1555        // Looks like JSON but isn't a valid string array.
1556        assert!(parse_emails("[not, valid]").is_err());
1557    }
1558
1559    // ---- compute_deletion_date ----
1560
1561    #[test]
1562    fn compute_deletion_date_positive() {
1563        let d = compute_deletion_date(7).unwrap();
1564        let now = Utc::now();
1565        let diff = d - now;
1566        assert!(diff.num_days() >= 6 && diff.num_days() <= 7);
1567    }
1568
1569    #[test]
1570    fn compute_deletion_date_rejects_zero() {
1571        assert!(compute_deletion_date(0).is_err());
1572    }
1573
1574    // `reject_path_traversal` moved to `tools::file_output` when `bw receive` needed the same
1575    // check for `--passwordfile` and its output path; its tests moved with it.
1576
1577    // ---- build_auth ----
1578
1579    #[test]
1580    fn build_auth_none_when_neither_flag_given() {
1581        assert!(matches!(
1582            build_auth(None, None).unwrap(),
1583            SendAuthType::None
1584        ));
1585    }
1586
1587    #[test]
1588    fn build_auth_password_only() {
1589        let auth = build_auth(Some("secret".to_string()), None).unwrap();
1590        assert!(matches!(auth, SendAuthType::Password { password } if password == "secret"));
1591    }
1592
1593    #[test]
1594    fn build_auth_emails_only() {
1595        let auth = build_auth(None, Some("[email protected]")).unwrap();
1596        match auth {
1597            SendAuthType::Emails { emails } => assert_eq!(emails, vec!["[email protected]".to_string()]),
1598            other => panic!("expected Emails, got {other:?}"),
1599        }
1600    }
1601
1602    #[test]
1603    fn build_auth_rejects_both() {
1604        assert!(build_auth(Some("p".into()), Some("[email protected]")).is_err());
1605    }
1606
1607    // ---- build_create_request ----
1608
1609    #[test]
1610    fn build_create_request_text_send() {
1611        let req = build_create_request(CreateInputs {
1612            file: None,
1613            text: Some("hello".into()),
1614            delete_in_days: 7,
1615            max_access_count: Some(5),
1616            hidden: true,
1617            name: Some("My Send".into()),
1618            notes: Some("notes".into()),
1619            password: None,
1620            emails: None,
1621        })
1622        .unwrap();
1623
1624        assert_eq!(req.name, "My Send");
1625        assert_eq!(req.notes.as_deref(), Some("notes"));
1626        assert_eq!(req.max_access_count, Some(5));
1627        match req.view_type {
1628            SendViewType::Text(t) => {
1629                assert_eq!(t.text.as_deref(), Some("hello"));
1630                assert!(t.hidden);
1631            }
1632            other => panic!("expected Text, got {other:?}"),
1633        }
1634        assert!(matches!(req.auth, SendAuthType::None));
1635    }
1636
1637    #[test]
1638    fn build_create_request_text_requires_name() {
1639        let err = build_create_request(CreateInputs {
1640            file: None,
1641            text: Some("hello".into()),
1642            delete_in_days: 7,
1643            max_access_count: None,
1644            hidden: false,
1645            name: None,
1646            notes: None,
1647            password: None,
1648            emails: None,
1649        })
1650        .unwrap_err();
1651        assert!(err.to_string().contains("--name is required"));
1652    }
1653
1654    /// File create derives the name from the path and leaves `SendFileView.size` unset (`None`):
1655    /// the legacy client does not set `file.size` on create (the server derives it from the
1656    /// uploaded blob), and a plaintext byte count would not match the uploaded ciphertext. The
1657    /// encrypted-buffer length is sent separately as `file_length` inside `create_file_send`.
1658    ///
1659    /// `build_create_request` does not touch the filesystem, so a placeholder path is fine here;
1660    /// the actual file read happens later in `run_create_file`.
1661    #[test]
1662    fn build_create_request_file_derives_name_and_leaves_size_unset() {
1663        let req = build_create_request(CreateInputs {
1664            file: Some("/tmp/secrets.txt".into()),
1665            text: None,
1666            delete_in_days: 7,
1667            max_access_count: None,
1668            hidden: false,
1669            name: None,
1670            notes: None,
1671            password: None,
1672            emails: None,
1673        })
1674        .unwrap();
1675
1676        assert_eq!(req.name, "secrets.txt");
1677        match req.view_type {
1678            SendViewType::File(f) => {
1679                assert_eq!(f.file_name, "secrets.txt");
1680                assert_eq!(f.size, None, "file.size must be unset on create");
1681            }
1682            other => panic!("expected File, got {other:?}"),
1683        }
1684    }
1685
1686    #[test]
1687    fn build_create_request_rejects_text_and_file_together() {
1688        let err = build_create_request(CreateInputs {
1689            file: Some("/tmp/x".into()),
1690            text: Some("hello".into()),
1691            delete_in_days: 7,
1692            max_access_count: None,
1693            hidden: false,
1694            name: Some("name".into()),
1695            notes: None,
1696            password: None,
1697            emails: None,
1698        })
1699        .unwrap_err();
1700        assert!(err.to_string().contains("mutually exclusive"));
1701    }
1702
1703    #[test]
1704    fn build_create_request_rejects_neither() {
1705        let err = build_create_request(CreateInputs {
1706            file: None,
1707            text: None,
1708            delete_in_days: 7,
1709            max_access_count: None,
1710            hidden: false,
1711            name: Some("name".into()),
1712            notes: None,
1713            password: None,
1714            emails: None,
1715        })
1716        .unwrap_err();
1717        assert!(err.to_string().contains("--text") || err.to_string().contains("--file"));
1718    }
1719
1720    #[test]
1721    fn build_create_request_password_auth() {
1722        let req = build_create_request(CreateInputs {
1723            file: None,
1724            text: Some("hello".into()),
1725            delete_in_days: 7,
1726            max_access_count: None,
1727            hidden: false,
1728            name: Some("name".into()),
1729            notes: None,
1730            password: Some("hunter2".into()),
1731            emails: None,
1732        })
1733        .unwrap();
1734        assert!(matches!(req.auth, SendAuthType::Password { .. }));
1735    }
1736
1737    #[test]
1738    fn build_create_request_email_auth() {
1739        let req = build_create_request(CreateInputs {
1740            file: None,
1741            text: Some("hello".into()),
1742            delete_in_days: 7,
1743            max_access_count: None,
1744            hidden: false,
1745            name: Some("name".into()),
1746            notes: None,
1747            password: None,
1748            emails: Some("[email protected],[email protected]".into()),
1749        })
1750        .unwrap();
1751        match req.auth {
1752            SendAuthType::Emails { emails } => assert_eq!(emails.len(), 2),
1753            other => panic!("expected Emails, got {other:?}"),
1754        }
1755    }
1756
1757    // ---- build_auth_for_edit ----
1758
1759    /// On edit, the `(None, None)` case must return `AuthEdit::Preserve`, not
1760    /// `AuthEdit::Set { auth: SendAuthType::None }` (overwrite to no-auth). This is the
1761    /// auth-strip regression boundary at the CLI helper level.
1762    #[test]
1763    fn build_auth_for_edit_no_flags_preserves() {
1764        let auth = build_auth_for_edit(None, None).unwrap();
1765        assert!(
1766            matches!(auth, AuthEdit::Preserve),
1767            "no flags must produce `AuthEdit::Preserve`, got {auth:?}"
1768        );
1769    }
1770
1771    #[test]
1772    fn build_auth_for_edit_password_overwrites() {
1773        let auth = build_auth_for_edit(Some("hunter2".into()), None).unwrap();
1774        assert!(matches!(
1775            auth,
1776            AuthEdit::Set { auth: SendAuthType::Password { ref password } } if password == "hunter2"
1777        ));
1778    }
1779
1780    #[test]
1781    fn build_auth_for_edit_emails_overwrites() {
1782        let auth = build_auth_for_edit(None, Some("[email protected],[email protected]")).unwrap();
1783        match auth {
1784            AuthEdit::Set {
1785                auth: SendAuthType::Emails { emails },
1786            } => assert_eq!(emails.len(), 2),
1787            other => panic!("expected AuthEdit::Set {{ auth: Emails }}, got {other:?}"),
1788        }
1789    }
1790
1791    #[test]
1792    fn build_auth_for_edit_rejects_both_flags() {
1793        assert!(build_auth_for_edit(Some("p".into()), Some("[email protected]")).is_err());
1794    }
1795
1796    // ---- build_edit_request ----
1797
1798    use bitwarden_send::{AuthType, SendType, SendView};
1799
1800    /// Helper producing a baseline `SendView` for edit fixtures. The relevant fields for
1801    /// these tests are `auth_type`, `has_password`, `emails`, and the text content.
1802    fn make_existing(auth_type: AuthType, has_password: bool, emails: Vec<String>) -> SendView {
1803        SendView {
1804            id: "25afb11c-9c95-4db5-8bac-c21cb204a3f1".parse().ok(),
1805            access_id: Some("access-id".to_string()),
1806            name: "existing".to_string(),
1807            notes: Some("notes".to_string()),
1808            key: Some("Pgui0FK85cNhBGWHAlBHBw".to_string()),
1809            new_password: None,
1810            has_password,
1811            r#type: SendType::Text,
1812            file: None,
1813            text: Some(SendTextView {
1814                text: Some("existing text".to_string()),
1815                hidden: false,
1816            }),
1817            max_access_count: Some(42),
1818            access_count: 0,
1819            disabled: false,
1820            hide_email: false,
1821            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
1822            deletion_date: "2030-01-01T00:00:00Z".parse().unwrap(),
1823            expiration_date: None,
1824            emails,
1825            auth_type,
1826        }
1827    }
1828
1829    fn no_override_edit() -> EditOverrides {
1830        EditOverrides {
1831            delete_in_days: None,
1832            max_access_count: None,
1833            hidden: false,
1834            password: None,
1835            emails: None,
1836        }
1837    }
1838
1839    /// This is the regression test for the auth-strip bug. Before the fix,
1840    /// `build_edit_request` for a Send with an existing Password gate (no auth flags
1841    /// provided) produced `auth: SendAuthType::None`, which the server treats as an
1842    /// overwrite that clears the password. After the fix, the request carries
1843    /// `auth: AuthEdit::Preserve` (the partial-update marker), and the SDK's
1844    /// `edit_send` consumes that to forward the existing password hash to the server
1845    /// verbatim.
1846    #[test]
1847    fn build_edit_request_preserves_existing_password_when_no_auth_flags() {
1848        let existing = make_existing(AuthType::Password, true, Vec::new());
1849        let req = build_edit_request(existing, no_override_edit()).unwrap();
1850        assert!(
1851            matches!(req.auth, AuthEdit::Preserve),
1852            "expected `AuthEdit::Preserve`, got {:?} — the previous behavior was \
1853             `AuthEdit::Set {{ auth: SendAuthType::None }}`, which silently strips the existing password",
1854            req.auth
1855        );
1856    }
1857
1858    #[test]
1859    fn build_edit_request_preserves_existing_emails_when_no_auth_flags() {
1860        let existing = make_existing(
1861            AuthType::Email,
1862            false,
1863            vec!["[email protected]".to_string(), "[email protected]".to_string()],
1864        );
1865        let req = build_edit_request(existing, no_override_edit()).unwrap();
1866        assert!(matches!(req.auth, AuthEdit::Preserve));
1867    }
1868
1869    /// Existing `AuthType::None` × no new auth flags → still preserve (i.e.
1870    /// `AuthEdit::Preserve`). The SDK will look at the existing repository row and
1871    /// emit `AuthType::None` on the wire; this CLI layer doesn't need to know that
1872    /// detail.
1873    #[test]
1874    fn build_edit_request_preserves_existing_none_auth_when_no_auth_flags() {
1875        let existing = make_existing(AuthType::None, false, Vec::new());
1876        let req = build_edit_request(existing, no_override_edit()).unwrap();
1877        assert!(matches!(req.auth, AuthEdit::Preserve));
1878    }
1879
1880    /// 4x4 matrix: existing { None, Password, Email } × override { None, Password,
1881    /// Emails }. The "preserve" cases all live above; the "overwrite" cases must
1882    /// produce a concrete `AuthEdit::Set { auth: _ }` regardless of the existing state.
1883    #[test]
1884    fn build_edit_request_password_flag_overwrites_regardless_of_existing() {
1885        for existing_auth in [AuthType::None, AuthType::Password, AuthType::Email] {
1886            let existing =
1887                make_existing(existing_auth, existing_auth == AuthType::Password, vec![]);
1888            let req = build_edit_request(
1889                existing,
1890                EditOverrides {
1891                    delete_in_days: None,
1892                    max_access_count: None,
1893                    hidden: false,
1894                    password: Some("hunter2".into()),
1895                    emails: None,
1896                },
1897            )
1898            .unwrap();
1899            assert!(
1900                matches!(
1901                    req.auth,
1902                    AuthEdit::Set { auth: SendAuthType::Password { ref password } } if password == "hunter2"
1903                ),
1904                "existing={existing_auth:?}, got auth={:?}",
1905                req.auth
1906            );
1907        }
1908    }
1909
1910    #[test]
1911    fn build_edit_request_emails_flag_overwrites_regardless_of_existing() {
1912        for existing_auth in [AuthType::None, AuthType::Password, AuthType::Email] {
1913            let existing =
1914                make_existing(existing_auth, existing_auth == AuthType::Password, vec![]);
1915            let req = build_edit_request(
1916                existing,
1917                EditOverrides {
1918                    delete_in_days: None,
1919                    max_access_count: None,
1920                    hidden: false,
1921                    password: None,
1922                    emails: Some("[email protected]".into()),
1923                },
1924            )
1925            .unwrap();
1926            match req.auth {
1927                AuthEdit::Set {
1928                    auth: SendAuthType::Emails { ref emails },
1929                } => {
1930                    assert_eq!(emails.len(), 1);
1931                }
1932                ref other => panic!(
1933                    "existing={existing_auth:?}, expected AuthEdit::Set {{ auth: Emails }}, got {other:?}"
1934                ),
1935            }
1936        }
1937    }
1938
1939    /// PM-39238 item #4 (disambiguation): when the server returns a Send carrying *both* `text`
1940    /// and `file` content, `edit` must reconstruct a single [`SendViewType`] and — matching the
1941    /// legacy CLI — prefer text. This pins that behavior so a future refactor can't silently flip
1942    /// it to file (which would drop the text body on a partial edit).
1943    #[test]
1944    fn build_edit_request_prefers_text_when_both_present() {
1945        let mut existing = make_existing(AuthType::None, false, vec![]);
1946        existing.text = Some(SendTextView {
1947            text: Some("the text body".to_string()),
1948            hidden: false,
1949        });
1950        existing.file = Some(SendFileView {
1951            id: Some("file-id".to_string()),
1952            file_name: "attachment.bin".to_string(),
1953            size: Some("10".to_string()),
1954            size_name: Some("10 B".to_string()),
1955        });
1956
1957        let req = build_edit_request(existing, no_override_edit()).unwrap();
1958        match req.view_type {
1959            SendViewType::Text(t) => assert_eq!(t.text.as_deref(), Some("the text body")),
1960            other => panic!("expected Text (legacy prefers text on mixed-shape), got {other:?}"),
1961        }
1962    }
1963
1964    #[test]
1965    fn build_edit_request_rejects_both_auth_flags() {
1966        let existing = make_existing(AuthType::None, false, vec![]);
1967        let err = build_edit_request(
1968            existing,
1969            EditOverrides {
1970                delete_in_days: None,
1971                max_access_count: None,
1972                hidden: false,
1973                password: Some("p".into()),
1974                emails: Some("[email protected]".into()),
1975            },
1976        )
1977        .unwrap_err();
1978        assert!(err.to_string().contains("mutually exclusive"));
1979    }
1980
1981    // ---- encoded_json parsing ----
1982
1983    /// A full-object text `SendView` in raw JSON, matching `bw send get`/`create --fullObject`
1984    /// output.
1985    const RAW_TEXT_SEND_JSON: &str = r#"{"name":"My Send","hasPassword":false,"type":0,"text":{"text":"hello","hidden":false},"accessCount":0,"disabled":false,"hideEmail":false,"revisionDate":"2025-01-01T00:00:00Z","deletionDate":"2030-01-01T00:00:00Z","emails":[],"authType":2}"#;
1986
1987    /// The "accept both transparently" decision: raw JSON and base64-of-JSON must parse to the
1988    /// same `SendView`.
1989    #[test]
1990    fn parse_encoded_send_view_accepts_raw_and_base64_equivalently() {
1991        let from_raw = parse_encoded_send_view(RAW_TEXT_SEND_JSON).unwrap();
1992        let b64 = STANDARD.encode(RAW_TEXT_SEND_JSON);
1993        let from_b64 = parse_encoded_send_view(&b64).unwrap();
1994        assert_eq!(from_raw, from_b64);
1995        assert_eq!(from_raw.name, "My Send");
1996        assert_eq!(from_raw.r#type, SendType::Text);
1997    }
1998
1999    #[test]
2000    fn parse_encoded_send_view_rejects_garbage() {
2001        let err = parse_encoded_send_view("!!!not-base64-and-not-json!!!").unwrap_err();
2002        assert!(
2003            err.to_string()
2004                .contains("Error parsing the encoded request data")
2005        );
2006    }
2007
2008    /// Mirrors `bw send template send.text` output with only the fields the template emits
2009    /// (`name`, `notes`, `type`, `text`, `deletionDate`) filled in — the documented
2010    /// template -> create round trip. Server-owned fields (`hasPassword`, `accessCount`,
2011    /// `revisionDate`, `emails`, `authType`, `disabled`, `hideEmail`) must not be required.
2012    #[test]
2013    fn parse_encoded_send_view_accepts_template_shaped_json() {
2014        let json = r#"{
2015            "name": "My Send",
2016            "notes": "",
2017            "type": 0,
2018            "text": {"text": "hello", "hidden": false},
2019            "deletionDate": "2030-01-01T00:00:00Z"
2020        }"#;
2021
2022        let view = parse_encoded_send_view(json).expect("template-shaped JSON should parse");
2023        assert_eq!(view.name, "My Send");
2024        assert!(!view.has_password);
2025        assert_eq!(view.access_count, 0);
2026        assert!(!view.disabled);
2027        assert!(!view.hide_email);
2028        assert!(view.emails.is_empty());
2029        assert!(matches!(view.auth_type, AuthType::None));
2030    }
2031
2032    /// `bw send get` output carries fields (`object`, `accessUrl`, ...) this CLI doesn't model.
2033    /// Rejecting them outright would break the `bw send get <id> | bw send edit` workflow.
2034    #[test]
2035    fn parse_encoded_send_view_tolerates_unknown_fields() {
2036        let json = r#"{
2037            "object": "send",
2038            "accessUrl": "https://vault.bitwarden.com/#/send/abc/def",
2039            "name": "My Send",
2040            "hasPassword": false,
2041            "type": 0,
2042            "text": {"text": "hello", "hidden": false},
2043            "accessCount": 0,
2044            "disabled": false,
2045            "hideEmail": false,
2046            "revisionDate": "2025-01-01T00:00:00Z",
2047            "deletionDate": "2030-01-01T00:00:00Z",
2048            "emails": [],
2049            "authType": 2
2050        }"#;
2051
2052        let view = parse_encoded_send_view(json).expect("unknown fields should be ignored");
2053        assert_eq!(view.name, "My Send");
2054    }
2055
2056    /// When the input decodes cleanly as base64 but the resulting JSON fails to deserialize,
2057    /// the *real* error (e.g. a missing required field) must surface — not a generic
2058    /// "expected value" error from re-parsing the base64 string itself as JSON.
2059    #[test]
2060    fn parse_encoded_send_view_surfaces_real_error_for_valid_base64_invalid_json() {
2061        let b64 = STANDARD.encode(r#"{"name": "My Send"}"#);
2062        let err = parse_encoded_send_view(&b64).unwrap_err();
2063        // `to_string()` only shows the top-level `wrap_err` message; the underlying
2064        // serde_json cause (what actually pins this down as the *real* error, not a
2065        // re-parse-as-JSON failure) is in the `{:?}` chain.
2066        let chain = format!("{err:?}");
2067        assert!(
2068            chain.contains("missing field"),
2069            "expected the real deserialize error, got: {chain}"
2070        );
2071    }
2072
2073    // ---- build_create_request_from_view ----
2074
2075    fn make_text_json(name: &str, notes: Option<&str>, hidden: bool, max: Option<u32>) -> SendView {
2076        SendView {
2077            id: None,
2078            access_id: None,
2079            name: name.to_string(),
2080            notes: notes.map(String::from),
2081            key: None,
2082            new_password: None,
2083            has_password: false,
2084            r#type: SendType::Text,
2085            file: None,
2086            text: Some(SendTextView {
2087                text: Some("body".to_string()),
2088                hidden,
2089            }),
2090            max_access_count: max,
2091            access_count: 0,
2092            disabled: false,
2093            hide_email: false,
2094            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
2095            deletion_date: "2030-06-01T00:00:00Z".parse().unwrap(),
2096            expiration_date: None,
2097            emails: Vec::new(),
2098            auth_type: AuthType::None,
2099        }
2100    }
2101
2102    fn no_create_overrides() -> CreateOverrides {
2103        CreateOverrides {
2104            name: None,
2105            notes: None,
2106            max_access_count: None,
2107            hidden: false,
2108            password: None,
2109            emails: None,
2110        }
2111    }
2112
2113    #[test]
2114    fn build_create_request_from_view_honors_json_fields() {
2115        let view = make_text_json("JSON Name", Some("json notes"), true, Some(9));
2116        let req = build_create_request_from_view(view, no_create_overrides()).unwrap();
2117
2118        assert_eq!(req.name, "JSON Name");
2119        assert_eq!(req.notes.as_deref(), Some("json notes"));
2120        assert_eq!(req.max_access_count, Some(9));
2121        assert_eq!(
2122            req.deletion_date,
2123            "2030-06-01T00:00:00Z"
2124                .parse::<chrono::DateTime<Utc>>()
2125                .unwrap()
2126        );
2127        match req.view_type {
2128            SendViewType::Text(t) => {
2129                assert_eq!(t.text.as_deref(), Some("body"));
2130                assert!(t.hidden);
2131            }
2132            other => panic!("expected Text, got {other:?}"),
2133        }
2134        assert!(matches!(req.auth, SendAuthType::None));
2135    }
2136
2137    #[test]
2138    fn build_create_request_from_view_flags_override_json() {
2139        let view = make_text_json("JSON Name", Some("json notes"), false, Some(9));
2140        let req = build_create_request_from_view(
2141            view,
2142            CreateOverrides {
2143                name: Some("Flag Name".into()),
2144                notes: Some("flag notes".into()),
2145                max_access_count: Some(3),
2146                hidden: true,
2147                password: Some("pw".into()),
2148                emails: None,
2149            },
2150        )
2151        .unwrap();
2152
2153        assert_eq!(req.name, "Flag Name");
2154        assert_eq!(req.notes.as_deref(), Some("flag notes"));
2155        assert_eq!(req.max_access_count, Some(3));
2156        match req.view_type {
2157            SendViewType::Text(t) => assert!(t.hidden, "--hidden must OR in over JSON"),
2158            other => panic!("expected Text, got {other:?}"),
2159        }
2160        assert!(matches!(req.auth, SendAuthType::Password { .. }));
2161    }
2162
2163    #[test]
2164    fn build_create_request_from_view_uses_json_auth_when_no_flags() {
2165        let mut view = make_text_json("n", None, false, None);
2166        view.emails = vec!["[email protected]".into(), "[email protected]".into()];
2167        let req = build_create_request_from_view(view, no_create_overrides()).unwrap();
2168        match req.auth {
2169            SendAuthType::Emails { emails } => assert_eq!(emails.len(), 2),
2170            other => panic!("expected Emails from JSON, got {other:?}"),
2171        }
2172    }
2173
2174    /// File Sends can't be created from JSON alone — the CLI needs the local file bytes. This
2175    /// must produce a clear, documented error rather than a confusing downstream failure.
2176    #[test]
2177    fn build_create_request_from_view_rejects_file_type() {
2178        let mut view = make_text_json("f", None, false, None);
2179        view.r#type = SendType::File;
2180        view.text = None;
2181        view.file = Some(SendFileView {
2182            id: None,
2183            file_name: "secret.txt".into(),
2184            size: None,
2185            size_name: None,
2186        });
2187        let err = build_create_request_from_view(view, no_create_overrides()).unwrap_err();
2188        assert!(
2189            err.to_string()
2190                .contains("file Sends from JSON is not supported"),
2191            "got: {err}"
2192        );
2193    }
2194
2195    // ---- build_edit_request_from_json ----
2196
2197    /// Full-object edit honors every JSON-owned field (name, notes, maxAccessCount, deletion
2198    /// date, disabled, hideEmail, text/hidden). No auth source → `AuthEdit::Preserve`.
2199    #[test]
2200    fn build_edit_request_from_json_honors_every_field() {
2201        let existing = make_existing(AuthType::None, false, vec![]);
2202        let mut req = make_text_json("New Name", Some("new notes"), true, Some(7));
2203        req.disabled = true;
2204        req.hide_email = true;
2205        req.deletion_date = "2031-02-02T00:00:00Z".parse().unwrap();
2206
2207        let out = build_edit_request_from_json(existing, req, no_override_edit()).unwrap();
2208
2209        assert_eq!(out.name, "New Name");
2210        assert_eq!(out.notes.as_deref(), Some("new notes"));
2211        assert_eq!(out.max_access_count, Some(7));
2212        assert!(out.disabled);
2213        assert!(out.hide_email);
2214        assert_eq!(
2215            out.deletion_date,
2216            "2031-02-02T00:00:00Z"
2217                .parse::<chrono::DateTime<Utc>>()
2218                .unwrap()
2219        );
2220        match out.view_type {
2221            SendViewType::Text(t) => {
2222                assert_eq!(t.text.as_deref(), Some("body"));
2223                assert!(t.hidden);
2224            }
2225            other => panic!("expected Text, got {other:?}"),
2226        }
2227        assert!(matches!(out.auth, AuthEdit::Preserve));
2228    }
2229
2230    /// Full replace, not sparse patch: a field absent from the JSON is cleared, not carried
2231    /// over from the existing server row. (`make_existing` has notes + maxAccessCount set.)
2232    #[test]
2233    fn build_edit_request_from_json_clears_fields_absent_from_json() {
2234        let existing = make_existing(AuthType::None, false, vec![]);
2235        let req = make_text_json("n", None, false, None);
2236        let out = build_edit_request_from_json(existing, req, no_override_edit()).unwrap();
2237        assert_eq!(out.notes, None, "notes absent from JSON must be cleared");
2238        assert_eq!(
2239            out.max_access_count, None,
2240            "maxAccessCount absent from JSON must be cleared"
2241        );
2242    }
2243
2244    /// Type is immutable on edit: a text→file (or file→text) change is rejected before any
2245    /// API call.
2246    #[test]
2247    fn build_edit_request_from_json_rejects_type_change() {
2248        let existing = make_existing(AuthType::None, false, vec![]);
2249        let mut req = make_text_json("n", None, false, None);
2250        req.r#type = SendType::File;
2251        req.text = None;
2252        req.file = Some(SendFileView {
2253            id: None,
2254            file_name: "f".into(),
2255            size: None,
2256            size_name: None,
2257        });
2258        let err = build_edit_request_from_json(existing, req, no_override_edit()).unwrap_err();
2259        assert!(
2260            err.to_string().contains("Cannot change a Send's type"),
2261            "got: {err}"
2262        );
2263    }
2264
2265    /// Auth precedence: a CLI `--password` flag beats a JSON-provided email gate.
2266    #[test]
2267    fn build_edit_request_from_json_cli_flag_beats_json_auth() {
2268        let existing = make_existing(AuthType::None, false, vec![]);
2269        let mut req = make_text_json("n", None, false, None);
2270        req.emails = vec!["[email protected]".into()];
2271        let out = build_edit_request_from_json(
2272            existing,
2273            req,
2274            EditOverrides {
2275                delete_in_days: None,
2276                max_access_count: None,
2277                hidden: false,
2278                password: Some("pw".into()),
2279                emails: None,
2280            },
2281        )
2282        .unwrap();
2283        assert!(matches!(
2284            out.auth,
2285            AuthEdit::Set {
2286                auth: SendAuthType::Password { .. }
2287            }
2288        ));
2289    }
2290
2291    /// Auth precedence: with no CLI flags, a JSON-provided email gate is applied (Set).
2292    #[test]
2293    fn build_edit_request_from_json_uses_json_auth_when_no_flags() {
2294        let existing = make_existing(AuthType::Password, true, vec![]);
2295        let mut req = make_text_json("n", None, false, None);
2296        req.emails = vec!["[email protected]".into(), "[email protected]".into()];
2297        let out = build_edit_request_from_json(existing, req, no_override_edit()).unwrap();
2298        match out.auth {
2299            AuthEdit::Set {
2300                auth: SendAuthType::Emails { emails },
2301            } => assert_eq!(emails.len(), 2),
2302            other => panic!("expected AuthEdit::Set {{ Emails }}, got {other:?}"),
2303        }
2304    }
2305
2306    /// `--deleteInDays` overrides the JSON's absolute `deletionDate` (flag > JSON).
2307    #[test]
2308    fn build_edit_request_from_json_delete_in_days_flag_overrides_json_date() {
2309        let existing = make_existing(AuthType::None, false, vec![]);
2310        let mut req = make_text_json("n", None, false, None);
2311        req.deletion_date = "2031-01-01T00:00:00Z".parse().unwrap();
2312        let out = build_edit_request_from_json(
2313            existing,
2314            req,
2315            EditOverrides {
2316                delete_in_days: Some(7),
2317                max_access_count: None,
2318                hidden: false,
2319                password: None,
2320                emails: None,
2321            },
2322        )
2323        .unwrap();
2324        let diff = out.deletion_date - Utc::now();
2325        assert!(
2326            diff.num_days() >= 6 && diff.num_days() <= 7,
2327            "flag deletion date should win over JSON's 2031 date"
2328        );
2329    }
2330}