nix-nar 0.4.0

Library to manipulate Nix Archive (nar) files
Documentation
use assert_matches::assert_matches;
use ignore::Walk;
use insta::assert_snapshot;
use nix_nar::{debug::pretty_print_nar_content, *};
use std::{
    fs::{self, File, Metadata},
    io::{BufReader, Read},
};

#[test]
fn invalid_archive() {
    let input = "not a nar archive".as_bytes();
    assert!(
        Decoder::new(input).is_err(),
        "the parser didn't reject a stream without the header"
    );
}

#[test]
fn parse_empty_file_low_level() {
    let input = include_bytes!("../test-data/02-empty-file.nar");
    assert_eq!(input.len(), 112);
    let dec = Decoder::new(&input[..]).unwrap();
    let mut entries = dec.entries().unwrap();
    let file_entry = entries.next();
    assert_matches!(
        file_entry,
        Some(Ok(Entry {
            path: None,
            content: Content::File {
                executable: false,
                size: 0,
                offset: 96,
                data: _,
            }
        }))
    );
    if let Some(Ok(Entry {
        content: Content::File { mut data, .. },
        ..
    })) = file_entry
    {
        let mut str = String::new();
        data.read_to_string(&mut str).unwrap();
        assert_eq!(str, "");
    }
    assert_matches!(entries.next(), None);
}

#[test]
fn parse_emtpy_dir() {
    let input = include_bytes!("../test-data/01-empty-dir.nar");
    assert_eq!(input.len(), 96);
    assert_snapshot!(pretty_print_nar_content(Decoder::new(&input[..]).unwrap()), @"ROOT");
}

#[test]
fn parse_empty_file() {
    let input = include_bytes!("../test-data/02-empty-file.nar");
    assert_eq!(input.len(), 112);
    assert_snapshot!(
        pretty_print_nar_content(Decoder::new(&input[..]).unwrap()),
        @"ROOT: executable=false, size=0, offset=96, data=''"
    );
}

