Skip to main content

appcore_transfer/
store.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: store.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/10/06 00:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/10/06 00:00:00 by dnettoRaw
8//      ###########      S: 0.1.0-beta.1
9// =============================================================================
10
11//! Locked, quota-accounted durable blocks and immutable object publication.
12use crate::{
13    descriptor::{hex, valid_hash},
14    filesystem as io, ObjectDescriptor, TransferControl, TransferError as E, TransferLimits,
15    TransferResult,
16};
17use fs2::FileExt;
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use std::{
21    fs::{self, File},
22    io::{Read, Write},
23    path::{Path, PathBuf},
24    time::{Duration, SystemTime, UNIX_EPOCH},
25};
26/// One exclusive process-local store owner. The file lock is held until drop.
27/// Existing ancestors must be deployment-protected; Windows additionally requires
28/// a deployment-owned private ACL. Reparse points are rejected on all Windows paths.
29#[derive(Debug)]
30pub struct TransferStore {
31    root: PathBuf,
32    limits: TransferLimits,
33    _lock: File,
34    #[cfg(windows)]
35    _root_guard: appcore_security::PrivateDirectoryGuard,
36    used: u64,
37    files: usize,
38}
39#[derive(Serialize)]
40struct Metadata {
41    format_version: u16,
42    created_ms: u64,
43    descriptor: ObjectDescriptor,
44}
45#[derive(Deserialize)]
46#[serde(deny_unknown_fields)]
47struct MetadataWire {
48    format_version: u16,
49    created_ms: u64,
50    descriptor: crate::descriptor::DescriptorWire,
51}
52/// An immutable, fully hash-verified object. This is not an authenticated release.
53#[derive(Debug, Clone)]
54pub struct VerifiedObject {
55    path: PathBuf,
56    descriptor: ObjectDescriptor,
57}
58impl VerifiedObject {
59    /// Immutable content path; no automatic installation or activation.
60    pub fn path(&self) -> &Path {
61        &self.path
62    }
63    /// Verified immutable object descriptor.
64    pub fn descriptor(&self) -> &ObjectDescriptor {
65        &self.descriptor
66    }
67    /// Rechecks complete content before returning a fresh reader; callers own their I/O deadline.
68    pub fn open(&self) -> TransferResult<File> {
69        verify_complete(&self.path, &self.descriptor)?;
70        io::open(&self.path)
71    }
72}
73/// Exclusive mutable resumable state for one object.
74pub struct TransferSession<'a> {
75    store: &'a mut TransferStore,
76    dir: PathBuf,
77    descriptor: ObjectDescriptor,
78    complete: Vec<bool>,
79    #[cfg(windows)]
80    _directory_guard: appcore_security::PrivateDirectoryGuard,
81}
82impl TransferStore {
83    /// Opens an existing owner-protected root, holds a nonblocking cross-process lock,
84    /// and scans quota before admitting new work. Never creates arbitrary ancestors.
85    pub fn open(root: impl Into<PathBuf>, limits: TransferLimits) -> TransferResult<Self> {
86        limits.validate()?;
87        let root = root.into();
88        io::validate_root(&root)?;
89        #[cfg(windows)]
90        let root_guard =
91            appcore_security::create_update_private_directory(&root).map_err(|_| E::UnsafePath)?;
92        let path = root.join(".lock");
93        let lock = if path.try_exists().map_err(|_| E::Storage)? {
94            io::open(&path)?
95        } else {
96            io::exclusive(&path)?
97        };
98        lock.try_lock_exclusive().map_err(|_| E::Busy)?;
99        let mut value = Self {
100            root,
101            limits,
102            _lock: lock,
103            #[cfg(windows)]
104            _root_guard: root_guard,
105            used: 0,
106            files: 0,
107        };
108        value.recount()?;
109        Ok(value)
110    }
111    /// Starts/reopens only this exact authenticated-by-caller descriptor. Recovered
112    /// block hashes are revalidated. Corrupt metadata or blocks fail closed and stay
113    /// available for diagnostics; purge the partial explicitly before redownloading.
114    pub fn begin(&mut self, descriptor: ObjectDescriptor) -> TransferResult<TransferSession<'_>> {
115        descriptor.validate(self.limits)?;
116        let dir = self.root.join(descriptor.object_hash());
117        if !dir.try_exists().map_err(|_| E::Storage)? {
118            self.reserve(0, 1)?;
119            io::make_directory(&dir)?;
120            self.recount()?;
121        }
122        io::private_directory(&dir)?;
123        #[cfg(windows)]
124        let directory_guard =
125            appcore_security::create_update_private_directory(&dir).map_err(|_| E::UnsafePath)?;
126        let metadata_path = dir.join("descriptor.json");
127        if metadata_path.try_exists().map_err(|_| E::Storage)? {
128            let metadata = read_metadata(&metadata_path, self.limits)?;
129            if metadata.descriptor != descriptor {
130                return Err(E::Integrity);
131            }
132        } else {
133            for entry in fs::read_dir(&dir).map_err(|_| E::Storage)? {
134                let entry = entry.map_err(|_| E::Storage)?;
135                io::regular(&entry.path())?;
136                if entry.file_name() != "descriptor.tmp" {
137                    return Err(E::Integrity);
138                }
139                fs::remove_file(entry.path()).map_err(|_| E::Storage)?;
140            }
141            self.recount()?;
142            let metadata = Metadata {
143                format_version: 1,
144                created_ms: now_ms()?,
145                descriptor: descriptor.clone(),
146            };
147            let bytes = serde_json::to_vec(&metadata).map_err(|_| E::Storage)?;
148            self.reserve(bytes.len() as u64, 2)?;
149            io::write(&metadata_path, &bytes)?;
150            self.recount()?;
151        }
152        cleanup_temporaries(&dir, &descriptor)?;
153        self.recount()?;
154        let mut complete = vec![false; descriptor.block_count()];
155        let final_path = dir.join("complete.bin");
156        if final_path.try_exists().map_err(|_| E::Storage)? {
157            verify_complete(&final_path, &descriptor)?;
158            complete.fill(true);
159        } else {
160            for (index, done) in complete.iter_mut().enumerate() {
161                let path = block_path(&dir, index);
162                if path.try_exists().map_err(|_| E::Storage)? {
163                    let bytes = io::read(&path, descriptor.block_len(index)? as u64)?;
164                    descriptor.verify_block(index, &bytes)?;
165                    *done = true;
166                }
167            }
168        }
169        Ok(TransferSession {
170            store: self,
171            dir,
172            descriptor,
173            complete,
174            #[cfg(windows)]
175            _directory_guard: directory_guard,
176        })
177    }
178    /// Explicit TTL cleanup of incomplete objects only, under the root lock.
179    /// Unknown/corrupt metadata fails closed. Complete objects are never removed.
180    pub fn prune_expired(&mut self, ttl: Duration) -> TransferResult<usize> {
181        if ttl.is_zero() || ttl > Duration::from_secs(365 * 86400) {
182            return Err(E::InvalidContract);
183        }
184        let now = now_ms()?;
185        let mut removed = 0;
186        for entry in fs::read_dir(&self.root).map_err(|_| E::Storage)? {
187            let entry = entry.map_err(|_| E::Storage)?;
188            if entry.file_name() == ".lock" {
189                continue;
190            }
191            let dir = entry.path();
192            io::private_directory(&dir)?;
193            let m = read_metadata(&dir.join("descriptor.json"), self.limits)?;
194            if dir.file_name().and_then(|name| name.to_str()) != Some(m.descriptor.object_hash()) {
195                return Err(E::Integrity);
196            }
197            validate_inventory(&dir, &m.descriptor)?;
198            if !dir
199                .join("complete.bin")
200                .try_exists()
201                .map_err(|_| E::Storage)?
202                && now.saturating_sub(m.created_ms) >= ttl.as_millis() as u64
203            {
204                fs::remove_dir_all(&dir).map_err(|_| E::Storage)?;
205                io::sync_parent(&dir)?;
206                removed += 1;
207            }
208        }
209        self.recount()?;
210        Ok(removed)
211    }
212    /// Explicitly removes only a known incomplete object's private inventory.
213    /// A complete object, unknown file or invalid metadata prevents deletion.
214    pub fn purge_partial(&mut self, descriptor: &ObjectDescriptor) -> TransferResult<()> {
215        descriptor.validate(self.limits)?;
216        let dir = self.root.join(descriptor.object_hash());
217        io::private_directory(&dir)?;
218        if read_metadata(&dir.join("descriptor.json"), self.limits)?.descriptor != *descriptor {
219            return Err(E::Integrity);
220        }
221        if dir
222            .join("complete.bin")
223            .try_exists()
224            .map_err(|_| E::Storage)?
225        {
226            return Err(E::InvalidContract);
227        }
228        validate_inventory(&dir, descriptor)?;
229        fs::remove_dir_all(&dir).map_err(|_| E::Storage)?;
230        io::sync_parent(&dir)?;
231        self.recount()
232    }
233    fn reserve(&self, bytes: u64, files: usize) -> TransferResult<()> {
234        if self
235            .used
236            .checked_add(bytes)
237            .is_none_or(|n| n > self.limits.max_store_bytes)
238            || self
239                .files
240                .checked_add(files)
241                .is_none_or(|n| n > self.limits.max_files)
242        {
243            return Err(E::Limit);
244        }
245        Ok(())
246    }
247    fn recount(&mut self) -> TransferResult<()> {
248        let mut bytes = 0u64;
249        let mut count = 0usize;
250        for entry in fs::read_dir(&self.root).map_err(|_| E::Storage)? {
251            let entry = entry.map_err(|_| E::Storage)?;
252            count += 1;
253            if count > self.limits.max_files {
254                return Err(E::Limit);
255            }
256            if entry.file_name() == ".lock" {
257                bytes = bytes
258                    .checked_add(io::regular(&entry.path())?.len())
259                    .ok_or(E::Limit)?;
260                continue;
261            }
262            if !entry.file_name().to_str().is_some_and(valid_hash) {
263                return Err(E::UnsafePath);
264            }
265            io::private_directory(&entry.path())?;
266            for file in fs::read_dir(entry.path()).map_err(|_| E::Storage)? {
267                let file = file.map_err(|_| E::Storage)?;
268                count += 1;
269                if count > self.limits.max_files {
270                    return Err(E::Limit);
271                }
272                bytes = bytes
273                    .checked_add(io::regular(&file.path())?.len())
274                    .ok_or(E::Limit)?;
275                if bytes > self.limits.max_store_bytes {
276                    return Err(E::Limit);
277                }
278            }
279        }
280        if bytes > self.limits.max_store_bytes {
281            return Err(E::Limit);
282        }
283        self.used = bytes;
284        self.files = count;
285        Ok(())
286    }
287}
288impl TransferSession<'_> {
289    /// Exact admitted descriptor.
290    pub fn descriptor(&self) -> &ObjectDescriptor {
291        &self.descriptor
292    }
293    /// Snapshot of missing block indices; bounded by max_blocks.
294    pub fn missing(&self) -> Vec<usize> {
295        self.complete
296            .iter()
297            .enumerate()
298            .filter_map(|(i, done)| (!done).then_some(i))
299            .collect()
300    }
301    /// Verifies a block before exclusively staging, syncing and atomically publishing.
302    /// An identical persisted block is an idempotent no-op.
303    pub fn accept(&mut self, index: usize, bytes: &[u8]) -> TransferResult<()> {
304        self.descriptor.verify_block(index, bytes)?;
305        if self.complete[index] {
306            return Ok(());
307        }
308        self.store.reserve(bytes.len() as u64, 2)?;
309        let result = io::write(&block_path(&self.dir, index), bytes);
310        self.store.recount()?;
311        result?;
312        self.complete[index] = true;
313        Ok(())
314    }
315    /// Publishes only after every block and full-object digest match. Failure never
316    /// marks incomplete content as complete. A post-rename directory-sync error may
317    /// leave complete bytes visible; retry/reopen revalidates before returning them.
318    pub fn finish(self) -> TransferResult<VerifiedObject> {
319        self.finish_inner(None)
320    }
321    /// Cooperatively checks cancellation between bounded assembly/hash operations.
322    /// Filesystem calls still require deployment-owned I/O bounds.
323    pub fn finish_with_control(self, control: &TransferControl) -> TransferResult<VerifiedObject> {
324        self.finish_inner(Some(control))
325    }
326    fn finish_inner(self, control: Option<&TransferControl>) -> TransferResult<VerifiedObject> {
327        checkpoint(control)?;
328        if self.complete.iter().any(|done| !*done) {
329            return Err(E::Integrity);
330        }
331        let path = self.dir.join("complete.bin");
332        if !path.try_exists().map_err(|_| E::Storage)? {
333            self.store.reserve(self.descriptor.total_bytes(), 1)?;
334            let result = io::atomic(&path, |file| {
335                assemble(file, &self.dir, &self.descriptor, control)
336            });
337            self.store.recount()?;
338            result?;
339        }
340        verify_complete_controlled(&path, &self.descriptor, control)?;
341        for index in 0..self.descriptor.block_count() {
342            let block = block_path(&self.dir, index);
343            if block.try_exists().map_err(|_| E::Storage)? {
344                io::regular(&block)?;
345                fs::remove_file(block).map_err(|_| E::Storage)?;
346            }
347        }
348        io::sync_parent(&path)?;
349        self.store.recount()?;
350        Ok(VerifiedObject {
351            path,
352            descriptor: self.descriptor,
353        })
354    }
355}
356fn assemble(
357    file: &mut File,
358    dir: &Path,
359    descriptor: &ObjectDescriptor,
360    control: Option<&TransferControl>,
361) -> TransferResult<()> {
362    let mut full = Sha256::new();
363    for index in 0..descriptor.block_count() {
364        checkpoint(control)?;
365        let bytes = io::read(&block_path(dir, index), descriptor.block_len(index)? as u64)?;
366        descriptor.verify_block(index, &bytes)?;
367        file.write_all(&bytes).map_err(|_| E::Storage)?;
368        full.update(&bytes);
369    }
370    checkpoint(control)?;
371    if hex(&full.finalize()) != descriptor.object_hash() {
372        return Err(E::Integrity);
373    }
374    Ok(())
375}
376fn checkpoint(control: Option<&TransferControl>) -> TransferResult<()> {
377    control.map_or(Ok(()), TransferControl::checkpoint)
378}
379fn verify_complete(path: &Path, descriptor: &ObjectDescriptor) -> TransferResult<()> {
380    verify_complete_controlled(path, descriptor, None)
381}
382fn verify_complete_controlled(
383    path: &Path,
384    descriptor: &ObjectDescriptor,
385    control: Option<&TransferControl>,
386) -> TransferResult<()> {
387    let mut file = io::open(path)?;
388    if file.metadata().map_err(|_| E::Storage)?.len() != descriptor.total_bytes() {
389        return Err(E::Integrity);
390    }
391    let mut hasher = Sha256::new();
392    let mut buffer = [0; 64 * 1024];
393    loop {
394        checkpoint(control)?;
395        let n = file.read(&mut buffer).map_err(|_| E::Storage)?;
396        if n == 0 {
397            break;
398        }
399        hasher.update(&buffer[..n]);
400    }
401    if hex(&hasher.finalize()) != descriptor.object_hash() {
402        return Err(E::Integrity);
403    }
404    Ok(())
405}
406fn block_path(dir: &Path, index: usize) -> PathBuf {
407    dir.join(format!("{index:04}.block"))
408}
409fn read_metadata(path: &Path, limits: TransferLimits) -> TransferResult<Metadata> {
410    let bytes = io::read(path, 1024 * 1024)?;
411    let wire: MetadataWire = serde_json::from_slice(&bytes).map_err(|_| E::Integrity)?;
412    if wire.format_version != 1 {
413        return Err(E::UnsupportedVersion);
414    }
415    let m = Metadata {
416        format_version: wire.format_version,
417        created_ms: wire.created_ms,
418        descriptor: ObjectDescriptor::try_from(wire.descriptor)?,
419    };
420    if m.format_version != 1 {
421        return Err(E::UnsupportedVersion);
422    }
423    m.descriptor.validate(limits)?;
424    Ok(m)
425}
426fn validate_inventory(dir: &Path, descriptor: &ObjectDescriptor) -> TransferResult<()> {
427    for entry in fs::read_dir(dir).map_err(|_| E::Storage)? {
428        let entry = entry.map_err(|_| E::Storage)?;
429        io::regular(&entry.path())?;
430        let name = entry.file_name();
431        let name = name.to_str().ok_or(E::UnsafePath)?;
432        let valid = name == "descriptor.json"
433            || name == "descriptor.tmp"
434            || name == "complete.bin"
435            || name == "complete.tmp"
436            || (0..descriptor.block_count())
437                .any(|i| name == format!("{i:04}.block") || name == format!("{i:04}.tmp"));
438        if !valid {
439            return Err(E::UnsafePath);
440        }
441    }
442    Ok(())
443}
444fn cleanup_temporaries(dir: &Path, descriptor: &ObjectDescriptor) -> TransferResult<()> {
445    validate_inventory(dir, descriptor)?;
446    for entry in fs::read_dir(dir).map_err(|_| E::Storage)? {
447        let entry = entry.map_err(|_| E::Storage)?;
448        if entry.path().extension().is_some_and(|e| e == "tmp") {
449            fs::remove_file(entry.path()).map_err(|_| E::Storage)?;
450        }
451    }
452    io::sync_parent(&dir.join("descriptor.json"))
453}
454fn now_ms() -> TransferResult<u64> {
455    u64::try_from(
456        SystemTime::now()
457            .duration_since(UNIX_EPOCH)
458            .map_err(|_| E::Storage)?
459            .as_millis(),
460    )
461    .map_err(|_| E::Storage)
462}