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