aede_core/playlist/files/
read.rs

1//! Read the selected file itself; playlist entries never trigger file/network I/O.
2
3use super::*;
4use std::io::Read;
5
6const MAX_BYTES: u64 = 8 * 1024 * 1024;
7const MAX_TRACKS: usize = 4096;
8
9/// Exact original text and a count of nonempty, noncomment entry lines.
10pub struct Document {
11    /// Currently permitted file identity and path.
12    pub file: File,
13    /// UTF-8 content, preserving BOM, whitespace and line endings.
14    pub content: String,
15    /// Entry lines, including repeated paths and URLs; no availability inference.
16    pub track_count: usize,
17}
18
19/// Read only an identity rediscovered under the current roots and exclusions.
20pub fn read(roots: &[String], excluded: &[String], id: &str) -> Result<Document, Error> {
21    read_with_open(roots, excluded, id, |path| fs::File::open(path))
22}
23
24fn read_with_open(
25    roots: &[String],
26    excluded: &[String],
27    id: &str,
28    open: impl FnOnce(&Path) -> std::io::Result<fs::File>,
29) -> Result<Document, Error> {
30    let suffix = id.strip_prefix("m3u-file-").ok_or(Error::InvalidIdentity)?;
31    if suffix.len() != 64
32        || !suffix
33            .bytes()
34            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
35    {
36        return Err(Error::InvalidIdentity);
37    }
38    let scope = || {
39        roots
40            .iter()
41            .chain(excluded)
42            .map(|path| Path::new(path).canonicalize().ok())
43            .collect::<Vec<_>>()
44    };
45    let before = scope();
46    let started = Instant::now();
47    let mut matches = discover(roots, excluded)?
48        .files
49        .into_iter()
50        .filter(|file| file.id == id);
51    let file = matches.next().ok_or(Error::NotFound)?;
52    if matches.next().is_some() {
53        return Err(Error::Changed);
54    }
55    let document = read_file(file, open)?;
56    if before != scope() {
57        return Err(Error::Changed);
58    }
59    if started.elapsed() >= LIMITS.elapsed {
60        return Err(Error::Timeout);
61    }
62    Ok(document)
63}
64
65fn safe_path(path: &Path) -> Result<fs::Metadata, Error> {
66    let metadata = fs::symlink_metadata(path).map_err(|_| Error::Unavailable)?;
67    if !metadata.is_file()
68        || metadata.file_type().is_symlink()
69        || path.canonicalize().map_err(|_| Error::Unavailable)? != path
70    {
71        return Err(Error::Changed);
72    }
73    if metadata.len() > MAX_BYTES {
74        return Err(Error::TooLarge);
75    }
76    Ok(metadata)
77}
78
79fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool {
80    #[cfg(unix)]
81    {
82        use std::os::unix::fs::MetadataExt;
83        if left.dev() != right.dev() || left.ino() != right.ino() {
84            return false;
85        }
86    }
87    left.is_file()
88        && right.is_file()
89        && left.len() == right.len()
90        && left
91            .modified()
92            .ok()
93            .zip(right.modified().ok())
94            .is_some_and(|(left, right)| left == right)
95}
96
97fn read_file(
98    file: File,
99    open: impl FnOnce(&Path) -> std::io::Result<fs::File>,
100) -> Result<Document, Error> {
101    let path = Path::new(&file.path);
102    let before = safe_path(path)?;
103    let handle = open(path).map_err(|_| Error::Unavailable)?;
104    let opened = handle.metadata().map_err(|_| Error::Unavailable)?;
105    if !same_file(&before, &opened) || !same_file(&opened, &safe_path(path)?) {
106        return Err(Error::Changed);
107    }
108    let mut bytes = Vec::new();
109    (&handle)
110        .take(MAX_BYTES + 1)
111        .read_to_end(&mut bytes)
112        .map_err(|_| Error::Unavailable)?;
113    if bytes.len() as u64 > MAX_BYTES {
114        return Err(Error::TooLarge);
115    }
116    if !same_file(&opened, &handle.metadata().map_err(|_| Error::Unavailable)?)
117        || !same_file(&opened, &safe_path(path)?)
118    {
119        return Err(Error::Changed);
120    }
121    let content = String::from_utf8(bytes).map_err(|_| Error::InvalidText)?;
122    if content
123        .chars()
124        .any(|character| character.is_control() && !matches!(character, '\t' | '\r' | '\n'))
125    {
126        return Err(Error::InvalidText);
127    }
128    let track_count = crate::playlist::entry_lines(&content).count();
129    if track_count > MAX_TRACKS {
130        return Err(Error::TooLarge);
131    }
132    Ok(Document {
133        file,
134        content,
135        track_count,
136    })
137}
138
139#[cfg(test)]
140#[path = "read_tests.rs"]
141mod tests;