Skip to main content

code_system_graph_core/
generated_client.rs

1use serde::{Deserialize, Serialize};
2use thiserror::Error;
3
4use crate::{ExtractionLimitExceeded, ExtractionTracker};
5
6/// Exact generated-client configuration or manifest observation.
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub struct GeneratedClientMetadata {
9    /// Generator tool family.
10    pub tool: String,
11    /// Named generator configuration when available.
12    pub name: Option<String>,
13    /// Generator implementation, such as `typescript-fetch`.
14    pub generator_name: Option<String>,
15    /// Repository-relative input contract path when explicitly configured.
16    pub input_spec: Option<String>,
17    /// Repository-relative generated output root when explicitly configured.
18    pub output: Option<String>,
19    /// Generator version when explicitly recorded.
20    pub version: Option<String>,
21    /// Generated file path when listed by a tool-owned manifest.
22    pub generated_file: Option<String>,
23    /// One-based direct evidence line.
24    pub line: u32,
25}
26
27/// Error returned for malformed generated-client metadata.
28#[derive(Debug, Error)]
29pub enum GeneratedClientError {
30    /// Path is not a supported public metadata artifact.
31    #[error("unsupported generated-client metadata `{0}`")]
32    UnsupportedPath(String),
33    /// Structured metadata is malformed.
34    #[error("invalid generated-client metadata: {0}")]
35    InvalidMetadata(#[from] serde_json::Error),
36    /// Metadata contains an unsafe repository-relative path.
37    #[error("generated-client metadata contains unsafe path `{0}`")]
38    UnsafePath(String),
39    /// Extraction exceeded one configured invocation resource.
40    #[error(transparent)]
41    LimitExceeded(#[from] ExtractionLimitExceeded),
42}
43
44/// Extracts `OpenAPI` Generator public configuration and output manifests.
45///
46/// # Errors
47///
48/// Returns [`GeneratedClientError`] for unsupported paths, malformed JSON, or unsafe paths.
49pub fn extract_generated_client_metadata(
50    source_path: &str,
51    content: &str,
52    tracker: &mut ExtractionTracker,
53) -> Result<Vec<GeneratedClientMetadata>, GeneratedClientError> {
54    tracker.check_input_bytes(u64::try_from(content.len()).unwrap_or(u64::MAX))?;
55    tracker.check_structured_time()?;
56    let normalized = source_path.replace('\\', "/");
57    tracker.charge_portable_path(&normalized)?;
58    if normalized.ends_with("openapitools.json") {
59        let output = extract_openapitools(&normalized, content, tracker);
60        if matches!(output, Err(GeneratedClientError::LimitExceeded(_))) {
61            return output;
62        }
63        tracker.check_structured_time()?;
64        return output;
65    }
66    if normalized.ends_with(".openapi-generator/VERSION") {
67        for _ in content.lines() {
68            tracker.charge_work(1)?;
69        }
70        let version = content.trim();
71        if version.is_empty() {
72            tracker.check_structured_time()?;
73            return Ok(Vec::new());
74        }
75        tracker.charge_string(version)?;
76        tracker.charge_observation(1)?;
77        let output = vec![GeneratedClientMetadata {
78            tool: "openapi-generator".to_owned(),
79            name: None,
80            generator_name: None,
81            input_spec: None,
82            output: None,
83            version: Some(version.to_owned()),
84            generated_file: None,
85            line: 1,
86        }];
87        tracker.check_structured_time()?;
88        return Ok(output);
89    }
90    if normalized.ends_with(".openapi-generator/FILES") {
91        let mut output = Vec::new();
92        for (index, line) in content.lines().enumerate() {
93            tracker.charge_work(1)?;
94            let path = line.trim();
95            if path.is_empty() {
96                continue;
97            }
98            let path = validate_relative(path)?;
99            tracker.charge_portable_path(&path)?;
100            tracker.charge_observation(1)?;
101            output.push(GeneratedClientMetadata {
102                tool: "openapi-generator".to_owned(),
103                name: None,
104                generator_name: None,
105                input_spec: None,
106                output: None,
107                version: None,
108                generated_file: Some(path),
109                line: u32::try_from(index + 1).unwrap_or(u32::MAX),
110            });
111        }
112        tracker.check_structured_time()?;
113        return Ok(output);
114    }
115    Err(GeneratedClientError::UnsupportedPath(
116        source_path.to_owned(),
117    ))
118}
119
120fn extract_openapitools(
121    source_path: &str,
122    content: &str,
123    tracker: &mut ExtractionTracker,
124) -> Result<Vec<GeneratedClientMetadata>, GeneratedClientError> {
125    crate::graphql_contracts::precheck_json_structure(content, tracker)?;
126    let root: serde_json::Value = serde_json::from_str(content)?;
127    let generators = root
128        .get("generator-cli")
129        .and_then(|value| value.get("generators"))
130        .and_then(serde_json::Value::as_object)
131        .ok_or_else(|| {
132            serde_json::Error::io(std::io::Error::new(
133                std::io::ErrorKind::InvalidData,
134                "`generator-cli.generators` must be an object",
135            ))
136        })?;
137    let version = root
138        .get("generator-cli")
139        .and_then(|value| value.get("version"))
140        .and_then(serde_json::Value::as_str);
141    let base = source_path.rsplit_once('/').map_or("", |(base, _)| base);
142    let mut output = Vec::new();
143    for (name, value) in generators {
144        tracker.charge_work(1)?;
145        tracker.charge_identifier(name)?;
146        let generator_name = value
147            .get("generatorName")
148            .and_then(serde_json::Value::as_str)
149            .map(|value| {
150                tracker.charge_identifier(value)?;
151                Ok::<_, ExtractionLimitExceeded>(value.to_owned())
152            })
153            .transpose()?;
154        let input_spec = value
155            .get("inputSpec")
156            .and_then(serde_json::Value::as_str)
157            .map(|path| resolve_relative(base, path))
158            .transpose()?;
159        let generated_output = value
160            .get("output")
161            .and_then(serde_json::Value::as_str)
162            .map(|path| resolve_relative(base, path))
163            .transpose()?;
164        if let Some(path) = &input_spec {
165            tracker.charge_portable_path(path)?;
166        }
167        if let Some(path) = &generated_output {
168            tracker.charge_portable_path(path)?;
169        }
170        let version = version
171            .map(|value| {
172                tracker.charge_string(value)?;
173                Ok::<_, ExtractionLimitExceeded>(value.to_owned())
174            })
175            .transpose()?;
176        tracker.charge_observation(1)?;
177        output.push(GeneratedClientMetadata {
178            tool: "openapi-generator".to_owned(),
179            name: Some(name.clone()),
180            generator_name,
181            input_spec,
182            output: generated_output,
183            version,
184            generated_file: None,
185            // `serde_json` does not expose member spans. Keep a stable file-level anchor instead
186            // of rescanning the complete attacker-controlled document for every generator.
187            line: 1,
188        });
189    }
190    output.sort_by(|left, right| left.name.cmp(&right.name));
191    Ok(output)
192}
193
194fn resolve_relative(base: &str, value: &str) -> Result<String, GeneratedClientError> {
195    if portable_absolute(value) {
196        return Err(GeneratedClientError::UnsafePath(value.to_owned()));
197    }
198    let portable = value.replace('\\', "/");
199    let mut normalized = base
200        .split('/')
201        .filter(|component| !component.is_empty())
202        .map(str::to_owned)
203        .collect::<Vec<_>>();
204    for component in portable.split('/') {
205        match component {
206            "" => return Err(GeneratedClientError::UnsafePath(value.to_owned())),
207            "." => {}
208            ".." => {
209                if normalized.pop().is_none() {
210                    return Err(GeneratedClientError::UnsafePath(value.to_owned()));
211                }
212            }
213            component => {
214                if component.contains(':') {
215                    return Err(GeneratedClientError::UnsafePath(value.to_owned()));
216                }
217                normalized.push(component.to_owned());
218            }
219        }
220    }
221    Ok(normalized.join("/"))
222}
223
224fn validate_relative(value: &str) -> Result<String, GeneratedClientError> {
225    let normalized = value.replace('\\', "/");
226    if portable_absolute(&normalized)
227        || normalized
228            .split('/')
229            .any(|component| component.is_empty() || component == ".." || component.contains(':'))
230    {
231        return Err(GeneratedClientError::UnsafePath(value.to_owned()));
232    }
233    Ok(normalized)
234}
235
236fn portable_absolute(value: &str) -> bool {
237    value.starts_with(['/', '\\'])
238        || value
239            .as_bytes()
240            .get(1)
241            .is_some_and(|separator| *separator == b':')
242}
243
244#[cfg(test)]
245mod tests {
246    use super::{GeneratedClientError, extract_generated_client_metadata};
247    use crate::{ExtractionBudgets, ExtractionResource, ExtractionTracker};
248
249    fn tracker(path: &str) -> ExtractionTracker {
250        ExtractionTracker::new(path, "generated-client", &ExtractionBudgets::default())
251    }
252
253    #[test]
254    fn openapitools_should_resolve_exact_input_and_output_paths() {
255        let result = extract_generated_client_metadata(
256            "clients/openapitools.json",
257            r#"{"generator-cli":{"version":"7.12.0","generators":{"web":{"generatorName":"typescript-fetch","inputSpec":"../openapi.yaml","output":"generated"}}}}"#,
258            &mut tracker("clients/openapitools.json"),
259        );
260
261        assert!(matches!(
262            result,
263            Ok(items)
264                if items.len() == 1
265                    && items[0].input_spec.as_deref() == Some("openapi.yaml")
266                    && items[0].output.as_deref() == Some("clients/generated")
267        ));
268    }
269
270    #[test]
271    fn generated_file_manifest_should_reject_checkout_escape() {
272        let result = extract_generated_client_metadata(
273            ".openapi-generator/FILES",
274            "../secret.txt\n",
275            &mut tracker(".openapi-generator/FILES"),
276        );
277
278        assert!(result.is_err());
279    }
280
281    #[test]
282    fn generated_file_manifest_should_normalize_safe_mixed_separators() {
283        let result = extract_generated_client_metadata(
284            ".openapi-generator\\FILES",
285            "src\\generated/api.ts\n",
286            &mut tracker(".openapi-generator\\FILES"),
287        );
288
289        assert!(matches!(
290            result,
291            Ok(items) if items[0].generated_file.as_deref() == Some("src/generated/api.ts")
292        ));
293    }
294
295    #[test]
296    fn generated_file_manifest_should_reject_drives_empty_components_and_parent_segments() {
297        for unsafe_path in [
298            "C:\\secret.txt",
299            "//server/share.txt",
300            "src//generated.ts",
301            "src\\..\\secret.txt",
302            "src/../secret.txt",
303        ] {
304            let result = extract_generated_client_metadata(
305                ".openapi-generator/FILES",
306                unsafe_path,
307                &mut tracker(".openapi-generator/FILES"),
308            );
309            assert!(
310                matches!(result, Err(GeneratedClientError::UnsafePath(_))),
311                "unsafe path was accepted: {unsafe_path}"
312            );
313        }
314    }
315
316    #[test]
317    fn generated_file_lines_and_facts_should_be_charged_before_accumulation() {
318        let work_budgets = ExtractionBudgets {
319            max_work_units_per_artifact: 2,
320            ..ExtractionBudgets::default()
321        };
322        let mut work = ExtractionTracker::new("FILES", "generated-client", &work_budgets);
323        let result = extract_generated_client_metadata(
324            ".openapi-generator/FILES",
325            "one.ts\ntwo.ts\nthree.ts\n",
326            &mut work,
327        );
328        assert!(matches!(
329            result,
330            Err(GeneratedClientError::LimitExceeded(error))
331                if error.resource == ExtractionResource::WorkUnits
332                    && error.observed == 3
333                    && error.maximum == 2
334        ));
335
336        let observation_budgets = ExtractionBudgets {
337            max_observations_per_artifact: 2,
338            ..ExtractionBudgets::default()
339        };
340        let mut observations =
341            ExtractionTracker::new("FILES", "generated-client", &observation_budgets);
342        let result = extract_generated_client_metadata(
343            ".openapi-generator/FILES",
344            "one.ts\ntwo.ts\nthree.ts\n",
345            &mut observations,
346        );
347        assert!(matches!(
348            result,
349            Err(GeneratedClientError::LimitExceeded(error))
350                if error.resource == ExtractionResource::Observations
351                    && error.observed == 3
352                    && error.maximum == 2
353        ));
354    }
355
356    #[test]
357    fn openapitools_should_preflight_depth_and_work_before_json_dom() {
358        let input_at_depth = format!("{}0{}", "[".repeat(64), "]".repeat(64));
359        let depth_budgets = ExtractionBudgets {
360            max_structural_depth_per_artifact: 64,
361            ..ExtractionBudgets::default()
362        };
363        let mut at_depth =
364            ExtractionTracker::new("openapitools.json", "generated-client", &depth_budgets);
365        assert!(!matches!(
366            extract_generated_client_metadata(
367                "openapitools.json",
368                &input_at_depth,
369                &mut at_depth,
370            ),
371            Err(GeneratedClientError::LimitExceeded(error))
372                if error.resource == ExtractionResource::StructuralDepth
373        ));
374
375        let input_over_depth = format!("{}0{}", "[".repeat(65), "]".repeat(65));
376        let mut over_depth =
377            ExtractionTracker::new("openapitools.json", "generated-client", &depth_budgets);
378        assert!(matches!(
379            extract_generated_client_metadata(
380                "openapitools.json",
381                &input_over_depth,
382                &mut over_depth,
383            ),
384            Err(GeneratedClientError::LimitExceeded(error))
385                if error.resource == ExtractionResource::StructuralDepth
386                    && error.observed == 65
387                    && error.maximum == 64
388        ));
389
390        let work_budgets = ExtractionBudgets {
391            max_work_units_per_artifact: 1,
392            ..ExtractionBudgets::default()
393        };
394        let mut work =
395            ExtractionTracker::new("openapitools.json", "generated-client", &work_budgets);
396        assert!(matches!(
397            extract_generated_client_metadata("openapitools.json", "{}", &mut work),
398            Err(GeneratedClientError::LimitExceeded(error))
399                if error.resource == ExtractionResource::WorkUnits
400                    && error.observed == 2
401                    && error.maximum == 1
402        ));
403    }
404}