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
//! Host library resolution: ldconfig cache, Nix store closures,
//! search-path lookups, and existing-lib / stub-loader detection.

use super::*;

mod candidates;
pub(crate) use candidates::{
    AdmittedLibrary, AdmittedUniverse, CandidateHit, GroupOutcome, GroupQuery, MissReason,
    counterpart_spelling, discover_group, library_dir_of, object_prefix, resolve_within,
};

/// Scan `dir` for existing shared libraries. Returns the set of sonames
/// found and, separately, the paths of any NixOS stub loaders. The scan
/// never deletes: the caller removes stubs only in the non-dry-run copy
/// phase so `--dry-run` stays side-effect free.
pub(crate) fn find_existing_libs(dir: &Path) -> (HashSet<String>, Vec<PathBuf>) {
    let mut libs = HashSet::new();
    let mut stubs = Vec::new();
    for entry in jwalk::WalkDir::new(dir).skip_hidden(false).sort(true) {
        let Ok(entry) = entry else { continue };
        let path = entry.path();
        let Some(name) = path.file_name() else {
            continue;
        };
        let name = name.to_string_lossy();
        if !name.contains(".so") {
            continue;
        }
        // A previous run may have copied NixOS's stub loader into the
        // bundle. Report it (rather than delete it here) so the caller can
        // replace it with a real loader in the copy phase; the soname is
        // treated as absent so it gets re-resolved.
        if is_nix_stub_ld(&path) {
            stubs.push(path.clone());
            continue;
        }
        libs.insert(name.into_owned());
    }
    (libs, stubs)
}

pub(crate) fn build_lib_cache() -> HashMap<String, Vec<PathBuf>> {
    let cache = parse_ldconfig_cache();
    if !cache.is_empty() {
        return cache;
    }

    // Fallback: on NixOS, ldconfig has no cache. Scan the system closure instead.
    if Path::new("/nix/store").is_dir() {
        return scan_nix_store_libs();
    }

    cache
}

pub(crate) fn parse_ldconfig_cache() -> HashMap<String, Vec<PathBuf>> {
    let mut cache: HashMap<String, Vec<PathBuf>> = HashMap::new();
    // `ldconfig` lives in /sbin (and /usr/sbin) on Debian, which is off a
    // non-root user's PATH. Try the bare name first (honoring PATH), then
    // the sbin locations, so non-root Debian runs still read the cache.
    let output = ["ldconfig", "/sbin/ldconfig", "/usr/sbin/ldconfig"]
        .into_iter()
        .find_map(|prog| Command::new(prog).arg("-p").output().ok());
    let Some(output) = output else {
        return cache;
    };
    // Lines like: "	libX11.so.6 (libc6,x86-64) => /usr/lib/libX11.so.6"
    for line in output.stdout.lines().map_while(Result::ok) {
        let line = line.trim();
        if let Some((left, right)) = line.split_once(" => ") {
            let soname = left.split_whitespace().next().unwrap_or("");
            if !soname.is_empty() {
                cache
                    .entry(soname.to_string())
                    .or_default()
                    .push(PathBuf::from(right.trim()));
            }
        }
    }
    cache
}

/// Scan lib/ directories from NixOS closures to build a soname map.
/// Scans the system closure, user profile, and home-manager profile.
pub(crate) fn scan_nix_store_libs() -> HashMap<String, Vec<PathBuf>> {
    let mut cache: HashMap<String, Vec<PathBuf>> = HashMap::new();

    // The system / user / per-user Nix closure roots (shared with the
    // framework bundlers' search-path builder).
    let store_paths = nix_closure_roots();
    if store_paths.is_empty() {
        return cache;
    }

    let lib_dirs: Vec<PathBuf> = store_paths
        .iter()
        .map(|p| PathBuf::from(p).join("lib"))
        .filter(|p| p.is_dir())
        .collect();

    eprintln!(
        "{} scanning {} store paths...",
        color::dim("NixOS detected,"),
        lib_dirs.len()
    );

    for lib_dir in &lib_dirs {
        for entry in jwalk::WalkDir::new(lib_dir)
            .max_depth(3)
            .skip_hidden(false)
            .sort(true)
        {
            let Ok(entry) = entry else { continue };
            // Follow symlinks: nix-store lib dirs expose versioned sonames as
            // symlinks to the real object, and `file_type()` (readdir, no
            // follow) would skip them, hiding those libs from resolution.
            if !entry.path().is_file() {
                continue;
            }
            if let Some(name) = entry.path().file_name() {
                let name = name.to_string_lossy();
                if name.contains(".so") {
                    cache
                        .entry(name.into_owned())
                        .or_default()
                        .push(entry.path());
                }
            }
        }
    }

    cache
}

