1use super::{
4 CliError, IndexWorkControl, IndexWorkFailure, IndexWorkResource, IndexWorkStage, Node,
5 NodeKind, SourceReadFailure, read_source_bytes_controlled, repo_path_to_native,
6 source_changed_during_derivation,
7};
8use jsonc_parser::{ParseOptions as JsoncParseOptions, parse_to_serde_value};
9use projectatlas_symbols::{
10 ConfiguredModuleResolution, EcmaScriptConfigKind, EcmaScriptModuleConfig,
11 EcmaScriptPathMapping, MAX_CONFIGURED_MODULE_CONFIGS, MAX_CONFIGURED_MODULE_MAPPINGS,
12 MAX_CONFIGURED_MODULE_TARGETS,
13};
14use serde_json::Value;
15use std::path::Path;
16
17const MAX_MODULE_CONFIG_FILE_BYTES: u64 = 1024 * 1024;
19const MAX_MODULE_CONFIG_TOTAL_BYTES: u64 = 16 * 1024 * 1024;
21const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF";
23const MODULE_CONFIG_CONTROL_INTERVAL: usize = 64;
25
26pub(super) fn load_configured_module_resolution(
28 root: &Path,
29 nodes: &[Node],
30 control: &IndexWorkControl,
31) -> Result<ConfiguredModuleResolution, CliError> {
32 let mut config_nodes = nodes
33 .iter()
34 .filter(|node| node.kind == NodeKind::File)
35 .filter_map(|node| config_kind(&node.path).map(|kind| (node, kind)))
36 .collect::<Vec<_>>();
37 config_nodes.sort_by(|(left, _), (right, _)| left.path.cmp(&right.path));
38 if config_nodes.len() > MAX_CONFIGURED_MODULE_CONFIGS {
39 return Err(module_config_resource_limit(
40 IndexWorkResource::Entries,
41 MAX_CONFIGURED_MODULE_CONFIGS,
42 config_nodes.len(),
43 ));
44 }
45
46 let mut total_bytes = 0_u64;
47 let mut total_mappings = 0_u64;
48 let mut configs = Vec::with_capacity(config_nodes.len());
49 for (index, (node, kind)) in config_nodes.into_iter().enumerate() {
50 check_config_work(control, index)?;
51 let size = node.size_bytes.unwrap_or_default();
52 if size > MAX_MODULE_CONFIG_FILE_BYTES {
53 return Err(module_config_resource_limit(
54 IndexWorkResource::SourceBytes,
55 MAX_MODULE_CONFIG_FILE_BYTES,
56 size,
57 ));
58 }
59 claim_module_config_total(
60 &mut total_bytes,
61 size,
62 MAX_MODULE_CONFIG_TOTAL_BYTES,
63 IndexWorkResource::SourceBytes,
64 )?;
65 let native_path = root.join(repo_path_to_native(&node.path));
66 let bytes = match read_source_bytes_controlled(
67 &native_path,
68 MAX_MODULE_CONFIG_FILE_BYTES,
69 IndexWorkStage::SymbolParsing,
70 control,
71 ) {
72 Ok(bytes) => bytes,
73 Err(SourceReadFailure::Io(source)) => {
74 return Err(CliError::Io {
75 path: native_path,
76 source,
77 });
78 }
79 Err(SourceReadFailure::IndexWork(failure)) => return Err(failure.into()),
80 Err(SourceReadFailure::LimitExceeded { observed }) => {
81 return Err(module_config_resource_limit(
82 IndexWorkResource::SourceBytes,
83 MAX_MODULE_CONFIG_FILE_BYTES,
84 observed,
85 ));
86 }
87 };
88 let current_hash = blake3::hash(&bytes).to_hex().to_string();
89 if node.content_hash.as_deref() != Some(current_hash.as_str()) {
90 return Err(source_changed_during_derivation(root, &node.path));
91 }
92 let content = std::str::from_utf8(bytes.strip_prefix(UTF8_BOM).unwrap_or(&bytes)).map_err(
93 |_utf8_error| {
94 CliError::InvalidInput(format!(
95 "compiler configuration '{}' is not valid UTF-8",
96 node.path
97 ))
98 },
99 )?;
100 let value: Value =
101 parse_to_serde_value(content, &JsoncParseOptions::default()).map_err(|error| {
102 CliError::InvalidInput(format!(
103 "failed to parse compiler configuration '{}': {error}",
104 node.path
105 ))
106 })?;
107 let config = decode_config(node.path.as_str(), kind, &value, control)?;
108 claim_module_config_total(
109 &mut total_mappings,
110 u64::try_from(config.1).unwrap_or(u64::MAX),
111 u64::try_from(MAX_CONFIGURED_MODULE_MAPPINGS).unwrap_or(u64::MAX),
112 IndexWorkResource::RelationRows,
113 )?;
114 configs.push(config.0);
115 }
116 ConfiguredModuleResolution::new(configs)
117 .map_err(|error| CliError::InvalidInput(error.to_string()))
118}
119
120fn decode_config(
122 config_path: &str,
123 kind: EcmaScriptConfigKind,
124 value: &Value,
125 control: &IndexWorkControl,
126) -> Result<(EcmaScriptModuleConfig, usize), CliError> {
127 let root = value.as_object().ok_or_else(|| {
128 CliError::InvalidInput(format!(
129 "compiler configuration '{config_path}' must contain a JSON object"
130 ))
131 })?;
132 let Some(compiler_options) = root.get("compilerOptions") else {
133 return EcmaScriptModuleConfig::new(config_path, kind, None, Vec::new())
134 .map(|config| (config, 0))
135 .map_err(|error| CliError::InvalidInput(error.to_string()));
136 };
137 let compiler_options = compiler_options.as_object().ok_or_else(|| {
138 CliError::InvalidInput(format!(
139 "compiler configuration '{config_path}' field 'compilerOptions' must be an object"
140 ))
141 })?;
142 let config_directory = config_path
143 .rsplit_once('/')
144 .map_or("", |(directory, _)| directory);
145 let base_url = compiler_options
146 .get("baseUrl")
147 .map(|value| {
148 value.as_str().ok_or_else(|| {
149 CliError::InvalidInput(format!(
150 "compiler configuration '{config_path}' field 'baseUrl' must be a string"
151 ))
152 })
153 })
154 .transpose()?
155 .map(|base_url| normalize_repository_target(config_directory, base_url, false))
156 .transpose()?;
157 let mapping_base = base_url.as_deref().unwrap_or(config_directory);
158 let mut mappings = Vec::new();
159 if let Some(paths) = compiler_options.get("paths") {
160 let paths = paths.as_object().ok_or_else(|| {
161 CliError::InvalidInput(format!(
162 "compiler configuration '{config_path}' field 'paths' must be an object"
163 ))
164 })?;
165 for (index, (pattern, targets)) in paths.iter().enumerate() {
166 check_config_work(control, index)?;
167 let targets = targets.as_array().ok_or_else(|| {
168 CliError::InvalidInput(format!(
169 "compiler configuration '{config_path}' path mapping '{pattern}' must be an array"
170 ))
171 })?;
172 if targets.is_empty() || targets.len() > MAX_CONFIGURED_MODULE_TARGETS {
173 return Err(module_config_resource_limit(
174 IndexWorkResource::RelationRows,
175 MAX_CONFIGURED_MODULE_TARGETS,
176 targets.len(),
177 ));
178 }
179 let targets = targets
180 .iter()
181 .map(|target| {
182 let target = target.as_str().ok_or_else(|| {
183 CliError::InvalidInput(format!(
184 "compiler configuration '{config_path}' path mapping '{pattern}' contains a non-string target"
185 ))
186 })?;
187 normalize_repository_target(mapping_base, target, true)
188 })
189 .collect::<Result<Vec<_>, _>>()?;
190 mappings.push(
191 EcmaScriptPathMapping::new(pattern, targets)
192 .map_err(|error| CliError::InvalidInput(error.to_string()))?,
193 );
194 }
195 }
196 let mapping_count = mappings.len();
197 EcmaScriptModuleConfig::new(config_path, kind, base_url, mappings)
198 .map(|config| (config, mapping_count))
199 .map_err(|error| CliError::InvalidInput(error.to_string()))
200}
201
202fn normalize_repository_target(
204 base: &str,
205 target: &str,
206 allow_wildcard: bool,
207) -> Result<String, CliError> {
208 let target = target.replace('\\', "/");
209 if target.starts_with('/') || target.contains(':') || target.contains('\0') {
210 return Err(CliError::InvalidInput(format!(
211 "configured module target {target:?} is absolute or invalid"
212 )));
213 }
214 if !allow_wildcard && target.contains('*') || target.matches('*').count() > 1 {
215 return Err(CliError::InvalidInput(format!(
216 "configured module target {target:?} contains an invalid wildcard"
217 )));
218 }
219 let mut components = base
220 .split('/')
221 .filter(|component| !component.is_empty())
222 .map(ToString::to_string)
223 .collect::<Vec<_>>();
224 for component in target.split('/') {
225 match component {
226 "" | "." => {}
227 ".." => {
228 if components.pop().is_none() {
229 return Err(CliError::InvalidInput(format!(
230 "configured module target {target:?} escapes the repository"
231 )));
232 }
233 }
234 component => components.push(component.to_string()),
235 }
236 }
237 Ok(components.join("/"))
238}
239
240fn config_kind(path: &str) -> Option<EcmaScriptConfigKind> {
242 match path.rsplit('/').next() {
243 Some("tsconfig.json") => Some(EcmaScriptConfigKind::TypeScript),
244 Some("jsconfig.json") => Some(EcmaScriptConfigKind::JavaScript),
245 _ => None,
246 }
247}
248
249fn check_config_work(control: &IndexWorkControl, index: usize) -> Result<(), CliError> {
251 if index.is_multiple_of(MODULE_CONFIG_CONTROL_INTERVAL) {
252 control.check(IndexWorkStage::SymbolParsing)?;
253 }
254 Ok(())
255}
256
257fn claim_module_config_total(
259 total: &mut u64,
260 added: u64,
261 limit: u64,
262 resource: IndexWorkResource,
263) -> Result<(), CliError> {
264 let observed = total.saturating_add(added);
265 if observed > limit {
266 return Err(module_config_resource_limit(resource, limit, observed));
267 }
268 *total = observed;
269 Ok(())
270}
271
272fn module_config_resource_limit(
274 resource: IndexWorkResource,
275 limit: impl TryInto<u64>,
276 observed: impl TryInto<u64>,
277) -> CliError {
278 IndexWorkFailure::resource_limit(
279 IndexWorkStage::SymbolParsing,
280 resource,
281 limit.try_into().unwrap_or(u64::MAX),
282 observed.try_into().unwrap_or(u64::MAX),
283 )
284 .into()
285}
286
287#[cfg(test)]
288mod tests {
289 use super::{
290 CliError, MAX_MODULE_CONFIG_FILE_BYTES, MAX_MODULE_CONFIG_TOTAL_BYTES, UTF8_BOM,
291 claim_module_config_total, decode_config, load_configured_module_resolution,
292 normalize_repository_target,
293 };
294 use projectatlas_core::{
295 IndexCancellation, IndexWorkControl, IndexWorkFailure, IndexWorkResource, IndexWorkStage,
296 Node, NodeKind,
297 };
298 use projectatlas_symbols::{
299 EcmaScriptConfigKind, MAX_CONFIGURED_MODULE_CONFIGS, MAX_CONFIGURED_MODULE_MAPPINGS,
300 MAX_CONFIGURED_MODULE_TARGETS,
301 };
302 use serde_json::json;
303 use std::error::Error;
304 use std::fs;
305 use std::path::Path;
306 use std::time::Instant;
307
308 fn config_node(path: &str, content: &[u8]) -> Node {
309 Node {
310 path: path.to_string(),
311 kind: NodeKind::File,
312 parent_path: Path::new(path)
313 .parent()
314 .and_then(Path::to_str)
315 .filter(|parent| !parent.is_empty())
316 .map(ToString::to_string),
317 extension: Some(".json".to_string()),
318 language: Some("json".to_string()),
319 size_bytes: Some(u64::try_from(content.len()).unwrap_or(u64::MAX)),
320 mtime_ns: Some(1),
321 content_hash: Some(blake3::hash(content).to_hex().to_string()),
322 }
323 }
324
325 #[test]
326 fn direct_json_config_is_normalized_without_leaving_the_repository()
327 -> Result<(), Box<dyn Error>> {
328 let control = IndexWorkControl::new(IndexCancellation::new(), None);
329 let (config, mappings) = decode_config(
330 "packages/app/tsconfig.json",
331 EcmaScriptConfigKind::TypeScript,
332 &json!({
333 "compilerOptions": {
334 "baseUrl": "src",
335 "paths": {
336 "@/*": ["*"],
337 "@shared/*": ["../../shared/*"]
338 }
339 }
340 }),
341 &control,
342 )?;
343 if mappings != 2 {
344 return Err(std::io::Error::other("expected both configured path mappings").into());
345 }
346 let _configured = projectatlas_symbols::ConfiguredModuleResolution::new(vec![config])?;
347 Ok(())
348 }
349
350 #[test]
351 fn compiler_config_loader_accepts_utf8_bom_for_both_config_kinds() -> Result<(), Box<dyn Error>>
352 {
353 let temp = tempfile::tempdir()?;
354 let content = br#"{"compilerOptions":{"baseUrl":"src"}}"#;
355 let control = IndexWorkControl::new(IndexCancellation::new(), None);
356
357 for config_path in ["tsconfig.json", "jsconfig.json"] {
358 fs::write(temp.path().join(config_path), content)?;
359 let plain = load_configured_module_resolution(
360 temp.path(),
361 &[config_node(config_path, content)],
362 &control,
363 )?;
364
365 let bom_content = [UTF8_BOM, content].concat();
366 fs::write(temp.path().join(config_path), &bom_content)?;
367 let with_bom = load_configured_module_resolution(
368 temp.path(),
369 &[config_node(config_path, &bom_content)],
370 &control,
371 )?;
372 if with_bom != plain {
373 return Err(std::io::Error::other(format!(
374 "{config_path} decoded differently with a UTF-8 BOM"
375 ))
376 .into());
377 }
378 }
379 Ok(())
380 }
381
382 #[test]
383 fn compiler_config_loader_rejects_malformed_and_non_utf8_after_bom()
384 -> Result<(), Box<dyn Error>> {
385 let temp = tempfile::tempdir()?;
386 let control = IndexWorkControl::new(IndexCancellation::new(), None);
387
388 for config_path in ["tsconfig.json", "jsconfig.json"] {
389 for (case, malformed) in [
390 ("after a leading BOM", [UTF8_BOM, b"{"].concat()),
391 (
392 "with a complete BOM after byte zero",
393 [b"{".as_slice(), UTF8_BOM, b"}".as_slice()].concat(),
394 ),
395 ] {
396 fs::write(temp.path().join(config_path), &malformed)?;
397 match load_configured_module_resolution(
398 temp.path(),
399 &[config_node(config_path, &malformed)],
400 &control,
401 ) {
402 Err(CliError::InvalidInput(message))
403 if message.contains("failed to parse compiler configuration") => {}
404 result => {
405 return Err(std::io::Error::other(format!(
406 "{config_path} malformed input {case} returned {result:?}"
407 ))
408 .into());
409 }
410 }
411 }
412
413 for (case, non_utf8) in [
414 ("after a BOM", [UTF8_BOM, &[0xFF]].concat()),
415 ("with a partial BOM", b"\xEF\xBB{}".to_vec()),
416 ] {
417 fs::write(temp.path().join(config_path), &non_utf8)?;
418 match load_configured_module_resolution(
419 temp.path(),
420 &[config_node(config_path, &non_utf8)],
421 &control,
422 ) {
423 Err(CliError::InvalidInput(message))
424 if message.contains("is not valid UTF-8") => {}
425 result => {
426 return Err(std::io::Error::other(format!(
427 "{config_path} non-UTF-8 input {case} returned {result:?}"
428 ))
429 .into());
430 }
431 }
432 }
433 }
434 Ok(())
435 }
436
437 #[test]
438 fn lexical_target_normalization_rejects_root_escape() {
439 let contained = normalize_repository_target("packages/app", "../shared/*", true);
440 assert!(contained.is_ok());
441 assert_eq!(contained.ok().as_deref(), Some("packages/shared/*"));
442 assert_eq!(
443 normalize_repository_target("src", "shared folder/*", true)
444 .ok()
445 .as_deref(),
446 Some("src/shared folder/*")
447 );
448 assert!(normalize_repository_target("", "../outside/*", true).is_err());
449 assert!(normalize_repository_target("src", "C:/outside/*", true).is_err());
450 }
451
452 #[test]
453 fn config_decode_observes_typed_cancellation_before_mapping_work() {
454 let control = IndexWorkControl::new(IndexCancellation::new(), None);
455 control.cancel();
456 let result = decode_config(
457 "tsconfig.json",
458 EcmaScriptConfigKind::TypeScript,
459 &json!({
460 "compilerOptions": {
461 "paths": {
462 "@/*": ["src/*"]
463 }
464 }
465 }),
466 &control,
467 );
468 assert!(matches!(
469 result,
470 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
471 stage: IndexWorkStage::SymbolParsing
472 }))
473 ));
474 }
475
476 #[test]
477 #[allow(clippy::panic_in_result_fn)]
478 fn configured_module_loader_enforces_deadline_currentness_and_bounds()
479 -> Result<(), Box<dyn Error>> {
480 let temp = tempfile::tempdir()?;
481 let content = br#"{"compilerOptions":{"paths":{"@/*":["src/*"]}}}"#;
482 fs::write(temp.path().join("tsconfig.json"), content)?;
483 let node = config_node("tsconfig.json", content);
484
485 let expired = IndexWorkControl::with_deadline(IndexCancellation::new(), Instant::now());
486 assert!(matches!(
487 load_configured_module_resolution(temp.path(), std::slice::from_ref(&node), &expired),
488 Err(CliError::IndexWork(IndexWorkFailure::DeadlineExceeded {
489 stage: IndexWorkStage::SymbolParsing
490 }))
491 ));
492
493 let mut changed = node.clone();
494 changed.content_hash = Some(blake3::hash(b"other").to_hex().to_string());
495 assert!(
496 load_configured_module_resolution(
497 temp.path(),
498 &[changed],
499 &IndexWorkControl::new(IndexCancellation::new(), None),
500 )
501 .is_err()
502 );
503
504 let mut oversized = node;
505 oversized.size_bytes = Some(MAX_MODULE_CONFIG_FILE_BYTES + 1);
506 assert!(matches!(
507 load_configured_module_resolution(
508 temp.path(),
509 &[oversized],
510 &IndexWorkControl::new(IndexCancellation::new(), None),
511 ),
512 Err(CliError::IndexWork(
513 IndexWorkFailure::ResourceLimitExceeded {
514 resource: IndexWorkResource::SourceBytes,
515 ..
516 }
517 ))
518 ));
519
520 let excessive_configs = (0..=MAX_CONFIGURED_MODULE_CONFIGS)
521 .map(|index| config_node(&format!("config-{index}/tsconfig.json"), content))
522 .collect::<Vec<_>>();
523 assert!(matches!(
524 load_configured_module_resolution(
525 temp.path(),
526 &excessive_configs,
527 &IndexWorkControl::new(IndexCancellation::new(), None),
528 ),
529 Err(CliError::IndexWork(
530 IndexWorkFailure::ResourceLimitExceeded {
531 resource: IndexWorkResource::Entries,
532 ..
533 }
534 ))
535 ));
536
537 let mut total = MAX_MODULE_CONFIG_TOTAL_BYTES;
538 assert!(matches!(
539 claim_module_config_total(
540 &mut total,
541 1,
542 MAX_MODULE_CONFIG_TOTAL_BYTES,
543 IndexWorkResource::SourceBytes,
544 ),
545 Err(CliError::IndexWork(
546 IndexWorkFailure::ResourceLimitExceeded {
547 resource: IndexWorkResource::SourceBytes,
548 ..
549 }
550 ))
551 ));
552 let mut mappings = u64::try_from(MAX_CONFIGURED_MODULE_MAPPINGS).unwrap_or(u64::MAX);
553 assert!(matches!(
554 claim_module_config_total(
555 &mut mappings,
556 1,
557 u64::try_from(MAX_CONFIGURED_MODULE_MAPPINGS).unwrap_or(u64::MAX),
558 IndexWorkResource::RelationRows,
559 ),
560 Err(CliError::IndexWork(
561 IndexWorkFailure::ResourceLimitExceeded {
562 resource: IndexWorkResource::RelationRows,
563 ..
564 }
565 ))
566 ));
567
568 let excessive_targets = (0..=MAX_CONFIGURED_MODULE_TARGETS)
569 .map(|index| format!("src/{index}/*"))
570 .collect::<Vec<_>>();
571 assert!(matches!(
572 decode_config(
573 "tsconfig.json",
574 EcmaScriptConfigKind::TypeScript,
575 &json!({"compilerOptions":{"paths":{"@/*": excessive_targets}}}),
576 &IndexWorkControl::new(IndexCancellation::new(), None),
577 ),
578 Err(CliError::IndexWork(
579 IndexWorkFailure::ResourceLimitExceeded {
580 resource: IndexWorkResource::RelationRows,
581 ..
582 }
583 ))
584 ));
585
586 Ok(())
587 }
588}