1use std::collections::BTreeMap;
2
3use crate::stdlib::capabilities::{self, FieldNormalization};
4use crate::{
5 BinOp, Diagnostic, DocCapability, DocCapabilityBindingKind, DocCapabilityLiteral, ExprId,
6 GlobalKind, MangledName, Param, Severity, Span, TypedAst, TypedExprKind, TypedStmtKind,
7};
8
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct DerivedCapability {
11 pub capability: String,
12 pub filter: Option<String>,
13 pub known_bindings: BTreeMap<String, String>,
15 pub warnings: Vec<Diagnostic>,
16}
17
18pub fn derive_call_site_capability(
19 tag: &DocCapability,
20 callee_params: &[Param],
21 caller_ast: &TypedAst,
22 actual_args: &[ExprId],
23) -> Result<DerivedCapability, crate::compiler_error::CompileError> {
24 let mut warnings = Vec::new();
25 let mut unresolved_http_host_span = None;
26 let filters = derive_filters(
27 tag,
28 callee_params,
29 caller_ast,
30 actual_args,
31 &mut warnings,
32 &mut unresolved_http_host_span,
33 );
34 if let Some(span) = unresolved_http_host_span {
35 warnings.push(unresolved_http_url_warning(span, &tag.capability));
36 }
37 let filters = filters.map_err(|fatal| {
38 crate::compiler_error::CompileError::from(fatal).with_prior_diagnostics(&warnings)
39 })?;
40 let known_bindings = filters.iter().cloned().collect();
41 Ok(DerivedCapability {
42 capability: tag.capability.clone(),
43 filter: (!filters.is_empty()).then(|| {
44 filters
45 .iter()
46 .map(|(field, operand)| format!("{field} == {operand}"))
47 .collect::<Vec<_>>()
48 .join(" and ")
49 }),
50 known_bindings,
51 warnings,
52 })
53}
54
55fn derive_filters(
56 tag: &DocCapability,
57 callee_params: &[Param],
58 caller_ast: &TypedAst,
59 actual_args: &[ExprId],
60 warnings: &mut Vec<Diagnostic>,
61 unresolved_http_host_span: &mut Option<Span>,
62) -> Result<Vec<(String, String)>, crate::compiler_error::CompilerFailure> {
63 let mut filters = Vec::new();
64 for binding in &tag.bindings {
65 match &binding.kind {
66 DocCapabilityBindingKind::Literal { value, .. } => {
67 filters.extend(binding_filter(
70 tag,
71 &binding.field,
72 StaticValue::from(value),
73 None,
74 warnings,
75 ));
76 }
77 DocCapabilityBindingKind::Type { .. } => {}
78 DocCapabilityBindingKind::Parameter { param, path, span } => {
79 let Some(param_index) = callee_params.iter().position(|p| p.name == *param) else {
80 warnings.push(warning(
81 *span,
82 format!("cannot derive filter for unknown parameter `${param}`"),
83 ));
84 continue;
85 };
86 let Some(actual) = actual_args.get(param_index) else {
87 warnings.push(warning(
88 *span,
89 format!("cannot derive filter for missing argument `${param}`"),
90 ));
91 continue;
92 };
93 let actual_span = caller_ast
94 .try_expr(*actual)
95 .map_err(crate::typechecker::arena_failure)?
96 .span;
97 match literal_from_expr_path(caller_ast, *actual, path)? {
98 Some(value) => filters.extend(binding_filter(
99 tag,
100 &binding.field,
101 value,
102 Some(actual_span),
103 warnings,
104 )),
105 None if is_http_url_binding(tag, param, path) => {
106 if matches!(path.as_slice(), [component] if component == "host") {
107 *unresolved_http_host_span = Some(actual_span);
108 }
109 }
110 None => {}
113 }
114 }
115 }
116 }
117 Ok(filters)
118}
119
120enum StaticValue {
122 String(String),
123 Number(String),
125 Boolean(bool),
126 Null,
127}
128
129impl From<&DocCapabilityLiteral> for StaticValue {
130 fn from(value: &DocCapabilityLiteral) -> Self {
131 match value {
132 DocCapabilityLiteral::String(value) => Self::String(value.clone()),
133 DocCapabilityLiteral::Number(value) => Self::Number(value.clone()),
134 DocCapabilityLiteral::Boolean(value) => Self::Boolean(*value),
135 DocCapabilityLiteral::Null => Self::Null,
136 }
137 }
138}
139
140fn binding_filter(
142 tag: &DocCapability,
143 field: &str,
144 value: StaticValue,
145 warn_at: Option<Span>,
146 warnings: &mut Vec<Diagnostic>,
147) -> Option<(String, String)> {
148 let operand = match value {
149 StaticValue::String(value) => {
150 if field_normalization(&tag.capability, field) == FieldNormalization::VfsPath
151 && !value.starts_with('/')
152 {
153 if let Some(span) = warn_at {
154 warnings.push(warning(span, format!("relative `{field}` depends on the session cwd; no static path filter for `{}`", tag.capability)));
155 }
156 return None;
157 }
158 let normalized = normalized_string(tag, field, value, warn_at, warnings);
159 let Some(literal) = filter_string_literal(&normalized) else {
160 if let Some(span) = warn_at {
161 warnings.push(warning(
162 span,
163 format!(
164 "`{field}` value contains `{VAR_PLACEHOLDER}`, which a filter reads as a variable reference; no static filter for `{}`",
165 tag.capability
166 ),
167 ));
168 }
169 return None;
170 };
171 literal
172 }
173 StaticValue::Number(spelling) => spelling,
174 StaticValue::Boolean(value) => value.to_string(),
175 StaticValue::Null => "null".to_string(),
176 };
177 Some((field.to_string(), operand))
178}
179
180fn normalized_string(
184 tag: &DocCapability,
185 field: &str,
186 value: String,
187 warn_at: Option<Span>,
188 warnings: &mut Vec<Diagnostic>,
189) -> String {
190 field_normalization(&tag.capability, field)
191 .apply(&value)
192 .unwrap_or_else(|reason| {
193 if let Some(span) = warn_at {
194 warnings.push(warning(
195 span,
196 format!(
197 "`{}` refuses `{field}` value \"{}\" at runtime: {reason}",
198 tag.capability,
199 escape(&value)
200 ),
201 ));
202 }
203 value
204 })
205}
206
207fn field_normalization(capability: &str, field: &str) -> FieldNormalization {
208 capabilities::find_any(capability)
209 .and_then(|entry| entry.filter_fields.iter().find(|f| f.name == field))
210 .map_or(FieldNormalization::Verbatim, |f| f.normalization)
211}
212
213fn is_http_url_binding(tag: &DocCapability, param: &str, path: &[String]) -> bool {
214 tag.capability.starts_with("http.")
215 && param == "url"
216 && matches!(path, [component] if matches!(component.as_str(), "host" | "path"))
217}
218
219fn unresolved_http_url_warning(span: Span, capability: &str) -> Diagnostic {
220 Diagnostic {
221 severity: Severity::Warning,
222 span,
223 message: format!(
224 "cannot statically resolve the host in the URL passed to `{capability}`; \
225 no host capability filter was derived"
226 ),
227 help: vec![
228 "call the HTTP function directly with a URL literal or a concatenation of \
229 string literals and top-level string constants"
230 .to_string(),
231 "when appending a dynamic path, include `/` after the host in the constant prefix, \
232 e.g. `\"https://api.example.com/\" + path` where `path` has no leading slash; \
233 this makes the host boundary statically known"
234 .to_string(),
235 ],
236 notes: vec![],
237 }
238}
239
240fn literal_from_expr_path(
241 ast: &TypedAst,
242 expr_id: ExprId,
243 path: &[String],
244) -> Result<Option<StaticValue>, crate::compiler_error::CompilerFailure> {
245 if path.is_empty() {
246 return literal_from_expr(ast, expr_id);
247 }
248 if let [segment] = path
254 && matches!(segment.as_str(), "host" | "path")
255 {
256 if let Some(url) = resolve_string_literal(ast, expr_id)? {
257 return Ok(url_component(&url, segment).map(StaticValue::String));
258 }
259 if segment == "host" {
260 return url_host_from_constant_prefix(ast, expr_id);
261 }
262 }
263 let mut current = expr_id;
264 for segment in path {
265 let TypedExprKind::ObjectLiteral { fields, .. } = &ast
266 .try_expr(current)
267 .map_err(crate::typechecker::arena_failure)?
268 .kind
269 else {
270 return Ok(None);
271 };
272 let Some(field) = fields.iter().find(|field| field.name.name == *segment) else {
273 return Ok(None);
274 };
275 current = match field.source.literal_expr_id() {
276 Some(value) => value,
277 None => return Ok(None),
278 };
279 }
280 literal_from_expr(ast, current)
281}
282
283fn url_host_from_constant_prefix(
284 ast: &TypedAst,
285 expr_id: ExprId,
286) -> Result<Option<StaticValue>, crate::compiler_error::CompilerFailure> {
287 let prefix = constant_string_prefix(ast, expr_id)?;
288 let authority_start = match prefix.find("://") {
289 Some(value) => value,
290 None => return Ok(None),
291 } + 3;
292 let path_start = match prefix[authority_start..].find('/') {
293 Some(value) => value,
294 None => return Ok(None),
295 } + authority_start;
296 Ok(url_component(&prefix[..=path_start], "host").map(StaticValue::String))
297}
298
299fn constant_string_prefix(
300 ast: &TypedAst,
301 expr_id: ExprId,
302) -> Result<String, crate::compiler_error::CompilerFailure> {
303 Ok(
304 match &ast
305 .try_expr(expr_id)
306 .map_err(crate::typechecker::arena_failure)?
307 .kind
308 {
309 TypedExprKind::String(value) => value.clone(),
310 TypedExprKind::GlobalRef { mangled, .. } => match const_initializer(ast, mangled)? {
311 Some(value) => constant_string_prefix(ast, value)?,
312 None => String::new(),
313 },
314 TypedExprKind::Binary {
315 op: BinOp::Add,
316 lhs,
317 rhs,
318 } => {
319 let Some(mut value) = resolve_string_literal(ast, *lhs)? else {
320 return constant_string_prefix(ast, *lhs);
321 };
322 value.push_str(&constant_string_prefix(ast, *rhs)?);
323 value
324 }
325 _ => String::new(),
326 },
327 )
328}
329
330fn literal_from_expr(
331 ast: &TypedAst,
332 expr_id: ExprId,
333) -> Result<Option<StaticValue>, crate::compiler_error::CompilerFailure> {
334 Ok(
335 match &ast
336 .try_expr(expr_id)
337 .map_err(crate::typechecker::arena_failure)?
338 .kind
339 {
340 TypedExprKind::String(value) => Some(StaticValue::String(value.clone())),
341 TypedExprKind::Binary { op: BinOp::Add, .. } => {
342 resolve_string_literal(ast, expr_id)?.map(StaticValue::String)
343 }
344 TypedExprKind::Number(value) => Some(StaticValue::Number(number_literal(*value))),
345 TypedExprKind::Boolean(value) => Some(StaticValue::Boolean(*value)),
346 TypedExprKind::Null => Some(StaticValue::Null),
347 TypedExprKind::GlobalRef { mangled, .. } => literal_from_expr(
348 ast,
349 match const_initializer(ast, mangled)? {
350 Some(value) => value,
351 None => return Ok(None),
352 },
353 )?,
354 _ => None,
355 },
356 )
357}
358
359fn resolve_string_literal(
362 ast: &TypedAst,
363 expr_id: ExprId,
364) -> Result<Option<String>, crate::compiler_error::CompilerFailure> {
365 Ok(
366 match &ast
367 .try_expr(expr_id)
368 .map_err(crate::typechecker::arena_failure)?
369 .kind
370 {
371 TypedExprKind::String(value) => Some(value.clone()),
372 TypedExprKind::Binary {
373 op: BinOp::Add,
374 lhs,
375 rhs,
376 } => {
377 let Some(mut value) = resolve_string_literal(ast, *lhs)? else {
378 return Ok(None);
379 };
380 value.push_str(&match resolve_string_literal(ast, *rhs)? {
381 Some(value) => value,
382 None => return Ok(None),
383 });
384 Some(value)
385 }
386 TypedExprKind::GlobalRef { mangled, .. } => resolve_string_literal(
387 ast,
388 match const_initializer(ast, mangled)? {
389 Some(value) => value,
390 None => return Ok(None),
391 },
392 )?,
393 _ => None,
394 },
395 )
396}
397
398fn const_initializer(
401 ast: &TypedAst,
402 mangled: &MangledName,
403) -> Result<Option<ExprId>, crate::compiler_error::CompilerFailure> {
404 let is_const = ast
405 .globals
406 .iter()
407 .any(|g| &g.mangled_name == mangled && matches!(g.kind, GlobalKind::Const));
408 if !is_const {
409 return Ok(None);
410 }
411 ast.top_level_statements
412 .iter()
413 .map(|stmt| {
414 Ok::<_, crate::compiler_error::CompilerFailure>(
415 match &ast
416 .try_stmt(*stmt)
417 .map_err(crate::typechecker::arena_failure)?
418 .kind
419 {
420 TypedStmtKind::AssignGlobal {
421 mangled: target,
422 value,
423 ..
424 } if target == mangled => Some(*value),
425 _ => None,
426 },
427 )
428 })
429 .find_map(Result::transpose)
430 .transpose()
431}
432
433fn url_component(url: &str, component: &str) -> Option<String> {
435 let parsed = url::Url::parse(url).ok()?;
436 let value = match component {
437 "host" => {
439 parsed.host_str()?;
440 crate::stdlib::url::host_without_trailing_dots(&parsed).to_string()
441 }
442 "path" => parsed.path().to_string(),
443 _ => return None,
444 };
445 Some(value)
446}
447
448const VAR_PLACEHOLDER: &str = "${vars.";
450
451#[doc(hidden)]
458pub fn filter_string_literal(value: &str) -> Option<String> {
459 (!value.contains(VAR_PLACEHOLDER)).then(|| format!("\"{}\"", escape(value)))
460}
461
462fn escape(value: &str) -> String {
464 let mut out = String::with_capacity(value.len());
465 for ch in value.chars() {
466 match ch {
467 '"' => out.push_str("\\\""),
468 '\\' => out.push_str("\\\\"),
469 '\n' => out.push_str("\\n"),
470 '\t' => out.push_str("\\t"),
471 '\r' => out.push_str("\\r"),
472 c => out.push(c),
473 }
474 }
475 out
476}
477
478fn number_literal(value: f64) -> String {
479 if value.fract() == 0.0 {
480 format!("{value:.0}")
481 } else {
482 value.to_string()
483 }
484}
485
486fn warning(span: Span, message: String) -> Diagnostic {
487 Diagnostic {
488 severity: Severity::Warning,
489 span,
490 message,
491 help: vec![],
492 notes: vec![],
493 }
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499 use crate::{Asi, TokenKind, infer, parse};
500
501 fn typed(source: &str) -> TypedAst {
502 let mut asi = Asi::new(source, crate::FileId(0));
503 let mut tokens = Vec::new();
504 loop {
505 let tok = asi.next_token();
506 let is_eof = matches!(tok.kind, TokenKind::Eof);
507 tokens.push(tok);
508 if is_eof {
509 break;
510 }
511 }
512 let lex_diags = asi.into_diagnostics();
513 assert!(lex_diags.is_empty(), "{lex_diags:?}");
514 let (ast, parse_diags) = parse(source, tokens, crate::FileId(0));
515 assert!(parse_diags.is_empty(), "{parse_diags:?}");
516 let (prelude_defs, host_defs, _) =
517 crate::runtime::prelude::cached_runtime_package_declarations();
518 let mut packages = Vec::with_capacity(prelude_defs.len() + host_defs.len());
519 packages.extend(prelude_defs.iter());
520 packages.extend(host_defs.iter());
521 let (ta, infer_diags) = infer(source, "main", &ast, &packages);
522 assert!(infer_diags.is_empty(), "{infer_diags:?}");
523 ta
524 }
525
526 fn first_doc_capability(source: &str) -> (TypedAst, DocCapability, Vec<Param>, Vec<ExprId>) {
527 let ta = typed(source);
528 let callee = ta
529 .functions
530 .iter()
531 .find(|f| f.name.name == "callee")
532 .expect("callee");
533 let tag = callee
534 .doc
535 .as_ref()
536 .and_then(|doc| doc.capabilities.first())
537 .cloned()
538 .expect("capability tag");
539 let params = callee
540 .params
541 .iter()
542 .map(|p| Param::new(p.name.name.clone(), p.ty.clone()))
543 .collect::<Vec<_>>();
544 let caller = ta
545 .functions
546 .iter()
547 .find(|f| f.name.name == "main")
548 .expect("main");
549 let crate::TypedStmtKind::Block(stmts) = &ta.try_stmt(caller.body).unwrap().kind else {
550 panic!("main body block");
551 };
552 let call_expr = stmts
553 .iter()
554 .find_map(|stmt| match &ta.try_stmt(*stmt).unwrap().kind {
555 crate::TypedStmtKind::Expr(call_expr) => Some(*call_expr),
556 _ => None,
557 })
558 .expect("call expr stmt");
559 let TypedExprKind::Call { args, .. } = &ta.try_expr(call_expr).unwrap().kind else {
560 panic!("direct call");
561 };
562 let args = args.clone();
563 (ta, tag, params, args)
564 }
565
566 #[test]
567 fn literal_arg_contributes_filter() {
568 let (ta, tag, params, args) = first_doc_capability(
569 "/** @capability secrets.get { name } */\n\
570 function callee(name: string): void { }\n\
571 function main(): void { callee(\"TOKEN\"); }\n",
572 );
573 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
574 assert_eq!(derived.filter.as_deref(), Some("name == \"TOKEN\""));
575 assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
576 }
577
578 #[test]
582 fn literal_holding_a_variable_reference_contributes_no_filter() {
583 let (ta, tag, params, args) = first_doc_capability(
584 "/** @capability secrets.get { name } */\n\
585 function callee(name: string): void { }\n\
586 function main(): void { callee(\"${vars.who}\"); }\n",
587 );
588 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
589 assert_eq!(derived.filter, None);
590 assert!(derived.known_bindings.is_empty());
591 assert_eq!(derived.warnings.len(), 1, "{:?}", derived.warnings);
592 assert!(derived.warnings[0].message.contains("${vars."));
593 }
594
595 #[test]
596 fn string_literal_escapes_what_the_filter_tokenizer_reads() {
597 assert_eq!(
598 filter_string_literal("a\"b\\c\nd\te\rf").as_deref(),
599 Some("\"a\\\"b\\\\c\\nd\\te\\rf\""),
600 );
601 assert_eq!(filter_string_literal("${vars.x}"), None);
602 assert_eq!(
603 filter_string_literal("${other}").as_deref(),
604 Some("\"${other}\"")
605 );
606 }
607
608 #[test]
609 fn non_literal_arg_retains_requirement_without_warning() {
610 let (ta, tag, params, args) = first_doc_capability(
611 "/** @capability secrets.get { name } */\n\
612 function callee(name: string): void { }\n\
613 function main(): void { let token = \"TOKEN\"; callee(token); }\n",
614 );
615 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
616 assert_eq!(derived.capability, "secrets.get");
617 assert_eq!(derived.filter, None);
618 assert!(derived.known_bindings.is_empty());
619 assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
620 }
621
622 #[test]
623 fn type_only_field_does_not_contribute_filter() {
624 let (ta, tag, params, args) = first_doc_capability(
625 "/** @capability x/op { amount: number } */\n\
626 function callee(amount: number): void { }\n\
627 function main(): void { callee(1); }\n",
628 );
629 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
630 assert_eq!(derived.filter, None);
631 assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
632 }
633
634 #[test]
635 fn fixed_literal_contributes_filter() {
636 let (ta, tag, params, args) = first_doc_capability(
637 "/** @capability db.query { readonly: true, query: string } */\n\
638 function callee(query: string): void { }\n\
639 function main(): void { callee(\"select 1\"); }\n",
640 );
641 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
642 assert_eq!(derived.filter.as_deref(), Some("readonly == true"));
643 assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
644 }
645
646 #[test]
647 fn url_host_and_path_extracted_from_literal() {
648 let (ta, tag, params, args) = first_doc_capability(
649 "/** @capability http.post { host: $url.host, path: $url.path } */\n\
650 function callee(url: string): void { }\n\
651 function main(): void { callee(\"https://r.jina.ai/read\"); }\n",
652 );
653 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
654 assert_eq!(
655 derived.filter.as_deref(),
656 Some("host == \"r.jina.ai\" and path == \"/read\"")
657 );
658 assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
659 }
660
661 #[test]
662 fn fully_qualified_host_derives_the_filter_the_runtime_checks() {
663 let (ta, tag, params, args) = first_doc_capability(
664 "/** @capability http.post { host: $url.host } */\n\
665 function callee(url: string): void { }\n\
666 function main(): void { callee(\"https://r.jina.ai./read\"); }\n",
667 );
668 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
669 assert_eq!(derived.filter.as_deref(), Some("host == \"r.jina.ai\""));
670 }
671
672 #[test]
673 fn const_url_folds_to_host_filter() {
674 let (ta, tag, params, args) = first_doc_capability(
675 "/** @capability http.post { host: $url.host } */\n\
676 function callee(url: string): void { }\n\
677 const ENDPOINT: string = \"https://r.jina.ai/\";\n\
678 function main(): void { callee(ENDPOINT); }\n",
679 );
680 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
681 assert_eq!(derived.filter.as_deref(), Some("host == \"r.jina.ai\""));
682 assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
683 }
684
685 #[test]
686 fn constant_string_concatenation_folds_to_url_filters() {
687 let (ta, tag, params, args) = first_doc_capability(
688 "/** @capability http.post { host: $url.host, path: $url.path } */\n\
689 function callee(url: string): void { }\n\
690 const ORIGIN: string = \"https://\" + \"api.example.com\";\n\
691 const VERSION: string = \"/v1\";\n\
692 function main(): void { callee(ORIGIN + VERSION + \"/items\"); }\n",
693 );
694 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
695 assert_eq!(
696 derived.filter.as_deref(),
697 Some("host == \"api.example.com\" and path == \"/v1/items\"")
698 );
699 assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
700 }
701
702 #[test]
703 fn constant_url_prefix_folds_only_the_host_filter() {
704 let (ta, tag, params, args) = first_doc_capability(
705 "/** @capability http.get { host: $url.host, path: $url.path } */\n\
706 function callee(url: string): void { }\n\
707 const API: string = \"https://api.example.com/v1/\";\n\
708 function main(path: string): void { callee(API + path); }\n",
709 );
710 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
711 assert_eq!(
712 derived.filter.as_deref(),
713 Some("host == \"api.example.com\"")
714 );
715 assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
716 }
717
718 #[test]
719 fn constant_url_prefix_without_path_does_not_claim_a_host() {
720 let (ta, tag, params, args) = first_doc_capability(
721 "/** @capability http.get { host: $url.host } */\n\
722 function callee(url: string): void { }\n\
723 const ORIGIN: string = \"https://api.example.com\";\n\
724 function main(suffix: string): void { callee(ORIGIN + suffix); }\n",
725 );
726 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
727 assert_eq!(derived.filter, None);
728 assert_eq!(derived.warnings.len(), 1);
729 }
730
731 #[test]
732 fn http_helper_result_is_not_folded_and_gets_one_targeted_warning() {
733 let (ta, tag, params, args) = first_doc_capability(
734 "/** @capability http.get { host: $url.host, path: $url.path } */\n\
735 function callee(url: string): void { }\n\
736 const ORIGIN: string = \"https://api.example.com\";\n\
737 function endpoint(): string { return ORIGIN + \"/v1/items\"; }\n\
738 function main(): void { callee(endpoint()); }\n",
739 );
740 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
741 assert_eq!(derived.filter.as_deref(), None);
742 assert_eq!(derived.warnings.len(), 1);
743 assert!(
744 derived.warnings[0]
745 .message
746 .contains("cannot statically resolve the host in the URL passed to `http.get`")
747 );
748 assert!(
749 derived.warnings[0].help[0].contains("call the HTTP function directly"),
750 "{:?}",
751 derived.warnings[0].help
752 );
753 }
754
755 #[test]
756 fn repository_url_and_path_derive_the_values_the_runtime_checks() {
757 let (ta, tag, params, args) = first_doc_capability(
758 "/** @capability git.clone { path, remote: $url, remoteName: \"origin\" } */\n\
759 function callee(url: string, path: string): void { }\n\
760 function main(): void { callee(\"https://GitHub.com:443\", \"repo/./x/..\"); }\n",
761 );
762 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
763 assert_eq!(
764 derived.filter.as_deref(),
765 Some("remote == \"https://github.com/\" and remoteName == \"origin\"")
766 );
767 assert_eq!(derived.warnings.len(), 1, "{:?}", derived.warnings);
768 }
769
770 #[test]
771 fn vfs_path_fields_derive_normalized_paths() {
772 let (ta, tag, params, args) = first_doc_capability(
773 "/** @capability fs.move { from, to } */\n\
774 function callee(from: string, to: string): void { }\n\
775 function main(): void { callee(\"/data/../in.csv\", \"out/\"); }\n",
776 );
777 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
778 assert_eq!(derived.filter.as_deref(), Some("from == \"/in.csv\""));
779 assert_eq!(derived.warnings.len(), 1, "{:?}", derived.warnings);
780 }
781
782 #[test]
783 fn fixed_vfs_path_literal_is_normalized() {
784 let (ta, tag, params, args) = first_doc_capability(
785 "/** @capability fs.read { path: \"data\" } */\n\
786 function callee(): void { }\n\
787 function main(): void { callee(); }\n",
788 );
789 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
790 assert_eq!(derived.filter, None);
791 assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
792 }
793
794 #[test]
795 fn fixed_literal_the_runtime_refuses_keeps_its_spelling_without_a_warning() {
796 let (ta, tag, params, args) = first_doc_capability(
797 "/** @capability fs.read { path: \"/..\" } */\n\
798 function callee(): void { }\n\
799 function main(): void { callee(); }\n",
800 );
801 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
802 assert_eq!(derived.filter.as_deref(), Some("path == \"/..\""));
803 assert!(derived.warnings.is_empty(), "{:?}", derived.warnings);
804 }
805
806 #[test]
807 fn download_destination_and_const_folded_paths_are_normalized() {
808 let (ta, tag, params, args) = first_doc_capability(
809 "/** @capability http.download { host: $url.host, vfs_path: $path } */\n\
810 function callee(url: string, path: string): void { }\n\
811 const DIR: string = \"out/\";\n\
812 function main(): void { callee(\"https://Example.com/f\", DIR + \"../dl.csv\"); }\n",
813 );
814 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
815 assert_eq!(derived.filter.as_deref(), Some("host == \"example.com\""));
816 assert_eq!(derived.warnings.len(), 1, "{:?}", derived.warnings);
817 }
818
819 #[test]
820 fn same_field_name_outside_the_catalog_stays_verbatim() {
821 let (ta, tag, params, args) = first_doc_capability(
822 "/** @capability acme.com/open { path } */\n\
823 function callee(path: string): void { }\n\
824 function main(): void { callee(\"repo\"); }\n",
825 );
826 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
827 assert_eq!(derived.filter.as_deref(), Some("path == \"repo\""));
828 }
829
830 #[test]
831 fn value_the_runtime_refuses_keeps_its_spelling_and_warns() {
832 let (ta, tag, params, args) = first_doc_capability(
833 "/** @capability git.clone { path, remote: $url } */\n\
834 function callee(url: string, path: string): void { }\n\
835 function main(): void { callee(\"http://github.com/a.git\", \"/..\"); }\n",
836 );
837 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
838 assert_eq!(
839 derived.filter.as_deref(),
840 Some("path == \"/..\" and remote == \"http://github.com/a.git\"")
841 );
842 let messages = derived
843 .warnings
844 .iter()
845 .map(|warning| warning.message.as_str())
846 .collect::<Vec<_>>();
847 assert_eq!(
848 messages,
849 [
850 "`git.clone` refuses `path` value \"/..\" at runtime: path escapes the VFS root",
851 "`git.clone` refuses `remote` value \"http://github.com/a.git\" at runtime: \
852 git: remote must be an HTTPS repository URL without credentials, query, or fragment",
853 ]
854 );
855 }
856
857 #[test]
858 fn mutable_global_let_is_not_folded() {
859 let (ta, tag, params, args) = first_doc_capability(
860 "/** @capability http.post { host: $url.host } */\n\
861 function callee(url: string): void { }\n\
862 let endpoint: string = \"https://r.jina.ai/\";\n\
863 function main(): void { callee(endpoint); }\n",
864 );
865 let derived = derive_call_site_capability(&tag, ¶ms, &ta, &args).unwrap();
866 assert_eq!(derived.filter, None);
867 assert_eq!(derived.warnings.len(), 1);
868 }
869}