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