onelf 0.6.1

Packer CLI for creating onelf single-binary packages
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
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
//! Cache management for the onelf runtime extraction cache.
//!
//! Lists cached packages, clears the cache, and garbage-collects stale
//! entries. Every operation speaks the same protocol the runtime does: the
//! cache root is resolved by the shared rules, and a package is only removed
//! once its exclusive lock proves no instance is running it.

use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::SystemTime;

use onelf_format::cache_layout as layout;

/// Resolve the cache root, or fail with a message. The packer offers no
/// fallback of its own: unlike the runtime it has no private per-uid scratch
/// directory to fall back to, and guessing a shared location is exactly what
/// this refuses to do.
fn cache_root() -> io::Result<PathBuf> {
    layout::resolve_root(rustix::process::getuid().as_raw(), None).ok_or_else(|| {
        io::Error::new(
            io::ErrorKind::NotFound,
            "no safe cache directory (set HOME or XDG_CACHE_HOME to an absolute path)",
        )
    })
}

/// Remove `package_id` only if no running instance holds its lock, mirroring
/// the runtime's collector. Returns true when the package was removed.
///
/// The lock is held across the removal so an instance cannot start midway
/// through it. The in-use lock file itself is deliberately left behind: the
/// protocol keys mutual exclusion on its inode, so unlinking it would let a
/// concurrent run create a fresh inode and defeat the lock.
///
/// A missing lock file means the package was never run under this protocol,
/// so there is nothing to conflict with. Any other open failure leaves it
/// alone, since idleness cannot be proven.
fn try_remove_locked(root: &Path, package_id: &str) -> bool {
    let lock_path = layout::lock_path(root, package_id);
    let held = match fs::File::open(&lock_path) {
        Ok(f) => f,
        Err(e) if e.kind() == io::ErrorKind::NotFound => {
            remove_package(root, package_id);
            return true;
        }
        Err(_) => return false,
    };
    if rustix::fs::flock(&held, rustix::fs::FlockOperation::NonBlockingLockExclusive).is_err() {
        return false; // a running instance holds it
    }
    remove_package(root, package_id);
    true
}

/// Remove a package's tree and its per-package protocol files.
fn remove_package(root: &Path, package_id: &str) {
    // Marker first: a concurrent reader must never see it without its tree.
    let _ = fs::remove_file(layout::ready_marker(root, package_id));
    let _ = fs::remove_dir_all(layout::pkg_dir(root, package_id));
    let _ = fs::remove_dir_all(layout::extract_tmp_dir(root, package_id));
    let _ = fs::remove_file(layout::meta_path(root, package_id));
    let _ = fs::remove_file(layout::extract_lock_path(root, package_id));
}

/// Package IDs the cache holds anything for: those with a last-used record,
/// and those with a tree, marker or extraction scratch in `pkg/`, since an
/// interrupted extraction leaves its scratch behind without a record.
fn cached_ids(root: &Path) -> std::collections::BTreeSet<String> {
    let mut ids = std::collections::BTreeSet::new();
    for dir in ["meta", "pkg"] {
        let Ok(entries) = fs::read_dir(root.join(dir)) else {
            continue;
        };
        for entry in entries.flatten() {
            let name = entry.file_name().to_string_lossy().into_owned();
            let id = name
                .strip_prefix('.')
                .and_then(|n| n.strip_suffix(".tmp").or_else(|| n.strip_suffix(".ready")))
                .unwrap_or(&name);
            if !id.is_empty() && !id.starts_with('.') {
                ids.insert(id.to_string());
            }
        }
    }
    ids
}

/// Remove content blobs no package directory hardlinks any more, returning
/// the bytes reclaimed. A blob reachable only through the store itself has a
/// link count of 1, so the filesystem already keeps the reference count.
///
/// Returns 0 without touching anything when an extraction is in flight: the
/// store lock is what makes link counts trustworthy, and a runtime that is
/// mid-extraction holds it shared.
fn collect_cas(root: &Path) -> u64 {
    use std::os::unix::fs::MetadataExt;

    let Ok(lock) = fs::File::options()
        .read(true)
        .write(true)
        .create(true)
        .truncate(false)
        .open(layout::cas_lock_path(root))
    else {
        return 0;
    };
    if rustix::fs::flock(&lock, rustix::fs::FlockOperation::NonBlockingLockExclusive).is_err() {
        return 0;
    }

    let Ok(shards) = fs::read_dir(root.join("cas")) else {
        return 0;
    };
    let mut reclaimed = 0u64;
    for shard in shards.flatten() {
        let Ok(blobs) = fs::read_dir(shard.path()) else {
            continue;
        };
        for blob in blobs.flatten() {
            let Ok(md) = blob.metadata() else { continue };
            if !md.is_file() || md.nlink() > 1 {
                continue;
            }
            let len = md.len();
            if fs::remove_file(blob.path()).is_ok() {
                reclaimed += len;
            }
        }
    }
    reclaimed
}