/// Extract the nix store path from a full path.
/// e.g. /nix/store/HASH-name/lib/foo.so -> /nix/store/HASH-name
pub(crate) fn nix_store_path(path: &Path) -> Option<PathBuf> {
    let s = path.to_string_lossy();
    let rest = s.strip_prefix("/nix/store/")?;
    let end = rest.find('/').unwrap_or(rest.len());
    Some(PathBuf::from(format!("/nix/store/{}", &rest[..end])))
}

/// When a lib is resolved from the nix store, scan its store path's closure
/// to discover transitive dependencies that may not be in the initial scan set.
/// Tracks already-expanded store paths to avoid redundant work.
pub(crate) fn expand_nix_cache(
    resolved: &Path,
    cache: &mut HashMap<String, Vec<PathBuf>>,
    expanded: &mut HashSet<PathBuf>,
) {
    let store_path = match nix_store_path(resolved) {
        Some(p) => p,
        None => return,
    };

    if !expanded.insert(store_path.clone()) {
        return; // already expanded this store path
    }

    let Ok(output) = Command::new("nix-store")
        .args(["-qR"])
        .arg(&store_path)
        .output()
    else {
        return;
    };

    if !output.status.success() {
        return;
    }

    for line in output.stdout.lines().map_while(Result::ok) {
        let lib_dir = PathBuf::from(line.trim()).join("lib");
        if !lib_dir.is_dir() {
            continue;
        }
        for entry in jwalk::WalkDir::new(&lib_dir)
            .max_depth(3)
            .skip_hidden(false)
            .sort(true)
        {
            let Ok(entry) = entry else { continue };
            // Follow symlinks: nix-store lib dirs expose versioned sonames as
            // symlinks to the real object, and `file_type()` (readdir, no
            // follow) would skip them, hiding those libs from resolution.
            if !entry.path().is_file() {
                continue;
            }
            if let Some(name) = entry.path().file_name() {
                let name = name.to_string_lossy();
                if name.contains(".so") {
                    let paths = cache.entry(name.into_owned()).or_default();
                    let path = entry.path();
                    if !paths.contains(&path) {
                        paths.push(path);
                    }
                }
            }
        }
    }
}

/// Which machine sources past the declared search paths a lookup may
/// answer from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LookupScope {
    /// Every source the packer's machine offers: the loader cache, the
    /// standard library paths, the environment's library paths and the
    /// store. An absent soname's first resolution searches them; a
    /// sysroot build never does.
    Host,
    /// The declared search paths alone. A repair for a soname the tree
    /// already carries answers from sources the build itself declares
    /// or owns, so no installation the host happens to have resolves
    /// one, whatever build form runs.
    Declared,
    /// Every source of [`Self::Host`] but the store sweep, for a lookup
    /// that runs once per bundled object and must stay cheap when it
    /// misses.
    WithoutStore,
}

