1use std::{
2 collections::BTreeMap,
3 ops::Bound,
4 path::{Path, PathBuf},
5};
6
7use anyhow::Result;
8use async_trait::async_trait;
9use bincode::{Decode, Encode};
10use serde::{Deserialize, Serialize};
11use turbo_rcstr::{RcStr, rcstr};
12use turbo_tasks::{
13 FxIndexMap, NonLocalValue, OperationValue, OperationVc, ReadRef, ResolvedVc, Vc,
14};
15use turbo_tasks_fs::{
16 DiskFileSystem, DiskFileSystemMap, DiskWatcherConfig, FileSystemPath, canonicalize_to_rcstr,
17};
18use turbopack_core::issue::{Issue, IssueSeverity, IssueStage, PlainIssue, StyledString};
19
20use crate::project::{
21 ProjectContainer, additional_root_path_operation, disk_file_system_operation,
22};
23
24#[derive(
26 Clone,
27 Debug,
28 PartialEq,
29 Eq,
30 Serialize,
31 Deserialize,
32 NonLocalValue,
33 OperationValue,
34 Encode,
35 Decode,
36)]
37pub struct AdditionalRootConfig {
38 pub key: RcStr,
39 pub path: RcStr,
40 pub ignore_if_missing: bool,
41}
42
43#[turbo_tasks::task_input]
44#[derive(
45 Clone, Debug, PartialEq, Eq, Hash, OperationValue, Serialize, Deserialize, Encode, Decode,
46)]
47enum AdditionalRootInvalidName {
48 Empty,
49 TooLong,
50 InvalidCharacter,
51 WindowsDeviceName,
52}
53
54impl AdditionalRootInvalidName {
55 fn description(&self) -> StyledString {
56 match self {
57 Self::Empty => StyledString::Text(rcstr!("the name must not be empty")),
58 Self::TooLong => {
59 StyledString::Text(rcstr!("the name must be at most 40 ASCII characters"))
60 }
61 Self::InvalidCharacter => StyledString::Text(rcstr!(
62 "the name must contain only ASCII letters, digits, underscores, and hyphens"
63 )),
64 Self::WindowsDeviceName => {
65 StyledString::Text(rcstr!("the name must not be a Windows device name"))
66 }
67 }
68 }
69}
70
71#[turbo_tasks::task_input]
72#[derive(
73 Clone, Debug, PartialEq, Eq, Hash, OperationValue, Serialize, Deserialize, Encode, Decode,
74)]
75enum AdditionalRootIssueReason {
76 Io(RcStr),
78 InvalidName(AdditionalRootInvalidName),
79 NameCollision { existing_key: RcStr },
80 OverlappingRoot { key: Option<RcStr>, path: RcStr },
81}
82
83impl AdditionalRootIssueReason {
84 fn description(&self) -> StyledString {
85 match self {
86 Self::Io(error) => StyledString::Text(error.clone()),
87 Self::InvalidName(reason) => reason.description(),
88 Self::NameCollision { existing_key } => StyledString::Line(vec![
89 StyledString::Text(rcstr!(
90 "the name collides case-insensitively with the earlier root "
91 )),
92 StyledString::Code(existing_key.clone()),
93 ]),
94 Self::OverlappingRoot {
95 key: Some(key),
96 path,
97 } => StyledString::Line(vec![
98 StyledString::Text(rcstr!("the root overlaps additional root ")),
99 StyledString::Code(path.clone()),
100 StyledString::Text(rcstr!(" configured as ")),
101 StyledString::Code(key.clone()),
102 ]),
103 Self::OverlappingRoot { key: None, path } => StyledString::Line(vec![
104 StyledString::Text(rcstr!("the additional root overlaps the project root ")),
105 StyledString::Code(path.clone()),
106 ]),
107 }
108 }
109}
110
111#[derive(Clone, Debug, PartialEq, Eq, NonLocalValue, OperationValue, Encode, Decode)]
112pub(crate) struct AdditionalDiskFileSystem {
113 pub canonical_path: RcStr,
114 pub file_system: OperationVc<DiskFileSystem>,
115}
116
117pub(crate) struct AdditionalRootsInitialization {
119 pub roots_by_name: FxIndexMap<RcStr, AdditionalDiskFileSystem>,
120 pub issues: Vec<ReadRef<PlainIssue>>,
121}
122
123pub(crate) async fn create_additional_root_file_systems(
124 container: ResolvedVc<ProjectContainer>,
125 additional_roots: Vec<AdditionalRootConfig>,
126 project_root: &RcStr,
127 watcher_config: DiskWatcherConfig,
128 map: OperationVc<DiskFileSystemMap>,
129 issue_path: FileSystemPath,
130) -> Result<AdditionalRootsInitialization> {
131 let mut overlapping_check = OverlappingRootCheck::new(project_root.clone());
132 let mut configured_names: FxIndexMap<RcStr, RcStr> = FxIndexMap::default();
133 let mut roots_by_name = FxIndexMap::default();
134 let mut issues: Vec<ReadRef<PlainIssue>> = Vec::new();
135 for additional_root in additional_roots {
136 let mut push_issue = async |reason: AdditionalRootIssueReason| -> Result<()> {
137 if let Some(issue) = &*additional_root_issue_operation(
138 container,
139 issue_path.clone(),
140 additional_root.key.clone(),
141 additional_root.path.clone(),
142 reason,
143 )
144 .read_strongly_consistent()
145 .await?
146 {
147 issues.push(issue.clone());
148 }
149 Ok(())
150 };
151
152 if let Err(reason) = validate_additional_root_name(&additional_root.key) {
153 push_issue(AdditionalRootIssueReason::InvalidName(reason)).await?;
154 continue;
155 }
156
157 let folded_name = RcStr::from(additional_root.key.to_ascii_lowercase());
158 if let Some(existing_key) = configured_names.get(&folded_name) {
159 push_issue(AdditionalRootIssueReason::NameCollision {
160 existing_key: existing_key.clone(),
161 })
162 .await?;
163 continue;
164 }
165 configured_names.insert(folded_name, additional_root.key.clone());
166
167 let canonical = match canonicalize_to_rcstr(Path::new(&*additional_root.path)) {
168 Ok(canonical) => canonical,
169 Err(_) if additional_root.ignore_if_missing => continue,
170 Err(error) => {
171 push_issue(AdditionalRootIssueReason::Io(RcStr::from(
172 error.to_string(),
173 )))
174 .await?;
175 continue;
176 }
177 };
178 if let Err((overlapping_key, overlapping_path)) =
179 overlapping_check.insert(Some(additional_root.key.clone()), canonical.clone())
180 {
181 push_issue(AdditionalRootIssueReason::OverlappingRoot {
182 key: overlapping_key,
183 path: overlapping_path,
184 })
185 .await?;
186 continue;
187 }
188 let canonical_root = additional_root_path_operation(container, additional_root.key.clone());
192 let operation = disk_file_system_operation(
193 RcStr::from(format!("@{}", additional_root.key)),
194 canonical_root,
195 Vec::new(),
196 watcher_config,
197 map,
198 );
199 roots_by_name.insert(
200 additional_root.key,
201 AdditionalDiskFileSystem {
202 canonical_path: canonical,
203 file_system: operation,
204 },
205 );
206 }
207
208 Ok(AdditionalRootsInitialization {
209 roots_by_name,
210 issues,
211 })
212}
213
214fn validate_additional_root_name(name: &str) -> Result<(), AdditionalRootInvalidName> {
215 if name.is_empty() {
216 return Err(AdditionalRootInvalidName::Empty);
217 }
218 if name.len() > 40 {
219 return Err(AdditionalRootInvalidName::TooLong);
220 }
221 if !name
222 .bytes()
223 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
224 {
225 return Err(AdditionalRootInvalidName::InvalidCharacter);
226 }
227
228 let uppercase_name = name.to_ascii_uppercase();
229 let is_device_number =
230 |suffix: &str| suffix.len() == 1 && matches!(suffix.as_bytes()[0], b'1'..=b'9');
231 let is_device_name = matches!(uppercase_name.as_str(), "CON" | "PRN" | "AUX" | "NUL")
232 || uppercase_name
233 .strip_prefix("COM")
234 .is_some_and(is_device_number)
235 || uppercase_name
236 .strip_prefix("LPT")
237 .is_some_and(is_device_number);
238 if is_device_name {
239 return Err(AdditionalRootInvalidName::WindowsDeviceName);
240 }
241
242 Ok(())
243}
244
245#[turbo_tasks::function(operation, root)]
246async fn additional_root_issue_operation(
247 container: ResolvedVc<ProjectContainer>,
248 path: FileSystemPath,
249 key: RcStr,
250 configured_path: RcStr,
251 reason: AdditionalRootIssueReason,
252) -> Result<Vc<OptionalAdditionalRootIssue>> {
253 let issue = AdditionalRootIssue {
254 path,
255 key,
256 configured_path,
257 reason,
258 };
259 let filter = container.project().issue_filter().await?;
260 Ok(Vc::cell(if filter.matches_ref(&issue).await? {
261 Some(ReadRef::new_owned(
262 PlainIssue::from_issue_ref(&issue, None).await?,
263 ))
264 } else {
265 None
266 }))
267}
268
269#[turbo_tasks::value(transparent, serialization = "skip")]
270struct OptionalAdditionalRootIssue(Option<ReadRef<PlainIssue>>);
271
272struct OverlappingRootCheck {
273 accepted: BTreeMap<PathBuf, (Option<RcStr>, RcStr)>,
274}
275
276impl OverlappingRootCheck {
277 fn new(project_root: RcStr) -> Self {
278 Self {
279 accepted: BTreeMap::from([(PathBuf::from(&*project_root), (None, project_root))]),
280 }
281 }
282
283 fn insert(&mut self, key: Option<RcStr>, path: RcStr) -> Result<(), (Option<RcStr>, RcStr)> {
284 let canonical = Path::new(&*path);
285 if let Some((root, value)) = self
286 .accepted
287 .range::<Path, _>((Bound::Unbounded, Bound::Included(canonical)))
288 .next_back()
289 && canonical.starts_with(root)
290 {
291 return Err(value.clone());
292 }
293 if let Some((_, value)) = self
294 .accepted
295 .range::<Path, _>((Bound::Included(canonical), Bound::Unbounded))
296 .next()
297 .filter(|(root, _)| root.starts_with(canonical))
298 {
299 return Err(value.clone());
300 }
301 self.accepted.insert(canonical.to_path_buf(), (key, path));
302 Ok(())
303 }
304}
305
306#[turbo_tasks::value(shared)]
307struct AdditionalRootIssue {
308 path: FileSystemPath,
309 key: RcStr,
310 configured_path: RcStr,
311 reason: AdditionalRootIssueReason,
312}
313
314#[async_trait]
315#[turbo_tasks::value_impl]
316impl Issue for AdditionalRootIssue {
317 fn stage(&self) -> IssueStage {
318 IssueStage::Config
319 }
320
321 fn severity(&self) -> IssueSeverity {
322 IssueSeverity::Warning
323 }
324
325 async fn file_path(&self) -> Result<FileSystemPath> {
326 Ok(self.path.clone())
327 }
328
329 async fn title(&self) -> Result<StyledString> {
330 Ok(StyledString::Text(rcstr!(
331 "Invalid Turbopack additional root"
332 )))
333 }
334
335 async fn description(&self) -> Result<Option<StyledString>> {
336 Ok(Some(StyledString::Line(vec![
337 StyledString::Text(rcstr!("The additional root ")),
338 StyledString::Code(self.configured_path.clone()),
339 StyledString::Text(rcstr!(" configured as ")),
340 StyledString::Code(self.key.clone()),
341 StyledString::Text(rcstr!(" is invalid: ")),
342 self.reason.description(),
343 ])))
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn identifies_an_overlapping_root() {
353 let mut roots = OverlappingRootCheck::new(rcstr!("/workspace/project"));
354 assert_eq!(
355 roots.insert(
356 Some(rcstr!("packages")),
357 rcstr!("/workspace/project/packages")
358 ),
359 Err((None, rcstr!("/workspace/project")))
360 );
361 roots
362 .insert(Some(rcstr!("vendor")), rcstr!("/workspace/vendor"))
363 .unwrap();
364 assert_eq!(
365 roots.insert(Some(rcstr!("workspace")), rcstr!("/workspace")),
366 Err((None, rcstr!("/workspace/project")))
367 );
368 assert_eq!(
369 roots.insert(Some(rcstr!("package")), rcstr!("/workspace/vendor/package")),
370 Err((Some(rcstr!("vendor")), rcstr!("/workspace/vendor")))
371 );
372 assert_eq!(
373 roots.insert(
374 Some(rcstr!("project-other")),
375 rcstr!("/workspace/project-other")
376 ),
377 Ok(())
378 );
379 }
380
381 #[test]
382 fn validates_additional_root_names() {
383 for valid in [
384 "linkedPackages",
385 "packages-1",
386 "with_underscore",
387 "letters-AND_123",
388 "COM10",
389 ] {
390 assert_eq!(validate_additional_root_name(valid), Ok(()), "{valid}");
391 }
392
393 assert_eq!(
394 validate_additional_root_name(""),
395 Err(AdditionalRootInvalidName::Empty)
396 );
397 assert_eq!(
398 validate_additional_root_name("this-name-is-more-than-forty-ascii-characters-long"),
399 Err(AdditionalRootInvalidName::TooLong)
400 );
401
402 for invalid_character in [
403 ".",
404 "..",
405 "nön-ascii",
406 "control\u{1f}",
407 "delete\u{7f}",
408 "with/slash",
409 "with space",
410 "trailing ",
411 "trailing.",
412 ] {
413 assert_eq!(
414 validate_additional_root_name(invalid_character),
415 Err(AdditionalRootInvalidName::InvalidCharacter),
416 "{invalid_character}"
417 );
418 }
419
420 for device_name in ["CON", "prn", "Aux", "NUL", "cOm1", "LPT9"] {
421 assert_eq!(
422 validate_additional_root_name(device_name),
423 Err(AdditionalRootInvalidName::WindowsDeviceName),
424 "{device_name}"
425 );
426 }
427
428 assert_eq!(
429 AdditionalRootInvalidName::InvalidCharacter.description(),
430 StyledString::Text(rcstr!(
431 "the name must contain only ASCII letters, digits, underscores, and hyphens"
432 ))
433 );
434 }
435}