Skip to content

Commit 75cec1b

Browse files
authored
fix: URL template rendering for upload endpoints (npm#129)
1 parent 87e4bb1 commit 75cec1b

2 files changed

Lines changed: 144 additions & 26 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@googleworkspace/cli": patch
3+
---
4+
5+
Fix URL template expansion so media upload endpoints substitute path parameters and avoid iterative replacement side effects.

src/executor.rs

Lines changed: 139 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
//! Responsibilities include multipart file uploads, response pagination,
1919
//! error mapping, and optionally running text content through Model Armor for sanitization.
2020
21-
use std::collections::HashMap;
21+
use std::collections::{HashMap, HashSet};
2222
use std::path::PathBuf;
2323

2424
use anyhow::Context;
@@ -513,7 +513,7 @@ fn build_url(
513513
};
514514

515515
// Substitute path parameters and separate query parameters
516-
let mut url_path = path_template.to_string();
516+
let path_parameters = extract_template_path_parameters(path_template);
517517
let mut query_params: HashMap<String, String> = HashMap::new();
518518

519519
for (key, value) in params {
@@ -522,29 +522,7 @@ fn build_url(
522522
other => other.to_string(),
523523
};
524524

525-
let placeholder = format!("{{{key}}}");
526-
// Also handle {+key} style
527-
let plus_placeholder = format!("{{+{key}}}");
528-
let has_plain_placeholder = url_path.contains(&placeholder);
529-
let has_plus_placeholder = url_path.contains(&plus_placeholder);
530-
531-
if has_plain_placeholder || has_plus_placeholder {
532-
// RFC 6570-like expansion:
533-
// - {var}: path segment expansion (encode all reserved characters)
534-
// - {+var}: reserved expansion (preserve '/' path separators only)
535-
if has_plain_placeholder {
536-
// TODO: This iterative `replace` can expand placeholder-looking
537-
// substrings that appear inside another parameter's value.
538-
// A single-pass template renderer would avoid that class of bugs.
539-
let encoded = crate::validate::encode_path_segment(&val_str);
540-
url_path = url_path.replace(&placeholder, &encoded);
541-
} else if has_plus_placeholder {
542-
// `{+var}` keeps `/` separators, so validate before encoding to
543-
// block traversal and URL-special injection payloads.
544-
let validated = crate::validate::validate_resource_name(&val_str)?;
545-
let encoded = crate::validate::encode_path_preserving_slashes(validated);
546-
url_path = url_path.replace(&plus_placeholder, &encoded);
547-
}
525+
if path_parameters.contains(key.as_str()) {
548526
continue;
549527
}
550528

@@ -565,6 +543,8 @@ fn build_url(
565543
query_params.insert(key.clone(), val_str);
566544
}
567545

546+
let url_path = render_path_template(path_template, params)?;
547+
568548
let full_url = if is_upload {
569549
// Use the upload endpoint from the Discovery Document
570550
let upload_endpoint = method
@@ -579,14 +559,85 @@ fn build_url(
579559
.to_string(),
580560
)
581561
})?;
582-
format!("{}{}", doc.root_url.trim_end_matches('/'), upload_endpoint)
562+
let upload_path = render_path_template(upload_endpoint, params)?;
563+
format!("{}{}", doc.root_url.trim_end_matches('/'), upload_path)
583564
} else {
584565
format!("{base_url}{url_path}")
585566
};
586567

587568
Ok((full_url, query_params))
588569
}
589570

