1use crate::transport::Method;
24use crate::PolicyError;
25use std::borrow::Cow;
26
27pub use reqwest::Url;
28
29pub trait RequestPolicy {
31 fn check(&self, method: Method, url: &Url) -> Result<(), PolicyError>;
34
35 fn check_query(&self, sql: &str) -> Result<(), PolicyError> {
39 let _ = sql;
40 Err(PolicyError::Query { reason: Cow::Borrowed("this policy allows no queries") })
41 }
42}
43
44impl<P: RequestPolicy> RequestPolicy for &P {
45 fn check(&self, method: Method, url: &Url) -> Result<(), PolicyError> {
46 (**self).check(method, url)
47 }
48 fn check_query(&self, sql: &str) -> Result<(), PolicyError> {
49 (**self).check_query(sql)
50 }
51}
52
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
56pub struct Permissive;
57
58impl RequestPolicy for Permissive {
59 fn check(&self, _: Method, _: &Url) -> Result<(), PolicyError> {
60 Ok(())
61 }
62 fn check_query(&self, _: &str) -> Result<(), PolicyError> {
63 Ok(())
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct Rule<'a> {
78 pub method: Method,
80 pub host: &'a str,
82 pub path: &'a str,
84}
85
86impl<'a> Rule<'a> {
87 #[must_use]
89 pub const fn new(method: Method, host: &'a str, path: &'a str) -> Self {
90 Self { method, host, path }
91 }
92}
93
94#[derive(Debug, Clone, Copy)]
109pub struct AllowList<'a> {
110 rules: &'a [Rule<'a>],
111 loopback_http: bool,
112}
113
114impl<'a> AllowList<'a> {
115 pub fn new(rules: &'a [Rule<'a>]) -> crate::Result<Self> {
117 for r in rules {
118 if !valid_host(r.host) {
119 return Err(crate::Error::protocol("allow list: invalid host"));
120 }
121 if !valid_template(r.path) {
122 return Err(crate::Error::protocol("allow list: invalid path template"));
123 }
124 }
125 Ok(Self { rules, loopback_http: false })
126 }
127
128 #[cfg(feature = "testing")]
132 #[must_use]
133 pub fn with_loopback_http(mut self) -> Self {
134 self.loopback_http = true;
135 self
136 }
137
138 #[must_use]
140 pub fn rules(&self) -> &'a [Rule<'a>] {
141 self.rules
142 }
143}
144
145impl RequestPolicy for AllowList<'_> {
146 fn check(&self, method: Method, url: &Url) -> Result<(), PolicyError> {
147 let refuse = |reason| Err(PolicyError::Endpoint { reason });
148 let host = url.host_str().unwrap_or("");
149 let loopback = match url.scheme() {
150 "https" => {
151 if url.port().is_some() {
152 return refuse("non-default port");
153 }
154 false
155 }
156 "http" if self.loopback_http && is_loopback_host(host) => true,
157 _ => return refuse("scheme must be https"),
158 };
159 if !url.username().is_empty() || url.password().is_some() {
160 return refuse("credentials in the URL");
161 }
162 if url.fragment().is_some() {
163 return refuse("fragment");
164 }
165 let path = url.path();
166 if !valid_path(path) {
167 return refuse("path is not normalised");
168 }
169 let hit = self.rules.iter().any(|r| {
170 r.method == method
171 && (loopback || r.host.eq_ignore_ascii_case(host))
172 && template_matches(r.path, path)
173 });
174 if hit {
175 Ok(())
176 } else {
177 refuse("not on the allow list")
178 }
179 }
180}
181
182fn is_loopback_host(host: &str) -> bool {
183 matches!(host, "127.0.0.1" | "[::1]" | "localhost")
184}
185
186fn valid_host(h: &str) -> bool {
187 !h.is_empty()
188 && h.len() <= 253
189 && h.split('.').all(|l| {
190 !l.is_empty()
191 && !l.starts_with('-')
192 && !l.ends_with('-')
193 && l.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
194 })
195}
196
197fn placeholder_suffix(seg: &str) -> Option<&str> {
201 let rest = seg.strip_prefix('{')?;
202 let (name, suffix) = rest.split_at(rest.find('}')?);
203 let suffix = &suffix[1..];
204 let name_ok = !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
205 let suffix_ok = suffix.is_empty()
206 || (suffix.len() > 1 && suffix.starts_with(':') && suffix.bytes().all(is_pchar));
207 (name_ok && suffix_ok).then_some(suffix)
208}
209
210fn valid_template(t: &str) -> bool {
211 let Some(rest) = t.strip_prefix('/') else { return false };
212 rest.split('/').all(|seg| {
213 placeholder_suffix(seg).is_some()
214 || (!seg.is_empty()
215 && seg != "."
216 && seg != ".."
217 && seg.bytes().all(|b| is_pchar(b) && b != b'%'))
218 })
219}
220
221fn is_pchar(b: u8) -> bool {
224 PCHAR[usize::from(b)]
225}
226
227const PCHAR: [bool; 256] = {
228 let mut t = [false; 256];
229 let mut i = 0;
230 while i < 256 {
231 let b = i as u8;
232 t[i] = b.is_ascii_alphanumeric();
233 i += 1;
234 }
235 let extra = b"-._~!$&'()*+,;=:@";
236 let mut k = 0;
237 while k < extra.len() {
238 t[extra[k] as usize] = true;
239 k += 1;
240 }
241 t
242};
243
244fn hex(b: u8) -> Option<u8> {
245 match b {
246 b'0'..=b'9' => Some(b - b'0'),
247 b'a'..=b'f' => Some(b - b'a' + 10),
248 b'A'..=b'F' => Some(b - b'A' + 10),
249 _ => None,
250 }
251}
252
253fn decoded(b: &[u8]) -> impl Iterator<Item = Option<u8>> + '_ {
256 let mut i = 0;
257 std::iter::from_fn(move || {
258 let c = *b.get(i)?;
259 if c != b'%' {
260 i += 1;
261 return Some(Some(c));
262 }
263 let v = b.get(i + 1).and_then(|&h| hex(h)).zip(b.get(i + 2).and_then(|&l| hex(l)));
264 i += 3;
265 Some(v.map(|(h, l)| h << 4 | l))
266 })
267}
268
269fn valid_segment(seg: &[u8]) -> bool {
270 let (mut escaped, mut dots) = (false, 0usize);
271 for &b in seg {
272 escaped |= b == b'%';
273 dots += usize::from(b == b'.');
274 if b != b'%' && !is_pchar(b) {
275 return false;
276 }
277 }
278 if !escaped {
279 return !(seg.len() <= 2 && dots == seg.len());
280 }
281 let (mut len, mut dots) = (0usize, 0usize);
282 for c in decoded(seg) {
283 let Some(c) = c else { return false };
284 if c == b'/' || c == b'\\' || c < 0x20 || c == 0x7f {
285 return false;
286 }
287 len += 1;
288 dots += usize::from(c == b'.');
289 }
290 !(len <= 2 && dots == len)
291}
292
293fn segments(path: &[u8]) -> impl Iterator<Item = &[u8]> + '_ {
295 path.get(1..).unwrap_or_default().split(|&b| b == b'/')
296}
297
298fn valid_path(path: &str) -> bool {
299 path.starts_with('/') && segments(path.as_bytes()).all(valid_segment)
300}
301
302fn template_matches(template: &str, path: &str) -> bool {
303 let mut t = segments(template.as_bytes());
304 let mut p = segments(path.as_bytes());
305 loop {
306 let (ts, ps) = match (t.next(), p.next()) {
307 (None, None) => return true,
308 (Some(ts), Some(ps)) => (ts, ps),
309 _ => return false,
310 };
311 let hit = if ts.first() == Some(&b'{') {
312 let close = ts.iter().position(|&b| b == b'}').unwrap_or(ts.len() - 1);
314 let sfx = &ts[close + 1..];
315 ps.strip_suffix(sfx)
316 .is_some_and(|v| !v.is_empty() && (sfx.is_empty() || valid_segment(v)))
317 } else {
318 ts == ps
319 };
320 if !hit {
321 return false;
322 }
323 }
324}
325
326fn segment_is(seg: &str, word: &str) -> bool {
332 let mut w = word.bytes();
333 for c in decoded(seg.as_bytes()) {
334 match c {
335 Some(b';' | b':') => break,
336 Some(c) if w.next() == Some(c.to_ascii_lowercase()) => {}
337 _ => return false,
338 }
339 }
340 w.next().is_none()
341}
342
343fn is_bigquery(url: &Url) -> bool {
344 let host = url.host_str().unwrap_or("");
345 host.get(..8).is_some_and(|h| h.eq_ignore_ascii_case("bigquery"))
346 || url.path().split('/').any(|s| segment_is(s, "bigquery"))
347}
348
349#[derive(serde::Deserialize)]
350struct QueryRequest<'a> {
351 #[serde(borrow)]
352 query: Cow<'a, str>,
353 #[serde(rename = "useLegacySql", alias = "use_legacy_sql")]
354 use_legacy_sql: Option<bool>,
355}
356
357#[derive(serde::Deserialize)]
358struct Job<'a> {
359 #[serde(borrow)]
360 configuration: JobConfiguration<'a>,
361}
362
363#[derive(serde::Deserialize)]
364struct JobConfiguration<'a> {
365 #[serde(borrow)]
366 query: Option<QueryConfig<'a>>,
367 load: Option<serde::de::IgnoredAny>,
368 copy: Option<serde::de::IgnoredAny>,
369 extract: Option<serde::de::IgnoredAny>,
370}
371
372#[derive(serde::Deserialize)]
373struct QueryConfig<'a> {
374 #[serde(borrow)]
375 query: Cow<'a, str>,
376 #[serde(rename = "useLegacySql", alias = "use_legacy_sql")]
377 use_legacy_sql: Option<bool>,
378 #[serde(rename = "tableDefinitions", alias = "table_definitions")]
379 table_definitions: Option<serde::de::IgnoredAny>,
380 #[serde(rename = "userDefinedFunctionResources", alias = "user_defined_function_resources")]
381 udf_resources: Option<serde::de::IgnoredAny>,
382}
383
384fn uncheckable(reason: &'static str) -> PolicyError {
385 PolicyError::UncheckableQuery { reason }
386}
387
388fn standard_sql(sql: Cow<'_, str>, legacy: Option<bool>) -> Result<Cow<'_, str>, PolicyError> {
389 if legacy != Some(false) {
390 return Err(uncheckable("useLegacySql must be false"));
391 }
392 let first = sql.trim_start().lines().next().unwrap_or("");
393 if first.starts_with('#') && first.to_ascii_lowercase().contains("legacysql") {
394 return Err(uncheckable("a #legacySQL prefix"));
395 }
396 Ok(sql)
397}
398
399pub(crate) fn query_sql<'b>(
404 method: Method,
405 url: &Url,
406 body: &'b [u8],
407) -> Result<Option<Cow<'b, str>>, PolicyError> {
408 if (method != Method::Post && body.is_empty()) || !is_bigquery(url) {
409 return Ok(None);
410 }
411 let path = url.path();
412 if path.split('/').any(|s| segment_is(s, "batch")) {
413 return Err(uncheckable("batch requests"));
414 }
415 let Some(last) = path.split('/').rev().find(|s| !s.is_empty()) else { return Ok(None) };
416 if segment_is(last, "queries") {
417 let q: QueryRequest<'_> =
418 serde_json::from_slice(body).map_err(|_| uncheckable("not a jobs.query body"))?;
419 standard_sql(q.query, q.use_legacy_sql).map(Some)
420 } else if segment_is(last, "jobs") {
421 let j: Job<'_> =
422 serde_json::from_slice(body).map_err(|_| uncheckable("not a jobs.insert body"))?;
423 let c = j.configuration;
424 let (Some(q), None, None, None) = (c.query, c.load, c.copy, c.extract) else {
425 return Err(uncheckable("not exactly one query job"));
426 };
427 if q.table_definitions.is_some() || q.udf_resources.is_some() {
428 return Err(uncheckable("external tables or UDF resources"));
429 }
430 standard_sql(q.query, q.use_legacy_sql).map(Some)
431 } else {
432 Ok(None)
433 }
434}
435
436#[doc(hidden)]
440pub fn fuzz(input: &[u8]) {
441 const RULES: &[Rule<'static>] = &[
442 Rule::new(Method::Get, "bigquery.googleapis.com", "/bigquery/v2/projects/{p}/datasets/{d}"),
443 Rule::new(Method::Post, "bigquery.googleapis.com", "/bigquery/v2/projects/{p}/queries"),
444 Rule::new(Method::Get, "storage.googleapis.com", "/storage/v1/b/{b}/o/{o}"),
445 ];
446 let Ok(s) = std::str::from_utf8(input) else { return };
447 let (raw, location) = s.split_once('\n').unwrap_or((s, ""));
448 let list = AllowList::new(RULES).expect("valid rules");
449 let Ok(url) = Url::parse(raw) else { return };
450 let verify = |u: &Url| {
451 for m in [Method::Get, Method::Post, Method::Put, Method::Delete, Method::Patch] {
452 if list.check(m, u).is_ok() {
453 assert_eq!(u.scheme(), "https");
454 assert!(u.port().is_none() && u.username().is_empty() && u.fragment().is_none());
455 let host = u.host_str().unwrap_or_default();
456 let path = u.path();
457 assert!(!path.contains("//") && !path.ends_with('/'));
458 for seg in path[1..].split('/') {
459 let lower = seg.to_ascii_lowercase();
460 assert!(
461 !lower.contains("%2f") && !lower.contains("%5c") && !lower.contains("%00")
462 );
463 assert!(!matches!(
464 lower.as_str(),
465 "." | ".." | "%2e" | "%2e%2e" | ".%2e" | "%2e."
466 ));
467 }
468 assert!(RULES.iter().any(|r| r.method == m
469 && r.host == host
470 && r.path.split('/').count() == path.split('/').count()));
471 }
472 }
473 let _ = query_sql(Method::Post, u, location.as_bytes());
474 };
475 verify(&url);
476 if url.scheme() != "https" {
481 return;
482 }
483 if let Ok(next) = url.join(location) {
484 verify(&next);
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 const H: &str = "bigquery.googleapis.com";
493 const RULES: &[Rule<'static>] = &[
494 Rule::new(Method::Get, H, "/bigquery/v2/projects/{project}/datasets/{dataset}"),
495 Rule::new(Method::Get, H, "/bigquery/v2/projects/{p}/datasets/{d}/tables/{t}"),
496 Rule::new(Method::Post, H, "/bigquery/v2/projects/{project}/queries"),
497 ];
498
499 fn list() -> AllowList<'static> {
500 AllowList::new(RULES).unwrap()
501 }
502
503 fn verdict(m: Method, u: &str) -> Result<(), PolicyError> {
504 list().check(m, &Url::parse(u).unwrap())
505 }
506
507 fn reason(m: Method, u: &str) -> &'static str {
508 match verdict(m, u) {
509 Err(PolicyError::Endpoint { reason }) => reason,
510 other => panic!("{u}: {other:?}"),
511 }
512 }
513
514 #[test]
515 fn listed_requests_pass() {
516 let base = "https://bigquery.googleapis.com/bigquery/v2/projects";
517 verdict(Method::Get, &format!("{base}/p/datasets/d")).unwrap();
518 verdict(Method::Get, &format!("{base}/my-proj/datasets/d_1/tables/t?view=BASIC")).unwrap();
519 verdict(Method::Post, &format!("{base}/p/queries")).unwrap();
520 verdict(Method::Get, "https://BigQuery.GoogleAPIs.com/bigquery/v2/projects/p/datasets/d")
521 .unwrap();
522 verdict(Method::Get, &format!("{base}/a%20b/datasets/d")).unwrap();
523 }
524
525 #[test]
526 fn the_policy_matrix_refuses_everything_else() {
527 let base = "https://bigquery.googleapis.com/bigquery/v2/projects";
528 let cases: &[(Method, String, &str)] = &[
529 (Method::Delete, format!("{base}/p/datasets/d"), "not on the allow list"),
530 (Method::Post, format!("{base}/p/datasets/d"), "not on the allow list"),
531 (Method::Get, format!("{base}/p/queries"), "not on the allow list"),
532 (Method::Get, format!("{base}/p/datasets/d/tables/t/data"), "not on the allow list"),
533 (Method::Get, format!("{base}/p/datasets"), "not on the allow list"),
534 (
535 Method::Get,
536 "https://evil.example/bigquery/v2/projects/p/datasets/d".into(),
537 "not on the allow list",
538 ),
539 (
540 Method::Get,
541 "https://bigquery.googleapis.com.evil.example/bigquery/v2/projects/p/datasets/d"
542 .into(),
543 "not on the allow list",
544 ),
545 (
546 Method::Get,
547 "http://bigquery.googleapis.com/bigquery/v2/projects/p/datasets/d".into(),
548 "scheme must be https",
549 ),
550 (
551 Method::Get,
552 "http://127.0.0.1:9/bigquery/v2/projects/p/datasets/d".into(),
553 "scheme must be https",
554 ),
555 (
556 Method::Get,
557 "https://bigquery.googleapis.com:8443/bigquery/v2/projects/p/datasets/d".into(),
558 "non-default port",
559 ),
560 (
561 Method::Get,
562 "https://u:pw@bigquery.googleapis.com/bigquery/v2/projects/p/datasets/d".into(),
563 "credentials in the URL",
564 ),
565 (Method::Get, format!("{base}/p/datasets/d#x"), "fragment"),
566 (Method::Get, format!("{base}/p%2Fx/datasets/d"), "path is not normalised"),
567 (Method::Get, format!("{base}/p%2fx/datasets/d"), "path is not normalised"),
568 (Method::Get, format!("{base}/p%5Cx/datasets/d"), "path is not normalised"),
569 (Method::Get, format!("{base}/p%00/datasets/d"), "path is not normalised"),
570 (Method::Get, format!("{base}/p%0a/datasets/d"), "path is not normalised"),
571 (Method::Get, format!("{base}/p%zz/datasets/d"), "path is not normalised"),
572 (Method::Get, format!("{base}//datasets/d"), "path is not normalised"),
573 (Method::Get, format!("{base}/p/datasets/d/"), "path is not normalised"),
574 (Method::Get, format!("{base}/p/%64atasets/d"), "not on the allow list"),
575 ];
576 for (m, u, want) in cases {
577 assert_eq!(reason(*m, u), *want, "{m:?} {u}");
578 }
579 }
580
581 #[test]
582 fn dot_segments_never_escape_a_template() {
583 let u = "https://bigquery.googleapis.com/bigquery/v2/projects/p/datasets/d/tables/%2e%2e/%2E%2E/x";
586 assert_eq!(Url::parse(u).unwrap().path(), "/bigquery/v2/projects/p/datasets/x");
587 verdict(Method::Get, u).unwrap();
588 let out = "https://bigquery.googleapis.com/bigquery/v2/projects/p/datasets/d/../../../../../../storage/v1/b/x";
589 assert_eq!(Url::parse(out).unwrap().path(), "/storage/v1/b/x");
590 assert_eq!(reason(Method::Get, out), "not on the allow list");
591 for s in [".", "..", "%2e", "%2E%2e", ".%2e"] {
593 assert!(!valid_segment(s.as_bytes()), "{s}");
594 }
595 assert!(["...", ".a", "a%2e"].iter().all(|s| valid_segment(s.as_bytes())));
596 }
597
598 #[test]
599 fn custom_method_suffixes_match_exactly() {
600 let rules = [Rule::new(
601 Method::Post,
602 "datalineage.googleapis.com",
603 "/v1/projects/{p}/locations/{l}:searchLinks",
604 )];
605 let l = AllowList::new(&rules).unwrap();
606 let at = |p: &str| {
607 l.check(
608 Method::Post,
609 &Url::parse(&format!("https://datalineage.googleapis.com{p}")).unwrap(),
610 )
611 };
612 at("/v1/projects/p/locations/us:searchLinks").unwrap();
613 for bad in [
614 "/v1/projects/p/locations/:searchLinks",
615 "/v1/projects/p/locations/us:searchLinksX",
616 "/v1/projects/p/locations/us:batchSearchLinkProcesses",
617 "/v1/projects/p/locations/us",
618 "/v1/projects/p/locations/..:searchLinks",
619 "/v1/projects/p/locations/us%3AsearchLinks",
620 ] {
621 assert!(at(bad).is_err(), "{bad}");
622 }
623 }
624
625 #[test]
626 fn malformed_rules_are_refused() {
627 for (h, p) in [
628 ("Bigquery.googleapis.com", "/x"),
629 ("bigquery.googleapis.com:443", "/x"),
630 ("", "/x"),
631 ("h", "x"),
632 ("h", "/x//y"),
633 ("h", "/x/"),
634 ("h", "/x/../y"),
635 ("h", "/x/%2e"),
636 ("h", "/{}"),
637 ("h", "/{a b}"),
638 ("h", "/{a}x"),
639 ("h", "/{a}:"),
640 ("h", "/{a}:{b}"),
641 ("h", "/x:{a}"),
642 ] {
643 assert!(AllowList::new(&[Rule::new(Method::Get, h, p)]).is_err(), "{h} {p}");
644 }
645 }
646
647 #[test]
648 fn allow_lists_allow_no_queries_and_permissive_allows_all() {
649 assert!(matches!(list().check_query("SELECT 1"), Err(PolicyError::Query { .. })));
650 let u = Url::parse("ftp://anything/at/all").unwrap();
651 assert_eq!(Permissive.check(Method::Delete, &u), Ok(()));
652 assert_eq!(Permissive.check_query("DROP TABLE x"), Ok(()));
653 let l = list();
654 let borrowed: &AllowList<'_> = &l;
655 let verdict = <&AllowList<'_> as RequestPolicy>::check(&borrowed, Method::Get, &u);
656 assert!(verdict.is_err(), "a borrowed policy is the policy");
657 }
658
659 fn sql(m: Method, u: &str, body: &str) -> Result<Option<String>, PolicyError> {
660 query_sql(m, &Url::parse(u).unwrap(), body.as_bytes()).map(|o| o.map(Cow::into_owned))
661 }
662
663 const Q: &str = "https://bigquery.googleapis.com/bigquery/v2/projects/p/queries";
664 const J: &str = "https://bigquery.googleapis.com/bigquery/v2/projects/p/jobs";
665
666 #[test]
667 fn query_text_is_isolated_from_both_job_endpoints() {
668 let body = r#"{"query":"SELECT 1","useLegacySql":false,"location":"US"}"#;
669 assert_eq!(sql(Method::Post, Q, body).unwrap().as_deref(), Some("SELECT 1"));
670 let esc = r#"{"query":"SELECT \"a\" ","useLegacySql":false}"#;
671 assert_eq!(sql(Method::Post, Q, esc).unwrap().as_deref(), Some("SELECT \"a\" "));
672 let job = r#"{"configuration":{"query":{"query":"SELECT 2","useLegacySql":false}}}"#;
673 assert_eq!(sql(Method::Post, J, job).unwrap().as_deref(), Some("SELECT 2"));
674 for u in [
676 "https://bigquery.googleapis.com/bigquery/v2/projects/p/queries/",
677 "https://x.example/%62igQuery/v2/projects/p/%71ueries",
678 "http://127.0.0.1:1/bigquery/v2/projects/p/queries;x=y",
679 "https://bigquery.us-east4.rep.googleapis.com/v2/projects/p/queries",
680 ] {
681 assert_eq!(sql(Method::Post, u, body).unwrap().as_deref(), Some("SELECT 1"), "{u}");
682 }
683 }
684
685 #[test]
686 fn uncheckable_query_bodies_are_refused() {
687 let bad = |u: &str, b: &str| {
688 assert!(
689 matches!(sql(Method::Post, u, b), Err(PolicyError::UncheckableQuery { .. })),
690 "{u} {b}"
691 );
692 };
693 bad(Q, "");
694 bad(Q, "SELECT 1");
695 bad(Q, r#"{"query":"SELECT 1"}"#); bad(Q, r#"{"query":"SELECT 1","useLegacySql":true}"#);
697 bad(Q, r##"{"query":"#legacySQL\nSELECT 1","useLegacySql":false}"##);
698 bad(Q, r#"{"query":"SELECT 1","query":"SELECT 2","useLegacySql":false}"#);
699 bad(Q, r#"{"query":"SELECT 1","useLegacySql":false,"use_legacy_sql":true}"#);
700 bad(Q, r#"{"query":null,"useLegacySql":false}"#);
701 bad(Q, "\u{feff}{\"query\":\"SELECT 1\",\"useLegacySql\":false}");
702 bad(J, r#"{"configuration":{"extract":{}}}"#);
703 bad(J, r#"{"configuration":{"load":{}}}"#);
704 bad(
705 J,
706 r#"{"configuration":{"query":{"query":"SELECT 1","useLegacySql":false},"extract":{}}}"#,
707 );
708 bad(
709 J,
710 r#"{"configuration":{"query":{"query":"SELECT 1","useLegacySql":false,"tableDefinitions":{}}}}"#,
711 );
712 bad(
713 J,
714 r#"{"configuration":{"query":{"query":"SELECT 1","useLegacySql":false,"user_defined_function_resources":[]}}}"#,
715 );
716 bad("https://bigquery.googleapis.com/batch/bigquery/v2", "--b\r\nPOST /x");
717 bad("https://upload.example/upload/bigquery/v2/projects/p/jobs", "--multipart");
718 assert!(sql(Method::Put, Q, "x").is_err());
720 }
721
722 #[test]
723 fn other_requests_carry_no_query() {
724 assert_eq!(sql(Method::Get, Q, ""), Ok(None), "getQueryResults");
725 assert_eq!(sql(Method::Get, J, ""), Ok(None), "jobs.list");
726 assert_eq!(sql(Method::Post, &format!("{J}/abc/cancel"), ""), Ok(None));
727 let vertex = "https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/l/jobs";
728 assert_eq!(sql(Method::Post, vertex, "{}"), Ok(None));
729 assert_eq!(sql(Method::Post, "https://storage.googleapis.com/b/queries", "x"), Ok(None));
730 }
731
732 #[test]
733 fn the_fuzz_entry_point_accepts_known_inputs() {
734 for s in [
735 "https://bigquery.googleapis.com/bigquery/v2/projects/p/queries\n/x/../y",
736 "https://bigquery.googleapis.com/bigquery/v2/projects/p/datasets/d\nhttps://evil.example/",
737 "not a url",
738 "",
739 ] {
740 fuzz(s.as_bytes());
741 }
742 }
743}