nix-nar-cli 0.3.0

Binary to manipulate Nix Archive (nar) files
use std::{
    collections::HashMap,
    fs::File,
    io::{self, BufReader, Write},
};

use anyhow::bail;
use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
use clap::Parser;
use nix_nar::{Content, Decoder};
use serde_json::json;

#[derive(Parser)]
pub struct Opts {
    // TODO-someday Support the --directory flag when `nix nar` has reasonable
    // semantics.  The current output looks like giberish.
    // Show directories rather than their contents.
    #[clap(short, long)]
    // pub directory: bool,

    /// Produce output in JSON format, suitable for consumption by
    /// another program.
    #[clap(long)]
    pub json: bool,

    /// Show detailed file information.
    #[clap(short, long)]
    pub long: bool,

    /// List subdirectories recursively.
    #[clap(short = 'R', long)]
    pub recursive: bool,

    /// NAR file to inspect.
    pub nar: Utf8PathBuf,

    /// Path inside the NAR file to list.
    pub path: Utf8PathBuf,
}

pub fn run<W: Write>(
    mut w: W,
    Opts {
        json,
        long,
        recursive,
        nar,
        path: path_to_list,
    }: Opts,
) -> Result<(), anyhow::Error> {
    let file = BufReader::new(File::open(nar)?);
    let dec = Decoder::new(file)?;
    let mut printed_something = false;
    let mut paths_to_show = vec![];
    let mut selected_a_dir = false;
    for entry in dec.entries()? {
        let entry = entry?;
        let abs_path = entry.abs_path();
        let is_selected_dir = (abs_path == path_to_list) && entry.content.is_directory();
        selected_a_dir = selected_a_dir || is_selected_dir;
        if !is_selected_dir
            && (abs_path == path_to_list
                || abs_path.parent() == Some(&path_to_list)
                || (recursive && abs_path.starts_with(&path_to_list)))
        {
            printed_something = true;
            let rel_path = if abs_path == path_to_list {
                entry
                    .path
                    .as_ref()
                    .and_then(|p| p.file_name().map(|s| Utf8Path::new(s).to_path_buf()))
                    .unwrap_or_else(|| abs_path.to_path_buf())
            } else if abs_path.parent() == Some(&path_to_list)
                || abs_path.starts_with(&path_to_list)
            {
                let mut p = Utf8Path::new(".").to_path_buf();
                p.push(abs_path.strip_prefix(&path_to_list)?);
                p
            } else {
                bail!(
                    "Don't know how to construct relative path of {abs_path} while listing {path_to_list}"
                );
            };
            paths_to_show.push((rel_path, entry.content));
        }
    }
    if json {
        print_json(&mut w, paths_to_show, selected_a_dir, recursive)?;
    } else {
        print_listing(&mut w, paths_to_show, long)?;
    }
    if !printed_something {
        if json {
            writeln!(io::stderr(), "Error: path '{path_to_list}' does not exist",)?;
            write!(w, "{{}}")?;
        } else {
            bail!("path '{path_to_list}' does not exist");
        }
    }
    Ok(())
}

fn print_listing<R, W: Write>(
    w: &mut W,
    paths_to_show: Vec<(Utf8PathBuf, Content<R>)>,
    long: bool,
) -> Result<(), anyhow::Error> {
    for (rel_path, content) in paths_to_show {
        match content {
            Content::Symlink { target } => {
                if long {
                    writeln!(w, "lrwxrwxrwx {:>20} {rel_path} -> {target}", 0,)?;
                } else {
                    writeln!(w, "{rel_path}")?;
                }
            }
            Content::Directory => {
                if long {
                    writeln!(w, "dr-xr-xr-x {:>20} {rel_path}", 0)?;
                } else {
                    writeln!(w, "{rel_path}")?;
                }
            }
            Content::File {
                executable,
                size,
                offset: _,
                data: _,
            } => {
                if long {
                    let perms = if executable {
                        "-r-xr-xr-x"
                    } else {
                        "-r--r--r--"
                    };
                    writeln!(w, "{perms} {size:>20} {rel_path}")?;
                } else {
                    writeln!(w, "{rel_path}")?;
                }
            }
        }
    }
    Ok(())
}

#[derive(Debug)]
enum JsonOutput {
    Directory {
        entries: HashMap<Utf8PathBuf, JsonOutput>,
    },
    Regular {
        size: u64,
        executable: bool,
        nar_offset: u64,
    },
    Symlink {
        target: Utf8PathBuf,
    },
}

