a3s-box-runtime 2.4.0

MicroVM runtime engine — VM lifecycle, OCI images, attestation, networking
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
//! Layer-level build cache for the image build engine.
//!
//! Implements a Docker/BuildKit-style cache keyed on a running "chain key":
//! a rolling hash of every instruction (and, for COPY/ADD, the content of the
//! source files) executed so far. When a layer-producing instruction
//! (RUN/COPY/ADD) is reached with a chain key that has been seen before, the
//! previously produced layer is reused instead of re-executing the instruction.
//!
//! The cache lives under `~/.a3s/buildcache`:
//! - `blobs/<digest>`  — the cached layer tar.gz, content-addressed.
//! - `keys/<chain-key>` — a small JSON record `{digest, diff_id, size}` pointing
//!   at the blob.
//!
//! All cache I/O is best-effort: any failure leaves the build uncached but does
//! NOT fail the build.

use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};

use a3s_box_core::dirs_home;
use serde::{Deserialize, Serialize};

use super::layer::{sha256_bytes, LayerInfo};

/// Per-process counter for unique staging-file names in `store`.
static STORE_SEQ: AtomicU64 = AtomicU64::new(0);

/// Default cap on the total size of cached layer blobs (2 GiB). Override with
/// `A3S_BOX_BUILDCACHE_MAX_BYTES`. When the cap is exceeded after a store, the
/// oldest blobs are evicted (FIFO) until the total is back under the cap; a
/// later build that needs an evicted layer simply re-runs the instruction.
const DEFAULT_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024;

fn configured_max_bytes() -> u64 {
    std::env::var("A3S_BOX_BUILDCACHE_MAX_BYTES")
        .ok()
        .and_then(|s| s.parse::<u64>().ok())
        .unwrap_or(DEFAULT_MAX_BYTES)
}

/// On-disk record stored at `keys/<chain-key>`.
#[derive(Debug, Serialize, Deserialize)]
struct KeyRecord {
    digest: String,
    diff_id: String,
    size: u64,
}

/// A cache hit: a previously produced layer that can be reused.
pub(crate) struct CachedLayer {
    /// Path to the cached layer tar.gz blob.
    pub(crate) blob_path: PathBuf,
    /// SHA256 digest (hex, no prefix) of the compressed layer.
    pub(crate) digest: String,
    /// diff_id (SHA256 of the uncompressed content).
    pub(crate) diff_id: String,
    /// Size in bytes of the compressed layer.
    pub(crate) size: u64,
}

/// Layer-level build cache rooted at `~/.a3s/buildcache`.
pub(crate) struct BuildCache {
    dir: PathBuf,
}

impl BuildCache {
    /// Open the build cache, creating its directory layout if needed.
    ///
    /// Returns `None` (so the build proceeds uncached) if the directories
    /// cannot be created.
    pub(crate) fn open() -> Option<Self> {
        Self::open_in(dirs_home().join("buildcache"))
    }

    /// Open a build cache rooted at an explicit directory.
    fn open_in(dir: PathBuf) -> Option<Self> {
        std::fs::create_dir_all(dir.join("blobs")).ok()?;
        std::fs::create_dir_all(dir.join("keys")).ok()?;
        Some(Self { dir })
    }

    /// Compute the next chain key from the previous key, the canonical
    /// instruction representation, and an optional input hash.
    ///
    /// The key is `sha256(prev_key + "\n" + instruction_repr + ("\n" + input_hash)?)`.
    /// This makes the key order-sensitive and sensitive to every instruction
    /// (including config-only ones like ENV/WORKDIR, which affect later RUNs).
    pub(crate) fn chain(
        prev_key: &str,
        instruction_repr: &str,
        input_hash: Option<&str>,
    ) -> String {
        let mut buf = String::with_capacity(prev_key.len() + instruction_repr.len() + 1);
        buf.push_str(prev_key);
        buf.push('\n');
        buf.push_str(instruction_repr);
        if let Some(h) = input_hash {
            buf.push('\n');
            buf.push_str(h);
        }
        sha256_bytes(buf.as_bytes())
    }