/// Resolve one soname against `search_paths` first and, when `scope`
/// allows, against the machine's own sources behind them. `sysroot`
/// limits the search to `search_paths`: in sysroot mode the packer's
/// own machine is never consulted, and a candidate under the sysroot
/// is read with its links resolved inside it. The candidate is
/// returned as found, a soname link say, not what it resolves to.
#[allow(clippy::too_many_arguments)]
pub(crate) fn locate_lib(
    soname: &str,
    ldconfig_cache: &HashMap<String, Vec<PathBuf>>,
    search_paths: &[PathBuf],
    target_class: Option<u8>,
    target_machine: Option<u16>,
    sysroot: Option<&Path>,
    scope: LookupScope,
) -> Option<PathBuf> {
    let class_matches = |path: &Path| -> bool {
        match target_class {
            Some(tc) => read_elf_class(path) == Some(tc),
            None => true,
        }
    };
    let machine_matches = |path: &Path| -> bool {
        match target_machine {
            Some(tm) => read_elf_machine(path) == Some(tm),
            None => true,
        }
    };
    // Reject NixOS's stub loader anywhere it surfaces. It exists on disk
    // but refuses to actually load foreign binaries, so bundling it would
    // produce a package that runs nowhere. Also reject a wrong-architecture
    // library from a multiarch or cross sysroot.
    let acceptable =
        |path: &Path| class_matches(path) && machine_matches(path) && !is_nix_stub_ld(path);

    // 1. --search-path directories (user-provided: highest priority)
    for dir in search_paths {
        let candidate = dir.join(soname);
        let real = match sysroot.and_then(|root| Some((root, candidate.strip_prefix(root).ok()?))) {
            Some((root, rel)) => match onelf_sysroot::path::in_root(root, rel) {
                Ok(real) => real,
                Err(_) => continue,
            },
            None => candidate.clone(),
        };
        if real.exists() && acceptable(&real) {
            return Some(candidate);
        }
    }

    if sysroot.is_some() || scope == LookupScope::Declared {
        return None;
    }

    // 2. ldconfig cache, in the order it lists candidates. `ldconfig -p`
    // prints the cache in the loader's own preference, so `/usr/local/lib`
    // wins over `/usr/lib/x86_64-linux-gnu` here as it does at runtime. A
    // cache scanned from the Nix store is filled from sorted closures and
    // sorted walks, which is stable already.
    if let Some(paths) = ldconfig_cache.get(soname) {
        for path in paths {
            if path.exists() && acceptable(path) {
                return Some(path.clone());
            }
        }
    }

    // 3. Standard paths
    for dir in STANDARD_LIB_PATHS {
        let candidate = Path::new(dir).join(soname);
        if candidate.exists() && acceptable(&candidate) {
            return Some(candidate);
        }
    }

    // 4. LD_LIBRARY_PATH and NIX_LD_LIBRARY_PATH
    for var in ["LD_LIBRARY_PATH", "NIX_LD_LIBRARY_PATH"] {
        if let Ok(val) = std::env::var(var) {
            for dir in val.split(':') {
                if dir.is_empty() {
                    continue;
                }
                let candidate = Path::new(dir).join(soname);
                if candidate.exists() && acceptable(&candidate) {
                    return Some(candidate);
                }
            }
        }
    }

    if scope == LookupScope::WithoutStore {
        return None;
    }

    // 5. NixOS fallback: scan /nix/store/*/lib/ directly. Sort the store
    // entries (and subdirs) so that when multiple store paths provide the
    // same soname, the chosen copy is deterministic rather than dependent
    // on read_dir order.
    if Path::new("/nix/store").is_dir()
        && let Ok(entries) = fs::read_dir("/nix/store")
    {
        let mut dirs: Vec<PathBuf> = entries.filter_map(Result::ok).map(|e| e.path()).collect();
        dirs.sort();
        for dir in dirs {
            let lib_dir = dir.join("lib");
            // Check lib/<soname> directly
            let candidate = lib_dir.join(soname);
            if candidate.exists() && acceptable(&candidate) {
                return Some(candidate);
            }
            // Also check one level of subdirs (e.g. lib/pulseaudio/)
            if let Ok(subdirs) = fs::read_dir(&lib_dir) {
                let mut subs: Vec<PathBuf> = subdirs
                    .filter_map(Result::ok)
                    .filter(|s| s.file_type().is_ok_and(|t| t.is_dir()))
                    .map(|s| s.path())
                    .collect();
                subs.sort();
                for subdir in subs {
                    let candidate = subdir.join(soname);
                    if candidate.exists() && acceptable(&candidate) {
                        return Some(candidate);
                    }
                }
            }
        }
    }

    None
}

/// Detect NixOS's stub-ld, a tiny loader that prints a message and exits.
/// On NixOS without nix-ld the stub sits at the glibc loader's path, such
/// as `/lib64/ld-linux-x86-64.so.2`.
/// We check two signals:
///
/// 1. The canonical path contains `stub-ld` (covers the fresh symlink case).
/// 2. The file content contains NixOS's signature error string (covers the
///    case where a previous bundle copied the stub into the AppDir itself,
///    so canonicalize no longer points at the nix store).
pub(crate) fn is_nix_stub_ld(path: &Path) -> bool {
    // Only a file named like a glibc loader can be a stub loader, since
    // stub-ld takes the glibc loader paths. Reject any other filename up
    // front, before the canonicalize shortcut, so a path that merely
    // contains "stub-ld" (or the phrase in its content) can never be
    // misclassified and, at the call site, deleted.
    let is_loader_name = path
        .file_name()
        .and_then(|n| n.to_str())
        .is_some_and(onelf_format::ldso::is_glibc_loader_name);
    if !is_loader_name {
        return false;
    }
    if let Ok(real) = fs::canonicalize(path)
        && real.to_string_lossy().contains("stub-ld")
    {
        return true;
    }
    // Real glibc ld-linux is >100 KB; the stub is ~35 KB. Cheap filter
    // before reading the file content.
    let Ok(meta) = fs::metadata(path) else {
        return false;
    };
    if !meta.is_file() || meta.len() > 128 * 1024 {
        return false;
    }
    let Ok(bytes) = fs::read(path) else {
        return false;
    };
    if bytes.len() < 4 || bytes[..4] != *b"\x7fELF" {
        return false;
    }
    bytes
        .windows(b"NixOS cannot run".len())
        .any(|w| w == b"NixOS cannot run")
}

