Skip to main content

fxcp_core/
fxar_remote.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3
4//! Remote FXAR access  --  open an FXAR archive from a local path or S3 URI
5//! for in-place update via the tail-rewrite engine.
6
7use std::path::PathBuf;
8
9/// Strategy used when opening a remote or local FXAR for update.
10pub 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    /// Parse a URI or local path into a `FxarSource`.
24    ///
25    /// `s3://bucket/key/path.fxar` -> `S3` variant (requires `cloud` feature).
26    /// Anything else -> `Local` variant.
27    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
43/// Handle for modifying a (possibly remote) FXAR archive.
44///
45/// For S3 sources the archive is downloaded to a temp file; call [`commit`](Self::commit)
46/// to re-upload after modifications.
47pub struct FxarUpdateHandle {
48    /// Path to the local file (may be a temp file for S3 sources).
49    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    /// Open a local path for update. No I/O performed.
58    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    /// Download an S3 FXAR to a local temp file and return a handle.
68    #[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    /// Open an FXAR archive from a CAS store for delta-aware update.
106    ///
107    /// Downloads the archive to a local temp file. After modification
108    /// (e.g. `update_fxar_ai_sections`), call [`commit`](Self::commit)
109    /// to re-upload only changed chunks via delta storage.
110    #[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    /// Open from any URI (detects local vs S3 automatically).
139    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    /// Commit changes: for S3, re-upload the modified temp file.
154    /// For local files this is a no-op.
155    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}