1use 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
36const DELETE_IN_DAYS_ALLOWED: &[&str] = &["1", "2", "3", "7", "14", "30"];
39
40fn 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 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 type Client = AnyState;
253
254 async fn run(self, state: AnyState) -> CommandResult {
255 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 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 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
330async 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
380async 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 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 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 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, 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, file: SendFileTemplateBody,
494 deletion_date: String,
495}
496
497impl Default for SendFileTemplate {
498 fn default() -> Self {
499 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
517fn 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#[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 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
625fn 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 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 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 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
737struct 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
747fn 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 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 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 (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
886fn 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 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 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 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 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 let url = build_access_url(client, &view)?;
1035 Ok(url.into())
1036}
1037
1038async 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 let bytes = std::fs::read(path).wrap_err_with(|| format!("Could not read file {path}"))?;
1055
1056 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
1080async 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
1107fn 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
1141fn 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
1173fn 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
1201fn 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
1211fn 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
1243fn 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
1278pub(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 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 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 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 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 #[test]
1339 fn to_url_b64_maps_standard_b64_to_url_safe() {
1340 assert_eq!(to_url_b64("ab+/cd=="), "ab-_cd");
1342 assert_eq!(
1343 to_url_b64("Pgui0FK85cNhBGWHAlBHBw=="),
1344 "Pgui0FK85cNhBGWHAlBHBw"
1345 );
1346 assert_eq!(to_url_b64("abcDEF123"), "abcDEF123");
1348 }
1349
1350 #[test]
1351 fn web_vault_from_api_url_strips_single_domain_api_suffix() {
1352 assert_eq!(
1354 web_vault_from_api_url("https://vault.example.com/api"),
1355 "https://vault.example.com"
1356 );
1357 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 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 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 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 assert_eq!(
1397 web_vault_from_api_url("https://apiary.example.com"),
1398 "https://apiary.example.com"
1399 );
1400 }
1401
1402 #[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 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 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 #[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 assert!(parse_emails("[not, valid]").is_err());
1482 }
1483
1484 #[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 #[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 #[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 #[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 #[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 use bitwarden_send::{AuthType, SendType, SendView};
1724
1725 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 let chain = format!("{err:?}");
1993 assert!(
1994 chain.contains("missing field"),
1995 "expected the real deserialize error, got: {chain}"
1996 );
1997 }
1998
1999 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}