#[test]
fn parse_dir_one_empty_file() {
    let input = include_bytes!("../test-data/03-dir-one-empty-file.nar");
    assert_eq!(input.len(), 288);
    assert_snapshot!(pretty_print_nar_content(Decoder::new(&input[..]).unwrap()), @r###"
    ROOT
    ├── an-empty-file: executable=false, size=0, offset=240, data=''
    "###);
}

#[test]
fn parse_small_file() {
    let input = include_bytes!("../test-data/04-small-file.nar");
    assert_eq!(input.len(), 136);
    assert_snapshot!(
        pretty_print_nar_content(Decoder::new(&input[..]).unwrap()),
        @r###"ROOT: executable=false, size=21, offset=96, data='This is a test file.\n'"###
    );
}

#[test]
fn parse_executable_file() {
    let input = include_bytes!("../test-data/05-executable-file.in.exe.nar");
    assert_eq!(input.len(), 144);
    assert_snapshot!(
        pretty_print_nar_content(Decoder::new(&input[..]).unwrap()),
        @"ROOT: executable=true, size=0, offset=128, data=''"
    );
}

#[test]
fn parse_symlink() {
    let input = include_bytes!("../test-data/06-symlink.nar");
    assert_eq!(input.len(), 128);
    assert_snapshot!(
        pretty_print_nar_content(Decoder::new(&input[..]).unwrap()),
        @"ROOT -> 02-empty-file.in"
    );
}

#[test]
fn parse_nested_dirs() {
    let input = include_bytes!("../test-data/07-nested-dirs.nar");
    assert_eq!(input.len(), 1504);
    assert_snapshot!(pretty_print_nar_content(Decoder::new(&input[..]).unwrap()), @r###"
    ROOT
    ├── 01-an-empty-file: executable=false, size=0, offset=240, data=''
    ├── 02-some-dir
    │   ├── link-to-an-empty-file -> ../01-an-empty-file
    │   ├── more-depth
    │   │   ├── deep-empty-file: executable=false, size=0, offset=944, data=''
    │   ├── small-file: executable=false, size=21, offset=1168, data='This is a test file.\n'
    ├── 03-executable-file.exe: executable=true, size=0, offset=1456, data=''
    "###);
}

#[test]
fn parse_nested_dirs_from_file() {
    let file = File::open("test-data/07-nested-dirs.nar").unwrap();
    assert_snapshot!(pretty_print_nar_content(Decoder::new(file).unwrap()), @r###"
    ROOT
    ├── 01-an-empty-file: executable=false, size=0, offset=240, data=''
    ├── 02-some-dir
    │   ├── link-to-an-empty-file -> ../01-an-empty-file
    │   ├── more-depth
    │   │   ├── deep-empty-file: executable=false, size=0, offset=944, data=''
    │   ├── small-file: executable=false, size=21, offset=1168, data='This is a test file.\n'
    ├── 03-executable-file.exe: executable=true, size=0, offset=1456, data=''
    "###);
}

#[test]
fn parse_nested_dirs_from_bufreader() {
    let file = BufReader::new(File::open("test-data/07-nested-dirs.nar").unwrap());
    assert_snapshot!(pretty_print_nar_content(Decoder::new(file).unwrap()), @r###"
    ROOT
    ├── 01-an-empty-file: executable=false, size=0, offset=240, data=''
    ├── 02-some-dir
    │   ├── link-to-an-empty-file -> ../01-an-empty-file
    │   ├── more-depth
    │   │   ├── deep-empty-file: executable=false, size=0, offset=944, data=''
    │   ├── small-file: executable=false, size=21, offset=1168, data='This is a test file.\n'
    ├── 03-executable-file.exe: executable=true, size=0, offset=1456, data=''
    "###);
}

#[cfg(target_family = "unix")]
fn get_mode(m: &Metadata) -> u32 {
    use std::os::unix::fs::PermissionsExt;
    m.permissions().mode()
}

#[cfg(target_family = "windows")]
fn get_mode(m: &Metadata) -> u32 {
    0
}

#[test]
#[cfg(target_family = "unix")]
fn unpack_unix() -> Result<(), anyhow::Error> {
    let file = BufReader::new(File::open("test-data/07-nested-dirs.nar").unwrap());
    let temp_dir = tempfile::tempdir()?;
    Decoder::new(file)?.unpack(temp_dir.path().join("unpacked"))?;
    let mut res = vec![];
    for entry in Walk::new(temp_dir.path()) {
        match entry {
            Ok(entry) => {
                let path = entry.path();
                let meta = {
                    let metadata = fs::symlink_metadata(path)?;
                    if metadata.is_dir() {
                        "DIR".to_string()
                    } else if metadata.is_symlink() {
                        let target = fs::read_link(path)?;
                        format!("-> {}", target.display())
                    } else if metadata.is_file() {
                        format!(
                            " FILE: size: {}, mode: 0o{:o}",
                            metadata.len(),
                            get_mode(&metadata)
                        )
                    } else {
                        "UNKNOWN FILE TYPE".to_string()
                    }
                };
                res.push(format!(
                    "{} {meta}",
                    entry.path().strip_prefix(temp_dir.path())?.display()
                ))
            }
            Err(err) => res.push(format!("ERROR: {}", err)),
        }
    }
    res.sort();
    assert_snapshot!(res.join("\n"), @r###"
     DIR
    unpacked DIR
    unpacked/01-an-empty-file  FILE: size: 0, mode: 0o100444
    unpacked/02-some-dir DIR
    unpacked/02-some-dir/link-to-an-empty-file -> ../01-an-empty-file
    unpacked/02-some-dir/more-depth DIR
    unpacked/02-some-dir/more-depth/deep-empty-file  FILE: size: 0, mode: 0o100444
    unpacked/02-some-dir/small-file  FILE: size: 21, mode: 0o100444
    unpacked/03-executable-file.exe  FILE: size: 0, mode: 0o100555
    "###
    );
    Ok(())
}

#[test]
#[cfg(target_family = "windows")]
fn unpack_windows() -> Result<(), anyhow::Error> {
    // Creating symlinks requires admin privileges on Windows but
    // there's no easy way to configure cargo tests to request this.
    // Until https://github.com/rust-lang/rfcs/issues/721 gets merged,
    // let's just not test this.
    let file =
        BufReader::new(File::open("test-data/08-nested-dirs-no-symlinks.nar").unwrap());
    let temp_dir = tempfile::tempdir()?;
    Decoder::new(file)?.unpack(&temp_dir.path().join("unpacked"))?;
    let mut res = vec![];
    for entry in Walk::new(&temp_dir.path()) {
        match entry {
            Ok(entry) => {
                let path = entry.path();
                let meta = {
                    let metadata = fs::symlink_metadata(path)?;
                    if metadata.is_dir() {
                        "DIR".to_string()
                    } else if metadata.is_symlink() {
                        let target = fs::read_link(path)?;
                        format!("-> {}", target.display())
                    } else if metadata.is_file() {
                        format!(
                            " FILE: size: {}, mode: 0o{:o}",
                            metadata.len(),
                            get_mode(&metadata)
                        )
                    } else {
                        "UNKNOWN FILE TYPE".to_string()
                    }
                };
                res.push(format!(
                    "{} {meta}",
                    entry.path().strip_prefix(&temp_dir.path())?.display()
                ))
            }
            Err(err) => res.push(format!("ERROR: {}", err)),
        }
    }
    res.sort();
    assert_snapshot!(res.join("\n"), @r###"
     DIR
    unpacked DIR
    unpacked\01-an-empty-file  FILE: size: 0, mode: 0o0
    unpacked\02-some-dir DIR
    unpacked\02-some-dir\more-depth DIR
    unpacked\02-some-dir\more-depth\deep-empty-file  FILE: size: 0, mode: 0o0
    unpacked\02-some-dir\small-file  FILE: size: 21, mode: 0o0
    unpacked\03-executable-file.exe  FILE: size: 0, mode: 0o0
    "###
    );
    Ok(())
}