Skip to content

Commit c466765

Browse files
committed
feat(projfs): implement fetch_contents via ContentProvider with disk cache
Adds a cache_dir field to ProjFsPresenter (defaulting to the system temp directory) and implements fetch_contents to: - Return a cached file immediately if it exists on disk - Bail with a clear error when no ContentProvider is installed - Read the full file from the provider in 1 MiB chunks - Write to a .tmp file, then atomically rename to the cache path Builder method with_cache_dir, accessor cache_dir(), and private helper cache_path_for are added to support configuration and testing. The existing 'not yet implemented' test is replaced with a provider-missing assertion, and a new test exercises the full read-cache-round-trip with a MockContentProvider.
1 parent da7367c commit c466765

2 files changed

Lines changed: 133 additions & 11 deletions

File tree

crates/presenter-projfs/src/lib.rs

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,10 @@
4949
//! no equivalent of `FSKit`'s `update_state` push hook.
5050
//! - [`ProjFsPresenter::evict_item`] logs and returns `Ok(())`; `ProjFS`
5151
//! manages projection cache eviction at the OS layer.
52-
//! - [`ProjFsPresenter::fetch_contents`] intentionally bails: on-demand
53-
//! reads flow through the `GetFileData` callback, not through direct
54-
//! calls into this method, so there is nothing for it to do.
52+
//! - [`ProjFsPresenter::fetch_contents`] fetches file contents through
53+
//! the installed [`ContentProvider`] and caches them on disk. When no
54+
//! provider is installed, the method bails: on-demand reads must then
55+
//! flow through the `GetFileData` callback instead.
5556
//! - [`ProjFsPresenter::start`] marks the mount directory as a
5657
//! placeholder via `PrjMarkDirectoryAsPlaceholder` and begins
5758
//! virtualising via `PrjStartVirtualizing`.
@@ -839,6 +840,10 @@ pub struct ProjFsPresenter {
839840
/// `CallbackContextInner` so both callbacks see the same map.
840841
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
841842
cancellation_tokens: Arc<Mutex<HashMap<i32, CancellationToken>>>,
843+
/// Directory where `fetch_contents` caches downloaded files. Each
844+
/// file is named after its `ItemId` (with path separators replaced
845+
/// by `_`), written to a `.tmp` file first, then atomically renamed.
846+
cache_dir: PathBuf,
842847
}
843848

844849
/// Owning record of the heap allocation handed to `ProjFS` via
@@ -921,6 +926,7 @@ impl ProjFsPresenter {
921926
callback_ctx: Arc::new(tokio::sync::Mutex::new(None)),
922927
content_provider: None,
923928
cancellation_tokens: Arc::new(Mutex::new(HashMap::new())),
929+
cache_dir: std::env::temp_dir().join("cascade-projfs-cache"),
924930
}
925931
}
926932

@@ -962,12 +968,31 @@ impl ProjFsPresenter {
962968
self
963969
}
964970

971+
/// Override the cache directory used by [`Self::fetch_contents`].
972+
#[must_use]
973+
pub fn with_cache_dir(mut self, dir: impl Into<PathBuf>) -> Self {
974+
self.cache_dir = dir.into();
975+
self
976+
}
977+
965978
/// The configured mount point.
966979
#[must_use]
967980
pub fn mount_point(&self) -> &Path {
968981
&self.mount_point
969982
}
970983

984+
/// The configured cache directory.
985+
#[must_use]
986+
pub fn cache_dir(&self) -> &Path {
987+
&self.cache_dir
988+
}
989+
990+
/// Derive a cache file path for an item. Path separators in the id
991+
/// are replaced with `_` so the result is a flat filename.
992+
fn cache_path_for(&self, id: &ItemId) -> PathBuf {
993+
self.cache_dir.join(id.0.replace(['/', '\\'], "_"))
994+
}
995+
971996
/// Access the configured content provider, if any. Exposed for
972997
/// tests and the (future) consistency checks that want to confirm
973998
/// the presenter was built with one before `start()`.
@@ -1015,10 +1040,55 @@ impl VfsPresenter for ProjFsPresenter {
10151040
}
10161041