impl JsonOutput {
    fn entries(&mut self) -> Result<&mut HashMap<Utf8PathBuf, Self>, anyhow::Error> {
        match self {
            Self::Regular { .. } => {
                bail!("entries() called on Regular");
            }
            Self::Symlink { .. } => {
                bail!("entries() called on Symlink");
            }
            Self::Directory { ref mut entries } => Ok(entries),
        }
    }

    fn add_entry(&mut self, frag: &str, other: Self) -> Result<&mut Self, anyhow::Error> {
        let name = Utf8Path::new(frag).to_path_buf();
        Ok(self.entries()?.entry(name).or_insert(other))
    }

    fn subdir(&mut self, frag: &str) -> Result<&mut Self, anyhow::Error> {
        self.add_entry(frag, Self::new_dir())
    }

    fn new_dir() -> Self {
        JsonOutput::Directory {
            entries: HashMap::new(),
        }
    }
}

fn print_json<R, W: Write>(
    w: &mut W,
    paths_to_show: Vec<(Utf8PathBuf, Content<R>)>,
    selected_a_dir: bool,
    recursive: bool,
) -> Result<(), anyhow::Error> {
    let mut res: JsonOutput;
    if paths_to_show.is_empty() {
        return Ok(());
    } else if paths_to_show.len() == 1 {
        let file = match paths_to_show[0] {
            (
                _,
                Content::File {
                    ref size,
                    ref executable,
                    ref offset,
                    ..
                },
            ) => JsonOutput::Regular {
                size: *size,
                executable: *executable,
                nar_offset: *offset,
            },
            (_, Content::Symlink { ref target }) => JsonOutput::Symlink {
                target: target.clone(),
            },
            (_, Content::Directory) => {
                // This can't actually happen due to how path listing works
                JsonOutput::new_dir()
            }
        };
        if selected_a_dir {
            res = JsonOutput::new_dir();
            res.add_entry(paths_to_show[0].0.file_name().unwrap(), file)?;
        } else {
            res = file;
        }
    } else {
        res = JsonOutput::new_dir();
        for (path, content) in paths_to_show {
            let mut cur_dir = &mut res;
            // The always has a parent, even if that might be empty.
            for c in path.parent().unwrap().components() {
                match c {
                    Utf8Component::CurDir => {
                        // Nothing to do
                    }
                    Utf8Component::Normal(frag) => {
                        cur_dir = cur_dir.subdir(frag)?;
                    }
                    comp @ (Utf8Component::Prefix(_)
                    | Utf8Component::RootDir
                    | Utf8Component::ParentDir) => {
                        bail!("unexpected path component {:?}", comp)
                    }
                }
            }
            // At this point, we know that the path isn't empty
            let filename = path.file_name().unwrap();
            match content {
                Content::Directory => {
                    cur_dir.subdir(filename)?;
                }
                Content::File {
                    ref size,
                    ref executable,
                    ref offset,
                    ..
                } => {
                    cur_dir.add_entry(
                        filename,
                        JsonOutput::Regular {
                            size: *size,
                            executable: *executable,
                            nar_offset: *offset,
                        },
                    )?;
                }
                Content::Symlink { ref target } => {
                    cur_dir.add_entry(
                        filename,
                        JsonOutput::Symlink {
                            target: target.clone(),
                        },
                    )?;
                }
            }
        }
    }
    serde_json::to_writer(w, &convert_to_json(&res, recursive))?;
    Ok(())
}

fn convert_to_json(json_output: &JsonOutput, recursive: bool) -> serde_json::Value {
    match json_output {
        JsonOutput::Directory { entries } => {
            let entries: serde_json::Map<String, serde_json::Value> = entries
                .iter()
                .map(|(p, j)| {
                    (
                        p.to_string(),
                        if recursive {
                            convert_to_json(j, recursive)
                        } else {
                            json!({})
                        },
                    )
                })
                .collect();
            json!({ "type": "directory", "entries": entries })
        }
        JsonOutput::Regular {
            size,
            nar_offset,
            executable,
        } => {
            if *executable {
                json!({ "type": "regular", "size": size, "narOffset": nar_offset, "executable": true})
            } else {
                json!({ "type": "regular", "size": size, "narOffset": nar_offset})
            }
        }
        JsonOutput::Symlink { target } => {
            json!({"type": "symlink", "target": target.to_string()})
        }
    }
}