1#[cfg(not(unix))]
4use crate::discovery::modified_ns;
5use crate::discovery::{
6 DiscoveryOptions, PolicyRoot, ResourceKind, discover_with_policy, ensure_allowed_path,
7};
8use crate::{Error, ResourceId, Result};
9use serde::{Deserialize, Serialize};
10use std::fs::{File, Metadata};
11use std::io::{self, Read};
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(transparent)]
21pub struct ResourceVersion(Option<String>);
22
23impl ResourceVersion {
24 pub fn known(token: impl Into<String>) -> Self {
25 Self(Some(token.into()))
26 }
27 pub fn unknown() -> Self {
28 Self(None)
29 }
30 pub fn matches(&self, other: &Self) -> bool {
31 self.0.is_some() && self == other
32 }
33 pub fn is_known(&self) -> bool {
34 self.0.is_some()
35 }
36 pub(crate) fn file(size: u64, modified: Option<i64>) -> Self {
41 modified
42 .map(|t| Self::known(format!("fs:{size}:{t}")))
43 .unwrap_or_default()
44 }
45}
46
47#[derive(Debug, Clone)]
53pub struct ResourceScope {
54 pub roots: Vec<ResourceId>,
55 pub discovery: DiscoveryOptions,
56}
57
58impl ResourceScope {
59 pub(crate) fn contains(&self, id: &ResourceId) -> bool {
61 self.roots.iter().any(|root| {
62 if let (Some(path), Some(root)) = (id.to_file_path(), root.to_file_path()) {
63 path.starts_with(root)
64 } else {
65 id == root
66 || id
67 .as_str()
68 .strip_prefix(root.as_str())
69 .is_some_and(|rest| root.as_str().ends_with('/') || rest.starts_with('/'))
70 }
71 })
72 }
73
74 pub(crate) fn from_paths(
75 paths: &[PathBuf],
76 mut roots: Vec<ResourceId>,
77 discovery: DiscoveryOptions,
78 ) -> Result<Self> {
79 for root in &roots {
80 if let Some(path) = root.to_file_path() {
81 ensure_allowed_path(&path)?;
82 }
83 }
84 for p in paths {
85 let root = PolicyRoot::new(p)?;
88 roots.push(ResourceId::from_path(root.uri_path())?);
89 }
90 Ok(Self { roots, discovery })
91 }
92}
93
94#[derive(Debug, Clone)]
100pub struct ProvidedResource {
101 pub id: ResourceId,
102 pub kind: ResourceKind,
103 pub size_bytes: u64,
104 pub modified_ns: Option<i64>,
105 pub version: ResourceVersion,
106 pub title: Option<String>,
108}
109
110impl ProvidedResource {
111 pub(crate) fn fingerprint(&self) -> String {
113 let encoded = serde_json::to_vec(&(self.kind, &self.title)).expect("descriptor serializes");
114 blake3::hash(&encoded).to_hex().to_string()
115 }
116}
117
118pub trait ReadSeek: std::io::Read + std::io::Seek + Send + Sync {}
120impl<T: std::io::Read + std::io::Seek + Send + Sync> ReadSeek for T {}
121
122pub trait ResourceProvider: Send + Sync {
126 fn open(&self, id: &ResourceId) -> Result<Box<dyn ReadSeek>> {
128 Err(Error::UnsupportedAudio {
129 resource: id.clone(),
130 reason: "provider does not support seekable audio reads",
131 })
132 }
133 fn list(&self, scope: &ResourceScope) -> Result<Vec<ProvidedResource>>;
135 fn list_walked(&self, scope: &ResourceScope) -> Result<ResourceListing> {
138 Ok(ResourceListing {
139 resources: self.list(scope)?,
140 walked_roots: None,
141 })
142 }
143 fn supports_cached_listing(&self) -> bool {
146 false
147 }
148 fn list_cached(
151 &self,
152 scope: &ResourceScope,
153 _ids: &[ResourceId],
154 ) -> Result<Vec<ProvidedResource>> {
155 self.list(scope)
156 }
157 fn read(&self, id: &ResourceId) -> Result<Vec<u8>>;
158 fn version(&self, id: &ResourceId) -> Result<ResourceVersion>;
166 fn deletion_scope(&self, scope: &ResourceScope) -> Result<ResourceScope> {
169 Ok(scope.clone())
170 }
171 fn is_deleted(&self, _id: &ResourceId, _scope: &ResourceScope) -> Result<bool> {
175 Ok(false)
176 }
177}
178
179#[derive(Debug, Clone, Default)]
181pub struct ResourceListing {
182 pub resources: Vec<ProvidedResource>,
183 pub walked_roots: Option<Vec<ResourceId>>,
186}
187
188#[derive(Debug, Default)]
199pub struct FsProvider;
200
201fn file_path(id: &ResourceId) -> Result<PathBuf> {
202 id.to_file_path().ok_or_else(|| {
203 Error::Config(format!(
204 "filesystem provider cannot access {} resources",
205 id.scheme()
206 ))
207 })
208}
209
210impl FsProvider {
211 pub(crate) fn open_verified_handle(id: &ResourceId) -> Result<File> {
212 let path = file_path(id)?;
213 ensure_allowed_path(&path)?;
214 open_local_content(&path)
215 }
216}
217
218impl ResourceProvider for FsProvider {
219 fn open(&self, id: &ResourceId) -> Result<Box<dyn ReadSeek>> {
220 let path = file_path(id)?;
221 ensure_allowed_path(&path)?;
222 Ok(Box::new(open_local_content(&path)?))
223 }
224 fn list(&self, scope: &ResourceScope) -> Result<Vec<ProvidedResource>> {
225 Ok(self.list_walked(scope)?.resources)
226 }
227 fn list_walked(&self, scope: &ResourceScope) -> Result<ResourceListing> {
228 let paths = scope
229 .roots
230 .iter()
231 .map(file_path)
232 .collect::<Result<Vec<_>>>()?;
233 let walked = discover_with_policy(&paths, &scope.discovery, |_| true)?;
234 let resources = walked
235 .resources
236 .into_iter()
237 .map(|r| {
238 Ok(ProvidedResource {
239 id: ResourceId::from_path(&r.path)?,
240 kind: r.kind,
241 size_bytes: r.size_bytes,
242 modified_ns: r.modified_ns,
243 version: ResourceVersion::file(r.size_bytes, r.modified_ns),
244 title: r.path.file_stem().map(|s| s.to_string_lossy().into_owned()),
245 })
246 })
247 .collect::<Result<_>>()?;
248 Ok(ResourceListing {
249 resources,
250 walked_roots: Some(
251 walked
252 .walked_roots
253 .iter()
254 .map(ResourceId::from_path)
255 .collect::<Result<_>>()?,
256 ),
257 })
258 }
259 fn read(&self, id: &ResourceId) -> Result<Vec<u8>> {
260 let path = file_path(id)?;
261 ensure_allowed_path(&path)?;
262 let mut file = open_local_content(&path)?;
263 let mut bytes = Vec::new();
264 file.read_to_end(&mut bytes)
265 .map_err(|e| Error::io(path, e))?;
266 Ok(bytes)
267 }
268 fn version(&self, id: &ResourceId) -> Result<ResourceVersion> {
269 let path = file_path(id)?;
270 ensure_allowed_path(&path)?;
271 local_version(&path)
272 }
273 fn deletion_scope(&self, scope: &ResourceScope) -> Result<ResourceScope> {
274 let roots = scope
275 .roots
276 .iter()
277 .map(|root| {
278 let path = file_path(root)?;
279 let real = path.canonicalize().map_err(|e| Error::io(&path, e))?;
280 ResourceId::from_path(real)
283 })
284 .collect::<Result<Vec<_>>>()?;
285 Ok(ResourceScope {
286 roots,
287 discovery: scope.discovery.clone(),
288 })
289 }
290 fn is_deleted(&self, id: &ResourceId, scope: &ResourceScope) -> Result<bool> {
291 let Some(path) = id.to_file_path() else {
292 return Ok(false);
293 };
294 if !scope.contains(id) {
295 return Ok(false);
296 }
297 match std::fs::metadata(&path) {
298 Ok(_) => Ok(false),
299 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(true),
300 Err(e) => Err(Error::io(path, e)),
301 }
302 }
303}
304
305fn placeholder_marker(path: &Path) -> bool {
306 path.components().any(|part| {
307 let bytes = part.as_os_str().as_encoded_bytes();
308 bytes[bytes.len().saturating_sub(7)..].eq_ignore_ascii_case(b".icloud")
309 })
310}
311
312fn unavailable_content() -> io::Error {
313 io::Error::new(
314 io::ErrorKind::PermissionDenied,
315 "unmaterialized local content is unavailable; choose a fully local copy",
316 )
317}
318
319#[cfg(target_os = "macos")]
320pub(crate) fn ensure_materialized_flags(flags: u32) -> io::Result<()> {
321 const SF_DATALESS: u32 = 0x4000_0000;
323 if flags & SF_DATALESS != 0 {
324 Err(unavailable_content())
325 } else {
326 Ok(())
327 }
328}
329
330fn ensure_content_metadata(metadata: &Metadata) -> io::Result<()> {
331 if !metadata.is_file() {
332 return Err(io::Error::new(
333 io::ErrorKind::InvalidInput,
334 "content source is not a regular file",
335 ));
336 }
337 #[cfg(target_os = "macos")]
338 {
339 use std::os::darwin::fs::MetadataExt;
340 ensure_materialized_flags(metadata.st_flags())?;
341 }
342 Ok(())
343}
344
345#[cfg(unix)]
347fn local_version(path: &Path) -> Result<ResourceVersion> {
348 use crate::pinned_dir::{EntryType, PinnedDir, entry_type, link_component, modified_ns};
349 let stat = PinnedDir::open_parent(path)
350 .and_then(|(parent, name)| parent.stat(name))
351 .map_err(|e| Error::io(path, e))?;
352 if entry_type(&stat) == EntryType::Symlink {
353 return Err(Error::io(path, link_component()));
354 }
355 Ok(ResourceVersion::file(
356 stat.st_size as u64,
357 modified_ns(&stat),
358 ))
359}
360
361#[cfg(not(unix))]
362fn local_version(path: &Path) -> Result<ResourceVersion> {
363 let meta = std::fs::metadata(path).map_err(|e| Error::io(path, e))?;
364 Ok(ResourceVersion::file(meta.len(), modified_ns(&meta)))
365}
366
367#[cfg(unix)]
373fn open_local_content(path: &Path) -> Result<File> {
374 use crate::pinned_dir::{EntryType, PinnedDir, entry_type, identity, link_component};
375 use std::os::unix::fs::MetadataExt;
376 if placeholder_marker(path) {
377 return Err(Error::io(path, unavailable_content()));
378 }
379 let (parent, name) = PinnedDir::open_parent(path).map_err(|e| Error::io(path, e))?;
380 let before = parent.stat(name).map_err(|e| Error::io(path, e))?;
381 match entry_type(&before) {
382 EntryType::File => {
383 crate::pinned_dir::materialized(&before).map_err(|e| Error::io(path, e))?
384 }
385 EntryType::Symlink => return Err(Error::io(path, link_component())),
386 _ => {
387 return Err(Error::io(
388 path,
389 io::Error::new(
390 io::ErrorKind::InvalidInput,
391 "content source is not a regular file",
392 ),
393 ));
394 }
395 }
396 let file = parent.open_file(name).map_err(|e| Error::io(path, e))?;
398 let actual = file.metadata().map_err(|e| Error::io(path, e))?;
399 ensure_content_metadata(&actual).map_err(|e| Error::io(path, e))?;
400 if identity(&before) != (actual.dev(), actual.ino()) {
401 return Err(Error::ChangedWhileReading {
402 resource: ResourceId::from_path(path)?,
403 });
404 }
405 Ok(file)
406}
407
408#[cfg(not(unix))]
410fn open_local_content(path: &Path) -> Result<File> {
411 if placeholder_marker(path) {
412 return Err(Error::io(path, unavailable_content()));
413 }
414 let resolved = path.canonicalize().map_err(|e| Error::io(path, e))?;
415 if placeholder_marker(&resolved) {
416 return Err(Error::io(path, unavailable_content()));
417 }
418 let before = std::fs::metadata(path).map_err(|e| Error::io(path, e))?;
419 ensure_content_metadata(&before).map_err(|e| Error::io(path, e))?;
420 let file = File::open(path).map_err(|e| Error::io(path, e))?;
421 let actual = file.metadata().map_err(|e| Error::io(path, e))?;
422 ensure_content_metadata(&actual).map_err(|e| Error::io(path, e))?;
423 Ok(file)
424}
425
426#[cfg(all(test, target_os = "macos"))]
427mod materialization_tests {
428 use super::*;
429 #[test]
430 fn darwin_dataless_flag_is_denied_without_manufacturing_a_cloud_file() {
431 assert!(ensure_materialized_flags(0x4000_0000).is_err());
432 assert!(ensure_materialized_flags(0x4000_8000).is_err());
433 assert!(ensure_materialized_flags(0).is_ok());
434 assert!(ensure_materialized_flags(0x8000).is_ok());
435 }
436}