    /// Look up a cached layer by chain key.
    ///
    /// Returns the cached layer only if its key record exists AND the
    /// referenced blob is still present on disk.
    pub(crate) fn lookup(&self, key: &str) -> Option<CachedLayer> {
        let key_path = self.dir.join("keys").join(key);
        let bytes = std::fs::read(&key_path).ok()?;
        let record: KeyRecord = serde_json::from_slice(&bytes).ok()?;

        let blob_path = self.dir.join("blobs").join(&record.digest);
        if !blob_path.exists() {
            return None;
        }

        Some(CachedLayer {
            blob_path,
            digest: record.digest,
            diff_id: record.diff_id,
            size: record.size,
        })
    }

    /// Store a produced layer under the given chain key.
    ///
    /// Copies `layer.path` to `blobs/<layer.digest>` (only if absent) and writes
    /// the `keys/<key>` record. Best-effort: I/O errors are ignored.
    pub(crate) fn store(&self, key: &str, layer: &LayerInfo, diff_id: &str) {
        let blob_path = self.dir.join("blobs").join(&layer.digest);
        if !blob_path.exists() {
            // Copy to a unique temp file then atomically rename into place, so a
            // concurrent build (or a copy that fails partway) never publishes a
            // half-written blob that a key record then points at. The blob is
            // content-addressed, so a rename that overwrites a racing winner is
            // harmless (identical bytes).
            let seq = STORE_SEQ.fetch_add(1, Ordering::Relaxed);
            let staging = self.dir.join("blobs").join(format!(
                ".staging-{}-{}-{}",
                layer.digest,
                std::process::id(),
                seq
            ));
            if std::fs::copy(&layer.path, &staging).is_err() {
                let _ = std::fs::remove_file(&staging);
                return;
            }
            if std::fs::rename(&staging, &blob_path).is_err() {
                let _ = std::fs::remove_file(&staging);
                return;
            }
        }

        let record = KeyRecord {
            digest: layer.digest.clone(),
            diff_id: diff_id.to_string(),
            size: layer.size,
        };
        if let Ok(bytes) = serde_json::to_vec(&record) {
            let _ = std::fs::write(self.dir.join("keys").join(key), bytes);
        }

        self.prune_to(configured_max_bytes());
    }

    /// Evict oldest layer blobs (by modification time) until the total blob
    /// size is at or below `cap` bytes. Best-effort; key records that point at
    /// an evicted blob simply miss on the next lookup (and the instruction is
    /// re-run), so eviction can never corrupt a build.
    fn prune_to(&self, cap: u64) {
        let blobs_dir = self.dir.join("blobs");
        let Ok(read_dir) = std::fs::read_dir(&blobs_dir) else {
            return;
        };

        let mut blobs: Vec<(std::time::SystemTime, u64, PathBuf)> = Vec::new();
        let mut total: u64 = 0;
        for entry in read_dir.flatten() {
            let Ok(meta) = entry.metadata() else { continue };
            if !meta.is_file() {
                continue;
            }
            let len = meta.len();
            total += len;
            let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH);
            blobs.push((mtime, len, entry.path()));
        }

        if total <= cap {
            return;
        }

        blobs.sort_by_key(|(mtime, _, _)| *mtime); // oldest first
        for (_, len, path) in blobs {
            if total <= cap {
                break;
            }
            if std::fs::remove_file(&path).is_ok() {
                total = total.saturating_sub(len);
            }
        }

        // Blobs were just evicted — drop key records that now point at nothing,
        // so the keys/ dir doesn't accumulate dangling pointers without bound.
        self.prune_orphan_keys();
    }

    /// Remove key records whose referenced blob no longer exists. Each key is a
    /// small JSON record, but a long-lived build host edits-and-rebuilds many
    /// chain keys and `prune_to` only evicts blobs, so without this the keys/
    /// directory grows unbounded (and leaves dangling pointers after eviction).
    /// Best-effort; a missing keys/ dir is a no-op.
    fn prune_orphan_keys(&self) {
        let Ok(read_dir) = std::fs::read_dir(self.dir.join("keys")) else {
            return;
        };
        for entry in read_dir.flatten() {
            let path = entry.path();
            let keep = std::fs::read(&path)
                .ok()
                .and_then(|bytes| serde_json::from_slice::<KeyRecord>(&bytes).ok())
                .is_some_and(|record| self.dir.join("blobs").join(&record.digest).exists());
            if !keep {
                // Blob evicted, or an unparseable/truncated key record: cruft.
                let _ = std::fs::remove_file(&path);
            }
        }
    }
}

