Skip to main content

harmont_cli/orchestrator/
archive.rs

1//! Per-run source archive store.
2//!
3//! On build start the orchestrator tar.gzs the user's working
4//! directory once (via [`crate::orchestrator::source::build_archive_bytes`])
5//! and registers the bytes under an opaque `ArchiveId`. Step-executor
6//! plugins receive that ID in their `ExecutorInput` and pull bytes
7//! via `hm_archive_read`. The host caches archives in memory keyed
8//! by ID for the duration of a single `orchestrator::run` invocation.
9
10use std::collections::HashMap;
11use std::sync::Mutex;
12
13use hm_plugin_protocol::ArchiveId;
14use uuid::Uuid;
15
16#[derive(Debug, Default)]
17pub struct ArchiveStore {
18    archives: Mutex<HashMap<ArchiveId, Vec<u8>>>,
19}
20
21impl ArchiveStore {
22    #[must_use]
23    pub fn new() -> Self {
24        Self::default()
25    }
26
27    /// Register a new archive. Returns the freshly-minted ID.
28    pub fn register(&self, bytes: Vec<u8>) -> ArchiveId {
29        let id = ArchiveId(Uuid::new_v4());
30        let _ = self.archives.lock().map(|mut m| m.insert(id, bytes));
31        id
32    }
33
34    /// Total size of the archive identified by `id`, or `0` if no
35    /// such archive is registered.
36    #[must_use]
37    pub fn total_size(&self, id: ArchiveId) -> u64 {
38        self.archives
39            .lock()
40            .ok()
41            .and_then(|m| m.get(&id).map(|b| b.len() as u64))
42            .unwrap_or(0)
43    }
44
45    /// Read up to `max` bytes from offset `offset`. Returns empty
46    /// when offset is beyond end, or when the archive is unknown.
47    #[must_use]
48    pub fn read(&self, id: ArchiveId, offset: u64, max: u64) -> Vec<u8> {
49        // We must hold the lock guard across the read so the bytes
50        // slice we copy out is consistent. `let...else` here would
51        // require holding `bytes` across the early-return branch.
52        let Ok(g) = self.archives.lock() else {
53            return Vec::new();
54        };
55        let Some(bytes) = g.get(&id) else {
56            return Vec::new();
57        };
58        // Archive sizes fit in `usize` on 64-bit hosts (the only
59        // supported targets); local-mode archives are at most a few
60        // hundred MB. The cast cannot truncate in practice.
61        #[allow(
62            clippy::cast_possible_truncation,
63            reason = "archive sizes fit in usize on supported 64-bit hosts"
64        )]
65        let start = (offset as usize).min(bytes.len());
66        #[allow(
67            clippy::cast_possible_truncation,
68            reason = "archive sizes fit in usize on supported 64-bit hosts"
69        )]
70        let max_us = max as usize;
71        let end = start.saturating_add(max_us).min(bytes.len());
72        bytes[start..end].to_vec()
73    }
74}
75
76#[cfg(test)]
77#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn register_then_read_round_trip() {
83        let s = ArchiveStore::new();
84        let id = s.register(b"hello world".to_vec());
85        assert_eq!(s.total_size(id), 11);
86        assert_eq!(s.read(id, 0, 5), b"hello");
87        assert_eq!(s.read(id, 6, 5), b"world");
88        assert_eq!(s.read(id, 100, 5), Vec::<u8>::new());
89    }
90
91    #[test]
92    fn unknown_id_returns_empty() {
93        let s = ArchiveStore::new();
94        let bogus = ArchiveId(Uuid::new_v4());
95        assert_eq!(s.total_size(bogus), 0);
96        assert_eq!(s.read(bogus, 0, 100), Vec::<u8>::new());
97    }
98}