/// Total bytes held by the content store.
fn cas_size(root: &Path) -> u64 {
    let Ok(shards) = fs::read_dir(root.join("cas")) else {
        return 0;
    };
    shards
        .flatten()
        .filter_map(|shard| fs::read_dir(shard.path()).ok())
        .flat_map(|blobs| blobs.flatten())
        .filter_map(|b| b.metadata().ok())
        .map(|m| m.len())
        .sum()
}

/// Reclaim the idle views under `root` unused for more than
/// `max_idle_secs`, with the runtime's own collector, so both sides speak
/// one lock protocol over one layout.
fn collect_views(root: &Path, max_idle_secs: u64) -> usize {
    onelf_rt::cache::collect_views(&onelf_rt::cache::view_store(root), max_idle_secs)
}

fn mib(bytes: u64) -> f64 {
    bytes as f64 / 1_048_576.0
}

/// The builds the store under `root` holds.
fn stored_builds(root: &Path) -> Vec<onelf_rt::platform::StoredBuild> {
    onelf_rt::platform::stored_builds(&layout::platform_dir(root))
}

pub fn cache_list() -> io::Result<()> {
    let root = cache_root()?;
    let now = SystemTime::now();
    let ago = |t: Option<SystemTime>| {
        t.and_then(|t| now.duration_since(t).ok())
            .map(|age| format!("{}s ago", age.as_secs()))
            .unwrap_or_else(|| "unknown".into())
    };

    let builds = stored_builds(&root);
    if !builds.is_empty() {
        println!("GL builds:");
        for build in &builds {
            println!(
                "  {} {} ({:.1} MB, last used: {})",
                build.label,
                build.hash.chars().take(16).collect::<String>(),
                mib(build.bytes),
                ago(build.used)
            );
        }
        println!();
    }

    let pkg_dir = root.join("pkg");
    if !pkg_dir.exists() {
        println!("No cached packages.");
        return Ok(());
    }

    let mut count = 0u64;

    for entry in fs::read_dir(&pkg_dir)? {
        let entry = entry?;
        if !entry.file_type()?.is_dir() {
            continue;
        }
        let name = entry.file_name();
        let id = name.to_string_lossy();

        let last_used = ago(fs::metadata(layout::meta_path(&root, &id))
            .and_then(|m| m.modified())
            .ok());

        println!("  {id} (last used: {last_used})");
        count += 1;
    }

    println!();
    println!(
        "{count} packages, CAS total: {:.1} MB (run `onelf cache gc` to reclaim)",
        mib(cas_size(&root))
    );
    Ok(())
}

/// Remove everything in the cache that no running instance is using.
///
/// A package still running keeps its tree, since it opens plugins, locale
/// data, and drivers from there lazily for as long as it runs. Every GL
/// build file goes: a running instance uses the build's extraction, which
/// is a package like any other, not the file. Idle execution views and
/// their scratch go the same way, through the runtime's collector. Directories left empty are removed; the root itself
/// stays only while something in it is in use.
pub fn cache_clear() -> io::Result<()> {
    let root = cache_root()?;
    println!("{}", clear_cache(&root)?);
    Ok(())
}

/// [`cache_clear`] against one root, returning what it did. Separate from
/// the command entry so the reclamation rules can be exercised against a
/// scratch store without touching the process's own cache.
fn clear_cache(root: &Path) -> io::Result<String> {
    if !root.exists() {
        return Ok("No cache to clear.".into());
    }

    let mut removed = 0u64;
    let mut skipped = 0u64;
    for id in cached_ids(root) {
        if try_remove_locked(root, &id) {
            removed += 1;
        } else {
            skipped += 1;
        }
    }
    let mut builds = 0u64;
    for build in stored_builds(root) {
        if fs::remove_file(&build.file).is_ok() {
            builds += 1;
            if let Some(parent) = build.file.parent() {
                let _ = fs::remove_dir(parent);
            }
        }
    }
    // Views first, so the store bytes they pinned are collectable in the
    // same sweep, and without holding any lock the content pass needs.
    let views = collect_views(root, 0);
    let reclaimed = collect_cas(root);
    remove_empty_dirs(root);

    let mut report = Vec::new();
    if views > 0 {
        report.push(format!("Removed {views} idle view(s)."));
    }
    if skipped == 0 {
        report.push("Cache cleared.".into());
    } else {
        report.push(format!(
            "Removed {removed} package(s) and {builds} GL build(s), reclaimed {:.1} MB.",
            mib(reclaimed)
        ));
        report.push(format!(
            "Skipped {skipped} package(s) still in use by a running instance."
        ));
    }
    Ok(report.join("\n"))
}