/// Hash the content of COPY/ADD source files for cache invalidation.
///
/// Each `src` pattern is resolved under `context_dir`. Files are hashed
/// recursively for directories, in a deterministic (sorted by relative path)
/// order; for each file the hash absorbs `relpath + len + bytes`. A changed
/// file therefore changes the resulting hash and invalidates the cache.
///
/// Returns `None` if any source cannot be read (so the caller treats it as a
/// cache miss and falls back to executing the instruction).
pub(crate) fn hash_context_sources(context_dir: &Path, src_patterns: &[String]) -> Option<String> {
    use sha2::{Digest, Sha256};

    let mut files: Vec<(PathBuf, PathBuf)> = Vec::new();
    for src in src_patterns {
        // Match handle_copy/handle_add: a leading slash is context-relative, not
        // a host absolute path (which `Path::join` would otherwise jump to).
        let src_path = context_dir.join(src.trim_start_matches('/'));
        if !src_path.exists() {
            return None;
        }
        if src_path.is_dir() {
            collect_files(&src_path, &src_path, &mut files)?;
        } else {
            let rel = PathBuf::from(src);
            files.push((rel, src_path));
        }
    }

    // Deterministic order regardless of filesystem traversal order.
    files.sort_by(|a, b| a.0.cmp(&b.0));

    let mut hasher = Sha256::new();
    for (rel, full) in &files {
        let bytes = std::fs::read(full).ok()?;
        hasher.update(rel.to_string_lossy().as_bytes());
        hasher.update(b"\0");
        hasher.update((bytes.len() as u64).to_le_bytes());
        hasher.update(&bytes);
    }
    Some(hex::encode(hasher.finalize()))
}

