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 google_cloud_auth::signer::Signer;
19use jiff::Timestamp;
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<Timestamp>,
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(Timestamp::now);
378        let request_timestamp = now.strftime("%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.strftime("%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            .checked_add(self.expiration)
428            .map_err(|e| SigningError::signing(format!("Invalid expiration duration: {e}")))?;
429        let expiration_str = expiration_time.strftime("%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: Timestamp) -> 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: Timestamp = test
575                .policy_input
576                .timestamp
577                .parse()
578                .expect("invalid timestamp");
579            let scheme = test.policy_input.scheme.clone();
580
581            let url_style = match test.policy_input.url_style.as_deref() {
582                Some("VIRTUAL_HOSTED_STYLE") => UrlStyle::VirtualHostedStyle,
583                Some("BUCKET_BOUND_HOSTNAME") => UrlStyle::BucketBoundHostname,
584                _ => UrlStyle::PathStyle,
585            };
586
587            let mut builder = PostPolicyV4Builder::for_object(
588                format!("projects/_/buckets/{}", test.policy_input.bucket),
589                test.policy_input.object.clone(),
590            )
591            .with_url_style(url_style)
592            .with_timestamp(timestamp)
593            .with_expiration(Duration::from_secs(test.policy_input.expiration));
594
595            if let Some(hostname) = &test.policy_input.bucket_bound_hostname {
596                builder = builder.with_endpoint(format!("{}://{}", scheme, hostname));
597            }
598
599            if let Some(endpoint) = &test.policy_input.client_endpoint {
600                builder = builder.with_endpoint(endpoint.clone());
601            }
602
603            if let Some(domain) = &test.policy_input.universe_domain {
604                builder = builder.with_universe_domain(domain.clone());
605            }
606
607            if let Some(fields) = &test.policy_input.fields {
608                for (k, v) in fields {
609                    builder = builder.with_field(k.clone(), v.clone());
610                }
611            }
612
613            if let Some(conds) = &test.policy_input.conditions {
614                if let Some(starts_with) = &conds.starts_with
615                    && starts_with.len() == 2
616                {
617                    builder =
618                        builder.with_starts_with(starts_with[0].clone(), starts_with[1].clone());
619                }
620                if let Some(range) = &conds.content_length_range
621                    && range.len() == 2
622                {
623                    builder = builder.with_content_length_range(range[0], range[1]);
624                }
625            }
626
627            let result = builder.sign_with(&signer).await;
628            let result = match result {
629                Ok(res) => res,
630                Err(e) => {
631                    println!("❌ Failed test: {}", test.description);
632                    println!("Error: {}", e);
633                    failed_tests.push(test.description);
634                    continue;
635                }
636            };
637
638            let expected_fields = &test.policy_output.fields;
639            let mut mismatch = false;
640
641            // Verify URL
642            if result.url != test.policy_output.url {
643                println!("❌ Failed test: {}", test.description);
644                let diff =
645                    pretty_assertions::StrComparison::new(&result.url, &test.policy_output.url);
646                println!("URL diff: {}", diff);
647                mismatch = true;
648            }
649
650            // Verify Fields
651            for (k, v) in expected_fields {
652                let actual_val = result.fields.get(k);
653                match actual_val {
654                    Some(actual) if actual == v => {}
655                    Some(actual) => {
656                        println!("❌ Failed test: {} (field: {})", test.description, k);
657                        let diff = pretty_assertions::StrComparison::new(actual, v);
658                        println!("Field '{}' diff: {}", k, diff);
659                        mismatch = true;
660                    }
661                    None => {
662                        println!(
663                            "❌ Failed test: {} (missing field: {})",
664                            test.description, k
665                        );
666                        mismatch = true;
667                    }
668                }
669            }
670
671            // Verify No Extra Fields in actual
672            for k in result.fields.keys() {
673                if !expected_fields.contains_key(k) {
674                    println!(
675                        "❌ Failed test: {} (extra actual field: {})",
676                        test.description, k
677                    );
678                    mismatch = true;
679                }
680            }
681
682            if mismatch {
683                failed_tests.push(test.description);
684            } else {
685                passed_tests.push(test.description);
686            }
687        }
688
689        let failed = !failed_tests.is_empty();
690        let total_passed = passed_tests.len();
691        for test in passed_tests {
692            println!("✅ Passed test: {}", test);
693        }
694        for test in failed_tests {
695            println!("❌ Failed test: {}", test);
696        }
697        println!("{}/{} tests passed", total_passed, total_tests);
698
699        if failed {
700            anyhow::bail!("Some conformance tests failed")
701        }
702        Ok(())
703    }
704
705    #[tokio::test]
706    async fn post_policy_v4_edge_cases() {
707        let builder = PostPolicyV4Builder::for_object("projects/_/buckets/bucket", "object")
708            .with_client_email("test@example.com")
709            .with_universe_domain("custom.domain")
710            .with_endpoint("https://custom.endpoint:8080")
711            .with_starts_with("no_dollar_sign", "prefix");
712
713        // Test if !f.starts_with('$')
714        assert_eq!(
715            builder.starts_with_conditions[0],
716            ("$no_dollar_sign".to_string(), "prefix".to_string()),
717        );
718
719        // Test with_client_email, with_universe_domain, with_endpoint properties
720        assert_eq!(builder.client_email.as_deref(), Some("test@example.com"));
721        assert_eq!(builder.universe_domain.as_deref(), Some("custom.domain"));
722        assert_eq!(
723            builder.endpoint.as_deref(),
724            Some("https://custom.endpoint:8080")
725        );
726
727        // Test the mapping error in resolve_url
728        let bad_endpoint_builder =
729            PostPolicyV4Builder::for_object("projects/_/buckets/bucket", "object")
730                .with_endpoint("");
731        assert!(bad_endpoint_builder.resolve_url().is_err());
732
733        let service_account_key = serde_json::from_slice(include_bytes!(
734            "conformance/test_service_account.not-a-test.json",
735        ))
736        .unwrap();
737        let signer = ServiceAccount::new(service_account_key)
738            .build_signer()
739            .expect("failed to build signer");
740
741        // Test malformed bucket name prefix validation
742        let bad_bucket_builder = PostPolicyV4Builder::for_object("bucket", "object");
743        assert!(bad_bucket_builder.sign_with(&signer).await.is_err());
744
745        // Test SigningError::invalid_parameter of expiration (> 7 days)
746        let bad_expiration_builder =
747            PostPolicyV4Builder::for_object("projects/_/buckets/bucket", "object")
748                .with_expiration(Duration::from_secs(604801)); // > 7 days
749        assert!(bad_expiration_builder.sign_with(&signer).await.is_err());
750
751        // Test SigningError::invalid_parameter of content_length_range (min > max)
752        let bad_content_length_builder =
753            PostPolicyV4Builder::for_object("projects/_/buckets/bucket", "object")
754                .with_content_length_range(10, 5); // min > max
755        assert!(bad_content_length_builder.sign_with(&signer).await.is_err());
756    }
757
758    #[tokio::test]
759    async fn post_policy_v4_custom_fields() {
760        let assert_not_contains = |conditions: &[serde_json::Value], item: serde_json::Value| {
761            assert!(
762                !conditions.contains(&item),
763                "Expected conditions to NOT contain: {:?}",
764                item
765            );
766        };
767
768        let service_account_key = serde_json::from_slice(include_bytes!(
769            "conformance/test_service_account.not-a-test.json",
770        ))
771        .unwrap();
772        let signer = ServiceAccount::new(service_account_key)
773            .build_signer()
774            .expect("failed to build signer");
775
776        // Test custom fields:
777        // 1. Valid custom fields (e.g., x-goog-meta-*, acl) should be preserved.
778        // 2. Conflicting system keys should be silently overwritten in output, but both sent to backend.
779        let builder = PostPolicyV4Builder::for_object("projects/_/buckets/bucket", "object")
780            .with_field("x-goog-meta-custom", "custom_value")
781            .with_field("acl", "public-read")
782            .with_field("key", "malicious_key")
783            .with_field("x-goog-algorithm", "malicious_algo")
784            .with_field("x-goog-credential", "malicious_credential")
785            .with_field("x-goog-date", "malicious_date")
786            .with_field("x-goog-signature", "malicious_signature")
787            .with_field("policy", "malicious_policy")
788            .with_field("x-ignore-test-field", "ignored_value");
789
790        let result = builder.sign_with(&signer).await.unwrap();
791
792        // 1. Output Fields Check: Valid custom fields should be preserved
793        assert_eq!(
794            result.fields.get("x-goog-meta-custom").unwrap(),
795            "custom_value"
796        );
797        assert_eq!(result.fields.get("acl").unwrap(), "public-read");
798        assert_eq!(
799            result.fields.get("x-ignore-test-field").unwrap(),
800            "ignored_value"
801        );
802
803        // 2. Output Fields Check: System keys silently overwrote the malicious ones
804        assert_eq!(result.fields.get("key").unwrap(), "object");
805        assert_eq!(
806            result.fields.get("x-goog-algorithm").unwrap(),
807            "GOOG4-RSA-SHA256"
808        );
809        assert_ne!(
810            result.fields.get("x-goog-credential").unwrap(),
811            "malicious_credential"
812        );
813        assert_ne!(result.fields.get("x-goog-date").unwrap(), "malicious_date");
814        assert_ne!(
815            result.fields.get("x-goog-signature").unwrap(),
816            "malicious_signature"
817        );
818        assert_ne!(result.fields.get("policy").unwrap(), "malicious_policy");
819
820        // 3. Conditions Input: Verify the conditions array inside the generated policy does NOT
821        // contain the user's conflicting keys, only the system keys.
822        let decoded_policy = BASE64_STANDARD
823            .decode(result.fields.get("policy").unwrap())
824            .unwrap();
825        let policy_json: serde_json::Value = serde_json::from_slice(&decoded_policy).unwrap();
826        let conditions = policy_json.get("conditions").unwrap().as_array().unwrap();
827
828        // Check valid custom fields
829        assert!(conditions.contains(&serde_json::json!({"x-goog-meta-custom": "custom_value"})));
830        assert!(conditions.contains(&serde_json::json!({"acl": "public-read"})));
831        // Check that x-ignore- field is NOT in conditions of the signed policy
832        assert_not_contains(
833            conditions,
834            serde_json::json!({"x-ignore-test-field": "ignored_value"}),
835        );
836
837        // Check conflicting user keys vs system keys: system keys must override user keys in conditions
838        assert_not_contains(conditions, serde_json::json!({"key": "malicious_key"}));
839        assert_not_contains(
840            conditions,
841            serde_json::json!({"x-goog-algorithm": "malicious_algo"}),
842        );
843        assert_not_contains(
844            conditions,
845            serde_json::json!({"x-goog-credential": "malicious_credential"}),
846        );
847        assert_not_contains(
848            conditions,
849            serde_json::json!({"x-goog-date": "malicious_date"}),
850        );
851        assert_not_contains(
852            conditions,
853            serde_json::json!({"x-goog-signature": "malicious_signature"}),
854        );
855        assert_not_contains(
856            conditions,
857            serde_json::json!({"policy": "malicious_policy"}),
858        );
859
860        assert!(conditions.contains(&serde_json::json!({"key": "object"})));
861    }
862}