Skip to main content

code_system_graph_core/
http.rs

1use code_system_graph_model::{
2    Evidence, EvidenceId, Node, NodeId, NodeKind, Provenance, RepoId, stable_id
3};
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7use crate::{ExtractionBudgets, ExtractionLimitExceeded, ExtractionTracker, HttpConsumerConfig};
8
9const HTTP_METHODS: [&str; 8] = [
10    "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "TRACE",
11];
12
13/// Direction of an extracted HTTP operation at a repository boundary.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum BoundaryRole {
17    /// The repository serves the operation.
18    Provider,
19    /// The repository invokes the operation.
20    Consumer,
21}
22
23/// HTTP operation plus the evidence that established it.
24#[derive(Debug, Clone, PartialEq)]
25pub struct HttpBoundary {
26    /// Federated graph node for this repository-local boundary.
27    pub node: Node,
28    /// Canonical upper-case HTTP method.
29    pub method: String,
30    /// Canonical path template.
31    pub path: String,
32    /// Consumer or provider direction.
33    pub role: BoundaryRole,
34    /// Direct evidence for the boundary.
35    pub evidence: Evidence,
36}
37
38impl HttpBoundary {
39    /// Creates a declared HTTP consumer from strict manifest configuration.
40    #[must_use]
41    pub fn consumer(repo_id: RepoId, config: &HttpConsumerConfig) -> Self {
42        let method = config.method.trim().to_ascii_uppercase();
43        let path = normalize_http_path(&config.path);
44        boundary(
45            repo_id,
46            &method,
47            &path,
48            BoundaryRole::Consumer,
49            &config.source,
50            1.0,
51        )
52    }
53}
54
55/// Error returned while extracting an `OpenAPI` document.
56#[derive(Debug, Error)]
57pub enum HttpExtractionError {
58    /// YAML or JSON syntax is invalid.
59    #[error("invalid OpenAPI document: parser rejected malformed input")]
60    InvalidDocument,
61    /// Root document is not an object.
62    #[error("invalid OpenAPI document: root must be an object")]
63    InvalidRoot,
64    /// Required contract version is absent or unsupported.
65    #[error("unsupported HTTP contract version; expected OpenAPI 3.x or Swagger 2.0")]
66    UnsupportedVersion,
67    /// Required `paths` map is absent.
68    #[error("invalid OpenAPI document: `paths` must be an object")]
69    InvalidPaths,
70    /// Extraction exceeded one configured invocation resource.
71    #[error(transparent)]
72    LimitExceeded(#[from] ExtractionLimitExceeded),
73}
74
75/// Normalizes an HTTP path template for deterministic contract matching.
76#[must_use]
77pub fn normalize_http_path(path: &str) -> String {
78    let trimmed = path.trim();
79    if trimmed.is_empty() || trimmed == "/" {
80        return "/".to_owned();
81    }
82
83    let mut normalized = String::with_capacity(trimmed.len() + 1);
84    if !trimmed.starts_with('/') {
85        normalized.push('/');
86    }
87    let mut previous_slash = false;
88    for character in trimmed.chars() {
89        if character == '/' {
90            if !previous_slash {
91                normalized.push(character);
92            }
93            previous_slash = true;
94        } else {
95            normalized.push(character);
96            previous_slash = false;
97        }
98    }
99    while normalized.len() > 1 && normalized.ends_with('/') {
100        normalized.pop();
101    }
102    normalized
103}
104
105/// Extracts provider operations from an `OpenAPI` 3.x or Swagger 2.0 YAML/JSON document.
106///
107/// # Errors
108///
109/// Returns [`HttpExtractionError`] when the document is malformed, is not `OpenAPI` 3.x, or
110/// does not contain an object-valued `paths` field.
111pub fn extract_openapi(
112    repo_id: &RepoId,
113    source_path: &str,
114    input: &str,
115) -> Result<Vec<HttpBoundary>, HttpExtractionError> {
116    let mut tracker = ExtractionTracker::new(
117        source_path,
118        "code-system-graph.http.openapi",
119        &ExtractionBudgets::default(),
120    );
121    extract_openapi_with_tracker(repo_id, source_path, input, &mut tracker)
122}
123
124/// Extracts provider operations using an existing per-invocation tracker.
125///
126/// # Errors
127///
128/// Returns [`HttpExtractionError`] when the document is invalid or a configured extraction
129/// budget is exhausted.
130pub fn extract_openapi_with_tracker(
131    repo_id: &RepoId,
132    source_path: &str,
133    input: &str,
134    tracker: &mut ExtractionTracker,
135) -> Result<Vec<HttpBoundary>, HttpExtractionError> {
136    tracker.check_input_bytes(u64::try_from(input.len()).unwrap_or(u64::MAX))?;
137    tracker.charge_portable_path(source_path)?;
138    tracker.check_structured_time()?;
139    precheck_openapi_depth(input, tracker)?;
140    let parsed = crate::yaml::from_str_with_extraction_budgets(input, tracker.budgets());
141    tracker.check_structured_time()?;
142    let document: serde_json::Value =
143        parsed.map_err(|error| openapi_yaml_error(&error, tracker))?;
144    charge_openapi_document(&document, tracker)?;
145    let root = document
146        .as_object()
147        .ok_or(HttpExtractionError::InvalidRoot)?;
148    let openapi_version = root.get("openapi").and_then(serde_json::Value::as_str);
149    let swagger_version = root.get("swagger").and_then(serde_json::Value::as_str);
150    let is_openapi = openapi_version.is_some_and(|version| version.starts_with("3."));
151    let is_swagger = swagger_version == Some("2.0");
152    if !is_openapi && !is_swagger {
153        return Err(HttpExtractionError::UnsupportedVersion);
154    }
155    let base_path = if is_swagger {
156        root.get("basePath")
157            .and_then(serde_json::Value::as_str)
158            .map(|value| {
159                tracker.charge_portable_path(value)?;
160                Ok::<_, ExtractionLimitExceeded>(normalize_http_path(value))
161            })
162            .transpose()?
163    } else {
164        None
165    };
166    let paths = root
167        .get("paths")
168        .and_then(serde_json::Value::as_object)
169        .ok_or(HttpExtractionError::InvalidPaths)?;
170
171    let mut boundaries = Vec::new();
172    for (raw_path, path_item) in paths {
173        tracker.charge_work(1)?;
174        tracker.charge_portable_path(raw_path)?;
175        let Some(operations) = path_item.as_object() else {
176            continue;
177        };
178        let path = base_path.as_ref().map_or_else(
179            || normalize_http_path(raw_path),
180            |base| normalize_http_path(&format!("{base}/{raw_path}")),
181        );
182        tracker.charge_portable_path(&path)?;
183        for (raw_method, operation) in operations {
184            tracker.charge_work(1)?;
185            let Some(method) = HTTP_METHODS
186                .iter()
187                .find(|candidate| raw_method.eq_ignore_ascii_case(candidate))
188                .copied()
189            else {
190                continue;
191            };
192            if !operation.is_object() {
193                continue;
194            }
195            tracker.charge_identifier(method)?;
196            tracker.charge_observation(1)?;
197            boundaries.push(boundary(
198                repo_id.clone(),
199                method,
200                &path,
201                BoundaryRole::Provider,
202                source_path,
203                1.0,
204            ));
205        }
206    }
207    boundaries.sort_by(|left, right| (&left.path, &left.method).cmp(&(&right.path, &right.method)));
208    tracker.check_structured_time()?;
209    Ok(boundaries)
210}
211
212fn precheck_openapi_depth(
213    input: &str,
214    tracker: &mut ExtractionTracker,
215) -> Result<(), ExtractionLimitExceeded> {
216    let mut block_indents = Vec::new();
217    let mut flow_depth = 0_u64;
218    let mut quote = None;
219    let mut escaped = false;
220    let mut block_scalar: Option<(usize, u64)> = None;
221    let mut inspected = 0_u64;
222
223    for source_line in input.lines() {
224        let indentation = source_line
225            .as_bytes()
226            .iter()
227            .take_while(|byte| **byte == b' ')
228            .count();
229        let trimmed = source_line.trim();
230        if let Some((parent_indent, scalar_bytes)) = block_scalar {
231            if trimmed.is_empty() || indentation > parent_indent {
232                let content_bytes = source_line.len().saturating_sub(indentation);
233                let observed = scalar_bytes
234                    .saturating_add(u64::try_from(content_bytes).unwrap_or(u64::MAX))
235                    .saturating_add(1);
236                tracker.check_string_bytes(observed)?;
237                block_scalar = Some((parent_indent, observed));
238                continue;
239            }
240            block_scalar = None;
241        }
242        if trimmed.is_empty()
243            || trimmed.starts_with('#')
244            || matches!(trimmed, "---" | "...")
245            || trimmed.starts_with('%')
246        {
247            continue;
248        }
249        tracker.charge_work(1)?;
250        precheck_yaml_scalar_values(source_line, tracker)?;
251        while block_indents
252            .last()
253            .is_some_and(|parent| *parent >= indentation)
254        {
255            block_indents.pop();
256        }
257        let block_depth = u64::try_from(block_indents.len())
258            .unwrap_or(u64::MAX)
259            .saturating_add(1);
260        let compact_sequence_depth = compact_yaml_sequence_depth(trimmed);
261        tracker.check_structural_depth(
262            block_depth
263                .saturating_add(flow_depth)
264                .saturating_add(compact_sequence_depth),
265        )?;
266
267        let mut comment = false;
268        for current in source_line.chars() {
269            inspected = inspected.saturating_add(1);
270            if inspected.is_multiple_of(1_024) {
271                tracker.check_structured_time()?;
272            }
273            if comment {
274                continue;
275            }
276            if let Some(delimiter) = quote {
277                if delimiter == '"' && escaped {
278                    escaped = false;
279                } else if delimiter == '"' && current == '\\' {
280                    escaped = true;
281                } else if current == delimiter {
282                    quote = None;
283                }
284                continue;
285            }
286            match current {
287                '"' | '\'' => quote = Some(current),
288                '#' => comment = true,
289                '{' | '[' => {
290                    flow_depth = flow_depth.saturating_add(1);
291                    tracker.check_structural_depth(block_depth.saturating_add(flow_depth))?;
292                }
293                '}' | ']' => flow_depth = flow_depth.saturating_sub(1),
294                _ => {}
295            }
296        }
297        quote = None;
298        escaped = false;
299        let structural = trimmed.split('#').next().unwrap_or(trimmed).trim_end();
300        if structural.ends_with(['|', '>']) {
301            block_scalar = Some((indentation, 0_u64));
302        } else if structural == "-" || structural.ends_with(':') {
303            tracker.check_structural_depth(
304                block_depth
305                    .saturating_add(flow_depth)
306                    .saturating_add(compact_sequence_depth)
307                    .saturating_add(1),
308            )?;
309            block_indents.push(indentation);
310        }
311    }
312    tracker.check_structured_time()?;
313    Ok(())
314}
315
316fn precheck_yaml_scalar_values(
317    source_line: &str,
318    tracker: &ExtractionTracker,
319) -> Result<(), ExtractionLimitExceeded> {
320    let bytes = source_line.as_bytes();
321    let mut quote = None;
322    let mut quote_start = 0_usize;
323    let mut escaped = false;
324    let mut colon = None;
325    let mut comment = bytes.len();
326    let mut cursor = 0_usize;
327    while cursor < bytes.len() {
328        let byte = bytes[cursor];
329        if let Some(delimiter) = quote {
330            if delimiter == b'"' && escaped {
331                escaped = false;
332            } else if delimiter == b'"' && byte == b'\\' {
333                escaped = true;
334            } else if byte == delimiter {
335                let observed =
336                    u64::try_from(cursor.saturating_sub(quote_start)).unwrap_or(u64::MAX);
337                tracker.check_string_bytes(observed)?;
338                quote = None;
339            }
340        } else {
341            match byte {
342                b'"' | b'\'' => {
343                    quote = Some(byte);
344                    quote_start = cursor.saturating_add(1);
345                }
346                b'#' => {
347                    comment = cursor;
348                    break;
349                }
350                b':' if colon.is_none() => colon = Some(cursor),
351                _ => {}
352            }
353        }
354        cursor = cursor.saturating_add(1);
355    }
356
357    let meaningful = source_line[..comment].trim();
358    let sequence_value = meaningful
359        .strip_prefix('-')
360        .filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace))
361        .map(str::trim_start);
362    if let Some(colon) = colon.filter(|colon| *colon < comment) {
363        let key = source_line[..colon].trim().trim_start_matches('-').trim();
364        tracker.check_string_bytes(u64::try_from(key.len()).unwrap_or(u64::MAX))?;
365        let value = source_line[colon.saturating_add(1)..comment].trim();
366        if !value.is_empty() && !value.starts_with(['"', '\'', '{', '[', '|', '>', '&', '*', '!']) {
367            tracker.check_string_bytes(u64::try_from(value.len()).unwrap_or(u64::MAX))?;
368        }
369    } else if let Some(value) = sequence_value
370        && !value.is_empty()
371        && !value.starts_with(['"', '\'', '{', '[', '|', '>', '&', '*', '!'])
372    {
373        tracker.check_string_bytes(u64::try_from(value.len()).unwrap_or(u64::MAX))?;
374    }
375    Ok(())
376}
377
378fn openapi_yaml_error(
379    error: &serde_saphyr::DeserializeError,
380    tracker: &ExtractionTracker,
381) -> HttpExtractionError {
382    use serde_saphyr::budget::BudgetBreach;
383
384    let checked = match error {
385        serde_saphyr::Error::Budget { breach, .. } => match breach {
386            BudgetBreach::Events { events } => tracker.check_work_units(to_u64(*events)),
387            BudgetBreach::Aliases { aliases } => tracker.check_work_units(to_u64(*aliases)),
388            BudgetBreach::Anchors { anchors } => tracker.check_work_units(to_u64(*anchors)),
389            BudgetBreach::Depth { depth } => tracker.check_structural_depth(to_u64(*depth)),
390            BudgetBreach::Nodes { nodes } => tracker.check_work_units(to_u64(*nodes)),
391            BudgetBreach::ScalarBytes { total_scalar_bytes } => {
392                tracker.check_accumulated_string_bytes(to_u64(*total_scalar_bytes))
393            }
394            BudgetBreach::CommentBytes {
395                total_comment_bytes,
396            } => tracker.check_input_bytes(to_u64(*total_comment_bytes)),
397            BudgetBreach::MergeKeys { merge_keys } => tracker.check_work_units(to_u64(*merge_keys)),
398            BudgetBreach::InputBytes { input_bytes } => {
399                tracker.check_input_bytes(to_u64(*input_bytes))
400            }
401            _ => return HttpExtractionError::InvalidDocument,
402        },
403        serde_saphyr::Error::AliasReplayCounterOverflow { .. } => {
404            tracker.check_work_units(u64::MAX)
405        }
406        serde_saphyr::Error::AliasReplayLimitExceeded {
407            total_replayed_events,
408            ..
409        } => tracker.check_work_units(to_u64(*total_replayed_events)),
410        serde_saphyr::Error::AliasExpansionLimitExceeded { expansions, .. } => {
411            tracker.check_work_units(to_u64(*expansions))
412        }
413        serde_saphyr::Error::AliasReplayStackDepthExceeded { depth, .. } => {
414            tracker.check_structural_depth(to_u64(*depth))
415        }
416        _ => return HttpExtractionError::InvalidDocument,
417    };
418    checked.err().map_or(
419        HttpExtractionError::InvalidDocument,
420        HttpExtractionError::from,
421    )
422}
423
424fn to_u64(value: usize) -> u64 {
425    u64::try_from(value).unwrap_or(u64::MAX)
426}
427
428fn compact_yaml_sequence_depth(mut value: &str) -> u64 {
429    let mut depth = 0_u64;
430    loop {
431        let Some(remainder) = value.strip_prefix('-') else {
432            return depth;
433        };
434        if !remainder.is_empty()
435            && !remainder
436                .as_bytes()
437                .first()
438                .is_some_and(u8::is_ascii_whitespace)
439        {
440            return depth;
441        }
442        depth = depth.saturating_add(1);
443        value = remainder.trim_start();
444    }
445}
446
447fn charge_openapi_document(
448    document: &serde_json::Value,
449    tracker: &mut ExtractionTracker,
450) -> Result<(), ExtractionLimitExceeded> {
451    tracker.check_structural_depth(1)?;
452    tracker.charge_work(1)?;
453    let mut pending = vec![(document, 1_u64)];
454    while let Some((value, depth)) = pending.pop() {
455        match value {
456            serde_json::Value::Object(object) => {
457                let child_depth = depth.saturating_add(1);
458                for (key, child) in object {
459                    tracker.charge_string(key)?;
460                    tracker.check_structural_depth(child_depth)?;
461                    tracker.charge_work(1)?;
462                    pending.push((child, child_depth));
463                }
464            }
465            serde_json::Value::Array(array) => {
466                let child_depth = depth.saturating_add(1);
467                for child in array {
468                    tracker.check_structural_depth(child_depth)?;
469                    tracker.charge_work(1)?;
470                    pending.push((child, child_depth));
471                }
472            }
473            serde_json::Value::String(value) => tracker.charge_string(value)?,
474            serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {
475            }
476        }
477    }
478    Ok(())
479}
480
481fn boundary(
482    repo_id: RepoId,
483    method: &str,
484    path: &str,
485    role: BoundaryRole,
486    source_path: &str,
487    confidence: f32,
488) -> HttpBoundary {
489    let (role_key, extractor, provenance) = match role {
490        BoundaryRole::Provider => (
491            "provider",
492            "code-system-graph.http.openapi",
493            Provenance::Extracted,
494        ),
495        BoundaryRole::Consumer => (
496            "consumer",
497            "code-system-graph.http.declared",
498            Provenance::Declared,
499        ),
500    };
501    let stable_key = format!("http:{}:{role_key}:{method}:{path}", repo_id.as_str());
502    let evidence_key = format!("{stable_key}:{source_path}");
503    HttpBoundary {
504        node: Node {
505            id: NodeId::new(stable_id("node", &stable_key)),
506            kind: NodeKind::HttpOperation,
507            repo_id: Some(repo_id.clone()),
508            stable_key,
509            label: format!("{method} {path}"),
510        },
511        method: method.to_owned(),
512        path: path.to_owned(),
513        role,
514        evidence: Evidence {
515            id: EvidenceId::new(stable_id("evidence", &evidence_key)),
516            repo_id: Some(repo_id),
517            file_path: Some(source_path.to_owned()),
518            start_line: None,
519            end_line: None,
520            extractor: extractor.to_owned(),
521            extractor_version: env!("CARGO_PKG_VERSION").to_owned(),
522            provenance,
523            confidence,
524            observed_at_commit: None,
525            content_hash: Some(stable_id("content", &evidence_key)),
526            note: None,
527        },
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use code_system_graph_model::RepoId;
534
535    use super::{
536        extract_openapi, extract_openapi_with_tracker, normalize_http_path, precheck_openapi_depth
537    };
538    use crate::{ExtractionBudgets, ExtractionResource, ExtractionTracker};
539
540    #[test]
541    fn normalize_http_path_should_collapse_slashes_and_trailing_separator() {
542        assert_eq!(normalize_http_path("api//orders/"), "/api/orders");
543    }
544
545    #[test]
546    fn extract_openapi_should_return_sorted_provider_operations() {
547        let input = r"
548openapi: 3.1.0
549paths:
550  /api/orders:
551    post:
552      operationId: createOrder
553    get:
554      operationId: listOrders
555";
556        let result = extract_openapi(&RepoId::new("repo:api"), "openapi.yaml", input);
557        let methods = result.map(|items| {
558            items
559                .into_iter()
560                .map(|item| item.method)
561                .collect::<Vec<_>>()
562        });
563
564        assert!(matches!(
565            methods,
566            Ok(value) if value == vec!["GET".to_owned(), "POST".to_owned()]
567        ));
568    }
569
570    #[test]
571    fn tracked_openapi_should_enforce_observation_and_structural_budgets() {
572        let input = r"
573openapi: 3.1.0
574paths:
575  /orders:
576    get: {}
577    post: {}
578";
579        let observations = ExtractionBudgets {
580            max_observations_per_artifact: 1,
581            ..ExtractionBudgets::default()
582        };
583        let mut observation_tracker =
584            ExtractionTracker::new("openapi.yaml", "openapi", &observations);
585        assert!(matches!(
586            extract_openapi_with_tracker(
587                &RepoId::new("repo:api"),
588                "openapi.yaml",
589                input,
590                &mut observation_tracker,
591            ),
592            Err(super::HttpExtractionError::LimitExceeded(error))
593                if error.resource == ExtractionResource::Observations
594        ));
595
596        let depth = ExtractionBudgets {
597            max_structural_depth_per_artifact: 3,
598            ..ExtractionBudgets::default()
599        };
600        let mut depth_tracker = ExtractionTracker::new("openapi.yaml", "openapi", &depth);
601        assert!(matches!(
602            extract_openapi_with_tracker(
603                &RepoId::new("repo:api"),
604                "openapi.yaml",
605                input,
606                &mut depth_tracker,
607            ),
608            Err(super::HttpExtractionError::LimitExceeded(error))
609                if error.resource == ExtractionResource::StructuralDepth
610        ));
611    }
612
613    #[test]
614    fn openapi_precheck_should_accept_depth_64_and_reject_65_before_parsing() {
615        fn nested_openapi(depth: usize) -> String {
616            let mut source = String::from("openapi: 3.1.0\npaths: {}\n");
617            for level in 0..depth.saturating_sub(1) {
618                source.push_str(&" ".repeat(level));
619                source.push_str("x-");
620                source.push_str(&level.to_string());
621                source.push_str(":\n");
622            }
623            source
624        }
625
626        let budgets = ExtractionBudgets {
627            max_structural_depth_per_artifact: 64,
628            ..ExtractionBudgets::default()
629        };
630        let mut exact = ExtractionTracker::new("exact.yaml", "openapi", &budgets);
631        let mut above = ExtractionTracker::new("above.yaml", "openapi", &budgets);
632        assert!(
633            extract_openapi_with_tracker(
634                &RepoId::new("repo:api"),
635                "exact.yaml",
636                &nested_openapi(64),
637                &mut exact,
638            )
639            .is_ok()
640        );
641        assert!(matches!(
642            extract_openapi_with_tracker(
643                &RepoId::new("repo:api"),
644                "above.yaml",
645                &nested_openapi(65),
646                &mut above,
647            ),
648            Err(super::HttpExtractionError::LimitExceeded(error))
649                if error.resource == ExtractionResource::StructuralDepth
650                    && error.observed == 65
651                    && error.maximum == 64
652        ));
653    }
654
655    #[test]
656    fn openapi_precheck_should_count_compact_yaml_sequence_depth() {
657        fn compact_sequence(depth: usize) -> String {
658            format!("x:\n {}value\n", "- ".repeat(depth))
659        }
660
661        let budgets = ExtractionBudgets {
662            max_structural_depth_per_artifact: 64,
663            ..ExtractionBudgets::default()
664        };
665        let mut exact = ExtractionTracker::new("exact.yaml", "openapi", &budgets);
666        let mut above = ExtractionTracker::new("above.yaml", "openapi", &budgets);
667        assert!(precheck_openapi_depth(&compact_sequence(62), &mut exact).is_ok());
668        assert!(matches!(
669            precheck_openapi_depth(&compact_sequence(63), &mut above),
670            Err(error)
671                if error.resource == ExtractionResource::StructuralDepth
672                    && error.observed == 65
673                    && error.maximum == 64
674        ));
675    }
676
677    #[test]
678    fn openapi_parser_should_honor_structural_overrides_above_its_library_default() {
679        let mut input = String::from("openapi: 3.1.0\npaths: {}\nroot:\n");
680        for depth in 1..80 {
681            input.push_str(&" ".repeat(depth));
682            input.push_str("child:\n");
683        }
684        input.push_str(&" ".repeat(80));
685        input.push_str("value: true\n");
686        let budgets = ExtractionBudgets {
687            max_structural_depth_per_artifact: 96,
688            ..ExtractionBudgets::default()
689        };
690        let mut tracker = ExtractionTracker::new("deep.yaml", "openapi", &budgets);
691
692        let result = extract_openapi_with_tracker(
693            &RepoId::new("repo:api"),
694            "deep.yaml",
695            &input,
696            &mut tracker,
697        );
698        assert!(
699            result.is_ok(),
700            "configured depth was not honored: {result:?}"
701        );
702    }
703
704    #[test]
705    fn openapi_scalar_limit_should_apply_before_yaml_dom_materialization() {
706        let secret = "sensitive-scalar-value";
707        let input = format!("openapi: 3.1.0\ninfo:\n  description: {secret}\npaths: {{}}\n");
708        let budgets = ExtractionBudgets {
709            max_string_bytes_per_value: 12,
710            ..ExtractionBudgets::default()
711        };
712        let mut tracker = ExtractionTracker::new("secret.yaml", "openapi", &budgets);
713
714        let result = extract_openapi_with_tracker(
715            &RepoId::new("repo:api"),
716            "secret.yaml",
717            &input,
718            &mut tracker,
719        );
720        assert!(
721            matches!(
722                &result,
723                Err(super::HttpExtractionError::LimitExceeded(error))
724                    if error.resource == ExtractionResource::StringBytesPerValue
725                        && error.observed == u64::try_from(secret.len()).unwrap_or(u64::MAX)
726                        && error.maximum == 12
727            ),
728            "unexpected scalar budget result: {result:?}"
729        );
730        let diagnostic = format!("{result:?} {result:?}");
731        assert!(!diagnostic.contains(secret));
732    }
733
734    #[test]
735    fn malformed_openapi_error_should_not_echo_source_literals() {
736        let secret = "sensitive-duplicate-key-value";
737        let input =
738            format!("openapi: 3.1.0\ninfo:\n  {secret}: first\n  {secret}: second\npaths: {{}}\n");
739        let result = extract_openapi(&RepoId::new("repo:api"), "invalid.yaml", &input);
740
741        assert!(matches!(
742            &result,
743            Err(super::HttpExtractionError::InvalidDocument)
744        ));
745        let diagnostic = format!(
746            "{result:?} {}",
747            result.as_ref().expect_err("malformed input")
748        );
749        assert!(!diagnostic.contains(secret));
750    }
751
752    #[test]
753    fn yaml_node_budget_should_abort_before_openapi_dom_materialization() {
754        let input = "openapi: 3.1.0\npaths: { /orders: { get: {} } }\n";
755        let budgets = ExtractionBudgets {
756            max_work_units_per_artifact: 4,
757            ..ExtractionBudgets::default()
758        };
759        let mut tracker = ExtractionTracker::new("nodes.yaml", "openapi", &budgets);
760
761        assert!(matches!(
762            extract_openapi_with_tracker(
763                &RepoId::new("repo:api"),
764                "nodes.yaml",
765                input,
766                &mut tracker,
767            ),
768            Err(super::HttpExtractionError::LimitExceeded(error))
769                if error.resource == ExtractionResource::WorkUnits
770                    && error.observed == 5
771                    && error.maximum == 4
772        ));
773    }
774
775    #[test]
776    fn extract_swagger_should_conservatively_apply_base_path() {
777        let input = r#"
778swagger: "2.0"
779basePath: /api
780paths:
781  /orders/{id}:
782    get:
783      operationId: getOrder
784"#;
785        let result = extract_openapi(&RepoId::new("repo:api"), "swagger.yaml", input);
786
787        assert!(matches!(
788            result,
789            Ok(boundaries)
790                if boundaries.len() == 1
791                    && boundaries[0].method == "GET"
792                    && boundaries[0].path == "/api/orders/{id}"
793        ));
794    }
795}