Skip to main content

next_api/
additional_roots.rs

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/// A named additional filesystem root.
25#[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 errors are stringified because `io::Error` does not implement the required traits
77    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
117/// Constructed file systems and issues for the configured additional roots.
118pub(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        // We're not inside a turbo-task function: Call an operation to create a cell for us. We
189        // pass the `ProjectContainer` and a key, which both have a stable identity, this reduces
190        // invalidations when additional roots are added or removed.
191        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}