diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fa1385d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* -text diff --git a/Cargo.lock b/Cargo.lock index 482770c..e10f79e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -169,6 +169,20 @@ dependencies = [ "generic-array", ] +[[package]] +name = "boilerplate" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff9f82b3395618a18fdb7b586fe5cfac0c07af07d0aab300ff3dd64e4f3ec949" +dependencies = [ + "darling", + "mime", + "new_mime_guess", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "bstr" version = "1.10.0" @@ -442,6 +456,12 @@ dependencies = [ "serde", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "difflib" version = "0.4.0" @@ -551,16 +571,19 @@ dependencies = [ "assert_cmd", "assert_fs", "blake3", + "boilerplate", "camino", "clap", "clap_mangen", "dirs", "ed25519-dalek", "hex", + "html-escaper", "indicatif", "lexiclean", "owo-colors", "predicates", + "pretty_assertions", "rand", "regex", "serde", @@ -655,6 +678,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "html-escaper" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459a0ca33ee92551e0a3bb1774f2d3bdd1c09fb6341845736662dd25e1fcb52a" + [[package]] name = "iana-time-zone" version = "0.1.61" @@ -820,6 +849,22 @@ dependencies = [ "libc", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "new_mime_guess" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a2dfb3559d53e90b709376af1c379462f7fb3085a0177deb73e6ea0d99eff4" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -926,6 +971,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro2" version = "1.0.87" @@ -1304,6 +1359,15 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-ident" version = "1.0.13" @@ -1587,6 +1651,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "zerocopy" version = "0.7.35" diff --git a/Cargo.toml b/Cargo.toml index 20608b1..50b15d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,12 +14,14 @@ include = ["CHANGELOG.md", "CONTRIBUTING", "LICENSE", "README.md", "src", "tests [dependencies] blake3 = { version = "1.5.4", features = ["mmap", "rayon", "serde"] } +boilerplate = "1.0.1" camino = { version = "1.1.9", features = ["serde1"] } clap = { version = "4.5.16", features = ["derive"] } clap_mangen = "0.2.23" dirs = "5.0.1" ed25519-dalek = { version = "2.1.1", features = ["rand_core"] } hex = "0.4.3" +html-escaper = "0.2.0" indicatif = "0.17.8" lexiclean = "0.0.1" owo-colors = "4" @@ -35,10 +37,12 @@ walkdir = "2.5.0" assert_cmd = { version = "2.0.16", features = ["color-auto"] } assert_fs = { version = "1.1.2", features = ["color-auto"] } predicates = "3.1.2" +pretty_assertions = "1.4.1" regex = "1.10.6" [lints.clippy] all = { level = "deny", priority = -1 } +float-cmp = "allow" large_enum_variant = "allow" needless-pass-by-value = "allow" pedantic = { level = "deny", priority = -1 } diff --git a/justfile b/justfile index 5df6d95..b36e06e 100644 --- a/justfile +++ b/justfile @@ -105,3 +105,15 @@ verify-release: tmp --dir tmp \ $VERSION cargo run verify tmp --key 3c977ea3a31cd37f0b540f02f33eab158f2ed7449f42b05613c921181aa95b79 + +render: tmp + #!/usr/bin/env bash + set -euxo pipefail + VERSION=`bin/version` + gh release download \ + --repo casey/filepack \ + --pattern '*' \ + --dir tmp \ + $VERSION + rm tmp/filepack.json + cargo run create tmp --sign --metadata metadata.yaml diff --git a/metadata.yaml b/metadata.yaml new file mode 100644 index 0000000..fb527b2 --- /dev/null +++ b/metadata.yaml @@ -0,0 +1 @@ +title: filepack diff --git a/src/bytes.rs b/src/bytes.rs new file mode 100644 index 0000000..e2b9e35 --- /dev/null +++ b/src/bytes.rs @@ -0,0 +1,67 @@ +use super::*; + +pub(crate) struct Bytes(pub(crate) u64); + +impl Display for Bytes { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + const SUFFIXES: &[&str] = &["KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]; + + #[allow(clippy::cast_precision_loss)] + let mut value = self.0 as f64; + + let mut i = 0; + + while value >= 1024.0 { + value /= 1024.0; + i += 1; + } + + let suffix = if i == 0 { + if value == 1.0 { + "byte" + } else { + "bytes" + } + } else { + SUFFIXES[i - 1] + }; + + let formatted = format!("{value:.2}"); + let trimmed = formatted.trim_end_matches('0').trim_end_matches('.'); + write!(f, "{trimmed} {suffix}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const KI: u64 = 1 << 10; + const MI: u64 = KI << 10; + const GI: u64 = MI << 10; + const TI: u64 = GI << 10; + const PI: u64 = TI << 10; + const EI: u64 = PI << 10; + + #[test] + fn display() { + #[track_caller] + fn case(bytes: u64, expected: &str) { + assert_eq!(Bytes(bytes).to_string(), expected); + } + + case(0, "0 bytes"); + case(1, "1 byte"); + case(2, "2 bytes"); + case(KI, "1 KiB"); + case(512 * KI, "512 KiB"); + case(MI, "1 MiB"); + case(MI + 512 * KI, "1.5 MiB"); + case(1024 * MI + 512 * MI, "1.5 GiB"); + case(GI, "1 GiB"); + case(TI, "1 TiB"); + case(PI, "1 PiB"); + case(EI, "1 EiB"); + case(u64::MAX, "16 EiB"); + } +} diff --git a/src/filesystem.rs b/src/filesystem.rs index 12326cb..11a9895 100644 --- a/src/filesystem.rs +++ b/src/filesystem.rs @@ -11,3 +11,7 @@ pub(crate) fn metadata(path: &Utf8Path) -> Result { pub(crate) fn write(path: &Utf8Path, contents: &[u8]) -> Result { std::fs::write(path, contents).context(error::Io { path }) } + +pub(crate) fn exists(path: &Utf8Path) -> Result { + path.try_exists().context(error::Io { path }) +} diff --git a/src/main.rs b/src/main.rs index 12c894e..e534af4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,17 @@ use { self::{ - arguments::Arguments, display_path::DisplayPath, display_secret::DisplaySecret, entry::Entry, - error::Error, hash::Hash, io_result_ext::IoResultExt, lint::Lint, lint_group::LintGroup, - list::List, manifest::Manifest, metadata::Metadata, options::Options, - owo_colorize_ext::OwoColorizeExt, private_key::PrivateKey, public_key::PublicKey, + arguments::Arguments, bytes::Bytes, display_path::DisplayPath, display_secret::DisplaySecret, + entry::Entry, error::Error, hash::Hash, io_result_ext::IoResultExt, lint::Lint, + lint_group::LintGroup, list::List, manifest::Manifest, metadata::Metadata, options::Options, + owo_colorize_ext::OwoColorizeExt, page::Page, private_key::PrivateKey, public_key::PublicKey, relative_path::RelativePath, signature::Signature, signature_error::SignatureError, style::Style, subcommand::Subcommand, template::Template, utf8_path_ext::Utf8PathExt, }, blake3::Hasher, + boilerplate::Boilerplate, camino::{Utf8Component, Utf8Path, Utf8PathBuf}, clap::{Parser, ValueEnum}, + html_escaper::Escape, indicatif::{ProgressBar, ProgressStyle}, lexiclean::Lexiclean, owo_colors::Styled, @@ -20,7 +22,7 @@ use { array::TryFromSliceError, backtrace::{Backtrace, BacktraceStatus}, cmp::Ordering, - collections::{BTreeMap, HashMap}, + collections::{BTreeMap, HashMap, HashSet}, env, fmt::{self, Display, Formatter}, fs::{self, File}, @@ -36,6 +38,7 @@ use { use assert_fs::TempDir; mod arguments; +mod bytes; mod display_path; mod display_secret; mod entry; @@ -50,6 +53,7 @@ mod manifest; mod metadata; mod options; mod owo_colorize_ext; +mod page; mod private_key; mod progress_bar; mod public_key; diff --git a/src/manifest.rs b/src/manifest.rs index 2d35048..9bb2a5e 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -25,9 +25,18 @@ impl Manifest { hasher.finalize().into() } + pub(crate) fn load(path: &Utf8Path) -> Result { + serde_json::from_str(&filesystem::read_to_string(path)?) + .context(error::DeserializeManifest { path }) + } + pub(crate) fn to_json(&self) -> String { serde_json::to_string(self).unwrap() } + + pub(crate) fn total_size(&self) -> u64 { + self.files.values().map(|entry| entry.size).sum() + } } #[cfg(test)] diff --git a/src/metadata.rs b/src/metadata.rs index 0a7107c..2971d1f 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -3,12 +3,17 @@ use super::*; #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub(crate) struct Metadata { - title: String, + pub(crate) title: String, } impl Metadata { pub(crate) const FILENAME: &'static str = "metadata.json"; + pub(crate) fn load(path: &Utf8Path) -> Result { + serde_json::from_str(&filesystem::read_to_string(path)?) + .context(error::DeserializeMetadata { path }) + } + pub(crate) fn to_json(&self) -> String { serde_json::to_string(self).unwrap() } diff --git a/src/page.rs b/src/page.rs new file mode 100644 index 0000000..cd9f9a1 --- /dev/null +++ b/src/page.rs @@ -0,0 +1,91 @@ +use super::*; + +#[derive(Boilerplate)] +#[boilerplate(filename = "page.html")] +pub(crate) struct Page { + pub(crate) manifest: Manifest, + pub(crate) metadata: Option, + pub(crate) present: HashSet, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hash() -> Hash { + Hash::bytes(&[]) + } + + fn private_key() -> PrivateKey { + "0".repeat(64).parse().unwrap() + } + + fn public_key() -> PublicKey { + private_key().into() + } + + fn signature() -> Signature { + private_key().sign(&[]) + } + + #[test] + fn display() { + let page = Page { + manifest: Manifest { + files: [( + "foo".parse().unwrap(), + Entry { + hash: hash(), + size: 1024, + }, + )] + .into(), + signatures: [(public_key(), signature())].into(), + }, + metadata: Some(Metadata { + title: "foo".into(), + }), + present: ["foo".parse().unwrap()].into(), + }; + + pretty_assertions::assert_eq!( + page.to_string(), + r#" + + + + + foo + + + +

foo

+
+
file count
+
1
+
total size
+
1 KiB
+
root hash
+
2e2f6ca534371afe8783a9bcace2237a7611e2e5aa87eb272782b563f70d14ac
+
signatures
+
3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29
+
files
+
+ + + + + +
foo1 KiB
+
+
+ + +"#, + ); + } +} diff --git a/src/subcommand.rs b/src/subcommand.rs index 24e1962..712bb3f 100644 --- a/src/subcommand.rs +++ b/src/subcommand.rs @@ -11,6 +11,7 @@ mod hash; mod key; mod keygen; mod man; +mod render; mod sign; mod verify; @@ -35,7 +36,9 @@ pub(crate) enum Subcommand { Keygen, #[command(about = "Print man page")] Man, - #[command(about = "Add signature to manifest")] + #[command(about = "Render manifest")] + Render(render::Render), + #[command(about = "Sign manifest")] Sign(sign::Sign), #[command(about = "Verify manifest")] Verify(verify::Verify), @@ -49,6 +52,7 @@ impl Subcommand { Self::Key => key::run(options), Self::Keygen => keygen::run(options), Self::Man => man::run(), + Self::Render(render) => render.run(), Self::Sign(sign) => sign.run(options), Self::Verify(verify) => verify.run(options), } diff --git a/src/subcommand/render.rs b/src/subcommand/render.rs new file mode 100644 index 0000000..bb5ebc8 --- /dev/null +++ b/src/subcommand/render.rs @@ -0,0 +1,54 @@ +use super::*; + +#[derive(Parser)] +pub(crate) struct Render { + #[arg( + help = "Render . May be a path to a manifest or a directory containing a manifest named \ + `filepack.json`. If omitted, the manifest `filepack.json` in the current directory is rendered." + )] + root: Option, +} + +impl Render { + pub(crate) fn run(self) -> Result { + let path = if let Some(path) = self.root { + if filesystem::metadata(&path)?.is_dir() { + path.join(Manifest::FILENAME) + } else { + path + } + } else { + current_dir()?.join(Manifest::FILENAME) + }; + + let manifest = Manifest::load(&path)?; + + let root = path.parent().unwrap(); + + let metadata_path = root.join(Metadata::FILENAME); + + let metadata = if filesystem::exists(&metadata_path)? { + Some(Metadata::load(&metadata_path)?) + } else { + None + }; + + let mut present = HashSet::new(); + + for path in manifest.files.keys() { + if filesystem::exists(&root.join(path))? { + present.insert(path.clone()); + } + } + + let page = Page { + manifest, + metadata, + present, + }; + + print!("{page}"); + + Ok(()) + } +} diff --git a/src/subcommand/sign.rs b/src/subcommand/sign.rs index 02ab72c..bd82053 100644 --- a/src/subcommand/sign.rs +++ b/src/subcommand/sign.rs @@ -23,10 +23,7 @@ impl Sign { current_dir()?.join(Manifest::FILENAME) }; - let json = filesystem::read_to_string(&path)?; - - let mut manifest = serde_json::from_str::(&json) - .context(error::DeserializeManifest { path: &path })?; + let mut manifest = Manifest::load(&path)?; let root_hash = manifest.root_hash(); diff --git a/templates/page.html b/templates/page.html new file mode 100644 index 0000000..752da80 --- /dev/null +++ b/templates/page.html @@ -0,0 +1,47 @@ + + + + + +%% if let Some(metadata) = &self.metadata { + {{ metadata.title }} +%% } + + + +%% if let Some(metadata) = &self.metadata { +

{{ metadata.title }}

+%% } +
+
file count
+
{{ self.manifest.files.len() }}
+
total size
+
{{ Bytes(self.manifest.total_size()) }}
+
root hash
+
{{ self.manifest.root_hash() }}
+
signatures
+%% for key in self.manifest.signatures.keys() { +
{{ key }}
+%% } +
files
+
+ +%% for (path, entry) in &self.manifest.files { + +%% if self.present.contains(path) { + +%% } else { + +%% } + + +%% } +
{{ path }}{{ path }}{{ Bytes(entry.size.into()) }}
+
+
+ + diff --git a/tests/lib.rs b/tests/lib.rs index 4d5dd9f..f9ab671 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -53,5 +53,6 @@ mod key; mod keygen; mod man; mod misc; +mod render; mod sign; mod verify; diff --git a/tests/render.rs b/tests/render.rs new file mode 100644 index 0000000..359bd9a --- /dev/null +++ b/tests/render.rs @@ -0,0 +1,133 @@ +use super::*; + +#[test] +fn from_current_directory() { + let dir = TempDir::new().unwrap(); + + Command::cargo_bin("filepack") + .unwrap() + .arg("create") + .current_dir(&dir) + .assert() + .success(); + + Command::cargo_bin("filepack") + .unwrap() + .arg("render") + .current_dir(&dir) + .assert() + .stdout(is_match(".*")) + .success(); +} + +#[test] +fn from_directory() { + let dir = TempDir::new().unwrap(); + + Command::cargo_bin("filepack") + .unwrap() + .arg("create") + .current_dir(&dir) + .assert() + .success(); + + Command::cargo_bin("filepack") + .unwrap() + .args(["render", "."]) + .current_dir(&dir) + .assert() + .stdout(is_match(".*")) + .success(); +} + +#[test] +fn from_file() { + let dir = TempDir::new().unwrap(); + + Command::cargo_bin("filepack") + .unwrap() + .arg("create") + .current_dir(&dir) + .assert() + .success(); + + Command::cargo_bin("filepack") + .unwrap() + .args(["render", "filepack.json"]) + .current_dir(&dir) + .assert() + .stdout(is_match(".*")) + .success(); +} + +#[test] +fn with_metadata() { + let dir = TempDir::new().unwrap(); + + dir.child("metadata.yaml").write_str("title: foo").unwrap(); + + dir.child("foo/bar").touch().unwrap(); + + Command::cargo_bin("filepack") + .unwrap() + .args(["create", "--metadata", "metadata.yaml", "foo"]) + .current_dir(&dir) + .assert() + .success(); + + Command::cargo_bin("filepack") + .unwrap() + .args(["render", "foo"]) + .current_dir(&dir) + .assert() + .stdout(is_match(".*foo.*")) + .success(); +} + +#[test] +fn links_to_present_files() { + let dir = TempDir::new().unwrap(); + + dir.child("foo/bar").touch().unwrap(); + + Command::cargo_bin("filepack") + .unwrap() + .args(["create", "foo"]) + .current_dir(&dir) + .assert() + .success(); + + Command::cargo_bin("filepack") + .unwrap() + .args(["render", "foo"]) + .current_dir(&dir) + .assert() + .stdout(is_match( + r#".*bar.*"#, + )) + .success(); +} + +#[test] +fn does_not_link_to_missing_files() { + let dir = TempDir::new().unwrap(); + + dir.child("foo/bar").touch().unwrap(); + + Command::cargo_bin("filepack") + .unwrap() + .args(["create", "foo"]) + .current_dir(&dir) + .assert() + .success(); + + fs::remove_file(dir.child("foo/bar")).unwrap(); + + Command::cargo_bin("filepack") + .unwrap() + .args(["render", "foo"]) + .current_dir(&dir) + .assert() + .stdout(is_match(r".*bar.*")) + .success(); +}