Skip to main content

seyk_core/
provider.rs

1//! Resource access. Filesystem is the only supported production provider.
2
3#[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/// Opaque change token. Equal known tokens let the warm pass skip reading.
15/// Filesystem tokens are cheap change hints, not proof of unchanged bytes.
16/// Unknown tokens force a reread and hash check.
17///
18/// Unstable: may change before 1.0; filesystem is the only supported provider.
19#[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    /// Build `fs:{size}:{mtime}` as a cheap change hint, not a content proof.
37    /// An equal-size rewrite with a restored mtime can skip the warm-pass read,
38    /// leaving stale vectors and keyword rows. Extraction still checks the
39    /// content hash and refuses changed text.
40    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/// Scope for enumeration and deletion checks. Filters retain filesystem semantics.
48/// Roots identify a resource or hierarchical URI/path descendants for deletion.
49/// DiscoveryOptions remains a filesystem-specific boundary leak.
50///
51/// Unstable: may change before 1.0; filesystem is the only supported provider.
52#[derive(Debug, Clone)]
53pub struct ResourceScope {
54    pub roots: Vec<ResourceId>,
55    pub discovery: DiscoveryOptions,
56}
57
58impl ResourceScope {
59    /// Check hierarchical root boundaries without filesystem access.
60    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            // Retain the caller's directory names (e.g. a .docker symlink).
86            // Discovery checks logical children before emitting canonical IDs.
87            let root = PolicyRoot::new(p)?;
88            roots.push(ResourceId::from_path(root.uri_path())?);
89        }
90        Ok(Self { roots, discovery })
91    }
92}
93
94/// A resource available for indexing, without loading its contents.
95/// `kind` currently comes from filesystem discovery; ResourceKind/TextFormat
96/// should move into shared content types at the crate split.
97///
98/// Unstable: may change before 1.0; filesystem is the only supported provider.
99#[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    /// Title prefix supplied to the embedder (the file stem for local files).
107    pub title: Option<String>,
108}
109
110impl ProvidedResource {
111    /// Hash every descriptor field that influences preparation, independently of bytes.
112    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
118/// Seekable encoded source; verification is the audio reader's responsibility.
119pub trait ReadSeek: std::io::Read + std::io::Seek + Send + Sync {}
120impl<T: std::io::Read + std::io::Seek + Send + Sync> ReadSeek for T {}
121
122/// Enumerate, read and check freshness without coupling the engine to local files.
123///
124/// Unstable: may change before 1.0; filesystem is the only supported provider.
125pub trait ResourceProvider: Send + Sync {
126    /// Seekable source for bounded audio reads; no whole-file fallback.
127    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    /// Return resources in deterministic order, without duplicate identities.
134    fn list(&self, scope: &ResourceScope) -> Result<Vec<ProvidedResource>>;
135    /// `list`, plus the resolved root identity each scope root was walked under
136    /// when the provider can prove it. The default proves nothing.
137    fn list_walked(&self, scope: &ResourceScope) -> Result<ResourceListing> {
138        Ok(ResourceListing {
139            resources: self.list(scope)?,
140            walked_roots: None,
141        })
142    }
143    /// Opt in to authorizing indexed identities without discovering uncached descendants.
144    /// Other providers retain their normal listing behavior in cached-only searches.
145    fn supports_cached_listing(&self) -> bool {
146        false
147    }
148    /// Current eligibility for supplied cached identities, including scope and policy.
149    /// Called only after `supports_cached_listing` opts in; no content may be read here.
150    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    /// Return a change token consistent with the token supplied by `list`.
159    /// Equal known tokens let the warm pass skip reading when preparation is
160    /// unchanged. Filesystem size and mtime are cheap change hints, not proof
161    /// of unchanged bytes: an equal-size rewrite with a restored mtime can
162    /// leave stale vectors and keyword rows. Extraction still checks the
163    /// content hash and refuses changed text. Use unknown tokens in both
164    /// `list` and `version` to force a reread and hash check.
165    fn version(&self, id: &ResourceId) -> Result<ResourceVersion>;
166    /// Resolve deletion roots once per pass, before filtering cached identities.
167    /// An error retains entries and is reported as skipped work by the engine.
168    fn deletion_scope(&self, scope: &ResourceScope) -> Result<ResourceScope> {
169        Ok(scope.clone())
170    }
171    /// True only for confirmed deletion inside scope. Errors and exclusions
172    /// must not count as deletion. Pass the scope returned by `deletion_scope`.
173    /// The conservative default retains entries.
174    fn is_deleted(&self, _id: &ResourceId, _scope: &ResourceScope) -> Result<bool> {
175        Ok(false)
176    }
177}
178
179/// A provider listing and, when known, the roots that listing actually walked.
180#[derive(Debug, Clone, Default)]
181pub struct ResourceListing {
182    pub resources: Vec<ProvidedResource>,
183    /// Resolved once per walked scope root, before its walk, in scope order.
184    /// `None` when the provider cannot attribute its listing to resolved roots.
185    pub walked_roots: Option<Vec<ResourceId>>,
186}
187
188/// Local files, using the same discovery filters and no-follow walk as `discover`.
189///
190/// On Unix, `read`, `open` and `version` reach a resource from "/" one
191/// component at a time without following any symbolic link, so swapping an
192/// ancestor for a link cannot redirect them outside the tree that discovery
193/// walked. Resource ids must therefore be canonical paths, as discovery and
194/// `SearchEngine` produce. An id with a link component (an explicit symlink,
195/// or an alias such as /tmp or /var on macOS) fails closed with an error that
196/// says so; canonicalize such a path before building its id. On Windows these
197/// methods remain path-based and are not pinned.
198#[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                // Both roots and children are compared through the file-URI codec,
281                // normalizing Windows verbatim prefixes without statting a missing child.
282                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    // Darwin sys/stat.h: read-only synthetic SF_DATALESS, not a settable user flag.
322    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/// Same token as discovery: size and modification time of the file itself.
346#[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/// Check again for each content open; discovery/version metadata is never permission to read.
368/// This rejects observed placeholders before open and checks the actual descriptor afterward.
369/// A provider can still change materialization between check and open: this is not atomic
370/// protection against every hydration race. The path is opened from "/" without following
371/// any link (see [`FsProvider`]), so it cannot leave the canonical tree it names.
372#[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    // O_NONBLOCK: a substitution with a FIFO must not block before validation.
397    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/// Path-based on Windows: ordinary explicit symlinks are followed.
409#[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}