/// Remove `dir` and every directory under it that holds nothing. Lock files
/// of running packages keep their directories, and the root with them.
fn remove_empty_dirs(dir: &Path) {
    let Ok(entries) = fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        if entry.file_type().is_ok_and(|t| t.is_dir()) {
            remove_empty_dirs(&entry.path());
        }
    }
    let _ = fs::remove_dir(dir);
}

pub fn cache_gc(max_age_days: u64) -> io::Result<()> {
    let root = cache_root()?;
    println!("{}", gc_cache(&root, max_age_days)?);
    Ok(())
}

/// [`cache_gc`] against one root, returning what it did. Separate from
/// the command entry so the reclamation rules can be exercised against a
/// scratch store without touching the process's own cache.
fn gc_cache(root: &Path, max_age_days: u64) -> io::Result<String> {
    let mut report = Vec::new();
    let now = SystemTime::now();
    let max_age = std::time::Duration::from_secs(max_age_days.saturating_mul(86400));

    // A build's file is touched on every use, so its age is its idleness.
    // Its extraction is a package like any other and goes through the
    // package pass below when idle.
    let (builds_removed, build_bytes) =
        onelf_rt::platform::sweep_idle_builds(&layout::platform_dir(root), max_age, true);
    if builds_removed > 0 {
        report.push(format!(
            "Removed {builds_removed} GL build(s) unused for {max_age_days} days, \
             reclaimed {:.1} MB.",
            mib(build_bytes)
        ));
    }

    // An idle view ages from the last use its lease records, under the
    // same lock rules the runtime collector applies; a live lease keeps
    // it whatever its age.
    let views = collect_views(root, max_age_days.saturating_mul(86400));
    if views > 0 {
        report.push(format!("Removed {views} idle view(s)."));
    }

    let meta_dir = root.join("meta");
    if !meta_dir.exists() {
        report.push("No cached packages.".into());
        return Ok(report.join("\n"));
    }

    let mut removed = 0u64;
    let mut skipped = 0u64;

    for entry in fs::read_dir(&meta_dir)? {
        let entry = entry?;
        let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else {
            continue;
        };
        let Ok(age) = now.duration_since(modified) else {
            continue; // future mtime: treat as freshly used
        };
        if age <= max_age {
            continue;
        }
        let name = entry.file_name();
        let id = name.to_string_lossy();
        if try_remove_locked(root, &id) {
            removed += 1;
        } else {
            skipped += 1;
        }
    }

    let reclaimed = if removed > 0 || views > 0 {
        collect_cas(root)
    } else {
        0
    };

    report.push(format!(
        "Removed {removed} stale packages (older than {max_age_days} days), \
         reclaimed {:.1} MB.",
        mib(reclaimed)
    ));
    if skipped > 0 {
        report.push(format!(
            "Skipped {skipped} package(s) still in use by a running instance."
        ));
    }
    Ok(report.join("\n"))
}
#[cfg(test)]
mod tests {
    use super::*;

    use rustix::fs::FlockOperation;

    /// The view store under `root`.
    fn store(root: &Path) -> PathBuf {
        onelf_rt::cache::view_store(root)
    }

    /// A scratch cache root, with the directories a store that has run
    /// anything would have.
    fn temp_root(tag: &str) -> PathBuf {
        let root = std::env::temp_dir().join(format!("onelf-cliview-{tag}-{}", std::process::id()));
        let _ = fs::remove_dir_all(&root);
        for sub in ["cas/ab", "lock", "meta"] {
            fs::create_dir_all(root.join(sub)).unwrap();
        }
        fs::create_dir_all(store(&root)).unwrap();
        root
    }

    /// Publish view `name` in `root`'s view store, complete enough that
    /// the runtime's readiness check accepts it.
    fn publish_view(root: &Path, name: &str) -> PathBuf {
        let view = store(root).join(name);
        fs::create_dir_all(view.join("lib")).unwrap();
        fs::write(view.join("lib/app.so"), b"view payload").unwrap();
        fs::write(view.join("config"), b"onelf-view 1\nuid 1000\n").unwrap();
        fs::write(view.join("ready"), "onelf-view-ready 1\n").unwrap();
        assert!(onelf_rt::links::view::ready(&view));
        view
    }