571+
fn extract_template_path_parameters(path_template: &str) -> HashSet<&str> {
572+
let mut found = HashSet::new();
573+
let mut cursor = 0;
574+
575+
while let Some(open_idx) = path_template[cursor..].find('{') {
576+
let token_start = cursor + open_idx;
577+
let Some(close_idx) = path_template[token_start..].find('}') else {
578+
break;
579+
};
580+
581+
let token_end = token_start + close_idx;
582+
let token = &path_template[token_start + 1..token_end];
583+
if let Some(key) = token.strip_prefix('+') {
584+
found.insert(key);
585+
} else {
586+
found.insert(token);
587+
}
588+
cursor = token_end + 1;
589+
}
590+
591+
found
592+
}
593+
594+
fn render_path_template(
595+
path_template: &str,
596+
params: &Map<String, Value>,
597+
) -> Result<String, GwsError> {
598+
let mut rendered = String::with_capacity(path_template.len());
599+
let mut cursor = 0;
600+
601+
while let Some(open_idx) = path_template[cursor..].find('{') {
602+
let token_start = cursor + open_idx;
603+
rendered.push_str(&path_template[cursor..token_start]);
604+
605+
let Some(close_idx) = path_template[token_start..].find('}') else {
606+
rendered.push_str(&path_template[token_start..]);
607+
return Ok(rendered);
608+
};
609+
610+
let token_end = token_start + close_idx;
611+
let token = &path_template[token_start + 1..token_end];
612+
let (is_plus, key) = if let Some(key) = token.strip_prefix('+') {
613+
(true, key)
614+
} else {
615+
(false, token)
616+
};
617+
618+
if let Some(value) = params.get(key) {
619+
let val_str = match value {
620+
Value::String(s) => s.clone(),
621+
other => other.to_string(),
622+
};
623+
let encoded = if is_plus {
624+
let validated = crate::validate::validate_resource_name(&val_str)?;
625+
crate::validate::encode_path_preserving_slashes(validated)
626+
} else {
627+
crate::validate::encode_path_segment(&val_str)
628+
};
629+
rendered.push_str(&encoded);
630+
} else {
631+
rendered.push_str(&path_template[token_start..=token_end]);
632+
}
633+
634+
cursor = token_end + 1;
635+
}
636+
637+
rendered.push_str(&path_template[cursor..]);
638+
Ok(rendered)
639+
}
640+
590641
/// Attempts to extract a GCP console enable URL from a Google API `accessNotConfigured`
591642
/// error message.
592643
///
@@ -1339,6 +1390,68 @@ mod tests {
13391390
assert!(err.to_string().contains("path traversal"));
13401391
}
13411392

1393+
#[test]
1394+
fn test_build_url_upload_endpoint_substitutes_path_params() {
1395+
let doc = RestDescription {
1396+
root_url: "https://www.googleapis.com/".to_string(),
1397+
..Default::default()
1398+
};
1399+
let mut parameters = HashMap::new();
1400+
parameters.insert(
1401+
"fileId".to_string(),
1402+
crate::discovery::MethodParameter {
1403+
location: Some("path".to_string()),
1404+
..Default::default()
1405+
},
1406+
);
1407+
let method = RestMethod {
1408+
path: "drive/v3/files/{fileId}".to_string(),
1409+
flat_path: Some("drive/v3/files/{fileId}".to_string()),
1410+
parameters,
1411+
media_upload: Some(crate::discovery::MediaUpload {
1412+
protocols: Some(crate::discovery::MediaUploadProtocols {
1413+
simple: Some(crate::discovery::MediaUploadProtocol {
1414+
path: "/upload/drive/v3/files/{fileId}".to_string(),
1415+
multipart: Some(true),
1416+
}),
1417+
}),
1418+
..Default::default()
1419+
}),
1420+
..Default::default()
1421+
};
1422+
1423+
let mut params = Map::new();
1424+
params.insert("fileId".to_string(), json!("abc/123"));
1425+
1426+
let (url, _) = build_url(&doc, &method, &params, true).unwrap();
1427+
assert_eq!(
1428+
url,
1429+
"https://www.googleapis.com/upload/drive/v3/files/abc%2F123"
1430+
);
1431+
}
1432+
1433+
#[test]
1434+
fn test_build_url_does_not_replace_placeholder_like_values() {
1435+
let doc = RestDescription {
1436+
base_url: Some("https://api.example.com/".to_string()),
1437+
..Default::default()
1438+
};
1439+
let method = RestMethod {
1440+
path: "v1/{parent}/{child}".to_string(),
1441+
flat_path: Some("v1/{parent}/{child}".to_string()),
1442+
..Default::default()
1443+
};
1444+
let mut params = Map::new();
1445+
params.insert("parent".to_string(), json!("literal-{child}-value"));
1446+
params.insert("child".to_string(), json!("ok"));
1447+
1448+
let (url, _) = build_url(&doc, &method, &params, false).unwrap();
1449+
assert_eq!(
1450+
url,
1451+
"https://api.example.com/v1/literal%2D%7Bchild%7D%2Dvalue/ok"
1452+
);
1453+
}
1454+
13421455
#[test]
13431456
fn test_build_url_errors_for_path_param_not_in_template() {
13441457
let doc = RestDescription {

0 commit comments

Comments
 (0)