1use std::path::PathBuf;
8
9pub enum FxarSource {
11 Local(PathBuf),
12 #[cfg(feature = "cloud")]
13 S3 { bucket: String, key: String },
14 #[cfg(feature = "cloud")]
15 Cas {
16 store: std::sync::Arc<tokio::sync::Mutex<crate::s3_cas::S3CasStore>>,
17 archive_name: String,
18 local_copy: tempfile::NamedTempFile,
19 },
20}
21
22impl FxarSource {
23 pub fn from_uri(uri: &str) -> Self {
28 if uri.starts_with("s3://") {
29 #[cfg(feature = "cloud")]
30 {
31 let rest = &uri["s3://".len()..];
32 if let Some(slash) = rest.find('/') {
33 let bucket = rest[..slash].to_string();
34 let key = rest[slash + 1..].to_string();
35 return FxarSource::S3 { bucket, key };
36 }
37 }
38 }
39 FxarSource::Local(PathBuf::from(uri))
40 }
41}
42
43pub struct FxarUpdateHandle {
48 pub local_path: PathBuf,
50 #[cfg_attr(not(feature = "cloud"), allow(dead_code))]
51 source: FxarSource,
52 #[cfg(feature = "cloud")]
53 _temp: Option<tempfile::NamedTempFile>,
54}
55
56impl FxarUpdateHandle {
57 pub fn local(path: impl Into<PathBuf>) -> Self {
59 Self {
60 local_path: path.into(),
61 source: FxarSource::Local(PathBuf::new()),
62 #[cfg(feature = "cloud")]
63 _temp: None,
64 }
65 }
66
67 #[cfg(feature = "cloud")]
69 pub async fn from_s3(bucket: &str, key: &str) -> std::io::Result<Self> {
70 use object_store::aws::AmazonS3Builder;
71 use object_store::{ObjectStore, path::Path as ObjPath};
72 use std::io::Write;
73
74 let store = AmazonS3Builder::from_env()
75 .with_bucket_name(bucket)
76 .build()
77 .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
78
79 let obj_path = ObjPath::from(key);
80 let result = store
81 .get(&obj_path)
82 .await
83 .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
84
85 let bytes = result
86 .bytes()
87 .await
88 .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
89
90 let mut tmp = tempfile::NamedTempFile::new()?;
91 tmp.write_all(&bytes)?;
92 tmp.flush()?;
93
94 let local_path = tmp.path().to_path_buf();
95 Ok(Self {
96 local_path,
97 source: FxarSource::S3 {
98 bucket: bucket.to_string(),
99 key: key.to_string(),
100 },
101 _temp: Some(tmp),
102 })
103 }
104
105 #[cfg(feature = "cloud")]
111 pub async fn open_cas(
112 store: crate::s3_cas::S3CasStore,
113 archive_name: &str,
114 ) -> std::io::Result<Self> {
115 use std::io::Write;
116
117 let fxar_bytes = store
118 .read_fxar_from_cas(archive_name)
119 .await
120 .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
121
122 let mut tmp = tempfile::NamedTempFile::new()?;
123 tmp.write_all(&fxar_bytes)?;
124 tmp.flush()?;
125
126 let local_path = tmp.path().to_path_buf();
127 Ok(Self {
128 local_path,
129 source: FxarSource::Cas {
130 store: std::sync::Arc::new(tokio::sync::Mutex::new(store)),
131 archive_name: archive_name.to_string(),
132 local_copy: tmp,
133 },
134 _temp: None,
135 })
136 }
137
138 pub async fn open(uri: &str) -> std::io::Result<Self> {
140 let source = FxarSource::from_uri(uri);
141 match source {
142 FxarSource::Local(path) => Ok(Self::local(path)),
143 #[cfg(feature = "cloud")]
144 FxarSource::S3 {
145 ref bucket,
146 ref key,
147 } => Self::from_s3(bucket, key).await,
148 #[cfg(feature = "cloud")]
149 FxarSource::Cas { .. } => unreachable!("Cas variant is not produced by from_uri"),
150 }
151 }
152
153 pub async fn commit(self) -> std::io::Result<()> {
156 #[cfg(feature = "cloud")]
157 {
158 if let FxarSource::S3 { ref bucket, ref key } = self.source {
159 use object_store::aws::AmazonS3Builder;
160 use object_store::{ObjectStore, PutPayload, path::Path as ObjPath};
161
162 let store = AmazonS3Builder::from_env()
163 .with_bucket_name(bucket)
164 .build()
165 .map_err(|e| {
166 std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
167 })?;
168
169 let data = std::fs::read(&self.local_path)?;
170 let obj_path = ObjPath::from(key.as_str());
171 store
172 .put(&obj_path, PutPayload::from_bytes(data.into()))
173 .await
174 .map_err(|e| {
175 std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
176 })?;
177 }
178 if let FxarSource::Cas {
179 ref store,
180 ref archive_name,
181 ..
182 } = self.source
183 {
184 let data = std::fs::read(&self.local_path)?;
185 let mut store_lock = store.lock().await;
186 store_lock
187 .delta_store_file(&format!("__fxar__/{archive_name}"), &data)
188 .await
189 .map_err(|e| {
190 std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
191 })?;
192 store_lock
193 .flush()
194 .await
195 .map_err(|e| {
196 std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
197 })?;
198 }
199 }
200 Ok(())
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
207 use super::*;
208
209 #[test]
210 fn test_from_uri_local() {
211 let src = FxarSource::from_uri("/tmp/archive.fxar");
212 assert!(matches!(src, FxarSource::Local(_)));
213 }
214
215 #[test]
216 fn test_from_uri_s3() {
217 let src = FxarSource::from_uri("s3://my-bucket/path/to/archive.fxar");
218 #[cfg(feature = "cloud")]
219 assert!(matches!(src, FxarSource::S3 { .. }));
220 #[cfg(not(feature = "cloud"))]
221 assert!(matches!(src, FxarSource::Local(_)));
222 }
223
224 #[test]
225 fn test_local_handle_path() {
226 let handle = FxarUpdateHandle::local("/tmp/test.fxar");
227 assert_eq!(handle.local_path, std::path::Path::new("/tmp/test.fxar"));
228 }
229
230 #[tokio::test]
231 async fn test_commit_local_noop() {
232 let handle = FxarUpdateHandle::local("/tmp/nonexistent_commit_test.fxar");
233 assert!(handle.commit().await.is_ok());
234 }
235
236 #[tokio::test]
237 async fn test_fxar_remote_local_roundtrip() {
238 use crate::fxar::{
239 update_fxar_ai_sections, write_archive_seekable, FxarReader,
240 FXAR_EXT_HNSW,
241 };
242 use crate::version_store::VersionStore;
243
244 let dir = tempfile::TempDir::new().unwrap();
245 let vs_root = dir.path().join(".foxing_versions");
246 let snap_dir = vs_root.join("2026-04-29T000000");
247 let tree_dir = snap_dir.join("tree");
248 std::fs::create_dir_all(&tree_dir).unwrap();
249 std::fs::write(tree_dir.join("data.bin"), b"binary payload").unwrap();
250 let summary = serde_json::json!({
251 "timestamp": "2026-04-29T00:00:00Z", "status": "success", "type": "full",
252 "source": "/test", "trigger": "test", "files": 1, "size_bytes": 14,
253 "disk_usage_bytes": 14, "savings_pct": 0.0, "elapsed_ms": 1
254 });
255 std::fs::write(
256 snap_dir.join("summary.json"),
257 serde_json::to_string(&summary).unwrap(),
258 )
259 .unwrap();
260
261 let store = VersionStore::open(dir.path());
262 let archive_path = dir.path().join("remote_test.fxar");
263 let file = std::fs::File::create(&archive_path).unwrap();
264 write_archive_seekable(&store, file, "none", None).unwrap();
265
266 let handle = FxarUpdateHandle::open(&archive_path.to_string_lossy())
267 .await
268 .unwrap();
269 assert_eq!(handle.local_path, archive_path);
270
271 update_fxar_ai_sections(
272 &handle.local_path,
273 &[(FXAR_EXT_HNSW, b"test_hnsw_data".to_vec())],
274 )
275 .unwrap();
276
277 handle.commit().await.unwrap();
278
279 let f = std::fs::File::open(&archive_path).unwrap();
280 let mut reader = FxarReader::open(f).unwrap();
281 let hnsw = reader.read_extension_section(FXAR_EXT_HNSW).unwrap();
282 assert_eq!(hnsw.unwrap(), b"test_hnsw_data");
283 }
284}
285
286#[cfg(all(test, feature = "cloud"))]
287mod cas_tests {
288 use super::*;
289 use crate::chunker::GearChunker;
290 use crate::s3_cas::S3CasStore;
291 use object_store::memory::InMemory;
292 use std::sync::Arc;
293
294 fn test_chunker() -> GearChunker {
295 GearChunker::new(64, 256, 1024).unwrap()
296 }
297
298 fn test_data(size: usize) -> Vec<u8> {
299 let mut data = Vec::with_capacity(size);
300 let mut state: u32 = 0xDEAD_BEEF;
301 for _ in 0..size {
302 state = state.wrapping_mul(1103515245).wrapping_add(12345);
303 data.push((state >> 16) as u8);
304 }
305 data
306 }
307
308 #[tokio::test]
309 async fn test_open_cas_downloads_fxar() {
310 let backend: Arc<dyn object_store::ObjectStore> = Arc::new(InMemory::new());
311 let mut store = S3CasStore::new(Arc::clone(&backend), "test", test_chunker());
312
313 let fxar_data = test_data(2048);
314 store
315 .store_fxar_as_cas("download.fxar", &fxar_data)
316 .await
317 .unwrap();
318 store.flush().await.unwrap();
319
320 let handle = FxarUpdateHandle::open_cas(store, "download.fxar")
321 .await
322 .unwrap();
323
324 let temp_bytes = std::fs::read(&handle.local_path).unwrap();
325 assert_eq!(temp_bytes, fxar_data);
326 }
327
328 #[tokio::test]
329 async fn test_cas_commit_round_trip() {
330 let backend: Arc<dyn object_store::ObjectStore> = Arc::new(InMemory::new());
331 let mut store = S3CasStore::new(Arc::clone(&backend), "rt", test_chunker());
332
333 let original_data = test_data(4096);
334 store
335 .store_fxar_as_cas("roundtrip.fxar", &original_data)
336 .await
337 .unwrap();
338 store.flush().await.unwrap();
339
340 let handle = FxarUpdateHandle::open_cas(store, "roundtrip.fxar")
341 .await
342 .unwrap();
343 handle.commit().await.unwrap();
344
345 let store2 = S3CasStore::open(Arc::clone(&backend), "rt", test_chunker())
346 .await
347 .unwrap();
348 let restored = store2.read_fxar_from_cas("roundtrip.fxar").await.unwrap();
349 assert_eq!(restored, original_data);
350 }
351
352 #[tokio::test]
353 async fn test_existing_local_commit_unchanged() {
354 let dir = tempfile::TempDir::new().unwrap();
355 let file_path = dir.path().join("local.fxar");
356 std::fs::write(&file_path, b"local data").unwrap();
357
358 let handle = FxarUpdateHandle::local(&file_path);
359 assert_eq!(handle.local_path, file_path);
360 handle.commit().await.unwrap();
361
362 let data = std::fs::read(&file_path).unwrap();
363 assert_eq!(data, b"local data");
364 }
365}