1use crate::{error::SigningError, signed_url::UrlStyle};
16use base64::Engine;
17use base64::prelude::BASE64_STANDARD;
18use chrono::{DateTime, Utc};
19use google_cloud_auth::signer::Signer;
20use std::collections::BTreeMap;
21use std::time::Duration;
22
23#[derive(Debug, Clone)]
84pub struct PostPolicyV4Builder {
85 bucket: String,
86 object: String,
87 expiration: Duration,
88 timestamp: Option<DateTime<Utc>>,
89 url_style: UrlStyle,
90 starts_with_conditions: Vec<(String, String)>,
91 content_length_range: Option<(u64, u64)>,
92 fields: BTreeMap<String, String>,
93 client_email: Option<String>,
94 universe_domain: Option<String>,
95 endpoint: Option<String>,
96}
97
98#[derive(Debug, Clone, serde::Serialize)]
100#[non_exhaustive]
101pub struct PostPolicyV4Result {
102 pub url: String,
104 pub fields: BTreeMap<String, String>,
106}
107
108#[derive(Debug, serde::Serialize)]
110struct PostPolicyV4Document {
111 conditions: Vec<serde_json::Value>,
112 expiration: String,
113}
114
115impl PostPolicyV4Builder {
116 pub fn for_object<B, O>(bucket: B, object: O) -> Self
125 where
126 B: Into<String>,
127 O: Into<String>,
128 {
129 Self {
130 bucket: bucket.into(),
131 object: object.into(),
132 expiration: Duration::from_secs(604800), timestamp: None,
134 url_style: UrlStyle::PathStyle,
135 starts_with_conditions: Vec::new(),
136 content_length_range: None,
137 fields: BTreeMap::new(),
138 client_email: None,
139 universe_domain: None,
140 endpoint: None,
141 }
142 }
143
144 pub fn with_expiration(mut self, expiration: Duration) -> Self {
156 self.expiration = expiration;
157 self
158 }
159
160 pub fn with_url_style(mut self, url_style: UrlStyle) -> Self {
172 self.url_style = url_style;
173 self
174 }
175
176 pub fn with_client_email<S: Into<String>>(mut self, client_email: S) -> Self {
187 self.client_email = Some(client_email.into());
188 self
189 }
190
191 pub fn with_universe_domain<S: Into<String>>(mut self, universe_domain: S) -> Self {
202 self.universe_domain = Some(universe_domain.into());
203 self
204 }
205
206 pub fn with_endpoint<S: Into<String>>(mut self, endpoint: S) -> Self {
217 self.endpoint = Some(endpoint.into());
218 self
219 }
220
221 pub fn with_field<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
233 self.fields.insert(key.into(), value.into());
234 self
235 }
236
237 pub fn with_starts_with<F: Into<String>, P: Into<String>>(
248 mut self,
249 field: F,
250 prefix: P,
251 ) -> Self {
252 let mut f: String = field.into();
253 if !f.starts_with('$') {
254 f.insert(0, '$');
255 }
256 self.starts_with_conditions.push((f, prefix.into()));
257 self
258 }
259
260 pub fn with_content_length_range(mut self, min: u64, max: u64) -> Self {
271 self.content_length_range = Some((min, max));
272 self
273 }
274
275 fn bucket_name(&self) -> &str {
276 self.bucket
277 .strip_prefix("projects/_/buckets/")
278 .unwrap_or(&self.bucket)
279 }
280
281 fn check_bucket_name(&self) -> Result<(), SigningError> {
282 self.bucket
283 .strip_prefix("projects/_/buckets/")
284 .ok_or_else(|| {
285 SigningError::invalid_parameter(
286 "bucket",
287 format!(
288 "malformed bucket name, it must start with `projects/_/buckets/`: {}",
289 self.bucket
290 ),
291 )
292 })?;
293 Ok(())
294 }
295
296 fn resolve_endpoint(&self) -> String {
297 match self.endpoint.as_ref() {
298 Some(e) if e.starts_with("http://") => e.clone(),
299 Some(e) if e.starts_with("https://") => e.clone(),
300 Some(e) => format!("https://{}", e),
301 None => {
302 let universe_domain = self.universe_domain.as_deref().unwrap_or("googleapis.com");
303 format!("https://storage.{universe_domain}")
304 }
305 }
306 }
307
308 fn resolve_url(&self) -> Result<String, SigningError> {
309 let bucket_name = self.bucket_name();
310
311 let endpoint_url = self.resolve_endpoint();
312 let url = url::Url::parse(&endpoint_url)
313 .map_err(|err| SigningError::invalid_parameter("endpoint", err))?;
314
315 let scheme = url.scheme();
316 let _host = url
317 .host_str()
318 .ok_or_else(|| SigningError::invalid_parameter("endpoint", "Missing host"))?;
319
320 let path = url.path();
324 let scheme_prefix = format!("{}://", scheme);
325 let host_with_port = endpoint_url
326 .trim_start_matches(&scheme_prefix)
327 .trim_end_matches(path);
328
329 let url = match self.url_style {
330 UrlStyle::PathStyle => {
331 format!("{}://{}/{}/", scheme, host_with_port, bucket_name)
332 }
333 UrlStyle::VirtualHostedStyle => {
334 format!("{}://{}.{}/", scheme, bucket_name, host_with_port)
335 }
336 UrlStyle::BucketBoundHostname => {
337 format!("{}://{}/", scheme, host_with_port)
338 }
339 };
340
341 Ok(url)
342 }
343
344 pub async fn sign_with(mut self, signer: &Signer) -> Result<PostPolicyV4Result, SigningError> {
359 self.check_bucket_name()?;
360
361 if self.expiration > Duration::from_secs(604800) {
362 return Err(SigningError::invalid_parameter(
363 "expiration",
364 "Expiration cannot exceed 7 days (604,800 seconds)",
365 ));
366 }
367
368 if let Some((min, max)) = self.content_length_range
369 && min > max
370 {
371 return Err(SigningError::invalid_parameter(
372 "content_length_range",
373 "min must be less than or equal to max",
374 ));
375 }
376
377 let now = self.timestamp.unwrap_or_else(Utc::now);
378 let request_timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
379
380 let client_email = if let Some(email) = self.client_email.take() {
381 email
382 } else {
383 signer.client_email().await.map_err(SigningError::signing)?
384 };
385 let credential = format!(
386 "{client_email}/{}/auto/storage/goog4_request",
387 now.format("%Y%m%d")
388 );
389
390 let mut conditions = Vec::new();
391
392 let system_keys = [
394 "bucket",
395 "key",
396 "x-goog-date",
397 "x-goog-credential",
398 "x-goog-algorithm",
399 "x-goog-signature",
400 "policy",
401 ];
402 for (key, value) in &self.fields {
403 if !key.starts_with("x-ignore-") && !system_keys.contains(&key.as_str()) {
404 conditions.push(serde_json::json!({ key: value }));
405 }
406 }
407
408 for (field, prefix) in &self.starts_with_conditions {
410 conditions.push(serde_json::json!(["starts-with", field, prefix]));
411 }
412
413 if let Some((min, max)) = self.content_length_range {
415 conditions.push(serde_json::json!(["content-length-range", min, max]));
416 }
417
418 conditions.push(serde_json::json!({ "bucket": self.bucket_name() }));
420 conditions.push(serde_json::json!({ "key": self.object }));
421 conditions.push(serde_json::json!({ "x-goog-date": request_timestamp }));
422 conditions.push(serde_json::json!({ "x-goog-credential": credential }));
423 conditions.push(serde_json::json!({ "x-goog-algorithm": "GOOG4-RSA-SHA256" }));
424
425 let expiration_time = now
427 + chrono::Duration::from_std(self.expiration)
428 .map_err(|e| SigningError::signing(format!("Invalid expiration duration: {e}")))?;
429 let expiration_str = expiration_time.format("%Y-%m-%dT%H:%M:%SZ").to_string();
430
431 let doc = PostPolicyV4Document {
432 conditions,
433 expiration: expiration_str,
434 };
435
436 let serialized = serde_json::to_string(&doc)
438 .map_err(|e| SigningError::signing(format!("JSON serialization failed: {e}")))?;
439
440 let escaped_json = escape_non_ascii(&serialized);
441
442 let encoded_policy = BASE64_STANDARD.encode(escaped_json.as_bytes());
444
445 let signature_bytes = signer
447 .sign(encoded_policy.as_bytes())
448 .await
449 .map_err(SigningError::signing)?;
450
451 let signature_hex = hex::encode(signature_bytes);
452
453 let url = self.resolve_url()?;
455
456 let mut fields = BTreeMap::new();
458
459 for (key, value) in &self.fields {
461 fields.insert(key.clone(), value.clone());
462 }
463
464 fields.insert("key".to_string(), self.object.clone());
466 fields.insert(
467 "x-goog-algorithm".to_string(),
468 "GOOG4-RSA-SHA256".to_string(),
469 );
470 fields.insert("x-goog-credential".to_string(), credential);
471 fields.insert("x-goog-date".to_string(), request_timestamp);
472 fields.insert("x-goog-signature".to_string(), signature_hex);
473 fields.insert("policy".to_string(), encoded_policy);
474
475 Ok(PostPolicyV4Result { url, fields })
476 }
477}
478
479fn escape_non_ascii(s: &str) -> String {
480 use std::fmt::Write;
481 let mut escaped = String::with_capacity(s.len());
482 let mut buf = [0; 2];
483 for c in s.chars() {
484 if c.is_ascii() {
485 escaped.push(c);
486 } else {
487 for &mut unit in c.encode_utf16(&mut buf) {
488 let _ = write!(escaped, "\\u{:04x}", unit);
489 }
490 }
491 }
492 escaped
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use crate::signed_url::UrlStyle;
499 use google_cloud_auth::credentials::service_account::Builder as ServiceAccount;
500 use serde::Deserialize;
501 use std::collections::HashMap;
502
503 impl PostPolicyV4Builder {
504 pub(crate) fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
506 self.timestamp = Some(timestamp);
507 self
508 }
509 }
510
511 #[derive(Deserialize)]
512 #[serde(rename_all = "camelCase")]
513 struct PostPolicyV4TestSuite {
514 post_policy_v4_tests: Vec<PostPolicyV4Test>,
515 }
516
517 #[derive(Deserialize)]
518 #[serde(rename_all = "camelCase")]
519 struct PostPolicyV4Test {
520 description: String,
521 policy_input: PostPolicyV4TestInput,
522 policy_output: PostPolicyV4TestOutput,
523 }
524
525 #[derive(Deserialize)]
526 #[serde(rename_all = "camelCase")]
527 struct PostPolicyV4TestInput {
528 scheme: String,
529 bucket: String,
530 object: String,
531 expiration: u64,
532 timestamp: String,
533 url_style: Option<String>,
534 bucket_bound_hostname: Option<String>,
535 client_endpoint: Option<String>,
536 universe_domain: Option<String>,
537 fields: Option<HashMap<String, String>>,
538 conditions: Option<PostPolicyV4TestConditions>,
539 }
540
541 #[derive(Deserialize)]
542 #[serde(rename_all = "camelCase")]
543 struct PostPolicyV4TestConditions {
544 starts_with: Option<Vec<String>>,
545 content_length_range: Option<Vec<u64>>,
546 }
547
548 #[derive(Deserialize)]
549 #[serde(rename_all = "camelCase")]
550 struct PostPolicyV4TestOutput {
551 url: String,
552 fields: HashMap<String, String>,
553 _expected_decoded_policy: String,
554 }
555
556 #[tokio::test]
557 async fn post_policy_v4_conformance() -> anyhow::Result<()> {
558 let service_account_key = serde_json::from_slice(include_bytes!(
559 "conformance/test_service_account.not-a-test.json"
560 ))?;
561
562 let signer = ServiceAccount::new(service_account_key)
563 .build_signer()
564 .expect("failed to build signer");
565
566 let suite: PostPolicyV4TestSuite =
567 serde_json::from_slice(include_bytes!("conformance/v4_signatures.json"))?;
568
569 let mut failed_tests = Vec::new();
570 let mut passed_tests = Vec::new();
571 let total_tests = suite.post_policy_v4_tests.len();
572
573 for test in suite.post_policy_v4_tests {
574 let timestamp = DateTime::parse_from_rfc3339(&test.policy_input.timestamp)
575 .expect("invalid timestamp");
576 let scheme = test.policy_input.scheme.clone();
577
578 let url_style = match test.policy_input.url_style.as_deref() {
579 Some("VIRTUAL_HOSTED_STYLE") => UrlStyle::VirtualHostedStyle,
580 Some("BUCKET_BOUND_HOSTNAME") => UrlStyle::BucketBoundHostname,
581 _ => UrlStyle::PathStyle,
582 };
583
584 let mut builder = PostPolicyV4Builder::for_object(
585 format!("projects/_/buckets/{}", test.policy_input.bucket),
586 test.policy_input.object.clone(),
587 )
588 .with_url_style(url_style)
589 .with_timestamp(timestamp.into())
590 .with_expiration(Duration::from_secs(test.policy_input.expiration));
591
592 if let Some(hostname) = &test.policy_input.bucket_bound_hostname {
593 builder = builder.with_endpoint(format!("{}://{}", scheme, hostname));
594 }
595
596 if let Some(endpoint) = &test.policy_input.client_endpoint {
597 builder = builder.with_endpoint(endpoint.clone());
598 }
599
600 if let Some(domain) = &test.policy_input.universe_domain {
601 builder = builder.with_universe_domain(domain.clone());
602 }
603
604 if let Some(fields) = &test.policy_input.fields {
605 for (k, v) in fields {
606 builder = builder.with_field(k.clone(), v.clone());
607 }
608 }
609
610 if let Some(conds) = &test.policy_input.conditions {
611 if let Some(starts_with) = &conds.starts_with
612 && starts_with.len() == 2
613 {
614 builder =
615 builder.with_starts_with(starts_with[0].clone(), starts_with[1].clone());
616 }
617 if let Some(range) = &conds.content_length_range
618 && range.len() == 2
619 {
620 builder = builder.with_content_length_range(range[0], range[1]);
621 }
622 }
623
624 let result = builder.sign_with(&signer).await;
625 let result = match result {
626 Ok(res) => res,
627 Err(e) => {
628 println!("❌ Failed test: {}", test.description);
629 println!("Error: {}", e);
630 failed_tests.push(test.description);
631 continue;
632 }
633 };
634
635 let expected_fields = &test.policy_output.fields;
636 let mut mismatch = false;
637
638 if result.url != test.policy_output.url {
640 println!("❌ Failed test: {}", test.description);
641 let diff =
642 pretty_assertions::StrComparison::new(&result.url, &test.policy_output.url);
643 println!("URL diff: {}", diff);
644 mismatch = true;
645 }
646
647 for (k, v) in expected_fields {
649 let actual_val = result.fields.get(k);
650 match actual_val {
651 Some(actual) if actual == v => {}
652 Some(actual) => {
653 println!("❌ Failed test: {} (field: {})", test.description, k);
654 let diff = pretty_assertions::StrComparison::new(actual, v);
655 println!("Field '{}' diff: {}", k, diff);
656 mismatch = true;
657 }
658 None => {
659 println!(
660 "❌ Failed test: {} (missing field: {})",
661 test.description, k
662 );
663 mismatch = true;
664 }
665 }
666 }
667
668 for k in result.fields.keys() {
670 if !expected_fields.contains_key(k) {
671 println!(
672 "❌ Failed test: {} (extra actual field: {})",
673 test.description, k
674 );
675 mismatch = true;
676 }
677 }
678
679 if mismatch {
680 failed_tests.push(test.description);
681 } else {
682 passed_tests.push(test.description);
683 }
684 }
685
686 let failed = !failed_tests.is_empty();
687 let total_passed = passed_tests.len();
688 for test in passed_tests {
689 println!("✅ Passed test: {}", test);
690 }
691 for test in failed_tests {
692 println!("❌ Failed test: {}", test);
693 }
694 println!("{}/{} tests passed", total_passed, total_tests);
695
696 if failed {
697 anyhow::bail!("Some conformance tests failed")
698 }
699 Ok(())
700 }
701
702 #[tokio::test]
703 async fn post_policy_v4_edge_cases() {
704 let builder = PostPolicyV4Builder::for_object("projects/_/buckets/bucket", "object")
705 .with_client_email("test@example.com")
706 .with_universe_domain("custom.domain")
707 .with_endpoint("https://custom.endpoint:8080")
708 .with_starts_with("no_dollar_sign", "prefix");
709
710 assert_eq!(
712 builder.starts_with_conditions[0],
713 ("$no_dollar_sign".to_string(), "prefix".to_string()),
714 );
715
716 assert_eq!(builder.client_email.as_deref(), Some("test@example.com"));
718 assert_eq!(builder.universe_domain.as_deref(), Some("custom.domain"));
719 assert_eq!(
720 builder.endpoint.as_deref(),
721 Some("https://custom.endpoint:8080")
722 );
723
724 let bad_endpoint_builder =
726 PostPolicyV4Builder::for_object("projects/_/buckets/bucket", "object")
727 .with_endpoint("");
728 assert!(bad_endpoint_builder.resolve_url().is_err());
729
730 let service_account_key = serde_json::from_slice(include_bytes!(
731 "conformance/test_service_account.not-a-test.json",
732 ))
733 .unwrap();
734 let signer = ServiceAccount::new(service_account_key)
735 .build_signer()
736 .expect("failed to build signer");
737
738 let bad_bucket_builder = PostPolicyV4Builder::for_object("bucket", "object");
740 assert!(bad_bucket_builder.sign_with(&signer).await.is_err());
741
742 let bad_expiration_builder =
744 PostPolicyV4Builder::for_object("projects/_/buckets/bucket", "object")
745 .with_expiration(Duration::from_secs(604801)); assert!(bad_expiration_builder.sign_with(&signer).await.is_err());
747
748 let bad_content_length_builder =
750 PostPolicyV4Builder::for_object("projects/_/buckets/bucket", "object")
751 .with_content_length_range(10, 5); assert!(bad_content_length_builder.sign_with(&signer).await.is_err());
753 }
754
755 #[tokio::test]
756 async fn post_policy_v4_custom_fields() {
757 let assert_not_contains = |conditions: &[serde_json::Value], item: serde_json::Value| {
758 assert!(
759 !conditions.contains(&item),
760 "Expected conditions to NOT contain: {:?}",
761 item
762 );
763 };
764
765 let service_account_key = serde_json::from_slice(include_bytes!(
766 "conformance/test_service_account.not-a-test.json",
767 ))
768 .unwrap();
769 let signer = ServiceAccount::new(service_account_key)
770 .build_signer()
771 .expect("failed to build signer");
772
773 let builder = PostPolicyV4Builder::for_object("projects/_/buckets/bucket", "object")
777 .with_field("x-goog-meta-custom", "custom_value")
778 .with_field("acl", "public-read")
779 .with_field("key", "malicious_key")
780 .with_field("x-goog-algorithm", "malicious_algo")
781 .with_field("x-goog-credential", "malicious_credential")
782 .with_field("x-goog-date", "malicious_date")
783 .with_field("x-goog-signature", "malicious_signature")
784 .with_field("policy", "malicious_policy")
785 .with_field("x-ignore-test-field", "ignored_value");
786
787 let result = builder.sign_with(&signer).await.unwrap();
788
789 assert_eq!(
791 result.fields.get("x-goog-meta-custom").unwrap(),
792 "custom_value"
793 );
794 assert_eq!(result.fields.get("acl").unwrap(), "public-read");
795 assert_eq!(
796 result.fields.get("x-ignore-test-field").unwrap(),
797 "ignored_value"
798 );
799
800 assert_eq!(result.fields.get("key").unwrap(), "object");
802 assert_eq!(
803 result.fields.get("x-goog-algorithm").unwrap(),
804 "GOOG4-RSA-SHA256"
805 );
806 assert_ne!(
807 result.fields.get("x-goog-credential").unwrap(),
808 "malicious_credential"
809 );
810 assert_ne!(result.fields.get("x-goog-date").unwrap(), "malicious_date");
811 assert_ne!(
812 result.fields.get("x-goog-signature").unwrap(),
813 "malicious_signature"
814 );
815 assert_ne!(result.fields.get("policy").unwrap(), "malicious_policy");
816
817 let decoded_policy = BASE64_STANDARD
820 .decode(result.fields.get("policy").unwrap())
821 .unwrap();
822 let policy_json: serde_json::Value = serde_json::from_slice(&decoded_policy).unwrap();
823 let conditions = policy_json.get("conditions").unwrap().as_array().unwrap();
824
825 assert!(conditions.contains(&serde_json::json!({"x-goog-meta-custom": "custom_value"})));
827 assert!(conditions.contains(&serde_json::json!({"acl": "public-read"})));
828 assert_not_contains(
830 conditions,
831 serde_json::json!({"x-ignore-test-field": "ignored_value"}),
832 );
833
834 assert_not_contains(conditions, serde_json::json!({"key": "malicious_key"}));
836 assert_not_contains(
837 conditions,
838 serde_json::json!({"x-goog-algorithm": "malicious_algo"}),
839 );
840 assert_not_contains(
841 conditions,
842 serde_json::json!({"x-goog-credential": "malicious_credential"}),
843 );
844 assert_not_contains(
845 conditions,
846 serde_json::json!({"x-goog-date": "malicious_date"}),
847 );
848 assert_not_contains(
849 conditions,
850 serde_json::json!({"x-goog-signature": "malicious_signature"}),
851 );
852 assert_not_contains(
853 conditions,
854 serde_json::json!({"policy": "malicious_policy"}),
855 );
856
857 assert!(conditions.contains(&serde_json::json!({"key": "object"})));
858 }
859}