Skip to main content

google_cloud_storage/storage/
post_policy.rs

1// Copyright 2026 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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/// Creates V4 [Signed Policy Document] (POST Object Forms).
24///
25/// This builder allows you to generate signed V4 POST policy documents for Google Cloud Storage.
26/// A [Signed Policy Document] enables unauthenticated users to upload files to GCS using an HTML form
27/// by providing a time-limited signature and enforcing conditions on the upload (like file size limits).
28///
29/// # Example: Generating a Signed POST Policy
30///
31/// ```
32/// # use google_cloud_storage::builder::storage::PostPolicyV4Builder;
33/// use std::time::Duration;
34/// # use google_cloud_auth::signer::Signer;
35/// # async fn run(signer: &Signer) -> anyhow::Result<()> {
36/// let policy = PostPolicyV4Builder::for_object("projects/_/buckets/my-bucket", "uploads/my-object.txt")
37///     .with_expiration(Duration::from_secs(3600)) // 1 hour
38///     .with_content_length_range(1, 10 * 1024 * 1024) // 1 byte to 10 MiB limit
39///     .with_starts_with("$key", "uploads/") // Enforce upload prefix
40///     .with_field("Content-Type", "text/plain") // Enforce content type
41///     .sign_with(signer)
42///     .await?;
43///
44/// println!("Upload URL: {}", policy.url);
45/// for (key, value) in &policy.fields {
46///     println!("Form field -> {}: {}", key, value);
47/// }
48/// # Ok(())
49/// # }
50/// ```
51///
52/// # Example: Creating a Signer
53///
54/// You can use `google-cloud-auth` to create a `Signer`.
55///
56/// ## Using [Application Default Credentials] (ADC)
57///
58/// ```
59/// use google_cloud_auth::credentials::Builder;
60/// use google_cloud_auth::signer::Signer;
61///
62/// # fn build_signer() -> anyhow::Result<()> {
63/// let signer = Builder::default().build_signer()?;
64/// # Ok(())
65/// # }
66/// ```
67///
68/// ## Using a Service Account Key File
69///
70/// ```
71/// use google_cloud_auth::credentials::service_account::Builder;
72/// use google_cloud_auth::signer::Signer;
73///
74/// # async fn build_signer() -> anyhow::Result<()> {
75/// let service_account_key = serde_json::json!({ /* add details here */ });
76/// let signer = Builder::new(service_account_key).build_signer()?;
77/// # Ok(())
78/// # }
79/// ```
80///
81/// [Application Default Credentials]: https://docs.cloud.google.com/docs/authentication/application-default-credentials
82/// [Signed Policy Document]: https://docs.cloud.google.com/storage/docs/authentication/signatures#policy-document
83#[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/// The result of signing a V4 POST Policy Document.
99#[derive(Debug, Clone, serde::Serialize)]
100#[non_exhaustive]
101pub struct PostPolicyV4Result {
102    /// The destination URL for the POST request.
103    pub url: String,
104    /// The form fields (hidden inputs) that must be included in the multipart POST request.
105    pub fields: BTreeMap<String, String>,
106}
107
108/// Private internal structure for serializing the GCS policy JSON document.
109#[derive(Debug, serde::Serialize)]
110struct PostPolicyV4Document {
111    conditions: Vec<serde_json::Value>,
112    expiration: String,
113}
114
115impl PostPolicyV4Builder {
116    /// Creates a new builder for the specified bucket and object.
117    ///
118    /// # Example
119    ///
120    /// ```
121    /// # use google_cloud_storage::builder::storage::PostPolicyV4Builder;
122    /// let builder = PostPolicyV4Builder::for_object("projects/_/buckets/my-bucket", "my-object.txt");
123    /// ```
124    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), // Default to max: 7 days
133            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    /// Sets the policy expiration duration. Maximum is 7 days (604,800 seconds).
145    ///
146    /// # Example
147    ///
148    /// ```
149    /// # use google_cloud_storage::builder::storage::PostPolicyV4Builder;
150    /// use std::time::Duration;
151    ///
152    /// let builder = PostPolicyV4Builder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
153    ///     .with_expiration(Duration::from_secs(3600));
154    /// ```
155    pub fn with_expiration(mut self, expiration: Duration) -> Self {
156        self.expiration = expiration;
157        self
158    }
159
160    /// Sets the URL formatting style.
161    ///
162    /// # Example
163    ///
164    /// ```
165    /// # use google_cloud_storage::builder::storage::PostPolicyV4Builder;
166    /// use google_cloud_storage::signed_url::UrlStyle;
167    ///
168    /// let builder = PostPolicyV4Builder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
169    ///     .with_url_style(UrlStyle::VirtualHostedStyle);
170    /// ```
171    pub fn with_url_style(mut self, url_style: UrlStyle) -> Self {
172        self.url_style = url_style;
173        self
174    }
175
176    /// Sets the authorizer client email. If not set, it falls back to the signer's email.
177    ///
178    /// # Example
179    ///
180    /// ```
181    /// # use google_cloud_storage::builder::storage::PostPolicyV4Builder;
182    ///
183    /// let builder = PostPolicyV4Builder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
184    ///     .with_client_email("my-service-account@my-project.iam.gserviceaccount.com");
185    /// ```
186    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    /// Sets the GCS universe domain (defaults to `googleapis.com`).
192    ///
193    /// # Example
194    ///
195    /// ```
196    /// # use google_cloud_storage::builder::storage::PostPolicyV4Builder;
197    ///
198    /// let builder = PostPolicyV4Builder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
199    ///     .with_universe_domain("googleapis.com");
200    /// ```
201    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    /// Sets a custom endpoint.
207    ///
208    /// # Example
209    ///
210    /// ```
211    /// # use google_cloud_storage::builder::storage::PostPolicyV4Builder;
212    ///
213    /// let builder = PostPolicyV4Builder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
214    ///     .with_endpoint("https://private.googleapis.com");
215    /// ```
216    pub fn with_endpoint<S: Into<String>>(mut self, endpoint: S) -> Self {
217        self.endpoint = Some(endpoint.into());
218        self
219    }
220
221    /// Adds a form field/exact condition match (e.g. "acl" = "public-read").
222    ///
223    /// # Example
224    ///
225    /// ```
226    /// # use google_cloud_storage::builder::storage::PostPolicyV4Builder;
227    ///
228    /// let builder = PostPolicyV4Builder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
229    ///     .with_field("acl", "public-read")
230    ///     .with_field("Content-Type", "text/plain");
231    /// ```
232    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    /// Adds a starts-with condition constraint (e.g. "$key", "uploads/").
238    ///
239    /// # Example
240    ///
241    /// ```
242    /// # use google_cloud_storage::builder::storage::PostPolicyV4Builder;
243    ///
244    /// let builder = PostPolicyV4Builder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
245    ///     .with_starts_with("$key", "uploads/");
246    /// ```
247    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    /// Adds a content-length-range constraint (minimum and maximum file size in bytes).
261    ///
262    /// # Example
263    ///
264    /// ```
265    /// # use google_cloud_storage::builder::storage::PostPolicyV4Builder;
266    ///
267    /// let builder = PostPolicyV4Builder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
268    ///     .with_content_length_range(1, 10 * 1024 * 1024); // 1 byte to 10 MiB
269    /// ```
270    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        // Extract host and port exactly as they appear in the endpoint.
321        // We do this because the url crate omits default ports (80/443),
322        // but GCS requires them to be maintained if explicitly provided.
323        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    /// Sign the policy document.
345    ///
346    /// # Example
347    ///
348    /// ```
349    /// # use google_cloud_storage::builder::storage::PostPolicyV4Builder;
350    /// # use google_cloud_auth::signer::Signer;
351    /// async fn run(signer: &Signer) -> anyhow::Result<()> {
352    ///     let policy = PostPolicyV4Builder::for_object("projects/_/buckets/my-bucket", "my-object.txt")
353    ///         .sign_with(signer)
354    ///         .await?;
355    /// # Ok(())
356    /// }
357    /// ```
358    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        // 1. Add custom headers/metadata (except x-ignore- fields and system fields)
393        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        // 2. Add starts-with conditions
409        for (field, prefix) in &self.starts_with_conditions {
410            conditions.push(serde_json::json!(["starts-with", field, prefix]));
411        }
412
413        // 3. Add content-length-range condition
414        if let Some((min, max)) = self.content_length_range {
415            conditions.push(serde_json::json!(["content-length-range", min, max]));
416        }
417
418        // 4. Add required conditions
419        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        // Expiration
426        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        // Serialize to minified JSON string (retaining "conditions" first then "expiration")
437        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        // Base64 encode
443        let encoded_policy = BASE64_STANDARD.encode(escaped_json.as_bytes());
444
445        // Sign the base64 string
446        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        // Build target URL
454        let url = self.resolve_url()?;
455
456        // Build output form fields
457        let mut fields = BTreeMap::new();
458
459        // Add user-supplied fields (including custom metadata or x-ignore- fields)
460        for (key, value) in &self.fields {
461            fields.insert(key.clone(), value.clone());
462        }
463
464        // Add required system fields
465        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        /// Sets the creation timestamp for the policy signature. Only used in tests.
505        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            // Verify URL
639            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            // Verify Fields
648            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            // Verify No Extra Fields in actual
669            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        // Test if !f.starts_with('$')
711        assert_eq!(
712            builder.starts_with_conditions[0],
713            ("$no_dollar_sign".to_string(), "prefix".to_string()),
714        );
715
716        // Test with_client_email, with_universe_domain, with_endpoint properties
717        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        // Test the mapping error in resolve_url
725        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        // Test malformed bucket name prefix validation
739        let bad_bucket_builder = PostPolicyV4Builder::for_object("bucket", "object");
740        assert!(bad_bucket_builder.sign_with(&signer).await.is_err());
741
742        // Test SigningError::invalid_parameter of expiration (> 7 days)
743        let bad_expiration_builder =
744            PostPolicyV4Builder::for_object("projects/_/buckets/bucket", "object")
745                .with_expiration(Duration::from_secs(604801)); // > 7 days
746        assert!(bad_expiration_builder.sign_with(&signer).await.is_err());
747
748        // Test SigningError::invalid_parameter of content_length_range (min > max)
749        let bad_content_length_builder =
750            PostPolicyV4Builder::for_object("projects/_/buckets/bucket", "object")
751                .with_content_length_range(10, 5); // min > max
752        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        // Test custom fields:
774        // 1. Valid custom fields (e.g., x-goog-meta-*, acl) should be preserved.
775        // 2. Conflicting system keys should be silently overwritten in output, but both sent to backend.
776        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        // 1. Output Fields Check: Valid custom fields should be preserved
790        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        // 2. Output Fields Check: System keys silently overwrote the malicious ones
801        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        // 3. Conditions Input: Verify the conditions array inside the generated policy does NOT
818        // contain the user's conflicting keys, only the system keys.
819        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        // Check valid custom fields
826        assert!(conditions.contains(&serde_json::json!({"x-goog-meta-custom": "custom_value"})));
827        assert!(conditions.contains(&serde_json::json!({"acl": "public-read"})));
828        // Check that x-ignore- field is NOT in conditions of the signed policy
829        assert_not_contains(
830            conditions,
831            serde_json::json!({"x-ignore-test-field": "ignored_value"}),
832        );
833
834        // Check conflicting user keys vs system keys: system keys must override user keys in conditions
835        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}