aede_core/playlist/
files.rs1use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::{Duration, Instant};
7
8mod metadata;
9use metadata::metadata;
10mod read;
11pub use read::{Document, read};
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum Error {
16 InvalidIdentity,
18 NotFound,
20 Changed,
22 Unavailable,
24 InvalidPath,
26 TooLarge,
28 Timeout,
30 InvalidText,
32}
33
34#[derive(Clone, Debug)]
36pub struct File {
37 pub id: String,
39 pub name: String,
41 pub filename: String,
43 pub path: String,
45}
46
47pub struct Discovery {
49 pub files: Vec<File>,
51 pub unavailable_roots: usize,
53 pub unreadable_directories: usize,
55}
56
57#[derive(Clone, Copy)]
58struct Limits {
59 files: usize,
60 entries: usize,
61 depth: usize,
62 elapsed: Duration,
63}
64const LIMITS: Limits = Limits {
65 files: 10_000,
66 entries: 250_000,
67 depth: 64,
68 elapsed: Duration::from_secs(3),
69};
70
71struct Inspection {
72 roots: Vec<PathBuf>,
73 excluded: Vec<PathBuf>,
74 result: Discovery,
75 entries: usize,
76 started: Instant,
77 limits: Limits,
78 seen_files: BTreeMap<String, Vec<usize>>,
79}
80
81impl Inspection {
82 fn new(roots: &[String], excluded: &[String], limits: Limits) -> Result<Self, Error> {
83 let mut inspection = Self {
84 roots: Vec::new(),
85 excluded: Vec::new(),
86 result: Discovery {
87 files: Vec::new(),
88 unavailable_roots: 0,
89 unreadable_directories: 0,
90 },
91 entries: 0,
92 started: Instant::now(),
93 limits,
94 seen_files: BTreeMap::new(),
95 };
96 for path in excluded {
97 inspection.check()?;
98 let path = PathBuf::from(path);
99 inspection
100 .excluded
101 .push(path.canonicalize().unwrap_or(path));
102 }
103 for root in roots {
104 inspection.check()?;
105 match Path::new(root).canonicalize() {
106 Ok(root) if root.is_dir() => inspection.roots.push(root),
107 _ => inspection.result.unavailable_roots += 1,
108 }
109 }
110 inspection.roots.sort();
111 inspection.roots.dedup();
112 Ok(inspection)
113 }
114
115 fn check(&self) -> Result<(), Error> {
116 if self.started.elapsed() >= self.limits.elapsed {
117 Err(Error::Timeout)
118 } else {
119 Ok(())
120 }
121 }
122
123 fn excluded(&self, path: &Path) -> bool {
124 self.excluded
125 .iter()
126 .any(|excluded| path.starts_with(excluded))
127 }
128
129 fn walk(&mut self, folder: &Path, depth: usize) -> Result<(), Error> {
130 self.check()?;
131 if self.excluded(folder) {
132 return Ok(());
133 }
134 if depth > self.limits.depth {
135 return Err(Error::TooLarge);
136 }
137 let directory_metadata = match fs::symlink_metadata(folder) {
139 Ok(metadata) => metadata,
140 Err(_) => {
141 self.result.unreadable_directories += 1;
142 return Ok(());
143 }
144 };
145 let canonical = match folder.canonicalize() {
146 Ok(canonical) => canonical,
147 Err(_) => {
148 self.result.unreadable_directories += 1;
149 return Ok(());
150 }
151 };
152 if !directory_metadata.is_dir()
153 || directory_metadata.file_type().is_symlink()
154 || canonical != folder
155 {
156 return Err(Error::Changed);
157 }
158 let entries = match fs::read_dir(folder) {
159 Ok(entries) => entries,
160 Err(_) => {
161 self.result.unreadable_directories += 1;
162 return Ok(());
163 }
164 };
165 for entry in entries {
166 self.check()?;
167 self.entries += 1;
168 if self.entries > self.limits.entries {
169 return Err(Error::TooLarge);
170 }
171 let entry = match entry {
172 Ok(entry) => entry,
173 Err(_) => {
174 self.result.unreadable_directories += 1;
175 continue;
176 }
177 };
178 let path = entry.path();
179 if self.excluded(&path) {
180 continue;
181 }
182 let kind = match entry.file_type() {
183 Ok(kind) => kind,
184 Err(_) => {
185 self.result.unreadable_directories += 1;
186 continue;
187 }
188 };
189 if kind.is_dir() {
190 self.walk(&path, depth + 1)?;
191 }
192 if kind.is_file() && playlist_path(&path) {
193 let canonical = match path.canonicalize() {
194 Ok(canonical) => canonical,
195 Err(_) => {
196 self.result.unreadable_directories += 1;
197 continue;
198 }
199 };
200 if canonical != path {
201 return Err(Error::Changed);
202 }
203 let file = metadata(&path)?;
204 if self.seen_files.get(&file.id).is_some_and(|indices| {
205 indices
206 .iter()
207 .any(|&index| self.result.files[index].path == file.path)
208 }) {
209 continue;
210 }
211 if self.result.files.len() >= self.limits.files {
212 return Err(Error::TooLarge);
213 }
214 self.seen_files
215 .entry(file.id.clone())
216 .or_default()
217 .push(self.result.files.len());
218 self.result.files.push(file);
219 }
220 }
221 self.check()
222 }
223}
224
225fn playlist_path(path: &Path) -> bool {
226 path.extension()
227 .and_then(|extension| extension.to_str())
228 .is_some_and(|extension| {
229 extension.eq_ignore_ascii_case("m3u") || extension.eq_ignore_ascii_case("m3u8")
230 })
231}
232
233pub fn discover(roots: &[String], excluded: &[String]) -> Result<Discovery, Error> {
236 discover_with_limits(roots, excluded, LIMITS)
237}
238
239fn discover_with_limits(
240 roots: &[String],
241 excluded: &[String],
242 limits: Limits,
243) -> Result<Discovery, Error> {
244 let mut inspection = Inspection::new(roots, excluded, limits)?;
245 let mut covered = Vec::<PathBuf>::new();
246 for root in inspection.roots.clone() {
247 if covered.iter().any(|parent| root.starts_with(parent)) {
248 continue;
249 }
250 let unreadable = inspection.result.unreadable_directories;
251 inspection.walk(&root, 0)?;
252 if unreadable == inspection.result.unreadable_directories {
255 covered.push(root);
256 }
257 }
258 inspection
259 .result
260 .files
261 .sort_by(|left, right| left.path.cmp(&right.path));
262 Ok(inspection.result)
263}
264
265#[cfg(test)]
266#[path = "files_test_support.rs"]
267mod test_support;
268
269#[cfg(test)]
270#[path = "files_tests.rs"]
271mod tests;