    /// The lease inode of view `name`, opened without taking it.
    fn lease_file(root: &Path, name: &str) -> fs::File {
        let path = onelf_rt::links::view::lease_lock_path(&store(root).join(name)).unwrap();
        fs::File::options()
            .read(true)
            .write(true)
            .create(true)
            .truncate(false)
            .open(path)
            .unwrap()
    }

    /// Hold view `name`'s in-use lease the way a launch or its guardian
    /// does. The descriptor is the whole claim.
    fn hold_lease(root: &Path, name: &str) -> fs::File {
        let file = lease_file(root, name);
        rustix::fs::flock(&file, FlockOperation::LockShared).expect("the lease");
        file
    }

    /// A view whose lease a live launch holds is skipped whole by both
    /// sweep commands, the snapshot it references survives the content
    /// pass, and once the lease is released the idle view goes while its
    /// coordinating lock inodes and the store's own lock file stay.
    #[test]
    fn the_sweep_spares_a_leased_view_and_then_reclaims_it_idle() {
        let root = temp_root("leased");
        let view = publish_view(&root, "view-aa");
        let blob = root.join("cas/ab").join("cc".repeat(32));
        fs::write(&blob, b"snapshot bytes").unwrap();
        fs::hard_link(&blob, view.join("lib/snap.so")).unwrap();
        let lease = hold_lease(&root, "view-aa");

        let report = clear_cache(&root).unwrap();
        assert!(report.contains("Cache cleared."), "{report}");
        assert!(
            view.join("ready").is_file(),
            "a leased view must survive clear: {report}"
        );
        assert_eq!(collect_cas(&root), 0, "the referenced snapshot survives");
        assert!(blob.exists());

        let report = gc_cache(&root, 0).unwrap();
        assert!(
            !report.contains("idle view"),
            "a leased view is not idle, whatever its age: {report}"
        );
        assert!(view.join("ready").is_file());

        drop(lease);
        let report = clear_cache(&root).unwrap();
        assert!(
            report.contains("Removed 1 idle view(s)."),
            "the idle view is reclaimed: {report}"
        );
        assert!(!view.exists());
        assert!(
            !blob.exists(),
            "the bytes the view pinned went with the sweep that reclaimed it"
        );

        let views = store(&root);
        assert!(
            views.join("view-aa.lease").exists() && views.join("view-aa.create").exists(),
            "coordinating lock inodes survive the sweep"
        );
        assert!(
            root.join("lock/cas").exists(),
            "the store's lock inode survives the sweep"
        );

        let _ = fs::remove_dir_all(&root);
    }

    /// `gc` keeps an idle view its lease records as used within
    /// `--max-age` and reclaims it once that use is older, while a stale
    /// view and a dead creator's scratch go whatever the age, and a held
    /// lease keeps its view.
    #[test]
    fn the_sweep_ages_idle_views_and_reclaims_stale_scratch() {
        let root = temp_root("idle");
        let idle = publish_view(&root, "view-bb");
        let used = lease_file(&root, "view-bb");
        let stale = store(&root).join("view-cc");
        fs::create_dir_all(&stale).unwrap();
        fs::write(stale.join("config"), b"onelf-view 1\nuid 1000\n").unwrap();
        fs::write(stale.join("ready"), b"onelf-view-ready 0\n").unwrap();
        let staging = store(&root).join("view-bb.staging-42-0");
        fs::create_dir_all(staging.join("lib")).unwrap();
        fs::write(staging.join("lib/half.so"), b"half").unwrap();
        let live = publish_view(&root, "view-dd");
        let lease = hold_lease(&root, "view-dd");

        let report = gc_cache(&root, 30).unwrap();
        assert!(
            report.contains("Removed 1 idle view(s)."),
            "only the stale view goes while the idle one is recent: {report}"
        );
        assert!(idle.join("ready").is_file(), "a recently used view stays");
        assert!(!stale.exists());
        assert!(!staging.exists());
        assert!(live.join("ready").is_file());

        let old = SystemTime::now() - std::time::Duration::from_secs(31 * 86400);
        used.set_modified(old).unwrap();
        drop(used);
        drop(lease);
        lease_file(&root, "view-dd").set_modified(old).unwrap();
        let report = gc_cache(&root, 30).unwrap();
        assert!(
            report.contains("Removed 2 idle view(s)."),
            "views unused past the age go: {report}"
        );
        assert!(!idle.exists());
        assert!(!live.exists());

        let _ = fs::remove_dir_all(&root);
    }
}