/// Recursively collect `(relative_path, full_path)` pairs for files under `root`.
fn collect_files(root: &Path, current: &Path, out: &mut Vec<(PathBuf, PathBuf)>) -> Option<()> {
    for entry in std::fs::read_dir(current).ok()? {
        let entry = entry.ok()?;
        let path = entry.path();
        if path.is_dir() {
            collect_files(root, &path, out)?;
        } else {
            let rel = path.strip_prefix(root).ok()?.to_path_buf();
            out.push((rel, path));
        }
    }
    Some(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    /// Test constructor: open a cache at an explicit directory.
    fn open_at(dir: &Path) -> BuildCache {
        BuildCache::open_in(dir.to_path_buf()).expect("open build cache at temp dir")
    }

    #[test]
    fn test_chain_is_deterministic() {
        let a = BuildCache::chain("prev", "RUN echo hi", None);
        let b = BuildCache::chain("prev", "RUN echo hi", None);
        assert_eq!(a, b);
        assert_eq!(a.len(), 64); // SHA256 hex
    }

    #[test]
    fn test_chain_is_order_sensitive() {
        // Different repr -> different key.
        let a = BuildCache::chain("prev", "RUN echo a", None);
        let b = BuildCache::chain("prev", "RUN echo b", None);
        assert_ne!(a, b);

        // Different prev key -> different key (order of instructions matters).
        let c = BuildCache::chain("prev1", "RUN echo a", None);
        let d = BuildCache::chain("prev2", "RUN echo a", None);
        assert_ne!(c, d);
    }

    #[test]
    fn test_chain_input_hash_changes_key() {
        let none = BuildCache::chain("prev", "COPY . /app", None);
        let some = BuildCache::chain("prev", "COPY . /app", Some("deadbeef"));
        assert_ne!(none, some);

        let other = BuildCache::chain("prev", "COPY . /app", Some("cafebabe"));
        assert_ne!(some, other);
    }

    #[test]
    fn test_store_then_lookup_round_trips() {
        let tmp = TempDir::new().unwrap();
        let cache_dir = tmp.path().join("buildcache");
        let cache = open_at(&cache_dir);

        // Create a fake layer blob to be cached.
        let layer_path = tmp.path().join("layer.tar.gz");
        fs::write(&layer_path, b"fake layer contents").unwrap();
        let layer = LayerInfo {
            path: layer_path,
            digest: "abc123def456".to_string(),
            size: 19,
        };

        let key = BuildCache::chain("", "RUN echo hi", None);
        assert!(cache.lookup(&key).is_none());

        cache.store(&key, &layer, "diff-id-xyz");

        let hit = cache
            .lookup(&key)
            .expect("expected a cache hit after store");
        assert_eq!(hit.digest, "abc123def456");
        assert_eq!(hit.diff_id, "diff-id-xyz");
        assert_eq!(hit.size, 19);
        assert!(hit.blob_path.exists());
        assert_eq!(fs::read(&hit.blob_path).unwrap(), b"fake layer contents");
    }

    #[test]
    fn prune_evicts_orphan_key_records() {
        let tmp = TempDir::new().unwrap();
        let cache_dir = tmp.path().join("buildcache");
        let cache = open_at(&cache_dir);

        let layer_path = tmp.path().join("layer.tar.gz");
        fs::write(&layer_path, vec![0u8; 4096]).unwrap();
        let layer = LayerInfo {
            path: layer_path,
            digest: "cafef00d".to_string(),
            size: 4096,
        };
        let key = BuildCache::chain("", "RUN make", None);
        cache.store(&key, &layer, "diff");

        let blob = cache_dir.join("blobs").join("cafef00d");
        let key_file = cache_dir.join("keys").join(&key);
        assert!(blob.exists() && key_file.exists());

        // Force the cache under the blob size: evicts the blob AND prunes its
        // now-dangling key record (previously the key file leaked forever).
        cache.prune_to(0);
        assert!(!blob.exists(), "blob should be evicted");
        assert!(!key_file.exists(), "orphaned key record should be pruned");
    }

    #[test]
    fn test_lookup_misses_when_blob_removed() {
        let tmp = TempDir::new().unwrap();
        let cache = open_at(&tmp.path().join("buildcache"));

        let layer_path = tmp.path().join("layer.tar.gz");
        fs::write(&layer_path, b"data").unwrap();
        let layer = LayerInfo {
            path: layer_path,
            digest: "deadbeef".to_string(),
            size: 4,
        };
        let key = BuildCache::chain("", "RUN x", None);
        cache.store(&key, &layer, "diff");

        // Remove the blob; the key record remains but lookup must miss.
        fs::remove_file(tmp.path().join("buildcache/blobs/deadbeef")).unwrap();
        assert!(cache.lookup(&key).is_none());
    }

    #[test]
    fn test_hash_context_sources_detects_change() {
        let ctx = TempDir::new().unwrap();
        fs::write(ctx.path().join("a.txt"), "hello").unwrap();
        fs::create_dir(ctx.path().join("sub")).unwrap();
        fs::write(ctx.path().join("sub/b.txt"), "world").unwrap();

        let srcs = vec![".".to_string()];
        let h1 = hash_context_sources(ctx.path(), &srcs).unwrap();
        let h2 = hash_context_sources(ctx.path(), &srcs).unwrap();
        assert_eq!(h1, h2, "stable hash for unchanged content");

        // Change a file -> different hash.
        fs::write(ctx.path().join("a.txt"), "HELLO").unwrap();
        let h3 = hash_context_sources(ctx.path(), &srcs).unwrap();
        assert_ne!(h1, h3, "changed content must change the hash");
    }

    #[test]
    fn test_prune_evicts_until_under_cap() {
        let tmp = TempDir::new().unwrap();
        let cache = open_at(&tmp.path().join("buildcache"));

        // Store three ~100-byte blobs under distinct keys/digests.
        let payload = vec![b'x'; 100];
        for i in 0..3 {
            let src = tmp.path().join(format!("src{i}"));
            fs::write(&src, &payload).unwrap();
            let layer = LayerInfo {
                path: src,
                digest: format!("digest{i:040}"),
                size: payload.len() as u64,
            };
            cache.store(
                &BuildCache::chain("", &format!("RUN step {i}"), None),
                &layer,
                "d",
            );
        }

        let blobs_dir = tmp.path().join("buildcache/blobs");
        let total = |dir: &Path| -> u64 {
            fs::read_dir(dir)
                .unwrap()
                .flatten()
                .map(|e| e.metadata().unwrap().len())
                .sum()
        };
        assert_eq!(total(&blobs_dir), 300, "three blobs stored");

        // Cap at 150 bytes: prune must leave the total at or below the cap.
        cache.prune_to(150);
        assert!(
            total(&blobs_dir) <= 150,
            "prune must bring total under the cap"
        );
        assert!(total(&blobs_dir) > 0, "prune must keep what fits");
    }

    #[test]
    fn test_hash_context_sources_missing_source_is_none() {
        let ctx = TempDir::new().unwrap();
        let srcs = vec!["does-not-exist".to_string()];
        assert!(hash_context_sources(ctx.path(), &srcs).is_none());
    }
}