Skip to main content

turbo_tasks_fs/
content.rs

1//! File and directory content value types: [`FileContent`], [`File`], JSON and
2//! line views, and link/permission metadata.
3
4use std::{
5    cmp::{Ordering, min},
6    fmt::{self, Debug, Formatter},
7    io::{self, BufRead, BufReader, Read},
8    path::Path,
9};
10
11use anyhow::{Context, Result, bail};
12use bincode::{Decode, Encode};
13use jsonc_parser::{ParseOptions, parse_to_serde_value};
14use mime::Mime;
15use serde_json::Value;
16use turbo_rcstr::{RcStr, rcstr};
17use turbo_tasks::{NonLocalValue, ReadRef, ResolvedVc, ValueToString, Vc};
18use turbo_tasks_hash::{
19    DeterministicHash, DeterministicHasher, HashAlgorithm, deterministic_hash, hash_xxh3_hash64,
20};
21
22use crate::{
23    DiskFileSystem, FileSystemEntryType, FileSystemPath, RealPathErrorType,
24    json::UnparsableJson,
25    retry::retry_blocking,
26    rope::{Rope, RopeReader},
27    util::extract_disk_access,
28};
29
30#[derive(Clone, Copy, Debug, Default, DeterministicHash, PartialOrd, Ord)]
31#[turbo_tasks::value(shared)]
32pub enum Permissions {
33    Readable,
34    #[default]
35    Writable,
36    Executable,
37}
38
39// Only handle the permissions on unix platform for now
40
41#[cfg(unix)]
42impl From<Permissions> for std::fs::Permissions {
43    fn from(perm: Permissions) -> Self {
44        use std::os::unix::fs::PermissionsExt;
45        match perm {
46            Permissions::Readable => std::fs::Permissions::from_mode(0o444),
47            Permissions::Writable => std::fs::Permissions::from_mode(0o664),
48            Permissions::Executable => std::fs::Permissions::from_mode(0o755),
49        }
50    }
51}
52
53#[cfg(unix)]
54impl From<std::fs::Permissions> for Permissions {
55    fn from(perm: std::fs::Permissions) -> Self {
56        use std::os::unix::fs::PermissionsExt;
57        if perm.readonly() {
58            Permissions::Readable
59        } else {
60            // https://github.com/fitzgen/is_executable/blob/master/src/lib.rs#L96
61            if perm.mode() & 0o111 != 0 {
62                Permissions::Executable
63            } else {
64                Permissions::Writable
65            }
66        }
67    }
68}
69
70#[cfg(not(unix))]
71impl From<std::fs::Permissions> for Permissions {
72    fn from(_: std::fs::Permissions) -> Self {
73        Permissions::default()
74    }
75}
76
77#[turbo_tasks::value(shared, serialization = "hash")]
78#[derive(Clone, Debug, PartialOrd, Ord)]
79pub enum FileContent {
80    Content(File),
81    NotFound,
82}
83
84impl From<File> for FileContent {
85    fn from(file: File) -> Self {
86        FileContent::Content(file)
87    }
88}
89
90/// A persisted version of [`FileContent`] that stores the full file content in the task cache.
91///
92/// [`FileContent`] uses `serialization = "hash"`, so only a hash is kept in the persistent cache.
93/// When reading the file content back from the cache, the hash is compared to detect changes, but
94/// the actual data is not available. `PersistedFileContent` provides the full data so that
95/// `DiskFileSystem::write` can retrieve it without re-reading from disk.
96#[turbo_tasks::value(shared)]
97#[derive(Clone, Debug, DeterministicHash, PartialOrd, Ord)]
98pub enum PersistedFileContent {
99    Content(File),
100    NotFound,
101}
102
103impl PersistedFileContent {
104    /// Performs a comparison of self's data against a disk file's streamed read.
105    pub(crate) async fn streaming_compare(&self, path: &Path) -> Result<FileComparison> {
106        let old_file =
107            extract_disk_access(retry_blocking(|| std::fs::File::open(path)).await, path)?;
108        let Some(old_file) = old_file else {
109            return Ok(match self {
110                PersistedFileContent::NotFound => FileComparison::Equal,
111                _ => FileComparison::Create,
112            });
113        };
114        // We know old file exists, does the new file?
115        let PersistedFileContent::Content(new_file) = self else {
116            return Ok(FileComparison::NotEqual);
117        };
118
119        let old_meta = extract_disk_access(retry_blocking(|| old_file.metadata()).await, path)?;
120        let Some(old_meta) = old_meta else {
121            // If we failed to get meta, then the old file has been deleted between the
122            // handle open. In which case, we just pretend the file never existed.
123            return Ok(FileComparison::Create);
124        };
125        // If the meta is different, we need to rewrite the file to update it.
126        if new_file.meta != old_meta.into() {
127            return Ok(FileComparison::NotEqual);
128        }
129
130        // So meta matches, and we have a file handle. Let's stream the contents to see
131        // if they match.
132        let mut new_contents = new_file.read();
133        let mut old_contents = BufReader::new(old_file);
134        Ok(loop {
135            let new_chunk = new_contents.fill_buf()?;
136            let Ok(old_chunk) = old_contents.fill_buf() else {
137                break FileComparison::NotEqual;
138            };
139
140            let len = min(new_chunk.len(), old_chunk.len());
141            if len == 0 {
142                if new_chunk.len() == old_chunk.len() {
143                    break FileComparison::Equal;
144                } else {
145                    break FileComparison::NotEqual;
146                }
147            }
148
149            if new_chunk[0..len] != old_chunk[0..len] {
150                break FileComparison::NotEqual;
151            }
152
153            new_contents.consume(len);
154            old_contents.consume(len);
155        })
156    }
157}
158
159#[derive(Clone, Debug, Eq, PartialEq)]
160pub(crate) enum FileComparison {
161    Create,
162    Equal,
163    NotEqual,
164}
165
166/// The target of a symbolic link, as read from a filesystem.
167///
168/// Every variant carries the `resolved` path the link points at, computed once by
169/// [`crate::FileSystem::read_link`]. A link whose target cannot be resolved into its filesystem
170/// or another configured filesystem is [`LinkContent::Invalid`] instead.
171#[derive(Clone, Debug, Hash, PartialEq, Eq, NonLocalValue, Encode, Decode)]
172pub enum LinkTarget {
173    /// The link is an absolute path on disk.
174    Absolute {
175        raw: RcStr,
176        resolved: FileSystemPath,
177    },
178    Relative {
179        /// The value read from the link. The path is lexically converted to a [unix-style
180        /// path][turbo_unix_path::sys_to_unix], but it may contain `..` relative to the *directory
181        /// containing the link*.
182        raw: RcStr,
183        /// The link target relative the a `DiskFileSystem` root.
184        resolved: FileSystemPath,
185    },
186}
187
188impl LinkTarget {
189    /// The path this link points at.
190    pub fn file_system_path(&self) -> &FileSystemPath {
191        match self {
192            LinkTarget::Absolute { resolved, .. } | LinkTarget::Relative { resolved, .. } => {
193                resolved
194            }
195        }
196    }
197
198    /// The type of the file this link points at.
199    ///
200    /// This only follows a single link: if the target is itself a symbolic link, this returns
201    /// [`FileSystemEntryType::Symlink`]. Use [`FileSystemPath::realpath`] to follow a chain of
202    /// links.
203    ///
204    /// A dangling link returns [`FileSystemEntryType::NotFound`].
205    pub async fn target_type(&self) -> Result<FileSystemEntryType> {
206        Ok(*self.file_system_path().get_type().await?)
207    }
208
209    /// The type of the file this link ultimately points at.
210    ///
211    /// This follows a chain of links. A dangling link returns
212    /// [`FileSystemEntryType::NotFound`], while any other unresolvable link returns
213    /// [`FileSystemEntryType::Error`].
214    pub async fn resolved_type(&self) -> Result<FileSystemEntryType> {
215        match self.file_system_path().realpath().await? {
216            Ok(path) => Ok(*path.get_type().await?),
217            Err(error) => Ok(match error.kind() {
218                RealPathErrorType::NotFound => FileSystemEntryType::NotFound,
219                _ => FileSystemEntryType::Error,
220            }),
221        }
222    }
223}
224
225/// The contents of a symbolic link, as read from a filesystem. On Windows, this may be a junction
226/// point.
227///
228/// We treat symbolic links and junction points on Windows as equivalent when reading.
229///
230/// This describes the link itself and never the type of the file it points at. Use
231/// [`LinkTarget::target_type`] if you need the type of the target.
232#[turbo_tasks::value(shared)]
233#[derive(Debug)]
234pub enum LinkContent {
235    /// A valid symbolic link pointing to `target`.
236    Link { target: LinkTarget },
237    /// The link itself does not exist at the path given to [`FileSystemPath::read_link`].
238    ///
239    /// This says nothing about whether the link's target exists: a dangling link is still
240    /// returned as [`LinkContent::Link`].
241    NotFound,
242    /// The link could not be read.
243    ///
244    /// This includes all I/O errors other than `NotFound`, denied paths, and targets that leave the
245    /// filesystem root. A relative target that steps out of the root and back into it is also
246    /// invalid, since resolving it would need the names of the root's own ancestors, which a
247    /// root-relative path doesn't carry.
248    Invalid { reason: RcStr },
249}
250
251/// The file type of the target of a newly written link. This value is only used on Windows.
252#[derive(Clone, Debug, Hash, PartialEq, Eq, NonLocalValue, DeterministicHash, Encode, Decode)]
253pub enum WriteLinkTargetType {
254    /// Represents a link to a file or a symbolic link that is not a junction point. This is likely
255    /// to fail on Windows, where symbolic links are not enabled by default.
256    FileNonPortable,
257    /// Represents a link to a directory. On Windows, this may also be a link to a junction point.
258    DirectoryOrJunctionPoint,
259}
260
261/// The symbolic link to create at a path, passed to [`FileSystemPath::write_link`].
262///
263/// This is separate from [`LinkContent`] because writing needs to know whether the target is a
264/// directory, while reading a link does not: on Windows we always create junction points for
265/// directories, because symlink creation may fail if "developer mode" is not enabled and we're
266/// running in an unprivileged environment.
267#[turbo_tasks::value(shared)]
268#[derive(Clone, Debug)]
269pub struct WriteLinkContent {
270    pub target: FileSystemPath,
271    pub target_type: WriteLinkTargetType,
272}
273
274impl WriteLinkContent {
275    /// Hashes the link target and target type, not the target's contents. Returns a hash that is
276    /// stable across builds.
277    pub async fn hash(&self, salt: &RcStr, algorithm: HashAlgorithm) -> Result<RcStr> {
278        // convert the fs Vc (which is not stable across cold builds therefore cannot implement
279        // DeterministicHash) to the configured name, which should be globally unique and stable
280        // across cold builds.
281        let target_fs = ResolvedVc::try_downcast_type::<DiskFileSystem>(self.target.fs)
282            .context("link target must use a disk filesystem")?
283            .await?;
284        let target_fs_name = target_fs.name();
285
286        Ok(RcStr::from(deterministic_hash(
287            salt,
288            (target_fs_name, &self.target.path, &self.target_type),
289            algorithm,
290        )))
291    }
292}
293
294#[turbo_tasks::value(shared)]
295#[derive(Clone, DeterministicHash, PartialOrd, Ord)]
296pub struct File {
297    #[turbo_tasks(debug_ignore)]
298    content: Rope,
299    pub(crate) meta: FileMeta,
300}
301
302impl File {
303    /// Reads a [File] from the given path
304    pub(crate) fn from_path(p: &Path) -> io::Result<Self> {
305        let mut file = std::fs::File::open(p)?;
306        let metadata = file.metadata()?;
307
308        let mut output = Vec::with_capacity(metadata.len() as usize);
309        file.read_to_end(&mut output)?;
310
311        Ok(File {
312            meta: metadata.into(),
313            content: Rope::from(output),
314        })
315    }
316
317    /// Creates a [File] from raw bytes.
318    pub(crate) fn from_bytes(content: Vec<u8>) -> Self {
319        File {
320            meta: FileMeta::default(),
321            content: Rope::from(content),
322        }
323    }
324
325    /// Creates a [File] from a rope.
326    fn from_rope(content: Rope) -> Self {
327        File {
328            meta: FileMeta::default(),
329            content,
330        }
331    }
332
333    /// Returns the content type associated with this file.
334    pub fn content_type(&self) -> Option<&Mime> {
335        self.meta.content_type.as_ref()
336    }
337
338    /// Sets the content type associated with this file.
339    pub fn with_content_type(mut self, content_type: Mime) -> Self {
340        self.meta.content_type = Some(content_type);
341        self
342    }
343
344    /// Returns a Read/AsyncRead/Stream/Iterator to access the File's contents.
345    pub fn read(&self) -> RopeReader<'_> {
346        self.content.read()
347    }
348}
349
350impl Debug for File {
351    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
352        f.debug_struct("File")
353            .field("meta", &self.meta)
354            .field("content (hash)", &hash_xxh3_hash64(&self.content))
355            .finish()
356    }
357}
358
359impl From<RcStr> for File {
360    fn from(s: RcStr) -> Self {
361        s.into_owned().into()
362    }
363}
364
365impl From<String> for File {
366    fn from(s: String) -> Self {
367        File::from_bytes(s.into_bytes())
368    }
369}
370
371impl From<ReadRef<RcStr>> for File {
372    fn from(s: ReadRef<RcStr>) -> Self {
373        File::from_bytes(s.as_bytes().to_vec())
374    }
375}
376
377impl From<&str> for File {
378    fn from(s: &str) -> Self {
379        File::from_bytes(s.as_bytes().to_vec())
380    }
381}
382
383impl From<Vec<u8>> for File {
384    fn from(bytes: Vec<u8>) -> Self {
385        File::from_bytes(bytes)
386    }
387}
388
389impl From<&[u8]> for File {
390    fn from(bytes: &[u8]) -> Self {
391        File::from_bytes(bytes.to_vec())
392    }
393}
394
395impl From<ReadRef<Rope>> for File {
396    fn from(rope: ReadRef<Rope>) -> Self {
397        File::from_rope(ReadRef::into_owned(rope))
398    }
399}
400
401impl From<Rope> for File {
402    fn from(rope: Rope) -> Self {
403        File::from_rope(rope)
404    }
405}
406
407impl File {
408    pub fn new(meta: FileMeta, content: Vec<u8>) -> Self {
409        Self {
410            meta,
411            content: Rope::from(content),
412        }
413    }
414
415    /// Returns the associated [FileMeta] of this file.
416    pub fn meta(&self) -> &FileMeta {
417        &self.meta
418    }
419
420    /// Returns the immutable contents of this file.
421    pub fn content(&self) -> &Rope {
422        &self.content
423    }
424}
425
426#[turbo_tasks::value(shared)]
427#[derive(Debug, Clone, Default)]
428pub struct FileMeta {
429    // Size of the file
430    // len: u64,
431    pub(crate) permissions: Permissions,
432    #[turbo_tasks(unsafe_ignore)]
433    #[bincode(with = "turbo_bincode::mime_option")]
434    content_type: Option<Mime>,
435}
436
437impl Ord for FileMeta {
438    fn cmp(&self, other: &Self) -> Ordering {
439        self.permissions
440            .cmp(&other.permissions)
441            .then_with(|| self.content_type.as_ref().cmp(&other.content_type.as_ref()))
442    }
443}
444
445impl PartialOrd for FileMeta {
446    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
447        Some(self.cmp(other))
448    }
449}
450
451impl From<std::fs::Metadata> for FileMeta {
452    fn from(meta: std::fs::Metadata) -> Self {
453        let permissions = meta.permissions().into();
454
455        Self {
456            permissions,
457            content_type: None,
458        }
459    }
460}
461
462impl DeterministicHash for FileMeta {
463    fn deterministic_hash<H: DeterministicHasher>(&self, state: &mut H) {
464        self.permissions.deterministic_hash(state);
465        if let Some(content_type) = &self.content_type {
466            content_type.to_string().deterministic_hash(state);
467        }
468    }
469}
470
471impl FileContent {
472    pub fn new(file: File) -> Self {
473        FileContent::Content(file)
474    }
475
476    pub fn is_content(&self) -> bool {
477        matches!(self, FileContent::Content(_))
478    }
479
480    pub fn as_content(&self) -> Option<&File> {
481        match self {
482            FileContent::Content(file) => Some(file),
483            FileContent::NotFound => None,
484        }
485    }
486
487    pub fn parse_json_ref(&self) -> FileJsonContent {
488        match self {
489            FileContent::Content(file) => {
490                let content = file.content.clone().into_bytes();
491                let de = &mut serde_json::Deserializer::from_slice(&content);
492                match serde_path_to_error::deserialize(de) {
493                    Ok(data) => FileJsonContent::Content(data),
494                    Err(e) => FileJsonContent::Unparsable(Box::new(
495                        UnparsableJson::from_serde_path_to_error(e),
496                    )),
497                }
498            }
499            FileContent::NotFound => FileJsonContent::NotFound,
500        }
501    }
502
503    pub fn parse_json_with_comments_ref(&self) -> FileJsonContent {
504        match self {
505            FileContent::Content(file) => match file.content.to_str() {
506                Ok(string) => match parse_to_serde_value(
507                    &string,
508                    &ParseOptions {
509                        allow_comments: true,
510                        allow_trailing_commas: true,
511                        allow_loose_object_property_names: false,
512                    },
513                ) {
514                    Ok(data) => match data {
515                        Some(value) => FileJsonContent::Content(value),
516                        None => FileJsonContent::unparsable(rcstr!(
517                            "text content doesn't contain any json data"
518                        )),
519                    },
520                    Err(e) => FileJsonContent::Unparsable(Box::new(
521                        UnparsableJson::from_jsonc_error(e, string.as_ref()),
522                    )),
523                },
524                Err(_) => FileJsonContent::unparsable(rcstr!("binary is not valid utf-8 text")),
525            },
526            FileContent::NotFound => FileJsonContent::NotFound,
527        }
528    }
529
530    pub fn parse_json5_ref(&self) -> FileJsonContent {
531        match self {
532            FileContent::Content(file) => match file.content.to_str() {
533                Ok(string) => match parse_to_serde_value(
534                    &string,
535                    &ParseOptions {
536                        allow_comments: true,
537                        allow_trailing_commas: true,
538                        allow_loose_object_property_names: true,
539                    },
540                ) {
541                    Ok(data) => match data {
542                        Some(value) => FileJsonContent::Content(value),
543                        None => FileJsonContent::unparsable(rcstr!(
544                            "text content doesn't contain any json data"
545                        )),
546                    },
547                    Err(e) => FileJsonContent::Unparsable(Box::new(
548                        UnparsableJson::from_jsonc_error(e, string.as_ref()),
549                    )),
550                },
551                Err(_) => FileJsonContent::unparsable(rcstr!("binary is not valid utf-8 text")),
552            },
553            FileContent::NotFound => FileJsonContent::NotFound,
554        }
555    }
556
557    pub fn lines_ref(&self) -> FileLinesContent {
558        match self {
559            FileContent::Content(file) => match file.content.to_str() {
560                Ok(string) => {
561                    let mut bytes_offset = 0;
562                    FileLinesContent::Lines(
563                        string
564                            .split('\n')
565                            .map(|l| {
566                                let line = FileLine {
567                                    content: l.to_string(),
568                                    bytes_offset,
569                                };
570                                bytes_offset += (l.len() + 1) as u32;
571                                line
572                            })
573                            .collect(),
574                    )
575                }
576                Err(_) => FileLinesContent::Unparsable,
577            },
578            FileContent::NotFound => FileLinesContent::NotFound,
579        }
580    }
581}
582
583#[turbo_tasks::value_impl]
584impl FileContent {
585    #[turbo_tasks::function]
586    pub fn len(&self) -> Result<Vc<Option<u64>>> {
587        Ok(Vc::cell(match self {
588            FileContent::Content(file) => Some(file.content.len() as u64),
589            FileContent::NotFound => None,
590        }))
591    }
592
593    #[turbo_tasks::function]
594    pub fn parse_json(&self) -> Result<Vc<FileJsonContent>> {
595        Ok(self.parse_json_ref().cell())
596    }
597
598    #[turbo_tasks::function]
599    pub fn parse_json_with_comments(&self) -> Vc<FileJsonContent> {
600        self.parse_json_with_comments_ref().cell()
601    }
602
603    #[turbo_tasks::function]
604    pub fn parse_json5(&self) -> Vc<FileJsonContent> {
605        self.parse_json5_ref().cell()
606    }
607
608    #[turbo_tasks::function]
609    pub fn lines(&self) -> Vc<FileLinesContent> {
610        self.lines_ref().cell()
611    }
612
613    #[turbo_tasks::function]
614    pub async fn hash(&self, salt: Vc<RcStr>, algorithm: HashAlgorithm) -> Result<Vc<RcStr>> {
615        Ok(Vc::cell(RcStr::from(deterministic_hash(
616            &salt.await?,
617            self,
618            algorithm,
619        ))))
620    }
621
622    /// Converts this [`FileContent`] into a [`PersistedFileContent`] by cloning.
623    ///
624    /// Use this in contexts where the full file content must be serialized to the persistent
625    /// task cache (e.g., in `DiskFileSystem::write`).
626    #[turbo_tasks::function]
627    pub fn persist(&self) -> Vc<PersistedFileContent> {
628        match self {
629            FileContent::Content(file) => PersistedFileContent::Content(file.clone()).cell(),
630            FileContent::NotFound => PersistedFileContent::NotFound.cell(),
631        }
632    }
633
634    /// Compared to [FileContent::hash], this hashes only the bytes of the file content and
635    /// nothing else, returning `None` if the file does not exist.
636    ///
637    /// If `salt` is non-empty it is written into the hasher before the file bytes in a single
638    /// pass. An empty salt produces the same result as hashing without a prefix.
639    #[turbo_tasks::function]
640    pub async fn content_hash(
641        &self,
642        salt: Vc<RcStr>,
643        algorithm: HashAlgorithm,
644    ) -> Result<Vc<Option<RcStr>>> {
645        match self {
646            FileContent::Content(file) => Ok(Vc::cell(Some(
647                deterministic_hash(&salt.await?, file.content().content_hash(), algorithm).into(),
648            ))),
649            FileContent::NotFound => Ok(Vc::cell(None)),
650        }
651    }
652}
653
654/// A file's content interpreted as a JSON value.
655#[turbo_tasks::value(shared, serialization = "skip")]
656pub enum FileJsonContent {
657    Content(Value),
658    Unparsable(Box<UnparsableJson>),
659    NotFound,
660}
661
662#[turbo_tasks::value_impl]
663impl ValueToString for FileJsonContent {
664    /// Returns the JSON file content as a UTF-8 string.
665    ///
666    /// This operation will only succeed if the file contents are a valid JSON
667    /// value.
668    #[turbo_tasks::function]
669    fn to_string(&self) -> Result<Vc<RcStr>> {
670        match self {
671            FileJsonContent::Content(json) => Ok(Vc::cell(json.to_string().into())),
672            FileJsonContent::Unparsable(e) => bail!("File is not valid JSON: {}", e),
673            FileJsonContent::NotFound => bail!("File not found"),
674        }
675    }
676}
677
678#[turbo_tasks::value_impl]
679impl FileJsonContent {
680    #[turbo_tasks::function]
681    pub async fn content(self: Vc<Self>) -> Result<Vc<Value>> {
682        match &*self.await? {
683            FileJsonContent::Content(json) => Ok(Vc::cell(json.clone())),
684            FileJsonContent::Unparsable(e) => bail!("File is not valid JSON: {}", e),
685            FileJsonContent::NotFound => bail!("File not found"),
686        }
687    }
688}
689impl FileJsonContent {
690    pub fn unparsable(message: RcStr) -> Self {
691        FileJsonContent::Unparsable(Box::new(UnparsableJson {
692            message,
693            path: None,
694            start_location: None,
695            end_location: None,
696        }))
697    }
698
699    pub fn unparsable_with_message(message: RcStr) -> Self {
700        FileJsonContent::Unparsable(Box::new(UnparsableJson {
701            message,
702            path: None,
703            start_location: None,
704            end_location: None,
705        }))
706    }
707}
708
709#[derive(Debug, PartialEq, Eq)]
710pub struct FileLine {
711    pub content: String,
712    pub bytes_offset: u32,
713}
714
715impl FileLine {
716    pub fn len(&self) -> usize {
717        self.content.len()
718    }
719
720    #[must_use]
721    pub fn is_empty(&self) -> bool {
722        self.len() == 0
723    }
724}
725
726#[turbo_tasks::value(shared, serialization = "skip")]
727pub enum FileLinesContent {
728    Lines(#[turbo_tasks(unsafe_ignore)] Vec<FileLine>),
729    Unparsable,
730    NotFound,
731}