1use std::path::PathBuf;
10
11use bitwarden_pm::PasswordManagerClient;
12use bitwarden_send::{
13 AuthEdit, SendAddRequest, SendAuthType, SendEditRequest, SendFileView, SendId, SendTextView,
14 SendViewType,
15};
16use chrono::{Duration, Utc};
17use clap::{
18 Args, Subcommand,
19 builder::{PossibleValuesParser, TypedValueParser as _},
20};
21use color_eyre::eyre::{Context as _, eyre};
22use serde::Serialize;
23
24use crate::{
25 client_state::{AnyState, BwCommand, BwCommandExt as _, ClientContext, LoggedIn},
26 platform::read_config_json,
27 render::{CommandOutput, CommandResult},
28};
29
30const DELETE_IN_DAYS_ALLOWED: &[&str] = &["1", "2", "3", "7", "14", "30"];
33
34fn delete_in_days_parser() -> impl clap::builder::TypedValueParser<Value = u64> {
37 PossibleValuesParser::new(DELETE_IN_DAYS_ALLOWED)
38 .map(|s| s.parse::<u64>().expect("allowed values are valid u64"))
39}
40
41#[derive(Args, Clone)]
42pub struct SendArgs {
43 pub data: Option<String>,
45
46 #[arg(short = 'f', long, help = "Specifies that <data> is a filepath.")]
47 pub file: bool,
48
49 #[arg(
50 short = 'd',
51 long = "deleteInDays",
52 help = "The number of days in the future to set deletion date.",
53 default_value_t = 7,
54 value_parser = delete_in_days_parser(),
55 )]
56 pub delete_in_days: u64,
57
58 #[arg(
59 long,
60 conflicts_with = "emails",
61 help = "Optional password to access this Send."
62 )]
63 pub password: Option<String>,
64
65 #[arg(
66 long,
67 help = "Email addresses for OTP authentication (single, JSON array, comma- or space-separated)."
68 )]
69 pub emails: Option<String>,
70
71 #[arg(
72 short = 'a',
73 long = "maxAccessCount",
74 help = "The amount of max possible accesses."
75 )]
76 pub max_access_count: Option<u32>,
77
78 #[arg(long, help = "Hide <data> in web by default.")]
79 pub hidden: bool,
80
81 #[arg(short = 'n', long, help = "The name of the Send.")]
82 pub name: Option<String>,
83
84 #[arg(long, help = "Notes to add to the Send.")]
85 pub notes: Option<String>,
86
87 #[arg(
88 long = "fullObject",
89 help = "Specifies that the full Send object should be returned."
90 )]
91 pub full_object: bool,
92
93 #[command(subcommand)]
94 pub command: Option<SendCommands>,
95}
96
97#[derive(Subcommand, Clone, Debug)]
98pub enum SendCommands {
99 #[command(about = "List all the Sends owned by you.")]
100 List(SendListArgs),
101
102 #[command(about = "Get json templates for send objects.")]
103 Template(SendTemplateArgs),
104
105 #[command(about = "Get Sends owned by you.")]
106 Get(SendGetArgs),
107
108 #[command(about = "Access a Bitwarden Send from a url.")]
109 Receive(SendReceiveArgs),
110
111 #[command(about = "Create a Send.")]
112 Create(SendCreateArgs),
113
114 #[command(about = "Edit a Send.")]
115 Edit(SendEditArgs),
116
117 #[command(about = "Removes the saved password from a Send.")]
118 RemovePassword(SendRemovePasswordArgs),
119
120 #[command(about = "Delete a Send.")]
121 Delete(SendDeleteArgs),
122}
123
124#[derive(Args, Clone, Debug)]
125pub struct SendListArgs;
126
127#[derive(Args, Clone, Debug)]
128pub struct SendTemplateArgs {
129 pub object: String,
130}
131
132#[derive(Args, Clone, Debug)]
133pub struct SendGetArgs {
134 pub id: SendId,
135
136 #[arg(
140 long = "output",
141 help = "File path to save a file-type Send's decrypted contents to."
142 )]
143 pub output_path: Option<String>,
144
145 #[arg(long, help = "Only return the access url.")]
146 pub text: bool,
147}
148
149#[derive(Args, Clone, Debug)]
150pub struct SendReceiveArgs {
151 pub url: String,
152
153 #[arg(long, help = "Optional password for the Send.")]
154 pub password: Option<String>,
155
156 #[arg(long, help = "Specify a file path to save a File-type Send to.")]
157 pub obj: Option<String>,
158}
159
160#[derive(Args, Clone, Debug)]
161pub struct SendCreateArgs {
162 pub encoded_json: Option<String>,
163
164 #[arg(short = 'f', long, help = "Path to the file to Send.")]
165 pub file: Option<String>,
166
167 #[arg(long, help = "Text to Send.")]
168 pub text: Option<String>,
169
170 #[arg(
171 short = 'd',
172 long = "deleteInDays",
173 help = "The number of days in the future to set deletion date.",
174 default_value_t = 7,
175 value_parser = delete_in_days_parser(),
176 )]
177 pub delete_in_days: u64,
178
179 #[arg(
180 long = "maxAccessCount",
181 help = "The maximum number of times this Send can be accessed."
182 )]
183 pub max_access_count: Option<u32>,
184
185 #[arg(long, help = "Hide text.")]
186 pub hidden: bool,
187
188 #[arg(short = 'n', long, help = "The name of the Send.")]
189 pub name: Option<String>,
190
191 #[arg(long, help = "Notes to add to the Send.")]
192 pub notes: Option<String>,
193
194 #[arg(
195 long,
196 conflicts_with = "emails",
197 help = "Optional password to access this Send."
198 )]
199 pub password: Option<String>,
200
201 #[arg(
202 long,
203 help = "Email addresses for OTP authentication (single, JSON array, comma- or space-separated)."
204 )]
205 pub emails: Option<String>,
206
207 #[arg(
208 long = "fullObject",
209 help = "Return full Send object instead of access url."
210 )]
211 pub full_object: bool,
212}
213
214#[derive(Args, Clone, Debug)]
215pub struct SendEditArgs {
216 pub encoded_json: Option<String>,
217
218 #[arg(long, help = "Overrides the itemId provided in encodedJson.")]
219 pub itemid: Option<SendId>,
220
221 #[arg(
222 short = 'd',
223 long = "deleteInDays",
224 help = "The number of days in the future to set deletion date.",
225 value_parser = delete_in_days_parser(),
226 )]
227 pub delete_in_days: Option<u64>,
228
229 #[arg(
230 long = "maxAccessCount",
231 help = "The maximum number of times this Send can be accessed."
232 )]
233 pub max_access_count: Option<u32>,
234
235 #[arg(long, help = "Hide text.")]
236 pub hidden: bool,
237
238 #[arg(
239 long,
240 conflicts_with = "emails",
241 help = "Optional password to access this Send."
242 )]
243 pub password: Option<String>,
244
245 #[arg(
246 long,
247 help = "Email addresses for OTP authentication (single, JSON array, comma- or space-separated)."
248 )]
249 pub emails: Option<String>,
250}
251
252#[derive(Args, Clone, Debug)]
253pub struct SendRemovePasswordArgs {
254 pub id: SendId,
255}
256
257#[derive(Args, Clone, Debug)]
258pub struct SendDeleteArgs {
259 pub id: SendId,
260}
261
262impl BwCommand for SendArgs {
263 type Client = AnyState;
267
268 async fn run(self, state: AnyState) -> CommandResult {
269 let ctx = ClientContext {
273 global: state.global,
274 user: state.user,
275 };
276 match self.command.clone() {
277 None => {
278 let LoggedIn { user, .. } = LoggedIn::try_from(ctx)?;
279 create_shortcut(&user, self).await
280 }
281 Some(SendCommands::Create(args)) if args.encoded_json.is_some() => Err(eyre!(
287 "`encoded_json` input on `bw send create` is not yet implemented (tracked under PM-39240)."
288 )),
289 Some(SendCommands::Edit(args)) if args.encoded_json.is_some() => Err(eyre!(
290 "`encoded_json` input on `bw send edit` is not yet implemented (tracked under PM-39240)."
291 )),
292 Some(SendCommands::Get(args)) if args.output_path.is_some() => Err(eyre!(
296 "`--output` on `bw send get` is not yet implemented (tracked under PM-39238)."
297 )),
298 Some(SendCommands::List(args)) => args.dispatch(ctx).await,
299 Some(SendCommands::Template(args)) => args.dispatch(ctx).await,
300 Some(SendCommands::Get(args)) => args.dispatch(ctx).await,
301 Some(SendCommands::Receive(args)) => args.dispatch(ctx).await,
302 Some(SendCommands::Create(args)) => args.dispatch(ctx).await,
303 Some(SendCommands::Edit(args)) => args.dispatch(ctx).await,
304 Some(SendCommands::RemovePassword(args)) => args.dispatch(ctx).await,
305 Some(SendCommands::Delete(args)) => args.dispatch(ctx).await,
306 }
307 }
308}
309
310impl BwCommand for SendListArgs {
311 type Client = LoggedIn;
312
313 async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
314 list_sends(&user).await
315 }
316}
317
318impl BwCommand for SendTemplateArgs {
319 type Client = AnyState;
322
323 async fn run(self, _: AnyState) -> CommandResult {
324 render_template(&self.object)
325 }
326}
327
328impl BwCommand for SendGetArgs {
329 type Client = LoggedIn;
330
331 async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
332 get_send(&user, self.id, self.text).await
337 }
338}
339
340impl BwCommand for SendReceiveArgs {
341 type Client = AnyState;
346
347 async fn run(self, _: AnyState) -> CommandResult {
348 Err(eyre!(
349 "`bw send receive` is not yet implemented (tracked under PM-34718)."
350 ))
351 }
352}
353
354impl BwCommand for SendCreateArgs {
355 type Client = LoggedIn;
356
357 async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
358 let SendCreateArgs {
361 encoded_json: _,
362 file,
363 text,
364 delete_in_days,
365 max_access_count,
366 hidden,
367 name,
368 notes,
369 password,
370 emails,
371 full_object,
372 } = self;
373
374 let request = build_create_request(CreateInputs {
375 file,
376 text,
377 delete_in_days,
378 max_access_count,
379 hidden,
380 name,
381 notes,
382 password,
383 emails,
384 })?;
385
386 run_create(&user, request, full_object).await
387 }
388}
389
390impl BwCommand for SendEditArgs {
391 type Client = LoggedIn;
392
393 async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
394 let SendEditArgs {
397 encoded_json: _,
398 itemid,
399 delete_in_days,
400 max_access_count,
401 hidden,
402 password,
403 emails,
404 } = self;
405
406 let send_id = itemid.ok_or_else(|| {
407 eyre!("--itemid is required (or provide it via encoded JSON; see PM-39240).")
408 })?;
409
410 let existing = user.sends().get(send_id).await?;
412
413 let request = build_edit_request(
414 existing,
415 EditOverrides {
416 delete_in_days,
417 max_access_count,
418 hidden,
419 password,
420 emails,
421 },
422 )?;
423
424 let view = user.sends().edit(send_id, request).await?;
425 Ok(CommandOutput::Object(Box::new(view)))
426 }
427}
428
429impl BwCommand for SendRemovePasswordArgs {
430 type Client = LoggedIn;
431
432 async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
433 let view = user.sends().remove_password(self.id).await?;
434 Ok(CommandOutput::Object(Box::new(view)))
435 }
436}
437
438impl BwCommand for SendDeleteArgs {
439 type Client = LoggedIn;
440
441 async fn run(self, LoggedIn { user, .. }: LoggedIn) -> CommandResult {
442 user.sends().delete(self.id).await?;
443 Ok("Send deleted.".into())
444 }
445}
446
447async fn list_sends(client: &PasswordManagerClient) -> CommandResult {
448 let views = client.sends().list().await?;
449 Ok(CommandOutput::Object(Box::new(views)))
450}
451
452async fn get_send(client: &PasswordManagerClient, id: SendId, text: bool) -> CommandResult {
453 let view = client.sends().get(id).await?;
454
455 if text {
456 let url = build_access_url(client, &view)?;
459 return Ok(url.into());
460 }
461
462 Ok(CommandOutput::Object(Box::new(view)))
463}
464
465fn render_template(object: &str) -> CommandResult {
466 match object {
470 "send.text" => Ok(CommandOutput::Object(Box::new(SendTextTemplate::default()))),
471 "send.file" => Ok(CommandOutput::Object(Box::new(SendFileTemplate::default()))),
472 other => Err(eyre!("Unknown template object: {other}")),
473 }
474}
475
476#[derive(Serialize, Default)]
477#[serde(rename_all = "camelCase")]
478struct SendTextTemplate {
479 name: String,
480 notes: String,
481 #[serde(rename = "type")]
482 send_type: u8, text: SendTextTemplateBody,
484 deletion_date: String,
485}
486
487#[derive(Serialize, Default)]
488struct SendTextTemplateBody {
489 text: String,
490 hidden: bool,
491}
492
493#[derive(Serialize, Default)]
494#[serde(rename_all = "camelCase")]
495struct SendFileTemplate {
496 name: String,
497 notes: String,
498 #[serde(rename = "type")]
499 send_type: u8, file: SendFileTemplateBody,
501 deletion_date: String,
502}
503
504#[derive(Serialize, Default)]
505#[serde(rename_all = "camelCase")]
506struct SendFileTemplateBody {
507 file_name: String,
508}
509
510struct CreateInputs {
511 file: Option<String>,
512 text: Option<String>,
513 delete_in_days: u64,
514 max_access_count: Option<u32>,
515 hidden: bool,
516 name: Option<String>,
517 notes: Option<String>,
518 password: Option<String>,
519 emails: Option<String>,
520}
521
522fn build_create_request(inputs: CreateInputs) -> color_eyre::eyre::Result<SendAddRequest> {
523 let CreateInputs {
524 file,
525 text,
526 delete_in_days,
527 max_access_count,
528 hidden,
529 name,
530 notes,
531 password,
532 emails,
533 } = inputs;
534
535 let deletion_date = compute_deletion_date(delete_in_days)?;
536
537 let view_type = match (file.as_deref(), text.as_deref()) {
538 (Some(path), None) => {
539 let path = PathBuf::from(path);
541 let file_name = path
542 .file_name()
543 .and_then(|s| s.to_str())
544 .ok_or_else(|| eyre!("Could not derive a file name from --file path"))?
545 .to_string();
546 SendViewType::File(SendFileView {
547 id: None,
548 file_name,
549 size: None,
550 size_name: None,
551 })
552 }
553 (None, Some(t)) => SendViewType::Text(SendTextView {
554 text: Some(t.to_string()),
555 hidden,
556 }),
557 (Some(_), Some(_)) => {
558 return Err(eyre!("--file and --text are mutually exclusive."));
559 }
560 (None, None) => {
561 return Err(eyre!(
562 "Either --text <data> or --file <path> is required when creating a Send."
563 ));
564 }
565 };
566
567 let resolved_name = match (name, &view_type) {
570 (Some(n), _) => n,
571 (None, SendViewType::File(f)) => f.file_name.clone(),
572 (None, SendViewType::Text(_)) => {
573 return Err(eyre!("--name is required for text Sends."));
574 }
575 };
576
577 let auth = build_auth(password, emails.as_deref())?;
578
579 Ok(SendAddRequest {
580 name: resolved_name,
581 notes,
582 view_type,
583 max_access_count,
584 disabled: false,
585 hide_email: false,
586 deletion_date,
587 expiration_date: None,
588 auth,
589 })
590}
591
592struct EditOverrides {
593 delete_in_days: Option<u64>,
594 max_access_count: Option<u32>,
595 hidden: bool,
596 password: Option<String>,
597 emails: Option<String>,
598}
599
600fn build_edit_request(
601 existing: bitwarden_send::SendView,
602 overrides: EditOverrides,
603) -> color_eyre::eyre::Result<SendEditRequest> {
604 let EditOverrides {
605 delete_in_days,
606 max_access_count,
607 hidden,
608 password,
609 emails,
610 } = overrides;
611
612 let deletion_date = match delete_in_days {
613 Some(d) => compute_deletion_date(d)?,
614 None => existing.deletion_date,
615 };
616
617 let view_type = match (existing.text, existing.file) {
618 (Some(t), None) => SendViewType::Text(SendTextView {
619 text: t.text,
620 hidden: if hidden { true } else { t.hidden },
621 }),
622 (None, Some(f)) => SendViewType::File(f),
623 (Some(t), Some(_)) => SendViewType::Text(SendTextView {
626 text: t.text,
627 hidden: if hidden { true } else { t.hidden },
628 }),
629 (None, None) => {
630 return Err(eyre!(
631 "Cannot edit Send {:?}: server returned neither text nor file content.",
632 existing.id
633 ));
634 }
635 };
636
637 let auth = build_auth_for_edit(password, emails.as_deref())?;
638
639 Ok(SendEditRequest {
640 name: existing.name,
641 notes: existing.notes,
642 view_type,
643 max_access_count: max_access_count.or(existing.max_access_count),
644 disabled: existing.disabled,
645 hide_email: existing.hide_email,
646 deletion_date,
647 expiration_date: existing.expiration_date,
648 auth,
649 })
650}
651
652async fn create_shortcut(client: &PasswordManagerClient, args: SendArgs) -> CommandResult {
653 let data = args
654 .data
655 .clone()
656 .ok_or_else(|| eyre!("Missing <data> argument. Run `bw send --help` for usage."))?;
657
658 let inputs = if args.file {
659 CreateInputs {
660 file: Some(data),
661 text: None,
662 delete_in_days: args.delete_in_days,
663 max_access_count: args.max_access_count,
664 hidden: args.hidden,
665 name: args.name,
666 notes: args.notes,
667 password: args.password,
668 emails: args.emails,
669 }
670 } else {
671 CreateInputs {
672 file: None,
673 text: Some(data),
674 delete_in_days: args.delete_in_days,
675 max_access_count: args.max_access_count,
676 hidden: args.hidden,
677 name: args.name.or_else(|| Some("Send".to_string())),
680 notes: args.notes,
681 password: args.password,
682 emails: args.emails,
683 }
684 };
685
686 let request = build_create_request(inputs)?;
687 run_create(client, request, args.full_object).await
688}
689
690async fn run_create(
691 client: &PasswordManagerClient,
692 request: SendAddRequest,
693 full_object: bool,
694) -> CommandResult {
695 let is_file = matches!(request.view_type, SendViewType::File(_));
696
697 if is_file {
701 return Err(eyre!(
702 "Creating file Sends from the Rust CLI is not yet implemented (tracked under PM-39238)."
703 ));
704 }
705
706 let view = client.sends().create(request).await?;
707
708 if full_object {
709 return Ok(CommandOutput::Object(Box::new(view)));
710 }
711
712 let url = build_access_url(client, &view)?;
715 Ok(url.into())
716}
717
718fn build_access_url(
734 client: &PasswordManagerClient,
735 view: &bitwarden_send::SendView,
736) -> color_eyre::eyre::Result<String> {
737 let access_id = view
738 .access_id
739 .as_deref()
740 .ok_or_else(|| eyre!("Send is missing an access id; cannot build a shareable URL."))?;
741 let key = view
742 .key
743 .as_deref()
744 .ok_or_else(|| eyre!("Send is missing a key; cannot build a shareable URL."))?;
745
746 let web_vault = web_vault_url(client);
747 let url_key = to_url_b64(key);
748
749 Ok(format!("{web_vault}/#/send/{access_id}/{url_key}"))
750}
751
752fn web_vault_url(client: &PasswordManagerClient) -> String {
764 if let Ok(Some(config)) = read_config_json() {
765 if let Some(web_vault) = config.web_vault.as_deref() {
766 return web_vault.trim_end_matches('/').to_string();
767 }
768 if let Some(server) = config.server.as_deref() {
769 return server.trim_end_matches('/').to_string();
770 }
771 }
772
773 let api_url = client
774 .0
775 .internal
776 .get_api_configurations()
777 .api_config
778 .base_path
779 .clone();
780
781 web_vault_from_api_url(&api_url)
782}
783
784fn web_vault_from_api_url(api_url: &str) -> String {
799 let trimmed = api_url.trim_end_matches('/');
800
801 if let Some(base) = trimmed.strip_suffix("/api") {
802 return base.trim_end_matches('/').to_string();
803 }
804
805 if let Some(vault) = rewrite_api_host_to_vault(trimmed) {
806 return vault;
807 }
808
809 trimmed.to_string()
810}
811
812fn rewrite_api_host_to_vault(url: &str) -> Option<String> {
817 let (scheme, rest) = url.split_once("://")?;
818 let after_api = rest.strip_prefix("api.")?;
819 Some(format!("{scheme}://vault.{after_api}"))
820}
821
822fn to_url_b64(b64: &str) -> String {
828 b64.replace('+', "-").replace('/', "_").replace('=', "")
829}
830
831fn compute_deletion_date(days: u64) -> color_eyre::eyre::Result<chrono::DateTime<Utc>> {
832 if days == 0 {
833 return Err(eyre!("--deleteInDays must be a positive integer"));
834 }
835 let signed =
836 i64::try_from(days).wrap_err_with(|| format!("--deleteInDays out of range: {days}"))?;
837 Ok(Utc::now() + Duration::days(signed))
838}
839
840fn build_auth(
841 password: Option<String>,
842 emails: Option<&str>,
843) -> color_eyre::eyre::Result<SendAuthType> {
844 match (password, emails) {
845 (None, None) => Ok(SendAuthType::None),
846 (Some(p), None) => Ok(SendAuthType::Password { password: p }),
847 (None, Some(e)) => Ok(SendAuthType::Emails {
848 emails: parse_emails(e)?,
849 }),
850 (Some(_), Some(_)) => Err(eyre!("--password and --emails are mutually exclusive.")),
851 }
852}
853
854fn build_auth_for_edit(
872 password: Option<String>,
873 emails: Option<&str>,
874) -> color_eyre::eyre::Result<AuthEdit> {
875 match (password, emails) {
876 (None, None) => Ok(AuthEdit::Preserve),
877 (Some(p), None) => Ok(AuthEdit::Set {
878 auth: SendAuthType::Password { password: p },
879 }),
880 (None, Some(e)) => Ok(AuthEdit::Set {
881 auth: SendAuthType::Emails {
882 emails: parse_emails(e)?,
883 },
884 }),
885 (Some(_), Some(_)) => Err(eyre!("--password and --emails are mutually exclusive.")),
886 }
887}
888
889pub(crate) fn parse_emails(raw: &str) -> color_eyre::eyre::Result<Vec<String>> {
899 let trimmed = raw.trim();
900 if trimmed.is_empty() {
901 return Err(eyre!("--emails cannot be empty"));
902 }
903
904 if trimmed.starts_with('[') {
906 let arr: Vec<String> = serde_json::from_str(trimmed)
907 .wrap_err("--emails looked like a JSON array but failed to parse")?;
908 return finalize_emails(arr);
909 }
910
911 let parts: Vec<String> = if trimmed.contains(',') {
914 trimmed.split(',').map(|s| s.trim().to_string()).collect()
915 } else if trimmed.contains(char::is_whitespace) {
916 trimmed.split_whitespace().map(|s| s.to_string()).collect()
917 } else {
918 vec![trimmed.to_string()]
920 };
921
922 finalize_emails(parts)
923}
924
925fn finalize_emails(emails: Vec<String>) -> color_eyre::eyre::Result<Vec<String>> {
926 let cleaned: Vec<String> = emails
927 .into_iter()
928 .map(|e| e.trim().to_string())
929 .filter(|e| !e.is_empty())
930 .collect();
931 if cleaned.is_empty() {
932 return Err(eyre!("--emails must contain at least one address"));
933 }
934 for e in &cleaned {
935 if !e.contains('@') {
937 return Err(eyre!("Invalid email address: {e}"));
938 }
939 }
940 Ok(cleaned)
941}
942
943#[cfg(test)]
944mod tests {
945 use super::*;
946
947 #[test]
950 fn to_url_b64_maps_standard_b64_to_url_safe() {
951 assert_eq!(to_url_b64("ab+/cd=="), "ab-_cd");
953 assert_eq!(
954 to_url_b64("Pgui0FK85cNhBGWHAlBHBw=="),
955 "Pgui0FK85cNhBGWHAlBHBw"
956 );
957 assert_eq!(to_url_b64("abcDEF123"), "abcDEF123");
959 }
960
961 #[test]
962 fn web_vault_from_api_url_strips_single_domain_api_suffix() {
963 assert_eq!(
965 web_vault_from_api_url("https://vault.example.com/api"),
966 "https://vault.example.com"
967 );
968 assert_eq!(
970 web_vault_from_api_url("https://vault.example.com/api/"),
971 "https://vault.example.com"
972 );
973 }
974
975 #[test]
976 fn web_vault_from_api_url_rewrites_cloud_api_host_to_vault() {
977 assert_eq!(
980 web_vault_from_api_url("https://api.bitwarden.com"),
981 "https://vault.bitwarden.com"
982 );
983 assert_eq!(
984 web_vault_from_api_url("https://api.bitwarden.eu"),
985 "https://vault.bitwarden.eu"
986 );
987 assert_eq!(
989 web_vault_from_api_url("https://api.bitwarden.com/"),
990 "https://vault.bitwarden.com"
991 );
992 }
993
994 #[test]
995 fn web_vault_from_api_url_rewrites_split_domain_self_host() {
996 assert_eq!(
998 web_vault_from_api_url("https://api.example.com"),
999 "https://vault.example.com"
1000 );
1001 }
1002
1003 #[test]
1004 fn web_vault_from_api_url_leaves_unmappable_host_as_is() {
1005 assert_eq!(
1008 web_vault_from_api_url("https://apiary.example.com"),
1009 "https://apiary.example.com"
1010 );
1011 }
1012
1013 #[test]
1017 fn access_url_format_matches_legacy_and_round_trips() {
1018 let web_vault = web_vault_from_api_url("https://api.bitwarden.com");
1019 let access_id = "abcaccessid";
1020 let url_key = to_url_b64("Pgui0FK8+cNh/GWHAlBHBw==");
1022 let url = format!("{web_vault}/#/send/{access_id}/{url_key}");
1023
1024 assert_eq!(
1025 url,
1026 "https://vault.bitwarden.com/#/send/abcaccessid/Pgui0FK8-cNh_GWHAlBHBw"
1027 );
1028
1029 let (_, fragment) = url.split_once('#').expect("URL has a fragment");
1032 let segments: Vec<&str> = fragment.trim_start_matches('/').split('/').collect();
1033 let last_two = &segments[segments.len() - 2..];
1034 assert_eq!(last_two, ["abcaccessid", "Pgui0FK8-cNh_GWHAlBHBw"]);
1035 }
1036
1037 #[test]
1040 fn parse_emails_single() {
1041 let v = parse_emails("[email protected]").unwrap();
1042 assert_eq!(v, vec!["[email protected]".to_string()]);
1043 }
1044
1045 #[test]
1046 fn parse_emails_json_array() {
1047 let v = parse_emails(r#"["[email protected]","[email protected]"]"#).unwrap();
1048 assert_eq!(v, vec!["[email protected]".to_string(), "[email protected]".to_string()]);
1049 }
1050
1051 #[test]
1052 fn parse_emails_comma_separated() {
1053 let v = parse_emails("[email protected],[email protected] , [email protected]").unwrap();
1054 assert_eq!(
1055 v,
1056 vec![
1057 "[email protected]".to_string(),
1058 "[email protected]".to_string(),
1059 "[email protected]".to_string(),
1060 ]
1061 );
1062 }
1063
1064 #[test]
1065 fn parse_emails_space_separated() {
1066 let v = parse_emails("[email protected] [email protected] [email protected]").unwrap();
1067 assert_eq!(
1068 v,
1069 vec![
1070 "[email protected]".to_string(),
1071 "[email protected]".to_string(),
1072 "[email protected]".to_string(),
1073 ]
1074 );
1075 }
1076
1077 #[test]
1078 fn parse_emails_rejects_empty() {
1079 assert!(parse_emails("").is_err());
1080 assert!(parse_emails(" ").is_err());
1081 assert!(parse_emails("[]").is_err());
1082 }
1083
1084 #[test]
1085 fn parse_emails_rejects_no_at_sign() {
1086 assert!(parse_emails("not-an-email").is_err());
1087 }
1088
1089 #[test]
1090 fn parse_emails_rejects_malformed_json_array() {
1091 assert!(parse_emails("[not, valid]").is_err());
1093 }
1094
1095 #[test]
1098 fn compute_deletion_date_positive() {
1099 let d = compute_deletion_date(7).unwrap();
1100 let now = Utc::now();
1101 let diff = d - now;
1102 assert!(diff.num_days() >= 6 && diff.num_days() <= 7);
1103 }
1104
1105 #[test]
1106 fn compute_deletion_date_rejects_zero() {
1107 assert!(compute_deletion_date(0).is_err());
1108 }
1109
1110 #[test]
1113 fn build_auth_none_when_neither_flag_given() {
1114 assert!(matches!(
1115 build_auth(None, None).unwrap(),
1116 SendAuthType::None
1117 ));
1118 }
1119
1120 #[test]
1121 fn build_auth_password_only() {
1122 let auth = build_auth(Some("secret".to_string()), None).unwrap();
1123 assert!(matches!(auth, SendAuthType::Password { password } if password == "secret"));
1124 }
1125
1126 #[test]
1127 fn build_auth_emails_only() {
1128 let auth = build_auth(None, Some("[email protected]")).unwrap();
1129 match auth {
1130 SendAuthType::Emails { emails } => assert_eq!(emails, vec!["[email protected]".to_string()]),
1131 other => panic!("expected Emails, got {other:?}"),
1132 }
1133 }
1134
1135 #[test]
1136 fn build_auth_rejects_both() {
1137 assert!(build_auth(Some("p".into()), Some("[email protected]")).is_err());
1138 }
1139
1140 #[test]
1143 fn build_create_request_text_send() {
1144 let req = build_create_request(CreateInputs {
1145 file: None,
1146 text: Some("hello".into()),
1147 delete_in_days: 7,
1148 max_access_count: Some(5),
1149 hidden: true,
1150 name: Some("My Send".into()),
1151 notes: Some("notes".into()),
1152 password: None,
1153 emails: None,
1154 })
1155 .unwrap();
1156
1157 assert_eq!(req.name, "My Send");
1158 assert_eq!(req.notes.as_deref(), Some("notes"));
1159 assert_eq!(req.max_access_count, Some(5));
1160 match req.view_type {
1161 SendViewType::Text(t) => {
1162 assert_eq!(t.text.as_deref(), Some("hello"));
1163 assert!(t.hidden);
1164 }
1165 other => panic!("expected Text, got {other:?}"),
1166 }
1167 assert!(matches!(req.auth, SendAuthType::None));
1168 }
1169
1170 #[test]
1171 fn build_create_request_text_requires_name() {
1172 let err = build_create_request(CreateInputs {
1173 file: None,
1174 text: Some("hello".into()),
1175 delete_in_days: 7,
1176 max_access_count: None,
1177 hidden: false,
1178 name: None,
1179 notes: None,
1180 password: None,
1181 emails: None,
1182 })
1183 .unwrap_err();
1184 assert!(err.to_string().contains("--name is required"));
1185 }
1186
1187 #[test]
1188 fn build_create_request_file_derives_name() {
1189 let req = build_create_request(CreateInputs {
1190 file: Some("/tmp/secrets.txt".into()),
1191 text: None,
1192 delete_in_days: 7,
1193 max_access_count: None,
1194 hidden: false,
1195 name: None,
1196 notes: None,
1197 password: None,
1198 emails: None,
1199 })
1200 .unwrap();
1201
1202 assert_eq!(req.name, "secrets.txt");
1203 match req.view_type {
1204 SendViewType::File(f) => assert_eq!(f.file_name, "secrets.txt"),
1205 other => panic!("expected File, got {other:?}"),
1206 }
1207 }
1208
1209 #[test]
1210 fn build_create_request_rejects_text_and_file_together() {
1211 let err = build_create_request(CreateInputs {
1212 file: Some("/tmp/x".into()),
1213 text: Some("hello".into()),
1214 delete_in_days: 7,
1215 max_access_count: None,
1216 hidden: false,
1217 name: Some("name".into()),
1218 notes: None,
1219 password: None,
1220 emails: None,
1221 })
1222 .unwrap_err();
1223 assert!(err.to_string().contains("mutually exclusive"));
1224 }
1225
1226 #[test]
1227 fn build_create_request_rejects_neither() {
1228 let err = build_create_request(CreateInputs {
1229 file: None,
1230 text: None,
1231 delete_in_days: 7,
1232 max_access_count: None,
1233 hidden: false,
1234 name: Some("name".into()),
1235 notes: None,
1236 password: None,
1237 emails: None,
1238 })
1239 .unwrap_err();
1240 assert!(err.to_string().contains("--text") || err.to_string().contains("--file"));
1241 }
1242
1243 #[test]
1244 fn build_create_request_password_auth() {
1245 let req = build_create_request(CreateInputs {
1246 file: None,
1247 text: Some("hello".into()),
1248 delete_in_days: 7,
1249 max_access_count: None,
1250 hidden: false,
1251 name: Some("name".into()),
1252 notes: None,
1253 password: Some("hunter2".into()),
1254 emails: None,
1255 })
1256 .unwrap();
1257 assert!(matches!(req.auth, SendAuthType::Password { .. }));
1258 }
1259
1260 #[test]
1261 fn build_create_request_email_auth() {
1262 let req = build_create_request(CreateInputs {
1263 file: None,
1264 text: Some("hello".into()),
1265 delete_in_days: 7,
1266 max_access_count: None,
1267 hidden: false,
1268 name: Some("name".into()),
1269 notes: None,
1270 password: None,
1271 emails: Some("[email protected],[email protected]".into()),
1272 })
1273 .unwrap();
1274 match req.auth {
1275 SendAuthType::Emails { emails } => assert_eq!(emails.len(), 2),
1276 other => panic!("expected Emails, got {other:?}"),
1277 }
1278 }
1279
1280 #[test]
1286 fn build_auth_for_edit_no_flags_preserves() {
1287 let auth = build_auth_for_edit(None, None).unwrap();
1288 assert!(
1289 matches!(auth, AuthEdit::Preserve),
1290 "no flags must produce `AuthEdit::Preserve`, got {auth:?}"
1291 );
1292 }
1293
1294 #[test]
1295 fn build_auth_for_edit_password_overwrites() {
1296 let auth = build_auth_for_edit(Some("hunter2".into()), None).unwrap();
1297 assert!(matches!(
1298 auth,
1299 AuthEdit::Set { auth: SendAuthType::Password { ref password } } if password == "hunter2"
1300 ));
1301 }
1302
1303 #[test]
1304 fn build_auth_for_edit_emails_overwrites() {
1305 let auth = build_auth_for_edit(None, Some("[email protected],[email protected]")).unwrap();
1306 match auth {
1307 AuthEdit::Set {
1308 auth: SendAuthType::Emails { emails },
1309 } => assert_eq!(emails.len(), 2),
1310 other => panic!("expected AuthEdit::Set {{ auth: Emails }}, got {other:?}"),
1311 }
1312 }
1313
1314 #[test]
1315 fn build_auth_for_edit_rejects_both_flags() {
1316 assert!(build_auth_for_edit(Some("p".into()), Some("[email protected]")).is_err());
1317 }
1318
1319 use bitwarden_send::{AuthType, SendType, SendView};
1322
1323 fn make_existing(auth_type: AuthType, has_password: bool, emails: Vec<String>) -> SendView {
1326 SendView {
1327 id: "25afb11c-9c95-4db5-8bac-c21cb204a3f1".parse().ok(),
1328 access_id: Some("access-id".to_string()),
1329 name: "existing".to_string(),
1330 notes: Some("notes".to_string()),
1331 key: Some("Pgui0FK85cNhBGWHAlBHBw".to_string()),
1332 new_password: None,
1333 has_password,
1334 r#type: SendType::Text,
1335 file: None,
1336 text: Some(SendTextView {
1337 text: Some("existing text".to_string()),
1338 hidden: false,
1339 }),
1340 max_access_count: Some(42),
1341 access_count: 0,
1342 disabled: false,
1343 hide_email: false,
1344 revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
1345 deletion_date: "2030-01-01T00:00:00Z".parse().unwrap(),
1346 expiration_date: None,
1347 emails,
1348 auth_type,
1349 }
1350 }
1351
1352 fn no_override_edit() -> EditOverrides {
1353 EditOverrides {
1354 delete_in_days: None,
1355 max_access_count: None,
1356 hidden: false,
1357 password: None,
1358 emails: None,
1359 }
1360 }
1361
1362 #[test]
1370 fn build_edit_request_preserves_existing_password_when_no_auth_flags() {
1371 let existing = make_existing(AuthType::Password, true, Vec::new());
1372 let req = build_edit_request(existing, no_override_edit()).unwrap();
1373 assert!(
1374 matches!(req.auth, AuthEdit::Preserve),
1375 "expected `AuthEdit::Preserve`, got {:?} — the previous behavior was \
1376 `AuthEdit::Set {{ auth: SendAuthType::None }}`, which silently strips the existing password",
1377 req.auth
1378 );
1379 }
1380
1381 #[test]
1382 fn build_edit_request_preserves_existing_emails_when_no_auth_flags() {
1383 let existing = make_existing(
1384 AuthType::Email,
1385 false,
1386 vec!["[email protected]".to_string(), "[email protected]".to_string()],
1387 );
1388 let req = build_edit_request(existing, no_override_edit()).unwrap();
1389 assert!(matches!(req.auth, AuthEdit::Preserve));
1390 }
1391
1392 #[test]
1397 fn build_edit_request_preserves_existing_none_auth_when_no_auth_flags() {
1398 let existing = make_existing(AuthType::None, false, Vec::new());
1399 let req = build_edit_request(existing, no_override_edit()).unwrap();
1400 assert!(matches!(req.auth, AuthEdit::Preserve));
1401 }
1402
1403 #[test]
1407 fn build_edit_request_password_flag_overwrites_regardless_of_existing() {
1408 for existing_auth in [AuthType::None, AuthType::Password, AuthType::Email] {
1409 let existing =
1410 make_existing(existing_auth, existing_auth == AuthType::Password, vec![]);
1411 let req = build_edit_request(
1412 existing,
1413 EditOverrides {
1414 delete_in_days: None,
1415 max_access_count: None,
1416 hidden: false,
1417 password: Some("hunter2".into()),
1418 emails: None,
1419 },
1420 )
1421 .unwrap();
1422 assert!(
1423 matches!(
1424 req.auth,
1425 AuthEdit::Set { auth: SendAuthType::Password { ref password } } if password == "hunter2"
1426 ),
1427 "existing={existing_auth:?}, got auth={:?}",
1428 req.auth
1429 );
1430 }
1431 }
1432
1433 #[test]
1434 fn build_edit_request_emails_flag_overwrites_regardless_of_existing() {
1435 for existing_auth in [AuthType::None, AuthType::Password, AuthType::Email] {
1436 let existing =
1437 make_existing(existing_auth, existing_auth == AuthType::Password, vec![]);
1438 let req = build_edit_request(
1439 existing,
1440 EditOverrides {
1441 delete_in_days: None,
1442 max_access_count: None,
1443 hidden: false,
1444 password: None,
1445 emails: Some("[email protected]".into()),
1446 },
1447 )
1448 .unwrap();
1449 match req.auth {
1450 AuthEdit::Set {
1451 auth: SendAuthType::Emails { ref emails },
1452 } => {
1453 assert_eq!(emails.len(), 1);
1454 }
1455 ref other => panic!(
1456 "existing={existing_auth:?}, expected AuthEdit::Set {{ auth: Emails }}, got {other:?}"
1457 ),
1458 }
1459 }
1460 }
1461
1462 #[test]
1463 fn build_edit_request_rejects_both_auth_flags() {
1464 let existing = make_existing(AuthType::None, false, vec![]);
1465 let err = build_edit_request(
1466 existing,
1467 EditOverrides {
1468 delete_in_days: None,
1469 max_access_count: None,
1470 hidden: false,
1471 password: Some("p".into()),
1472 emails: Some("[email protected]".into()),
1473 },
1474 )
1475 .unwrap_err();
1476 assert!(err.to_string().contains("mutually exclusive"));
1477 }
1478}