1use serde::{Deserialize, Serialize};
2use thiserror::Error;
3
4use crate::{ExtractionLimitExceeded, ExtractionTracker};
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub struct GeneratedClientMetadata {
9 pub tool: String,
11 pub name: Option<String>,
13 pub generator_name: Option<String>,
15 pub input_spec: Option<String>,
17 pub output: Option<String>,
19 pub version: Option<String>,
21 pub generated_file: Option<String>,
23 pub line: u32,
25}
26
27#[derive(Debug, Error)]
29pub enum GeneratedClientError {
30 #[error("unsupported generated-client metadata `{0}`")]
32 UnsupportedPath(String),
33 #[error("invalid generated-client metadata: {0}")]
35 InvalidMetadata(#[from] serde_json::Error),
36 #[error("generated-client metadata contains unsafe path `{0}`")]
38 UnsafePath(String),
39 #[error(transparent)]
41 LimitExceeded(#[from] ExtractionLimitExceeded),
42}
43
44pub 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 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}