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::{
34 file_output::reject_path_traversal,
35 receive::{ReceiveInputs, run_receive},
36 },
37};
38
39const DELETE_IN_DAYS_ALLOWED: &[&str] = &["1", "2", "3", "7", "14", "30"];
42
43fn delete_in_days_parser() -> impl clap::builder::TypedValueParser<Value = u64> {
46 PossibleValuesParser::new(DELETE_IN_DAYS_ALLOWED)
47 .map(|s| s.parse::<u64>().expect("allowed values are valid u64"))
48}
49
50#[derive(Args, Clone)]
51pub struct SendArgs {
52 pub data: Option<String>,
54
55 #[arg(short = 'f', long, help = "Specifies that <data> is a filepath.")]
56 pub file: bool,
57
58 #[arg(
59 short = 'd',
60 long = "deleteInDays",
61 help = "The number of days in the future to set deletion date.",
62 default_value_t = 7,
63 value_parser = delete_in_days_parser(),
64 )]
65 pub delete_in_days: u64,
66
67 #[arg(
68 long,
69 conflicts_with = "emails",
70 help = "Optional password to access this Send."
71 )]
72 pub password: Option<String>,
73
74 #[arg(
75 long,
76 help = "Email addresses for OTP authentication (single, JSON array, comma- or space-separated)."
77 )]
78 pub emails: Option<String>,
79
80 #[arg(
81 short = 'a',
82 long = "maxAccessCount",
83 help = "The amount of max possible accesses."
84 )]
85 pub max_access_count: Option<u32>,
86
87 #[arg(long, help = "Hide <data> in web by default.")]
88 pub hidden: bool,
89
90 #[arg(short = 'n', long, help = "The name of the Send.")]
91 pub name: Option<String>,
92
93 #[arg(long, help = "Notes to add to the Send.")]
94 pub notes: Option<String>,
95
96 #[arg(
97 long = "fullObject",
98 help = "Specifies that the full Send object should be returned."
99 )]
100 pub full_object: bool,
101
102 #[command(subcommand)]
103 pub command: Option<SendCommands>,
104}
105
106#[derive(Subcommand, Clone, Debug)]
107pub enum SendCommands {
108 #[command(about = "List all the Sends owned by you.")]
109 List(SendListArgs),
110
111 #[command(about = "Get json templates for send objects.")]
112 Template(SendTemplateArgs),
113
114 #[command(about = "Get Sends owned by you.")]
115 Get(SendGetArgs),
116
117 #[command(about = "Access a Bitwarden Send from a url.")]
118 Receive(SendReceiveArgs),
119
120 #[command(about = "Create a Send.")]
121 Create(SendCreateArgs),
122
123 #[command(about = "Edit a Send.")]
124 Edit(SendEditArgs),
125
126 #[command(about = "Removes the saved password from a Send.")]
127 RemovePassword(SendRemovePasswordArgs),
128
129 #[command(about = "Delete a Send.")]
130 Delete(SendDeleteArgs),
131}
132
133#[derive(Args, Clone, Debug)]
134pub struct SendListArgs;
135
136#[derive(Args, Clone, Debug)]
137pub struct SendTemplateArgs {
138 pub object: String,
139}
140
141#[derive(Args, Clone, Debug)]
142pub struct SendGetArgs {
143 pub id: SendId,
144
145 #[arg(
149 long = "output",
150 help = "File path to save a file-type Send's decrypted contents to."
151 )]
152 pub output_path: Option<String>,
153
154 #[arg(long, help = "Only return the access url.")]
155 pub text: bool,
156}
157
158#[derive(Args, Clone, Debug)]
162#[command(after_help = "Notes:
163 If a password is required, the provided password is used or the user is prompted.")]
164pub struct SendReceiveArgs {
165 pub url: String,
166
167 #[arg(long, help = "Optional password for the Send.")]
168 pub password: Option<String>,
169
170 #[arg(long, help = "Environment variable storing the Send's password.")]
171 pub passwordenv: Option<String>,
172
173 #[arg(
174 long,
175 help = "Path to a file containing the Send's password as its first line."
176 )]
177 pub passwordfile: Option<String>,
178
179 #[arg(
184 long = "output",
185 help = "Specify a file path to save a File-type Send to."
186 )]
187 pub output_path: Option<String>,
188
189 #[arg(
190 long = "fullObject",
191 alias = "full-object",
192 help = "Return the Send's json object rather than its content."
193 )]
194 pub full_object: bool,
195}
196
197#[derive(Args, Clone, Debug)]
198pub struct SendCreateArgs {
199 pub encoded_json: Option<String>,
200
201 #[arg(short = 'f', long, help = "Path to the file to Send.")]
202 pub file: Option<String>,
203
204 #[arg(long, help = "Text to Send.")]
205 pub text: Option<String>,
206
207 #[arg(
208 short = 'd',
209 long = "deleteInDays",
210 help = "The number of days in the future to set deletion date.",
211 default_value_t = 7,
212 value_parser = delete_in_days_parser(),
213 )]
214 pub delete_in_days: u64,
215
216 #[arg(
217 long = "maxAccessCount",
218 help = "The maximum number of times this Send can be accessed."
219 )]
220 pub max_access_count: Option<u32>,
221
222 #[arg(long, help = "Hide text.")]
223 pub hidden: bool,
224
225 #[arg(short = 'n', long, help = "The name of the Send.")]
226 pub name: Option<String>,
227
228 #[arg(long, help = "Notes to add to the Send.")]
229 pub notes: Option<String>,
230
231 #[arg(
232 long,
233 conflicts_with = "emails",
234 help = "Optional password to access this Send."
235 )]
236 pub password: Option<String>,
237
238 #[arg(
239 long,
240 help = "Email addresses for OTP authentication (single, JSON array, comma- or space-separated)."
241 )]
242 pub emails: Option<String>,
243
244 #[arg(
245 long = "fullObject",
246 help = "Return full Send object instead of access url."
247 )]
248 pub full_object: bool,
249}
250
251#[derive(Args, Clone, Debug)]
252pub struct SendEditArgs {
253 pub encoded_json: Option<String>,
254
255 #[arg(long, help = "Overrides the itemId provided in encodedJson.")]
256 pub itemid: Option<SendId>,
257
258 #[arg(
259 short = 'd',
260 long = "deleteInDays",
261 help = "The number of days in the future to set deletion date.",
262 value_parser = delete_in_days_parser(),
263 )]
264 pub delete_in_days: Option<u64>,
265
266 #[arg(
267 long = "maxAccessCount",
268 help = "The maximum number of times this Send can be accessed."
269 )]
270 pub max_access_count: Option<u32>,
271
272 #[arg(long, help = "Hide text.")]
273 pub hidden: bool,
274
275 #[arg(
276 long,
277 conflicts_with = "emails",
278 help = "Optional password to access this Send."
279 )]
280 pub password: Option<String>,
281
282 #[arg(
283 long,
284 help = "Email addresses for OTP authentication (single, JSON array, comma- or space-separated)."
285 )]
286 pub emails: Option<String>,
287}
288
289#[derive(Args, Clone, Debug)]
290pub struct SendRemovePasswordArgs {
291 pub id: SendId,
292}
293
294#[derive(Args, Clone, Debug)]
295pub struct SendDeleteArgs {
296 pub id: SendId,
297}
298
299impl BwCommand for SendArgs {
300 type Client = AnyState;
304
305 async fn run(self, state: AnyState) -> CommandResult {
306 let ctx = ClientContext {
310 global: state.global,
311 user: state.user,
312 };
313 match self.command.clone() {
314 None => {
315 let LoggedIn { user, .. } = LoggedIn::try_from(ctx)?;
316 create_shortcut(&user, self).await
317 }
318 Some(SendCommands::Get(args)) if args.output_path.is_some() => {
322 Err(eyre!("`--output` on `bw send get` is not yet implemented"))
323 }
324 Some(SendCommands::Create(args)) => {
332 let stdin_eligible = args.text.is_none() && args.file.is_none();
333 let json = read_encoded_json_input(args.encoded_json.clone(), stdin_eligible)?
334 .map(|raw| parse_encoded_send_view(&raw))
335 .transpose()?;
336 let LoggedIn { user, .. } = LoggedIn::try_from(ctx)?;
337 dispatch_create(&user, args, json).await
338 }
339 Some(SendCommands::Edit(args)) => {
340 let stdin_eligible = args.delete_in_days.is_none()
341 && args.max_access_count.is_none()
342 && !args.hidden
343 && args.password.is_none()
344 && args.emails.is_none();
345 let json = read_encoded_json_input(args.encoded_json.clone(), stdin_eligible)?
346 .map(|raw| parse_encoded_send_view(&raw))
347 .transpose()?;
348 let LoggedIn { user, .. } = LoggedIn::try_from(ctx)?;
349 dispatch_edit(&user, args, json).await
350 }
351 Some(SendCommands::List(args)) => args.dispatch(ctx).await,
352 Some(SendCommands::Template(args)) => args.dispatch(ctx).await,
353 Some(SendCommands::Get(args)) => args.dispatch(ctx).await,
354 Some(SendCommands::Receive(args)) => args.dispatch(ctx).await,
355 Some(SendCommands::RemovePassword(args)) => args.dispatch(ctx).await,
356 Some(SendCommands::Delete(args)) => args.dispatch(ctx).await,
357 }
358 }
359}
360
361impl BwCommand for SendListArgs {
362 type Client = LoggedIn;
363
364 async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
365 list_sends(&user).await
366 }
367}
368
369impl BwCommand for SendTemplateArgs {
370 type Client = AnyState;
373
374 async fn run(self, _: AnyState) -> CommandResult {
375 render_template(&self.object)
376 }
377}
378
379impl BwCommand for SendGetArgs {
380 type Client = LoggedIn;
381
382 async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
383 get_send(&user, self.id, self.text).await
388 }
389}
390
391impl BwCommand for SendReceiveArgs {
392 type Client = AnyState;
396
397 async fn run(self, _: AnyState) -> CommandResult {
398 run_receive(ReceiveInputs {
399 url: self.url,
400 password: self.password,
401 passwordenv: self.passwordenv,
402 passwordfile: self.passwordfile,
403 output_path: self.output_path,
404 full_object: self.full_object,
405 })
406 .await
407 }
408}
409
410async fn dispatch_create(
414 user: &PasswordManagerClient,
415 args: SendCreateArgs,
416 json: Option<SendView>,
417) -> CommandResult {
418 let SendCreateArgs {
419 encoded_json: _,
420 file,
421 text,
422 delete_in_days,
423 max_access_count,
424 hidden,
425 name,
426 notes,
427 password,
428 emails,
429 full_object,
430 } = args;
431
432 let request = match json {
433 Some(view) => build_create_request_from_view(
434 view,
435 CreateOverrides {
436 name,
437 notes,
438 max_access_count,
439 hidden,
440 password,
441 emails,
442 },
443 )?,
444 None => build_create_request(CreateInputs {
445 file: file.clone(),
446 text,
447 delete_in_days,
448 max_access_count,
449 hidden,
450 name,
451 notes,
452 password,
453 emails,
454 })?,
455 };
456
457 run_create(user, request, file, full_object).await
458}
459
460async fn dispatch_edit(
464 user: &PasswordManagerClient,
465 args: SendEditArgs,
466 json: Option<SendView>,
467) -> CommandResult {
468 let SendEditArgs {
469 encoded_json: _,
470 itemid,
471 delete_in_days,
472 max_access_count,
473 hidden,
474 password,
475 emails,
476 } = args;
477
478 let send_id = itemid
481 .or_else(|| json.as_ref().and_then(|v| v.id))
482 .ok_or_else(|| eyre!("--itemid is required (or provide `id` in the encoded JSON)."))?;
483
484 let existing = user.sends().get(send_id).await?;
485
486 let overrides = EditOverrides {
487 delete_in_days,
488 max_access_count,
489 hidden,
490 password,
491 emails,
492 };
493 let request = match json {
494 Some(req) => build_edit_request_from_json(existing, req, overrides)?,
495 None => build_edit_request(existing, overrides)?,
496 };
497
498 let view = user.sends().edit(send_id, request).await?;
499 Ok(CommandOutput::Object(Box::new(view)))
500}
501
502impl BwCommand for SendRemovePasswordArgs {
503 type Client = LoggedIn;
504
505 async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
506 let view = user.sends().remove_password(self.id).await?;
507 Ok(CommandOutput::Object(Box::new(view)))
508 }
509}
510
511impl BwCommand for SendDeleteArgs {
512 type Client = LoggedIn;
513
514 async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
515 user.sends().delete(self.id).await?;
516 Ok("Send deleted.".into())
517 }
518}
519
520async fn list_sends(client: &PasswordManagerClient) -> CommandResult {
521 let views = client.sends().list().await?;
522 Ok(CommandOutput::Object(Box::new(views)))
523}
524
525async fn get_send(client: &PasswordManagerClient, id: SendId, text: bool) -> CommandResult {
526 let view = client.sends().get(id).await?;
527
528 if text {
529 let url = build_access_url(client, &view)?;
532 return Ok(url.into());
533 }
534
535 Ok(CommandOutput::Object(Box::new(view)))
536}
537
538fn render_template(object: &str) -> CommandResult {
539 match object {
543 "send.text" => Ok(CommandOutput::Object(Box::new(SendTextTemplate::default()))),
544 "send.file" => Ok(CommandOutput::Object(Box::new(SendFileTemplate::default()))),
545 other => Err(eyre!("Unknown template object: {other}")),
546 }
547}
548
549#[derive(Serialize, Default)]
550#[serde(rename_all = "camelCase")]
551struct SendTextTemplate {
552 name: String,
553 notes: String,
554 #[serde(rename = "type")]
555 send_type: u8, text: SendTextTemplateBody,
557 deletion_date: String,
558}
559
560#[derive(Serialize, Default)]
561struct SendTextTemplateBody {
562 text: String,
563 hidden: bool,
564}
565
566#[derive(Serialize)]
567#[serde(rename_all = "camelCase")]
568struct SendFileTemplate {
569 name: String,
570 notes: String,
571 #[serde(rename = "type")]
572 send_type: u8, file: SendFileTemplateBody,
574 deletion_date: String,
575}
576
577impl Default for SendFileTemplate {
578 fn default() -> Self {
579 Self {
582 name: String::new(),
583 notes: String::new(),
584 send_type: 1,
585 file: SendFileTemplateBody::default(),
586 deletion_date: String::new(),
587 }
588 }
589}
590
591#[derive(Serialize, Default)]
592#[serde(rename_all = "camelCase")]
593struct SendFileTemplateBody {
594 file_name: String,
595}
596
597fn read_encoded_json_input(
611 positional: Option<String>,
612 stdin_eligible: bool,
613) -> color_eyre::eyre::Result<Option<String>> {
614 if let Some(raw) = positional {
615 return Ok(Some(raw));
616 }
617 if !stdin_eligible || std::io::stdin().is_terminal() {
618 return Ok(None);
619 }
620 let mut buf = String::new();
621 std::io::stdin().read_to_string(&mut buf)?;
622 if buf.trim().is_empty() {
623 return Ok(None);
624 }
625 Ok(Some(buf))
626}
627
628#[derive(Deserialize)]
642#[serde(rename_all = "camelCase")]
643struct SendJsonInput {
644 id: Option<SendId>,
645 access_id: Option<String>,
646 #[serde(default)]
647 name: String,
648 notes: Option<String>,
649 key: Option<String>,
650 new_password: Option<String>,
651 #[serde(default)]
652 has_password: bool,
653 r#type: SendType,
654 file: Option<SendFileView>,
655 text: Option<SendTextView>,
656 max_access_count: Option<u32>,
657 #[serde(default)]
658 access_count: u32,
659 #[serde(default)]
660 disabled: bool,
661 #[serde(default)]
662 hide_email: bool,
663 #[serde(default = "Utc::now")]
664 revision_date: DateTime<Utc>,
665 deletion_date: DateTime<Utc>,
666 expiration_date: Option<DateTime<Utc>>,
667 #[serde(default)]
668 emails: Vec<String>,
669 #[serde(default = "default_auth_type")]
670 auth_type: AuthType,
671}
672
673fn default_auth_type() -> AuthType {
674 AuthType::None
675}
676
677impl From<SendJsonInput> for SendView {
678 fn from(input: SendJsonInput) -> Self {
679 SendView {
680 id: input.id,
681 access_id: input.access_id,
682 name: input.name,
683 notes: input.notes,
684 key: input.key,
685 new_password: input.new_password,
686 has_password: input.has_password,
687 r#type: input.r#type,
688 file: input.file,
689 text: input.text,
690 max_access_count: input.max_access_count,
691 access_count: input.access_count,
692 disabled: input.disabled,
693 hide_email: input.hide_email,
694 revision_date: input.revision_date,
695 deletion_date: input.deletion_date,
696 expiration_date: input.expiration_date,
697 emails: input.emails,
698 auth_type: input.auth_type,
699 }
700 }
701}
702
703fn parse_encoded_send_view(raw: &str) -> color_eyre::eyre::Result<SendView> {
713 let trimmed = raw.trim();
714 let json_text = STANDARD
715 .decode(trimmed)
716 .ok()
717 .and_then(|decoded| String::from_utf8(decoded).ok())
718 .unwrap_or_else(|| trimmed.to_string());
719
720 let input: SendJsonInput =
721 serde_json::from_str(&json_text).wrap_err("Error parsing the encoded request data.")?;
722 Ok(input.into())
723}
724
725struct CreateInputs {
726 file: Option<String>,
727 text: Option<String>,
728 delete_in_days: u64,
729 max_access_count: Option<u32>,
730 hidden: bool,
731 name: Option<String>,
732 notes: Option<String>,
733 password: Option<String>,
734 emails: Option<String>,
735}
736
737fn build_create_request(inputs: CreateInputs) -> color_eyre::eyre::Result<SendAddRequest> {
738 let CreateInputs {
739 file,
740 text,
741 delete_in_days,
742 max_access_count,
743 hidden,
744 name,
745 notes,
746 password,
747 emails,
748 } = inputs;
749
750 let deletion_date = compute_deletion_date(delete_in_days)?;
751
752 let view_type = match (file.as_deref(), text.as_deref()) {
753 (Some(path), None) => {
754 let path = PathBuf::from(path);
757 let file_name = path
758 .file_name()
759 .and_then(|s| s.to_str())
760 .ok_or_else(|| eyre!("Could not derive a file name from --file path"))?
761 .to_string();
762 SendViewType::File(SendFileView {
767 id: None,
768 file_name,
769 size: None,
770 size_name: None,
771 })
772 }
773 (None, Some(t)) => SendViewType::Text(SendTextView {
774 text: Some(t.to_string()),
775 hidden,
776 }),
777 (Some(_), Some(_)) => {
778 return Err(eyre!("--file and --text are mutually exclusive."));
779 }
780 (None, None) => {
781 return Err(eyre!(
782 "Either --text <data> or --file <path> is required when creating a Send."
783 ));
784 }
785 };
786
787 let resolved_name = match (name, &view_type) {
790 (Some(n), _) => n,
791 (None, SendViewType::File(f)) => f.file_name.clone(),
792 (None, SendViewType::Text(_)) => {
793 return Err(eyre!("--name is required for text Sends."));
794 }
795 };
796
797 let auth = build_auth(password, emails.as_deref())?;
798
799 Ok(SendAddRequest {
800 name: resolved_name,
801 notes,
802 view_type,
803 max_access_count,
804 disabled: false,
805 hide_email: false,
806 deletion_date,
807 expiration_date: None,
808 auth,
809 })
810}
811
812struct CreateOverrides {
814 name: Option<String>,
815 notes: Option<String>,
816 max_access_count: Option<u32>,
817 hidden: bool,
818 password: Option<String>,
819 emails: Option<String>,
820}
821
822fn build_create_request_from_view(
830 view: SendView,
831 overrides: CreateOverrides,
832) -> color_eyre::eyre::Result<SendAddRequest> {
833 let CreateOverrides {
834 name,
835 notes,
836 max_access_count,
837 hidden,
838 password,
839 emails,
840 } = overrides;
841
842 let view_type = match view.r#type {
843 SendType::Text => {
844 let text = view.text.unwrap_or(SendTextView {
845 text: None,
846 hidden: false,
847 });
848 SendViewType::Text(SendTextView {
849 text: text.text,
850 hidden: text.hidden || hidden,
851 })
852 }
853 SendType::File => {
854 return Err(eyre!(
859 "Creating file Sends from JSON is not supported: the CLI needs a local file path \
860 to read and encrypt the contents. Use `--file <path>` instead."
861 ));
862 }
863 SendType::Item => {
864 return Err(eyre!("Creating item Sends is not supported by the CLI."));
865 }
866 };
867
868 let resolved_name = name.unwrap_or(view.name);
869 if resolved_name.is_empty() {
870 return Err(eyre!("--name is required."));
871 }
872
873 let (password, emails) = if password.is_some() || emails.is_some() {
876 (password, emails)
877 } else {
878 let json_emails = (!view.emails.is_empty()).then(|| view.emails.join(","));
879 (view.new_password, json_emails)
880 };
881 let auth = build_auth(password, emails.as_deref())?;
882
883 Ok(SendAddRequest {
884 name: resolved_name,
885 notes: notes.or(view.notes),
886 view_type,
887 max_access_count: max_access_count.or(view.max_access_count),
888 disabled: view.disabled,
889 hide_email: view.hide_email,
890 deletion_date: view.deletion_date,
891 expiration_date: view.expiration_date,
892 auth,
893 })
894}
895
896struct EditOverrides {
897 delete_in_days: Option<u64>,
898 max_access_count: Option<u32>,
899 hidden: bool,
900 password: Option<String>,
901 emails: Option<String>,
902}
903
904fn build_edit_request(
905 existing: bitwarden_send::SendView,
906 overrides: EditOverrides,
907) -> color_eyre::eyre::Result<SendEditRequest> {
908 let EditOverrides {
909 delete_in_days,
910 max_access_count,
911 hidden,
912 password,
913 emails,
914 } = overrides;
915
916 let deletion_date = match delete_in_days {
917 Some(d) => compute_deletion_date(d)?,
918 None => existing.deletion_date,
919 };
920
921 let view_type = match (existing.text, existing.file) {
922 (Some(t), None) => SendViewType::Text(SendTextView {
923 text: t.text,
924 hidden: if hidden { true } else { t.hidden },
925 }),
926 (None, Some(f)) => SendViewType::File(f),
927 (Some(t), Some(_)) => SendViewType::Text(SendTextView {
935 text: t.text,
936 hidden: if hidden { true } else { t.hidden },
937 }),
938 (None, None) => {
939 return Err(eyre!(
940 "Cannot edit Send {:?}: server returned neither text nor file content.",
941 existing.id
942 ));
943 }
944 };
945
946 let auth = build_auth_for_edit(password, emails.as_deref())?;
947
948 Ok(SendEditRequest {
949 name: existing.name,
950 notes: existing.notes,
951 view_type,
952 max_access_count: max_access_count.or(existing.max_access_count),
953 disabled: existing.disabled,
954 hide_email: existing.hide_email,
955 deletion_date,
956 expiration_date: existing.expiration_date,
957 auth,
958 })
959}
960
961fn build_edit_request_from_json(
975 existing: SendView,
976 req: SendView,
977 overrides: EditOverrides,
978) -> color_eyre::eyre::Result<SendEditRequest> {
979 let EditOverrides {
980 delete_in_days,
981 max_access_count,
982 hidden,
983 password,
984 emails,
985 } = overrides;
986
987 if req.r#type != existing.r#type {
990 return Err(eyre!("Cannot change a Send's type."));
991 }
992
993 let view_type = match req.r#type {
994 SendType::Text => {
995 let text = req.text.unwrap_or(SendTextView {
996 text: None,
997 hidden: false,
998 });
999 SendViewType::Text(SendTextView {
1000 text: text.text,
1001 hidden: text.hidden || hidden,
1002 })
1003 }
1004 SendType::File => SendViewType::File(req.file.ok_or_else(|| {
1005 eyre!("JSON declares a file Send (type 1) but is missing the `file` object.")
1006 })?),
1007 SendType::Item => {
1008 return Err(eyre!("Editing item Sends is not supported by the CLI."));
1009 }
1010 };
1011
1012 let deletion_date = match delete_in_days {
1013 Some(d) => compute_deletion_date(d)?,
1014 None => req.deletion_date,
1015 };
1016
1017 let (password, emails) = if password.is_some() || emails.is_some() {
1021 (password, emails)
1022 } else {
1023 let json_emails = (!req.emails.is_empty()).then(|| req.emails.join(","));
1024 (req.new_password, json_emails)
1025 };
1026 let auth = build_auth_for_edit(password, emails.as_deref())?;
1027
1028 Ok(SendEditRequest {
1029 name: req.name,
1030 notes: req.notes,
1031 view_type,
1032 max_access_count: max_access_count.or(req.max_access_count),
1033 disabled: req.disabled,
1034 hide_email: req.hide_email,
1035 deletion_date,
1036 expiration_date: req.expiration_date,
1037 auth,
1038 })
1039}
1040
1041async fn create_shortcut(client: &PasswordManagerClient, args: SendArgs) -> CommandResult {
1042 let data = args
1043 .data
1044 .clone()
1045 .ok_or_else(|| eyre!("Missing <data> argument. Run `bw send --help` for usage."))?;
1046
1047 let file_path = if args.file { Some(data.clone()) } else { None };
1048
1049 let inputs = if args.file {
1050 CreateInputs {
1051 file: Some(data),
1052 text: None,
1053 delete_in_days: args.delete_in_days,
1054 max_access_count: args.max_access_count,
1055 hidden: args.hidden,
1056 name: args.name,
1057 notes: args.notes,
1058 password: args.password,
1059 emails: args.emails,
1060 }
1061 } else {
1062 CreateInputs {
1063 file: None,
1064 text: Some(data),
1065 delete_in_days: args.delete_in_days,
1066 max_access_count: args.max_access_count,
1067 hidden: args.hidden,
1068 name: args.name.or_else(|| Some("Send".to_string())),
1071 notes: args.notes,
1072 password: args.password,
1073 emails: args.emails,
1074 }
1075 };
1076
1077 let request = build_create_request(inputs)?;
1078 run_create(client, request, file_path, args.full_object).await
1079}
1080
1081async fn run_create(
1082 client: &PasswordManagerClient,
1083 request: SendAddRequest,
1084 file_path: Option<String>,
1085 full_object: bool,
1086) -> CommandResult {
1087 let is_file = matches!(request.view_type, SendViewType::File(_));
1088
1089 let view = if is_file {
1090 require_premium(client).await?;
1094
1095 let path = file_path.ok_or_else(|| {
1096 eyre!("Internal error: file Send created without a source file path.")
1097 })?;
1098 run_create_file(client, request, &path).await?
1099 } else {
1100 client.sends().create(request).await?
1101 };
1102
1103 if full_object {
1104 return Ok(CommandOutput::Object(Box::new(view)));
1105 }
1106
1107 let url = build_access_url(client, &view)?;
1110 Ok(url.into())
1111}
1112
1113async fn run_create_file(
1121 client: &PasswordManagerClient,
1122 request: SendAddRequest,
1123 path: &str,
1124) -> color_eyre::eyre::Result<bitwarden_send::SendView> {
1125 reject_path_traversal("--file", path)?;
1126
1127 let bytes = std::fs::read(path).wrap_err_with(|| format!("Could not read file {path}"))?;
1130
1131 let resp = client.sends().create_file_send(request, bytes).await?;
1134
1135 let send_id = resp
1136 .send
1137 .id
1138 .ok_or_else(|| eyre!("Server did not return an id for the created file Send."))?;
1139
1140 client
1141 .sends()
1142 .upload_send_file(
1143 send_id,
1144 resp.file_id,
1145 resp.encrypted_file_name,
1146 resp.file_upload_type,
1147 resp.url,
1148 resp.encrypted_file_buffer,
1149 )
1150 .await?;
1151
1152 Ok(resp.send)
1153}
1154
1155async fn require_premium(client: &PasswordManagerClient) -> color_eyre::eyre::Result<()> {
1160 let tokens = client
1161 .platform()
1162 .state()
1163 .setting(AUTHENTICATION_TOKENS)?
1164 .get()
1165 .await?
1166 .ok_or_else(|| eyre!("You must be logged in to create a file Send."))?;
1167
1168 let claims: JwtToken = tokens
1169 .access_token
1170 .parse()
1171 .wrap_err("Could not parse the current access token.")?;
1172
1173 if claims.premium == Some(true) {
1174 Ok(())
1175 } else {
1176 Err(eyre!(
1177 "A premium membership is required to create file Sends."
1178 ))
1179 }
1180}
1181
1182fn build_access_url(
1198 client: &PasswordManagerClient,
1199 view: &bitwarden_send::SendView,
1200) -> color_eyre::eyre::Result<String> {
1201 let access_id = view
1202 .access_id
1203 .as_deref()
1204 .ok_or_else(|| eyre!("Send is missing an access id; cannot build a shareable URL."))?;
1205 let key = view
1206 .key
1207 .as_deref()
1208 .ok_or_else(|| eyre!("Send is missing a key; cannot build a shareable URL."))?;
1209
1210 let web_vault = web_vault_url(client);
1211 let url_key = to_url_b64(key);
1212
1213 Ok(format!("{web_vault}/#/send/{access_id}/{url_key}"))
1214}
1215
1216fn web_vault_url(client: &PasswordManagerClient) -> String {
1228 if let Ok(Some(config)) = read_config_json() {
1229 if let Some(web_vault) = config.web_vault.as_deref() {
1230 return web_vault.trim_end_matches('/').to_string();
1231 }
1232 if let Some(server) = config.server.as_deref() {
1233 return server.trim_end_matches('/').to_string();
1234 }
1235 }
1236
1237 let api_url = client
1238 .0
1239 .internal
1240 .get_api_configurations()
1241 .api_config
1242 .base_path
1243 .clone();
1244
1245 web_vault_from_api_url(&api_url)
1246}
1247
1248fn web_vault_from_api_url(api_url: &str) -> String {
1263 let trimmed = api_url.trim_end_matches('/');
1264
1265 if let Some(base) = trimmed.strip_suffix("/api") {
1266 return base.trim_end_matches('/').to_string();
1267 }
1268
1269 if let Some(vault) = rewrite_api_host_to_vault(trimmed) {
1270 return vault;
1271 }
1272
1273 trimmed.to_string()
1274}
1275
1276fn rewrite_api_host_to_vault(url: &str) -> Option<String> {
1281 let (scheme, rest) = url.split_once("://")?;
1282 let after_api = rest.strip_prefix("api.")?;
1283 Some(format!("{scheme}://vault.{after_api}"))
1284}
1285
1286fn to_url_b64(b64: &str) -> String {
1292 b64.replace('+', "-").replace('/', "_").replace('=', "")
1293}
1294
1295fn compute_deletion_date(days: u64) -> color_eyre::eyre::Result<chrono::DateTime<Utc>> {
1296 if days == 0 {
1297 return Err(eyre!("--deleteInDays must be a positive integer"));
1298 }
1299 let signed =
1300 i64::try_from(days).wrap_err_with(|| format!("--deleteInDays out of range: {days}"))?;
1301 Ok(Utc::now() + Duration::days(signed))
1302}
1303
1304fn build_auth(
1305 password: Option<String>,
1306 emails: Option<&str>,
1307) -> color_eyre::eyre::Result<SendAuthType> {
1308 match (password, emails) {
1309 (None, None) => Ok(SendAuthType::None),
1310 (Some(p), None) => Ok(SendAuthType::Password { password: p }),
1311 (None, Some(e)) => Ok(SendAuthType::Emails {
1312 emails: parse_emails(e)?,
1313 }),
1314 (Some(_), Some(_)) => Err(eyre!("--password and --emails are mutually exclusive.")),
1315 }
1316}
1317
1318fn build_auth_for_edit(
1336 password: Option<String>,
1337 emails: Option<&str>,
1338) -> color_eyre::eyre::Result<AuthEdit> {
1339 match (password, emails) {
1340 (None, None) => Ok(AuthEdit::Preserve),
1341 (Some(p), None) => Ok(AuthEdit::Set {
1342 auth: SendAuthType::Password { password: p },
1343 }),
1344 (None, Some(e)) => Ok(AuthEdit::Set {
1345 auth: SendAuthType::Emails {
1346 emails: parse_emails(e)?,
1347 },
1348 }),
1349 (Some(_), Some(_)) => Err(eyre!("--password and --emails are mutually exclusive.")),
1350 }
1351}
1352
1353pub(crate) fn parse_emails(raw: &str) -> color_eyre::eyre::Result<Vec<String>> {
1363 let trimmed = raw.trim();
1364 if trimmed.is_empty() {
1365 return Err(eyre!("--emails cannot be empty"));
1366 }
1367
1368 if trimmed.starts_with('[') {
1370 let arr: Vec<String> = serde_json::from_str(trimmed)
1371 .wrap_err("--emails looked like a JSON array but failed to parse")?;
1372 return finalize_emails(arr);
1373 }
1374
1375 let parts: Vec<String> = if trimmed.contains(',') {
1378 trimmed.split(',').map(|s| s.trim().to_string()).collect()
1379 } else if trimmed.contains(char::is_whitespace) {
1380 trimmed.split_whitespace().map(|s| s.to_string()).collect()
1381 } else {
1382 vec![trimmed.to_string()]
1384 };
1385
1386 finalize_emails(parts)
1387}
1388
1389fn finalize_emails(emails: Vec<String>) -> color_eyre::eyre::Result<Vec<String>> {
1390 let cleaned: Vec<String> = emails
1391 .into_iter()
1392 .map(|e| e.trim().to_string())
1393 .filter(|e| !e.is_empty())
1394 .collect();
1395 if cleaned.is_empty() {
1396 return Err(eyre!("--emails must contain at least one address"));
1397 }
1398 for e in &cleaned {
1399 if !e.contains('@') {
1401 return Err(eyre!("Invalid email address: {e}"));
1402 }
1403 }
1404 Ok(cleaned)
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409 use super::*;
1410
1411 #[test]
1414 fn to_url_b64_maps_standard_b64_to_url_safe() {
1415 assert_eq!(to_url_b64("ab+/cd=="), "ab-_cd");
1417 assert_eq!(
1418 to_url_b64("Pgui0FK85cNhBGWHAlBHBw=="),
1419 "Pgui0FK85cNhBGWHAlBHBw"
1420 );
1421 assert_eq!(to_url_b64("abcDEF123"), "abcDEF123");
1423 }
1424
1425 #[test]
1426 fn web_vault_from_api_url_strips_single_domain_api_suffix() {
1427 assert_eq!(
1429 web_vault_from_api_url("https://vault.example.com/api"),
1430 "https://vault.example.com"
1431 );
1432 assert_eq!(
1434 web_vault_from_api_url("https://vault.example.com/api/"),
1435 "https://vault.example.com"
1436 );
1437 }
1438
1439 #[test]
1440 fn web_vault_from_api_url_rewrites_cloud_api_host_to_vault() {
1441 assert_eq!(
1444 web_vault_from_api_url("https://api.bitwarden.com"),
1445 "https://vault.bitwarden.com"
1446 );
1447 assert_eq!(
1448 web_vault_from_api_url("https://api.bitwarden.eu"),
1449 "https://vault.bitwarden.eu"
1450 );
1451 assert_eq!(
1453 web_vault_from_api_url("https://api.bitwarden.com/"),
1454 "https://vault.bitwarden.com"
1455 );
1456 }
1457
1458 #[test]
1459 fn web_vault_from_api_url_rewrites_split_domain_self_host() {
1460 assert_eq!(
1462 web_vault_from_api_url("https://api.example.com"),
1463 "https://vault.example.com"
1464 );
1465 }
1466
1467 #[test]
1468 fn web_vault_from_api_url_leaves_unmappable_host_as_is() {
1469 assert_eq!(
1472 web_vault_from_api_url("https://apiary.example.com"),
1473 "https://apiary.example.com"
1474 );
1475 }
1476
1477 #[test]
1481 fn access_url_format_matches_legacy_and_round_trips() {
1482 let web_vault = web_vault_from_api_url("https://api.bitwarden.com");
1483 let access_id = "abcaccessid";
1484 let url_key = to_url_b64("Pgui0FK8+cNh/GWHAlBHBw==");
1486 let url = format!("{web_vault}/#/send/{access_id}/{url_key}");
1487
1488 assert_eq!(
1489 url,
1490 "https://vault.bitwarden.com/#/send/abcaccessid/Pgui0FK8-cNh_GWHAlBHBw"
1491 );
1492
1493 let (_, fragment) = url.split_once('#').expect("URL has a fragment");
1496 let segments: Vec<&str> = fragment.trim_start_matches('/').split('/').collect();
1497 let last_two = &segments[segments.len() - 2..];
1498 assert_eq!(last_two, ["abcaccessid", "Pgui0FK8-cNh_GWHAlBHBw"]);
1499 }
1500
1501 #[test]
1504 fn parse_emails_single() {
1505 let v = parse_emails("[email protected]").unwrap();
1506 assert_eq!(v, vec!["[email protected]".to_string()]);
1507 }
1508
1509 #[test]
1510 fn parse_emails_json_array() {
1511 let v = parse_emails(r#"["[email protected]","[email protected]"]"#).unwrap();
1512 assert_eq!(v, vec!["[email protected]".to_string(), "[email protected]".to_string()]);
1513 }
1514
1515 #[test]
1516 fn parse_emails_comma_separated() {
1517 let v = parse_emails("[email protected],[email protected] , [email protected]").unwrap();
1518 assert_eq!(
1519 v,
1520 vec![
1521 "[email protected]".to_string(),
1522 "[email protected]".to_string(),
1523 "[email protected]".to_string(),
1524 ]
1525 );
1526 }
1527
1528 #[test]
1529 fn parse_emails_space_separated() {
1530 let v = parse_emails("[email protected] [email protected] [email protected]").unwrap();
1531 assert_eq!(
1532 v,
1533 vec![
1534 "[email protected]".to_string(),
1535 "[email protected]".to_string(),
1536 "[email protected]".to_string(),
1537 ]
1538 );
1539 }
1540
1541 #[test]
1542 fn parse_emails_rejects_empty() {
1543 assert!(parse_emails("").is_err());
1544 assert!(parse_emails(" ").is_err());
1545 assert!(parse_emails("[]").is_err());
1546 }
1547
1548 #[test]
1549 fn parse_emails_rejects_no_at_sign() {
1550 assert!(parse_emails("not-an-email").is_err());
1551 }
1552
1553 #[test]
1554 fn parse_emails_rejects_malformed_json_array() {
1555 assert!(parse_emails("[not, valid]").is_err());
1557 }
1558
1559 #[test]
1562 fn compute_deletion_date_positive() {
1563 let d = compute_deletion_date(7).unwrap();
1564 let now = Utc::now();
1565 let diff = d - now;
1566 assert!(diff.num_days() >= 6 && diff.num_days() <= 7);
1567 }
1568
1569 #[test]
1570 fn compute_deletion_date_rejects_zero() {
1571 assert!(compute_deletion_date(0).is_err());
1572 }
1573
1574 #[test]
1580 fn build_auth_none_when_neither_flag_given() {
1581 assert!(matches!(
1582 build_auth(None, None).unwrap(),
1583 SendAuthType::None
1584 ));
1585 }
1586
1587 #[test]
1588 fn build_auth_password_only() {
1589 let auth = build_auth(Some("secret".to_string()), None).unwrap();
1590 assert!(matches!(auth, SendAuthType::Password { password } if password == "secret"));
1591 }
1592
1593 #[test]
1594 fn build_auth_emails_only() {
1595 let auth = build_auth(None, Some("[email protected]")).unwrap();
1596 match auth {
1597 SendAuthType::Emails { emails } => assert_eq!(emails, vec!["[email protected]".to_string()]),
1598 other => panic!("expected Emails, got {other:?}"),
1599 }
1600 }
1601
1602 #[test]
1603 fn build_auth_rejects_both() {
1604 assert!(build_auth(Some("p".into()), Some("[email protected]")).is_err());
1605 }
1606
1607 #[test]
1610 fn build_create_request_text_send() {
1611 let req = build_create_request(CreateInputs {
1612 file: None,
1613 text: Some("hello".into()),
1614 delete_in_days: 7,
1615 max_access_count: Some(5),
1616 hidden: true,
1617 name: Some("My Send".into()),
1618 notes: Some("notes".into()),
1619 password: None,
1620 emails: None,
1621 })
1622 .unwrap();
1623
1624 assert_eq!(req.name, "My Send");
1625 assert_eq!(req.notes.as_deref(), Some("notes"));
1626 assert_eq!(req.max_access_count, Some(5));
1627 match req.view_type {
1628 SendViewType::Text(t) => {
1629 assert_eq!(t.text.as_deref(), Some("hello"));
1630 assert!(t.hidden);
1631 }
1632 other => panic!("expected Text, got {other:?}"),
1633 }
1634 assert!(matches!(req.auth, SendAuthType::None));
1635 }
1636
1637 #[test]
1638 fn build_create_request_text_requires_name() {
1639 let err = build_create_request(CreateInputs {
1640 file: None,
1641 text: Some("hello".into()),
1642 delete_in_days: 7,
1643 max_access_count: None,
1644 hidden: false,
1645 name: None,
1646 notes: None,
1647 password: None,
1648 emails: None,
1649 })
1650 .unwrap_err();
1651 assert!(err.to_string().contains("--name is required"));
1652 }
1653
1654 #[test]
1662 fn build_create_request_file_derives_name_and_leaves_size_unset() {
1663 let req = build_create_request(CreateInputs {
1664 file: Some("/tmp/secrets.txt".into()),
1665 text: None,
1666 delete_in_days: 7,
1667 max_access_count: None,
1668 hidden: false,
1669 name: None,
1670 notes: None,
1671 password: None,
1672 emails: None,
1673 })
1674 .unwrap();
1675
1676 assert_eq!(req.name, "secrets.txt");
1677 match req.view_type {
1678 SendViewType::File(f) => {
1679 assert_eq!(f.file_name, "secrets.txt");
1680 assert_eq!(f.size, None, "file.size must be unset on create");
1681 }
1682 other => panic!("expected File, got {other:?}"),
1683 }
1684 }
1685
1686 #[test]
1687 fn build_create_request_rejects_text_and_file_together() {
1688 let err = build_create_request(CreateInputs {
1689 file: Some("/tmp/x".into()),
1690 text: Some("hello".into()),
1691 delete_in_days: 7,
1692 max_access_count: None,
1693 hidden: false,
1694 name: Some("name".into()),
1695 notes: None,
1696 password: None,
1697 emails: None,
1698 })
1699 .unwrap_err();
1700 assert!(err.to_string().contains("mutually exclusive"));
1701 }
1702
1703 #[test]
1704 fn build_create_request_rejects_neither() {
1705 let err = build_create_request(CreateInputs {
1706 file: None,
1707 text: None,
1708 delete_in_days: 7,
1709 max_access_count: None,
1710 hidden: false,
1711 name: Some("name".into()),
1712 notes: None,
1713 password: None,
1714 emails: None,
1715 })
1716 .unwrap_err();
1717 assert!(err.to_string().contains("--text") || err.to_string().contains("--file"));
1718 }
1719
1720 #[test]
1721 fn build_create_request_password_auth() {
1722 let req = build_create_request(CreateInputs {
1723 file: None,
1724 text: Some("hello".into()),
1725 delete_in_days: 7,
1726 max_access_count: None,
1727 hidden: false,
1728 name: Some("name".into()),
1729 notes: None,
1730 password: Some("hunter2".into()),
1731 emails: None,
1732 })
1733 .unwrap();
1734 assert!(matches!(req.auth, SendAuthType::Password { .. }));
1735 }
1736
1737 #[test]
1738 fn build_create_request_email_auth() {
1739 let req = build_create_request(CreateInputs {
1740 file: None,
1741 text: Some("hello".into()),
1742 delete_in_days: 7,
1743 max_access_count: None,
1744 hidden: false,
1745 name: Some("name".into()),
1746 notes: None,
1747 password: None,
1748 emails: Some("[email protected],[email protected]".into()),
1749 })
1750 .unwrap();
1751 match req.auth {
1752 SendAuthType::Emails { emails } => assert_eq!(emails.len(), 2),
1753 other => panic!("expected Emails, got {other:?}"),
1754 }
1755 }
1756
1757 #[test]
1763 fn build_auth_for_edit_no_flags_preserves() {
1764 let auth = build_auth_for_edit(None, None).unwrap();
1765 assert!(
1766 matches!(auth, AuthEdit::Preserve),
1767 "no flags must produce `AuthEdit::Preserve`, got {auth:?}"
1768 );
1769 }
1770
1771 #[test]
1772 fn build_auth_for_edit_password_overwrites() {
1773 let auth = build_auth_for_edit(Some("hunter2".into()), None).unwrap();
1774 assert!(matches!(
1775 auth,
1776 AuthEdit::Set { auth: SendAuthType::Password { ref password } } if password == "hunter2"
1777 ));
1778 }
1779
1780 #[test]
1781 fn build_auth_for_edit_emails_overwrites() {
1782 let auth = build_auth_for_edit(None, Some("[email protected],[email protected]")).unwrap();
1783 match auth {
1784 AuthEdit::Set {
1785 auth: SendAuthType::Emails { emails },
1786 } => assert_eq!(emails.len(), 2),
1787 other => panic!("expected AuthEdit::Set {{ auth: Emails }}, got {other:?}"),
1788 }
1789 }
1790
1791 #[test]
1792 fn build_auth_for_edit_rejects_both_flags() {
1793 assert!(build_auth_for_edit(Some("p".into()), Some("[email protected]")).is_err());
1794 }
1795
1796 use bitwarden_send::{AuthType, SendType, SendView};
1799
1800 fn make_existing(auth_type: AuthType, has_password: bool, emails: Vec<String>) -> SendView {
1803 SendView {
1804 id: "25afb11c-9c95-4db5-8bac-c21cb204a3f1".parse().ok(),
1805 access_id: Some("access-id".to_string()),
1806 name: "existing".to_string(),
1807 notes: Some("notes".to_string()),
1808 key: Some("Pgui0FK85cNhBGWHAlBHBw".to_string()),
1809 new_password: None,
1810 has_password,
1811 r#type: SendType::Text,
1812 file: None,
1813 text: Some(SendTextView {
1814 text: Some("existing text".to_string()),
1815 hidden: false,
1816 }),
1817 max_access_count: Some(42),
1818 access_count: 0,
1819 disabled: false,
1820 hide_email: false,
1821 revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
1822 deletion_date: "2030-01-01T00:00:00Z".parse().unwrap(),
1823 expiration_date: None,
1824 emails,
1825 auth_type,
1826 }
1827 }
1828
1829 fn no_override_edit() -> EditOverrides {
1830 EditOverrides {
1831 delete_in_days: None,
1832 max_access_count: None,
1833 hidden: false,
1834 password: None,
1835 emails: None,
1836 }
1837 }
1838
1839 #[test]
1847 fn build_edit_request_preserves_existing_password_when_no_auth_flags() {
1848 let existing = make_existing(AuthType::Password, true, Vec::new());
1849 let req = build_edit_request(existing, no_override_edit()).unwrap();
1850 assert!(
1851 matches!(req.auth, AuthEdit::Preserve),
1852 "expected `AuthEdit::Preserve`, got {:?} — the previous behavior was \
1853 `AuthEdit::Set {{ auth: SendAuthType::None }}`, which silently strips the existing password",
1854 req.auth
1855 );
1856 }
1857
1858 #[test]
1859 fn build_edit_request_preserves_existing_emails_when_no_auth_flags() {
1860 let existing = make_existing(
1861 AuthType::Email,
1862 false,
1863 vec!["[email protected]".to_string(), "[email protected]".to_string()],
1864 );
1865 let req = build_edit_request(existing, no_override_edit()).unwrap();
1866 assert!(matches!(req.auth, AuthEdit::Preserve));
1867 }
1868
1869 #[test]
1874 fn build_edit_request_preserves_existing_none_auth_when_no_auth_flags() {
1875 let existing = make_existing(AuthType::None, false, Vec::new());
1876 let req = build_edit_request(existing, no_override_edit()).unwrap();
1877 assert!(matches!(req.auth, AuthEdit::Preserve));
1878 }
1879
1880 #[test]
1884 fn build_edit_request_password_flag_overwrites_regardless_of_existing() {
1885 for existing_auth in [AuthType::None, AuthType::Password, AuthType::Email] {
1886 let existing =
1887 make_existing(existing_auth, existing_auth == AuthType::Password, vec![]);
1888 let req = build_edit_request(
1889 existing,
1890 EditOverrides {
1891 delete_in_days: None,
1892 max_access_count: None,
1893 hidden: false,
1894 password: Some("hunter2".into()),
1895 emails: None,
1896 },
1897 )
1898 .unwrap();
1899 assert!(
1900 matches!(
1901 req.auth,
1902 AuthEdit::Set { auth: SendAuthType::Password { ref password } } if password == "hunter2"
1903 ),
1904 "existing={existing_auth:?}, got auth={:?}",
1905 req.auth
1906 );
1907 }
1908 }
1909
1910 #[test]
1911 fn build_edit_request_emails_flag_overwrites_regardless_of_existing() {
1912 for existing_auth in [AuthType::None, AuthType::Password, AuthType::Email] {
1913 let existing =
1914 make_existing(existing_auth, existing_auth == AuthType::Password, vec![]);
1915 let req = build_edit_request(
1916 existing,
1917 EditOverrides {
1918 delete_in_days: None,
1919 max_access_count: None,
1920 hidden: false,
1921 password: None,
1922 emails: Some("[email protected]".into()),
1923 },
1924 )
1925 .unwrap();
1926 match req.auth {
1927 AuthEdit::Set {
1928 auth: SendAuthType::Emails { ref emails },
1929 } => {
1930 assert_eq!(emails.len(), 1);
1931 }
1932 ref other => panic!(
1933 "existing={existing_auth:?}, expected AuthEdit::Set {{ auth: Emails }}, got {other:?}"
1934 ),
1935 }
1936 }
1937 }
1938
1939 #[test]
1944 fn build_edit_request_prefers_text_when_both_present() {
1945 let mut existing = make_existing(AuthType::None, false, vec![]);
1946 existing.text = Some(SendTextView {
1947 text: Some("the text body".to_string()),
1948 hidden: false,
1949 });
1950 existing.file = Some(SendFileView {
1951 id: Some("file-id".to_string()),
1952 file_name: "attachment.bin".to_string(),
1953 size: Some("10".to_string()),
1954 size_name: Some("10 B".to_string()),
1955 });
1956
1957 let req = build_edit_request(existing, no_override_edit()).unwrap();
1958 match req.view_type {
1959 SendViewType::Text(t) => assert_eq!(t.text.as_deref(), Some("the text body")),
1960 other => panic!("expected Text (legacy prefers text on mixed-shape), got {other:?}"),
1961 }
1962 }
1963
1964 #[test]
1965 fn build_edit_request_rejects_both_auth_flags() {
1966 let existing = make_existing(AuthType::None, false, vec![]);
1967 let err = build_edit_request(
1968 existing,
1969 EditOverrides {
1970 delete_in_days: None,
1971 max_access_count: None,
1972 hidden: false,
1973 password: Some("p".into()),
1974 emails: Some("[email protected]".into()),
1975 },
1976 )
1977 .unwrap_err();
1978 assert!(err.to_string().contains("mutually exclusive"));
1979 }
1980
1981 const RAW_TEXT_SEND_JSON: &str = r#"{"name":"My Send","hasPassword":false,"type":0,"text":{"text":"hello","hidden":false},"accessCount":0,"disabled":false,"hideEmail":false,"revisionDate":"2025-01-01T00:00:00Z","deletionDate":"2030-01-01T00:00:00Z","emails":[],"authType":2}"#;
1986
1987 #[test]
1990 fn parse_encoded_send_view_accepts_raw_and_base64_equivalently() {
1991 let from_raw = parse_encoded_send_view(RAW_TEXT_SEND_JSON).unwrap();
1992 let b64 = STANDARD.encode(RAW_TEXT_SEND_JSON);
1993 let from_b64 = parse_encoded_send_view(&b64).unwrap();
1994 assert_eq!(from_raw, from_b64);
1995 assert_eq!(from_raw.name, "My Send");
1996 assert_eq!(from_raw.r#type, SendType::Text);
1997 }
1998
1999 #[test]
2000 fn parse_encoded_send_view_rejects_garbage() {
2001 let err = parse_encoded_send_view("!!!not-base64-and-not-json!!!").unwrap_err();
2002 assert!(
2003 err.to_string()
2004 .contains("Error parsing the encoded request data")
2005 );
2006 }
2007
2008 #[test]
2013 fn parse_encoded_send_view_accepts_template_shaped_json() {
2014 let json = r#"{
2015 "name": "My Send",
2016 "notes": "",
2017 "type": 0,
2018 "text": {"text": "hello", "hidden": false},
2019 "deletionDate": "2030-01-01T00:00:00Z"
2020 }"#;
2021
2022 let view = parse_encoded_send_view(json).expect("template-shaped JSON should parse");
2023 assert_eq!(view.name, "My Send");
2024 assert!(!view.has_password);
2025 assert_eq!(view.access_count, 0);
2026 assert!(!view.disabled);
2027 assert!(!view.hide_email);
2028 assert!(view.emails.is_empty());
2029 assert!(matches!(view.auth_type, AuthType::None));
2030 }
2031
2032 #[test]
2035 fn parse_encoded_send_view_tolerates_unknown_fields() {
2036 let json = r#"{
2037 "object": "send",
2038 "accessUrl": "https://vault.bitwarden.com/#/send/abc/def",
2039 "name": "My Send",
2040 "hasPassword": false,
2041 "type": 0,
2042 "text": {"text": "hello", "hidden": false},
2043 "accessCount": 0,
2044 "disabled": false,
2045 "hideEmail": false,
2046 "revisionDate": "2025-01-01T00:00:00Z",
2047 "deletionDate": "2030-01-01T00:00:00Z",
2048 "emails": [],
2049 "authType": 2
2050 }"#;
2051
2052 let view = parse_encoded_send_view(json).expect("unknown fields should be ignored");
2053 assert_eq!(view.name, "My Send");
2054 }
2055
2056 #[test]
2060 fn parse_encoded_send_view_surfaces_real_error_for_valid_base64_invalid_json() {
2061 let b64 = STANDARD.encode(r#"{"name": "My Send"}"#);
2062 let err = parse_encoded_send_view(&b64).unwrap_err();
2063 let chain = format!("{err:?}");
2067 assert!(
2068 chain.contains("missing field"),
2069 "expected the real deserialize error, got: {chain}"
2070 );
2071 }
2072
2073 fn make_text_json(name: &str, notes: Option<&str>, hidden: bool, max: Option<u32>) -> SendView {
2076 SendView {
2077 id: None,
2078 access_id: None,
2079 name: name.to_string(),
2080 notes: notes.map(String::from),
2081 key: None,
2082 new_password: None,
2083 has_password: false,
2084 r#type: SendType::Text,
2085 file: None,
2086 text: Some(SendTextView {
2087 text: Some("body".to_string()),
2088 hidden,
2089 }),
2090 max_access_count: max,
2091 access_count: 0,
2092 disabled: false,
2093 hide_email: false,
2094 revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
2095 deletion_date: "2030-06-01T00:00:00Z".parse().unwrap(),
2096 expiration_date: None,
2097 emails: Vec::new(),
2098 auth_type: AuthType::None,
2099 }
2100 }
2101
2102 fn no_create_overrides() -> CreateOverrides {
2103 CreateOverrides {
2104 name: None,
2105 notes: None,
2106 max_access_count: None,
2107 hidden: false,
2108 password: None,
2109 emails: None,
2110 }
2111 }
2112
2113 #[test]
2114 fn build_create_request_from_view_honors_json_fields() {
2115 let view = make_text_json("JSON Name", Some("json notes"), true, Some(9));
2116 let req = build_create_request_from_view(view, no_create_overrides()).unwrap();
2117
2118 assert_eq!(req.name, "JSON Name");
2119 assert_eq!(req.notes.as_deref(), Some("json notes"));
2120 assert_eq!(req.max_access_count, Some(9));
2121 assert_eq!(
2122 req.deletion_date,
2123 "2030-06-01T00:00:00Z"
2124 .parse::<chrono::DateTime<Utc>>()
2125 .unwrap()
2126 );
2127 match req.view_type {
2128 SendViewType::Text(t) => {
2129 assert_eq!(t.text.as_deref(), Some("body"));
2130 assert!(t.hidden);
2131 }
2132 other => panic!("expected Text, got {other:?}"),
2133 }
2134 assert!(matches!(req.auth, SendAuthType::None));
2135 }
2136
2137 #[test]
2138 fn build_create_request_from_view_flags_override_json() {
2139 let view = make_text_json("JSON Name", Some("json notes"), false, Some(9));
2140 let req = build_create_request_from_view(
2141 view,
2142 CreateOverrides {
2143 name: Some("Flag Name".into()),
2144 notes: Some("flag notes".into()),
2145 max_access_count: Some(3),
2146 hidden: true,
2147 password: Some("pw".into()),
2148 emails: None,
2149 },
2150 )
2151 .unwrap();
2152
2153 assert_eq!(req.name, "Flag Name");
2154 assert_eq!(req.notes.as_deref(), Some("flag notes"));
2155 assert_eq!(req.max_access_count, Some(3));
2156 match req.view_type {
2157 SendViewType::Text(t) => assert!(t.hidden, "--hidden must OR in over JSON"),
2158 other => panic!("expected Text, got {other:?}"),
2159 }
2160 assert!(matches!(req.auth, SendAuthType::Password { .. }));
2161 }
2162
2163 #[test]
2164 fn build_create_request_from_view_uses_json_auth_when_no_flags() {
2165 let mut view = make_text_json("n", None, false, None);
2166 view.emails = vec!["[email protected]".into(), "[email protected]".into()];
2167 let req = build_create_request_from_view(view, no_create_overrides()).unwrap();
2168 match req.auth {
2169 SendAuthType::Emails { emails } => assert_eq!(emails.len(), 2),
2170 other => panic!("expected Emails from JSON, got {other:?}"),
2171 }
2172 }
2173
2174 #[test]
2177 fn build_create_request_from_view_rejects_file_type() {
2178 let mut view = make_text_json("f", None, false, None);
2179 view.r#type = SendType::File;
2180 view.text = None;
2181 view.file = Some(SendFileView {
2182 id: None,
2183 file_name: "secret.txt".into(),
2184 size: None,
2185 size_name: None,
2186 });
2187 let err = build_create_request_from_view(view, no_create_overrides()).unwrap_err();
2188 assert!(
2189 err.to_string()
2190 .contains("file Sends from JSON is not supported"),
2191 "got: {err}"
2192 );
2193 }
2194
2195 #[test]
2200 fn build_edit_request_from_json_honors_every_field() {
2201 let existing = make_existing(AuthType::None, false, vec![]);
2202 let mut req = make_text_json("New Name", Some("new notes"), true, Some(7));
2203 req.disabled = true;
2204 req.hide_email = true;
2205 req.deletion_date = "2031-02-02T00:00:00Z".parse().unwrap();
2206
2207 let out = build_edit_request_from_json(existing, req, no_override_edit()).unwrap();
2208
2209 assert_eq!(out.name, "New Name");
2210 assert_eq!(out.notes.as_deref(), Some("new notes"));
2211 assert_eq!(out.max_access_count, Some(7));
2212 assert!(out.disabled);
2213 assert!(out.hide_email);
2214 assert_eq!(
2215 out.deletion_date,
2216 "2031-02-02T00:00:00Z"
2217 .parse::<chrono::DateTime<Utc>>()
2218 .unwrap()
2219 );
2220 match out.view_type {
2221 SendViewType::Text(t) => {
2222 assert_eq!(t.text.as_deref(), Some("body"));
2223 assert!(t.hidden);
2224 }
2225 other => panic!("expected Text, got {other:?}"),
2226 }
2227 assert!(matches!(out.auth, AuthEdit::Preserve));
2228 }
2229
2230 #[test]
2233 fn build_edit_request_from_json_clears_fields_absent_from_json() {
2234 let existing = make_existing(AuthType::None, false, vec![]);
2235 let req = make_text_json("n", None, false, None);
2236 let out = build_edit_request_from_json(existing, req, no_override_edit()).unwrap();
2237 assert_eq!(out.notes, None, "notes absent from JSON must be cleared");
2238 assert_eq!(
2239 out.max_access_count, None,
2240 "maxAccessCount absent from JSON must be cleared"
2241 );
2242 }
2243
2244 #[test]
2247 fn build_edit_request_from_json_rejects_type_change() {
2248 let existing = make_existing(AuthType::None, false, vec![]);
2249 let mut req = make_text_json("n", None, false, None);
2250 req.r#type = SendType::File;
2251 req.text = None;
2252 req.file = Some(SendFileView {
2253 id: None,
2254 file_name: "f".into(),
2255 size: None,
2256 size_name: None,
2257 });
2258 let err = build_edit_request_from_json(existing, req, no_override_edit()).unwrap_err();
2259 assert!(
2260 err.to_string().contains("Cannot change a Send's type"),
2261 "got: {err}"
2262 );
2263 }
2264
2265 #[test]
2267 fn build_edit_request_from_json_cli_flag_beats_json_auth() {
2268 let existing = make_existing(AuthType::None, false, vec![]);
2269 let mut req = make_text_json("n", None, false, None);
2270 req.emails = vec!["[email protected]".into()];
2271 let out = build_edit_request_from_json(
2272 existing,
2273 req,
2274 EditOverrides {
2275 delete_in_days: None,
2276 max_access_count: None,
2277 hidden: false,
2278 password: Some("pw".into()),
2279 emails: None,
2280 },
2281 )
2282 .unwrap();
2283 assert!(matches!(
2284 out.auth,
2285 AuthEdit::Set {
2286 auth: SendAuthType::Password { .. }
2287 }
2288 ));
2289 }
2290
2291 #[test]
2293 fn build_edit_request_from_json_uses_json_auth_when_no_flags() {
2294 let existing = make_existing(AuthType::Password, true, vec![]);
2295 let mut req = make_text_json("n", None, false, None);
2296 req.emails = vec!["[email protected]".into(), "[email protected]".into()];
2297 let out = build_edit_request_from_json(existing, req, no_override_edit()).unwrap();
2298 match out.auth {
2299 AuthEdit::Set {
2300 auth: SendAuthType::Emails { emails },
2301 } => assert_eq!(emails.len(), 2),
2302 other => panic!("expected AuthEdit::Set {{ Emails }}, got {other:?}"),
2303 }
2304 }
2305
2306 #[test]
2308 fn build_edit_request_from_json_delete_in_days_flag_overrides_json_date() {
2309 let existing = make_existing(AuthType::None, false, vec![]);
2310 let mut req = make_text_json("n", None, false, None);
2311 req.deletion_date = "2031-01-01T00:00:00Z".parse().unwrap();
2312 let out = build_edit_request_from_json(
2313 existing,
2314 req,
2315 EditOverrides {
2316 delete_in_days: Some(7),
2317 max_access_count: None,
2318 hidden: false,
2319 password: None,
2320 emails: None,
2321 },
2322 )
2323 .unwrap();
2324 let diff = out.deletion_date - Utc::now();
2325 assert!(
2326 diff.num_days() >= 6 && diff.num_days() <= 7,
2327 "flag deletion date should win over JSON's 2031 date"
2328 );
2329 }
2330}