10171042
async fn fetch_contents(&self, id: &ItemId) -> anyhow::Result<PathBuf> {
1018-
tracing::debug!(id = %id, "fetch_contents (not yet implemented)");
1019-
anyhow::bail!(
1020-
"ProjFS fetch_contents is not yet implemented; the GetFileData callback should drive this"
1021-
)
1043+
let cache_path = self.cache_path_for(id);
1044+
if cache_path.exists() {
1045+
return Ok(cache_path);
1046+
}
1047+
1048+
let Some(provider) = &self.content_provider else {
1049+
anyhow::bail!(
1050+
"no ContentProvider installed; ProjFS reads flow through \
1051+
GetFileData callbacks, not fetch_contents"
1052+
);
1053+
};
1054+
1055+
let file_size = {
1056+
let items = self.items.read().await;
1057+
let item = items
1058+
.get(&id.0)
1059+
.ok_or_else(|| anyhow::anyhow!("item not found: {id}"))?;
1060+
item.size.unwrap_or(0)
1061+
};
1062+
1063+
tokio::fs::create_dir_all(&self.cache_dir).await?;
1064+
1065+
let mut all_bytes = Vec::new();
1066+
let mut offset = 0u64;
1067+
const READ_CHUNK: u32 = 1024 * 1024;
1068+
loop {
1069+
let remaining = file_size.saturating_sub(offset);
1070+
let to_read = if remaining == 0 {
1071+
READ_CHUNK
1072+
} else {
1073+
u32::try_from(remaining)
1074+
.unwrap_or(READ_CHUNK)
1075+
.min(READ_CHUNK)
1076+
};
1077+
let chunk = provider.read_range(id, offset, to_read)?;
1078+
if chunk.is_empty() {
1079+
break;
1080+
}
1081+
offset += chunk.len() as u64;
1082+
all_bytes.extend_from_slice(&chunk);
1083+
if remaining > 0 && chunk.len() < to_read as usize {
1084+
break;
1085+
}
1086+
}
1087+
1088+
let temp_path = cache_path.with_extension("tmp");
1089+
tokio::fs::write(&temp_path, &all_bytes).await?;
1090+
tokio::fs::rename(&temp_path, &cache_path).await?;
1091+
Ok(cache_path)
10221092
}
10231093

10241094
async fn evict_item(&self, id: &ItemId) -> anyhow::Result<()> {

crates/presenter-projfs/src/lib_tests.rs

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,17 @@ async fn stop_is_noop_on_non_windows() {
7474
presenter.stop().await.unwrap();
7575
}
7676

77-
/// `fetch_contents` is documented as unimplemented — the real work
78-
/// will live in the `GetFileData` callback.
77+
/// `fetch_contents` without a `ContentProvider` bails with a clear
78+
/// message directing callers to the `GetFileData` callback path.
7979
#[tokio::test]
80-
async fn fetch_contents_returns_unimplemented_error() {
80+
async fn fetch_contents_bails_without_content_provider() {
8181
let presenter = ProjFsPresenter::new(PathBuf::from("/tmp/cascade-projfs-test"));
8282
let id = ItemId::new("backend", "file");
8383
let err = presenter.fetch_contents(&id).await.unwrap_err();
84-
assert!(err.to_string().contains("not yet implemented"));
84+
assert!(
85+
err.to_string().contains("no ContentProvider installed"),
86+
"expected provider-missing error, got: {err}"
87+
);
8588
}
8689

8790
/// `update_state` and `evict_item` are intentional no-ops on
@@ -415,6 +418,55 @@ async fn with_content_provider_round_trips() {
415418
assert!(Arc::ptr_eq(installed, &provider));
416419
}
417420

421+
/// `fetch_contents` reads the full file through the `ContentProvider`,
422+
/// writes it to the cache directory, and returns the cache path. A
423+
/// second call for the same id returns the cached file without
424+
/// consulting the provider again.
425+
#[tokio::test]
426+
async fn fetch_contents_reads_via_provider_and_caches() {
427+
let cache_dir = std::env::temp_dir().join("cascade-projfs-test-fetch-contents");
428+
let _ = tokio::fs::remove_dir_all(&cache_dir).await;
429+
430+
let id = ItemId::new("backend", "file");
431+
let provider = MockContentProvider::default();
432+
let data = b"hello, projfs world!".to_vec();
433+
provider.insert(id.clone(), data.clone());
434+
435+
let presenter = ProjFsPresenter::new(PathBuf::from("/tmp/cascade-projfs-test"))
436+
.with_content_provider(Arc::new(provider))
437+
.with_cache_dir(&cache_dir);
438+
439+
// Upsert the item so `fetch_contents` can resolve its size.
440+
presenter
441+
.upsert_item(VfsItem {
442+
id: id.clone(),
443+
parent_id: ItemId::new("backend", "root"),
444+
name: "file.txt".to_string(),
445+
path: "file.txt".to_string(),
446+
is_dir: false,
447+
size: Some(data.len() as u64),
448+
mod_time: None,
449+
cache_state: CacheState::Online,
450+
mime_type: None,
451+
})
452+
.await
453+
.unwrap();
454+
455+
let path = presenter.fetch_contents(&id).await.unwrap();
456+
assert!(path.exists(), "cached file should exist on disk");
457+
let bytes = tokio::fs::read(&path).await.unwrap();
458+
assert_eq!(bytes, data);
459+
460+
// The cache path follows the id-slug convention.
461+
let expected_name = id.0.replace(['/', '\\'], "_");
462+
assert_eq!(
463+
path.file_name().unwrap().to_str().unwrap(),
464+
expected_name
465+
);
466+
467+
let _ = tokio::fs::remove_dir_all(&cache_dir).await;
468+
}
469+
418470
/// Every `PRJ_NOTIFICATION_*` flag maps to a distinct
419471
/// `NotificationEvent`, the destination is threaded through for
420472
/// the rename/hardlink variants, and unknown codes yield `None`.

0 commit comments

Comments
 (0)