/// Collect nix store paths from system and user closures.
pub(crate) fn nix_closure_roots() -> Vec<String> {
    let mut store_paths: HashSet<String> = HashSet::new();

    let mut query_paths: Vec<PathBuf> = vec![PathBuf::from("/run/current-system")];
    if let Ok(home) = std::env::var("HOME") {
        query_paths.push(PathBuf::from(format!("{home}/.nix-profile")));
    }
    // `/etc/profiles/per-user` is a parent directory, not a store path;
    // query each per-user profile subdirectory individually.
    if let Ok(entries) = fs::read_dir("/etc/profiles/per-user") {
        for entry in entries.flatten() {
            query_paths.push(entry.path());
        }
    }

    for qp in &query_paths {
        if !qp.exists() {
            continue;
        }
        let Ok(output) = Command::new("nix-store").arg("-qR").arg(qp).output() else {
            continue;
        };
        if output.status.success() {
            for line in output.stdout.lines().map_while(Result::ok) {
                store_paths.insert(line.trim().to_string());
            }
        }
    }

    // Sort so callers that pick a first match over duplicate sonames are
    // deterministic regardless of HashSet iteration order.
    let mut result: Vec<String> = store_paths.into_iter().collect();
    result.sort();
    result
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A distribution's absolute soname link names the sysroot's file.
    /// Followed on the packer's machine it names the packer's instead, or
    /// nothing at all.
    #[test]
    fn a_sysroot_candidate_is_read_through_links_inside_the_sysroot() {
        let td = std::env::temp_dir().join(format!("onelf-locate-{}", std::process::id()));
        let _ = fs::remove_dir_all(&td);
        let root = td.join("sysroot");
        let host = td.join("host");
        fs::create_dir_all(root.join("usr/lib")).unwrap();
        fs::create_dir_all(&host).unwrap();
        fs::write(host.join("libhost.so.1"), b"host").unwrap();
        fs::write(root.join("usr/lib/libfoo.so.1.2"), b"sysroot").unwrap();
        std::os::unix::fs::symlink("/usr/lib/libfoo.so.1.2", root.join("usr/lib/libfoo.so.1"))
            .unwrap();
        std::os::unix::fs::symlink(host.join("libhost.so.1"), root.join("usr/lib/libhost.so.1"))
            .unwrap();
        let dirs = [root.join("usr/lib")];
        let locate = |soname| {
            locate_lib(
                soname,
                &HashMap::new(),
                &dirs,
                None,
                None,
                Some(&root),
                LookupScope::Host,
            )
        };

        assert_eq!(
            locate("libfoo.so.1"),
            Some(root.join("usr/lib/libfoo.so.1"))
        );
        assert_eq!(
            locate("libhost.so.1"),
            None,
            "the packer's file is not the sysroot's"
        );
        let _ = fs::remove_dir_all(&td);
    }

    #[test]
    fn the_cache_order_decides_between_copies() {
        let root = std::env::temp_dir().join(format!("onelf-cacheorder-{}", std::process::id()));
        let _ = fs::remove_dir_all(&root);
        let local = root.join("usr/local/lib");
        let multiarch = root.join("usr/lib/x86_64-linux-gnu");
        for dir in [&local, &multiarch] {
            fs::create_dir_all(dir).unwrap();
            fs::write(dir.join("libfoo.so.1"), b"lib").unwrap();
        }
        let cache = HashMap::from([(
            "libfoo.so.1".to_string(),
            vec![local.join("libfoo.so.1"), multiarch.join("libfoo.so.1")],
        )]);
        let found = locate_lib(
            "libfoo.so.1",
            &cache,
            &[],
            None,
            None,
            None,
            LookupScope::Host,
        );
        let _ = fs::remove_dir_all(&root);
        assert_eq!(found, Some(local.join("libfoo.so.1")));
    }
}