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::{Result, bail};
12use bincode::{Decode, Encode};
13use bitflags::bitflags;
14use jsonc_parser::{ParseOptions, parse_to_serde_value};
15use mime::Mime;
16use serde_json::Value;
17use turbo_rcstr::{RcStr, rcstr};
18use turbo_tasks::{NonLocalValue, ReadRef, ValueToString, Vc, trace::TraceRawVcs};
19use turbo_tasks_hash::{
20    DeterministicHash, DeterministicHasher, HashAlgorithm, deterministic_hash, hash_xxh3_hash64,
21};
22
23use crate::{
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
166bitflags! {
167  #[derive(
168    Default,
169    TraceRawVcs,
170    NonLocalValue,
171    DeterministicHash,
172    Encode,
173    Decode,
174  )]
175  pub struct LinkType: u8 {
176      const DIRECTORY = 0b00000001;
177      const ABSOLUTE = 0b00000010;
178  }
179}
180
181/// The contents of a symbolic link. On Windows, this may be a junction point.
182///
183/// When reading, we treat symbolic links and junction points on Windows as equivalent. When
184/// creating a new link, we always create junction points, because symlink creation may fail if
185/// Windows "developer mode" is not enabled and we're running in an unprivileged environment.
186#[turbo_tasks::value(shared)]
187#[derive(Debug, DeterministicHash)]
188pub enum LinkContent {
189    /// A valid symbolic link pointing to `target`, a unix-style path.
190    ///
191    /// If [`LinkType::ABSOLUTE`] is set, `target` is normalized and relative to the *filesystem
192    /// root* (so that absolute system paths never end up in the persistent cache). Otherwise,
193    /// `target` is the raw value read from the link — unnormalized, may contain `..` — and is
194    /// relative to the *directory containing the link*.
195    ///
196    /// A relative `target` must stay raw so that [`FileSystem::write_link`] round-trips it
197    /// exactly: the value is written verbatim and compared against [`std::fs::read_link`] to
198    /// skip unchanged links.
199    Link {
200        target: RcStr,
201        link_type: LinkType,
202    },
203    // Invalid means the link is invalid it points out of the filesystem root
204    Invalid,
205    // The target was not found
206    NotFound,
207}
208
209#[turbo_tasks::value(shared)]
210#[derive(Clone, DeterministicHash, PartialOrd, Ord)]
211pub struct File {
212    #[turbo_tasks(debug_ignore)]
213    content: Rope,
214    pub(crate) meta: FileMeta,
215}
216
217impl File {
218    /// Reads a [File] from the given path
219    pub(crate) fn from_path(p: &Path) -> io::Result<Self> {
220        let mut file = std::fs::File::open(p)?;
221        let metadata = file.metadata()?;
222
223        let mut output = Vec::with_capacity(metadata.len() as usize);
224        file.read_to_end(&mut output)?;
225
226        Ok(File {
227            meta: metadata.into(),
228            content: Rope::from(output),
229        })
230    }
231
232    /// Creates a [File] from raw bytes.
233    pub(crate) fn from_bytes(content: Vec<u8>) -> Self {
234        File {
235            meta: FileMeta::default(),
236            content: Rope::from(content),
237        }
238    }
239
240    /// Creates a [File] from a rope.
241    fn from_rope(content: Rope) -> Self {
242        File {
243            meta: FileMeta::default(),
244            content,
245        }
246    }
247
248    /// Returns the content type associated with this file.
249    pub fn content_type(&self) -> Option<&Mime> {
250        self.meta.content_type.as_ref()
251    }
252
253    /// Sets the content type associated with this file.
254    pub fn with_content_type(mut self, content_type: Mime) -> Self {
255        self.meta.content_type = Some(content_type);
256        self
257    }
258
259    /// Returns a Read/AsyncRead/Stream/Iterator to access the File's contents.
260    pub fn read(&self) -> RopeReader<'_> {
261        self.content.read()
262    }
263}
264
265impl Debug for File {
266    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
267        f.debug_struct("File")
268            .field("meta", &self.meta)
269            .field("content (hash)", &hash_xxh3_hash64(&self.content))
270            .finish()
271    }
272}
273
274impl From<RcStr> for File {
275    fn from(s: RcStr) -> Self {
276        s.into_owned().into()
277    }
278}
279
280impl From<String> for File {
281    fn from(s: String) -> Self {
282        File::from_bytes(s.into_bytes())
283    }
284}
285
286impl From<ReadRef<RcStr>> for File {
287    fn from(s: ReadRef<RcStr>) -> Self {
288        File::from_bytes(s.as_bytes().to_vec())
289    }
290}
291
292impl From<&str> for File {
293    fn from(s: &str) -> Self {
294        File::from_bytes(s.as_bytes().to_vec())
295    }
296}
297
298impl From<Vec<u8>> for File {
299    fn from(bytes: Vec<u8>) -> Self {
300        File::from_bytes(bytes)
301    }
302}
303
304impl From<&[u8]> for File {
305    fn from(bytes: &[u8]) -> Self {
306        File::from_bytes(bytes.to_vec())
307    }
308}
309
310impl From<ReadRef<Rope>> for File {
311    fn from(rope: ReadRef<Rope>) -> Self {
312        File::from_rope(ReadRef::into_owned(rope))
313    }
314}
315
316impl From<Rope> for File {
317    fn from(rope: Rope) -> Self {
318        File::from_rope(rope)
319    }
320}
321
322impl File {
323    pub fn new(meta: FileMeta, content: Vec<u8>) -> Self {
324        Self {
325            meta,
326            content: Rope::from(content),
327        }
328    }
329
330    /// Returns the associated [FileMeta] of this file.
331    pub fn meta(&self) -> &FileMeta {
332        &self.meta
333    }
334
335    /// Returns the immutable contents of this file.
336    pub fn content(&self) -> &Rope {
337        &self.content
338    }
339}
340
341#[turbo_tasks::value(shared)]
342#[derive(Debug, Clone, Default)]
343pub struct FileMeta {
344    // Size of the file
345    // len: u64,
346    pub(crate) permissions: Permissions,
347    #[bincode(with = "turbo_bincode::mime_option")]
348    #[turbo_tasks(trace_ignore)]
349    content_type: Option<Mime>,
350}
351
352impl Ord for FileMeta {
353    fn cmp(&self, other: &Self) -> Ordering {
354        self.permissions
355            .cmp(&other.permissions)
356            .then_with(|| self.content_type.as_ref().cmp(&other.content_type.as_ref()))
357    }
358}
359
360impl PartialOrd for FileMeta {
361    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
362        Some(self.cmp(other))
363    }
364}
365
366impl From<std::fs::Metadata> for FileMeta {
367    fn from(meta: std::fs::Metadata) -> Self {
368        let permissions = meta.permissions().into();
369
370        Self {
371            permissions,
372            content_type: None,
373        }
374    }
375}
376
377impl DeterministicHash for FileMeta {
378    fn deterministic_hash<H: DeterministicHasher>(&self, state: &mut H) {
379        self.permissions.deterministic_hash(state);
380        if let Some(content_type) = &self.content_type {
381            content_type.to_string().deterministic_hash(state);
382        }
383    }
384}
385
386impl FileContent {
387    pub fn new(file: File) -> Self {
388        FileContent::Content(file)
389    }
390
391    pub fn is_content(&self) -> bool {
392        matches!(self, FileContent::Content(_))
393    }
394
395    pub fn as_content(&self) -> Option<&File> {
396        match self {
397            FileContent::Content(file) => Some(file),
398            FileContent::NotFound => None,
399        }
400    }
401
402    pub fn parse_json_ref(&self) -> FileJsonContent {
403        match self {
404            FileContent::Content(file) => {
405                let content = file.content.clone().into_bytes();
406                let de = &mut serde_json::Deserializer::from_slice(&content);
407                match serde_path_to_error::deserialize(de) {
408                    Ok(data) => FileJsonContent::Content(data),
409                    Err(e) => FileJsonContent::Unparsable(Box::new(
410                        UnparsableJson::from_serde_path_to_error(e),
411                    )),
412                }
413            }
414            FileContent::NotFound => FileJsonContent::NotFound,
415        }
416    }
417
418    pub fn parse_json_with_comments_ref(&self) -> FileJsonContent {
419        match self {
420            FileContent::Content(file) => match file.content.to_str() {
421                Ok(string) => match parse_to_serde_value(
422                    &string,
423                    &ParseOptions {
424                        allow_comments: true,
425                        allow_trailing_commas: true,
426                        allow_loose_object_property_names: false,
427                    },
428                ) {
429                    Ok(data) => match data {
430                        Some(value) => FileJsonContent::Content(value),
431                        None => FileJsonContent::unparsable(rcstr!(
432                            "text content doesn't contain any json data"
433                        )),
434                    },
435                    Err(e) => FileJsonContent::Unparsable(Box::new(
436                        UnparsableJson::from_jsonc_error(e, string.as_ref()),
437                    )),
438                },
439                Err(_) => FileJsonContent::unparsable(rcstr!("binary is not valid utf-8 text")),
440            },
441            FileContent::NotFound => FileJsonContent::NotFound,
442        }
443    }
444
445    pub fn parse_json5_ref(&self) -> FileJsonContent {
446        match self {
447            FileContent::Content(file) => match file.content.to_str() {
448                Ok(string) => match parse_to_serde_value(
449                    &string,
450                    &ParseOptions {
451                        allow_comments: true,
452                        allow_trailing_commas: true,
453                        allow_loose_object_property_names: true,
454                    },
455                ) {
456                    Ok(data) => match data {
457                        Some(value) => FileJsonContent::Content(value),
458                        None => FileJsonContent::unparsable(rcstr!(
459                            "text content doesn't contain any json data"
460                        )),
461                    },
462                    Err(e) => FileJsonContent::Unparsable(Box::new(
463                        UnparsableJson::from_jsonc_error(e, string.as_ref()),
464                    )),
465                },
466                Err(_) => FileJsonContent::unparsable(rcstr!("binary is not valid utf-8 text")),
467            },
468            FileContent::NotFound => FileJsonContent::NotFound,
469        }
470    }
471
472    pub fn lines_ref(&self) -> FileLinesContent {
473        match self {
474            FileContent::Content(file) => match file.content.to_str() {
475                Ok(string) => {
476                    let mut bytes_offset = 0;
477                    FileLinesContent::Lines(
478                        string
479                            .split('\n')
480                            .map(|l| {
481                                let line = FileLine {
482                                    content: l.to_string(),
483                                    bytes_offset,
484                                };
485                                bytes_offset += (l.len() + 1) as u32;
486                                line
487                            })
488                            .collect(),
489                    )
490                }
491                Err(_) => FileLinesContent::Unparsable,
492            },
493            FileContent::NotFound => FileLinesContent::NotFound,
494        }
495    }
496}
497
498#[turbo_tasks::value_impl]
499impl FileContent {
500    #[turbo_tasks::function]
501    pub fn len(&self) -> Result<Vc<Option<u64>>> {
502        Ok(Vc::cell(match self {
503            FileContent::Content(file) => Some(file.content.len() as u64),
504            FileContent::NotFound => None,
505        }))
506    }
507
508    #[turbo_tasks::function]
509    pub fn parse_json(&self) -> Result<Vc<FileJsonContent>> {
510        Ok(self.parse_json_ref().cell())
511    }
512
513    #[turbo_tasks::function]
514    pub fn parse_json_with_comments(&self) -> Vc<FileJsonContent> {
515        self.parse_json_with_comments_ref().cell()
516    }
517
518    #[turbo_tasks::function]
519    pub fn parse_json5(&self) -> Vc<FileJsonContent> {
520        self.parse_json5_ref().cell()
521    }
522
523    #[turbo_tasks::function]
524    pub fn lines(&self) -> Vc<FileLinesContent> {
525        self.lines_ref().cell()
526    }
527
528    #[turbo_tasks::function]
529    pub async fn hash(&self, salt: Vc<RcStr>, algorithm: HashAlgorithm) -> Result<Vc<RcStr>> {
530        Ok(Vc::cell(RcStr::from(deterministic_hash(
531            &salt.await?,
532            self,
533            algorithm,
534        ))))
535    }
536
537    /// Converts this [`FileContent`] into a [`PersistedFileContent`] by cloning.
538    ///
539    /// Use this in contexts where the full file content must be serialized to the persistent
540    /// task cache (e.g., in [`DiskFileSystem::write`]).
541    #[turbo_tasks::function]
542    pub fn persist(&self) -> Vc<PersistedFileContent> {
543        match self {
544            FileContent::Content(file) => PersistedFileContent::Content(file.clone()).cell(),
545            FileContent::NotFound => PersistedFileContent::NotFound.cell(),
546        }
547    }
548
549    /// Compared to [FileContent::hash], this hashes only the bytes of the file content and
550    /// nothing else, returning `None` if the file does not exist.
551    ///
552    /// If `salt` is non-empty it is written into the hasher before the file bytes in a single
553    /// pass. An empty salt produces the same result as hashing without a prefix.
554    #[turbo_tasks::function]
555    pub async fn content_hash(
556        &self,
557        salt: Vc<RcStr>,
558        algorithm: HashAlgorithm,
559    ) -> Result<Vc<Option<RcStr>>> {
560        match self {
561            FileContent::Content(file) => Ok(Vc::cell(Some(
562                deterministic_hash(&salt.await?, file.content().content_hash(), algorithm).into(),
563            ))),
564            FileContent::NotFound => Ok(Vc::cell(None)),
565        }
566    }
567}
568
569/// A file's content interpreted as a JSON value.
570#[turbo_tasks::value(shared, serialization = "skip")]
571pub enum FileJsonContent {
572    Content(Value),
573    Unparsable(Box<UnparsableJson>),
574    NotFound,
575}
576
577#[turbo_tasks::value_impl]
578impl ValueToString for FileJsonContent {
579    /// Returns the JSON file content as a UTF-8 string.
580    ///
581    /// This operation will only succeed if the file contents are a valid JSON
582    /// value.
583    #[turbo_tasks::function]
584    fn to_string(&self) -> Result<Vc<RcStr>> {
585        match self {
586            FileJsonContent::Content(json) => Ok(Vc::cell(json.to_string().into())),
587            FileJsonContent::Unparsable(e) => bail!("File is not valid JSON: {}", e),
588            FileJsonContent::NotFound => bail!("File not found"),
589        }
590    }
591}
592
593#[turbo_tasks::value_impl]
594impl FileJsonContent {
595    #[turbo_tasks::function]
596    pub async fn content(self: Vc<Self>) -> Result<Vc<Value>> {
597        match &*self.await? {
598            FileJsonContent::Content(json) => Ok(Vc::cell(json.clone())),
599            FileJsonContent::Unparsable(e) => bail!("File is not valid JSON: {}", e),
600            FileJsonContent::NotFound => bail!("File not found"),
601        }
602    }
603}
604impl FileJsonContent {
605    pub fn unparsable(message: RcStr) -> Self {
606        FileJsonContent::Unparsable(Box::new(UnparsableJson {
607            message,
608            path: None,
609            start_location: None,
610            end_location: None,
611        }))
612    }
613
614    pub fn unparsable_with_message(message: RcStr) -> Self {
615        FileJsonContent::Unparsable(Box::new(UnparsableJson {
616            message,
617            path: None,
618            start_location: None,
619            end_location: None,
620        }))
621    }
622}
623
624#[derive(Debug, PartialEq, Eq)]
625pub struct FileLine {
626    pub content: String,
627    pub bytes_offset: u32,
628}
629
630impl FileLine {
631    pub fn len(&self) -> usize {
632        self.content.len()
633    }
634
635    #[must_use]
636    pub fn is_empty(&self) -> bool {
637        self.len() == 0
638    }
639}
640
641#[turbo_tasks::value(shared, serialization = "skip")]
642pub enum FileLinesContent {
643    Lines(#[turbo_tasks(trace_ignore)] Vec<FileLine>),
644    Unparsable,
645    NotFound,
646}