Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-774-base64url-padding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---

fix(gmail): re-pad base64url attachment data for standard decoder compatibility
90 changes: 90 additions & 0 deletions crates/google-workspace-cli/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,13 @@ pub async fn execute_method(
.await
.context("Failed to read response body")?;

let body_text =
if method_id == "gmail.users.messages.attachments.get" {
pad_attachment_data(body_text)
} else {
body_text
};

let should_continue = handle_json_response(
&body_text,
pagination,
Expand Down Expand Up @@ -1186,6 +1193,30 @@ pub fn mime_to_extension(mime: &str) -> &str {
}
}

/// Re-pad the `data` field in a `gmail.users.messages.attachments.get` response.
///
/// Google's API returns unpadded base64url strings (RFC 4648 §5). Standard decoders
/// in Python, Node.js, and other languages require `=` padding; without it they raise
/// an error when `len % 4 != 0`. This function parses the JSON response, appends the
/// correct number of `=` characters to the `data` field if the field is present, and
/// re-serialises. If the field is already correctly padded the formula `(4 - len%4)%4`
/// yields 0, so no extra `=` characters are added.
fn pad_attachment_data(body: String) -> String {
let mut val: Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(_) => return body,
};

if let Some(obj) = val.as_object_mut() {
if let Some(Value::String(data)) = obj.get_mut("data") {
let padding = (4 - data.len() % 4) % 4;
data.push_str(&"=".repeat(padding));
}
}

serde_json::to_string(&val).unwrap_or_else(|_| body)
}
Comment on lines +1204 to +1218
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Parsing and re-serializing the entire JSON response body just to append a few padding characters to the data field is highly inefficient, especially for large Gmail attachments (which can be up to 25MB). This approach causes significant CPU overhead and multiple large memory allocations.

Instead, we can perform a highly efficient, in-place string scan to locate the "data" key and append the padding characters directly to the String without parsing or re-serializing the JSON.

fn pad_attachment_data(mut body: String) -> String {
    let mut start_idx = 0;
    while let Some(key_idx) = body[start_idx..].find("\"data\"").map(|idx| start_idx + idx) {
        let rest = &body[key_idx + 6..];
        if let Some(colon_relative_idx) = rest.find(|c: char| !c.is_whitespace()) {
            if rest.as_bytes()[colon_relative_idx] == b':' {
                let after_colon = &rest[colon_relative_idx + 1..];
                if let Some(quote_relative_idx) = after_colon.find(|c: char| !c.is_whitespace()) {
                    if after_colon.as_bytes()[quote_relative_idx] == b'"' {
                        let value_start = key_idx + 6 + colon_relative_idx + 1 + quote_relative_idx + 1;
                        let after_quote = &body[value_start..];
                        if let Some(quote_end) = after_quote.find('"') {
                            let value_end = value_start + quote_end;
                            let data_str = &body[value_start..value_end];
                            if data_str.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
                                let padding = (4 - data_str.len() % 4) % 4;
                                if padding > 0 {
                                    body.insert_str(value_end, &"=".repeat(padding));
                                }
                                return body;
                            }
                        }
                    }
                }
            }
        }
        start_idx = key_idx + 6;
    }
    body
}


#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -2024,6 +2055,65 @@ mod tests {
_ => panic!("Expected Api error"),
}
}

#[test]
fn test_pad_attachment_data_no_padding_needed() {
// "abcd" has length 4, so (4 - 4%4)%4 = 0 — no padding added
let input = r#"{"attachmentId":"id1","size":3,"data":"abcd"}"#.to_string();
let result = pad_attachment_data(input);
let val: Value = serde_json::from_str(&result).unwrap();
let data = val["data"].as_str().unwrap();
assert_eq!(data.len() % 4, 0, "already-padded data must not gain extra '='");
assert!(!data.ends_with("=="), "no extra padding for length-4 input");
}

#[test]
fn test_pad_attachment_data_pads_two() {
// "ab" has length 2, needs 2 '=' chars
let input = r#"{"attachmentId":"id1","size":1,"data":"ab"}"#.to_string();
let result = pad_attachment_data(input);
let val: Value = serde_json::from_str(&result).unwrap();
let data = val["data"].as_str().unwrap();
assert_eq!(data, "ab==");
assert_eq!(data.len() % 4, 0);
}

#[test]
fn test_pad_attachment_data_pads_one() {
// "abc" has length 3, needs 1 '=' char
let input = r#"{"attachmentId":"id1","size":2,"data":"abc"}"#.to_string();
let result = pad_attachment_data(input);
let val: Value = serde_json::from_str(&result).unwrap();
let data = val["data"].as_str().unwrap();
assert_eq!(data, "abc=");
assert_eq!(data.len() % 4, 0);
}

#[test]
fn test_pad_attachment_data_already_padded_not_double_padded() {
// "YWJj" is properly padded (length 4); should not gain extra '='
let input = r#"{"attachmentId":"id1","size":3,"data":"YWJj"}"#.to_string();
let result = pad_attachment_data(input);
let val: Value = serde_json::from_str(&result).unwrap();
let data = val["data"].as_str().unwrap();
assert_eq!(data, "YWJj", "already-padded data must be unchanged");
}

#[test]
fn test_pad_attachment_data_missing_data_field_is_noop() {
// Responses without a `data` field (shouldn't happen per spec, but must not panic)
let input = r#"{"attachmentId":"id1","size":0}"#.to_string();
let result = pad_attachment_data(input);
let val: Value = serde_json::from_str(&result).unwrap();
assert!(val.get("data").is_none());
}

#[test]
fn test_pad_attachment_data_invalid_json_passthrough() {
let input = "not json at all".to_string();
let result = pad_attachment_data(input.clone());
assert_eq!(result, input);
}
}

#[tokio::test]
Expand Down
Loading