git.delta.rocks / jrsonnet / refs/heads / master

difftreelog

source

crates/jrsonnet-pkg/src/install/hash.rs6.6 KiBsourcehistory
1use std::{2	collections::BTreeSet,3	fs::{self, File},4	io::Read,5	path::{Path, PathBuf},6};78use base64::{Engine, engine::general_purpose::STANDARD};9use sha2::{Digest, Sha256};10use tracing::{debug, warn};1112use super::{Error, Result, VERSION_FILE};1314/// Paths excluded from the hash of the directory they are nested in.15pub struct Exclusions(pub BTreeSet<PathBuf>);1617#[derive(Debug, Clone, Copy, PartialEq, Eq)]18pub enum Mode {19	/// Every file in the tree is hashed.20	Correct,21	/// Reproduces the checksums of `jsonnet-bundler`, which stops hashing the22	/// rest of the tree as soon as a file can't be read.23	JsonnetBundler,24}2526/// Checksum of a vendored directory, compatible with `jsonnet-bundler`:27/// base64 of sha256 over the concatenated contents of every file, walked in28/// lexicographic order per directory.29///30/// `version` is the contents of the [`VERSION_FILE`] marker written by `jrb`;31/// the marker is not part of the hash, as `jsonnet-bundler` doesn't write it.32pub fn hash_dir(33	dir: &Path,34	exclude: &Exclusions,35	version: Option<&str>,36	mode: Mode,37) -> Result<String> {38	let mut hasher = Sha256::new();39	walk(dir, version, exclude, mode, &mut hasher)?;40	Ok(STANDARD.encode(hasher.finalize()))41}4243/// Returns `false` if the walk was aborted the way `jsonnet-bundler` aborts it,44/// leaving the hash truncated.45fn walk(46	dir: &Path,47	version: Option<&str>,48	exclude: &Exclusions,49	mode: Mode,50	hasher: &mut Sha256,51) -> Result<bool> {52	let mut entries = fs::read_dir(dir)53		.and_then(Iterator::collect::<std::result::Result<Vec<_>, _>>)54		.map_err(|e| Error::Io(dir.to_owned(), e))?;55	entries.sort_by_key(fs::DirEntry::file_name);5657	for entry in entries {58		let path = entry.path();59		if exclude.0.contains(&path) {60			continue;61		}62		let ty = entry.file_type().map_err(|e| Error::Io(path.clone(), e))?;63		if ty.is_dir() {64			if !walk(&path, None, exclude, mode, hasher)? {65				return Ok(false);66			}67			continue;68		}69		if entry.file_name() == VERSION_FILE && is_marker(&path, version) {70			continue;71		}72		// jsonnet-bundler follows symlinks, and stops hashing the whole tree if73		// the link doesn't resolve to a file.74		if ty.is_symlink() && !fs::metadata(&path).is_ok_and(|m| m.is_file()) {75			if mode == Mode::JsonnetBundler {76				warn!(77					"{}: symlink to a non-file, jsonnet-bundler truncates the checksum of this tree here",78					path.display()79				);80				return Ok(false);81			}82			debug!("{}: symlink to a non-file, not hashed", path.display());83			continue;84		}85		if let Err(e) = hash_file(&path, hasher) {86			if mode == Mode::Correct {87				return Err(Error::Io(path, e));88			}89			warn!(90				"{}: {e}, jsonnet-bundler stops hashing here",91				path.display()92			);93			return Ok(false);94		}95	}9697	Ok(true)98}99100/// Whether this [`VERSION_FILE`] was written by `jrb`, and not shipped by the101/// dependency itself.102fn is_marker(path: &Path, version: Option<&str>) -> bool {103	let Some(version) = version else {104		return false;105	};106	fs::read_to_string(path).is_ok_and(|v| v.trim() == version)107}108109fn hash_file(path: &Path, hasher: &mut Sha256) -> std::io::Result<()> {110	let mut file = File::open(path)?;111	let mut buf = vec![0; 64 * 1024];112	loop {113		let read = file.read(&mut buf)?;114		if read == 0 {115			return Ok(());116		}117		hasher.update(&buf[..read]);118	}119}120121#[cfg(test)]122mod tests {123	use std::fs;124125	use super::*;126127	fn tempdir(name: &str) -> PathBuf {128		let dir = std::env::temp_dir().join(format!("jrsonnet-pkg-hash-{name}"));129		let _ = fs::remove_dir_all(&dir);130		fs::create_dir_all(&dir).expect("create tempdir");131		dir132	}133134	fn no_exclusions() -> Exclusions {135		Exclusions(BTreeSet::new())136	}137138	#[test]139	fn matches_jsonnet_bundler() {140		let dir = tempdir("basic");141		fs::write(dir.join("b.libsonnet"), "b").expect("write");142		fs::write(dir.join("a.libsonnet"), "a").expect("write");143		fs::create_dir(dir.join("sub")).expect("mkdir");144		fs::write(dir.join("sub/c.libsonnet"), "c").expect("write");145146		// sha256("abc"), as walked in lexicographic order147		assert_eq!(148			hash_dir(&dir, &no_exclusions(), None, Mode::Correct).expect("hash"),149			"ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0="150		);151	}152153	#[test]154	fn version_marker_is_not_hashed() {155		let dir = tempdir("version");156		fs::write(dir.join("a.libsonnet"), "a").expect("write");157		fs::write(dir.join(VERSION_FILE), "deadbeef\n").expect("write");158		fs::create_dir(dir.join("sub")).expect("mkdir");159		fs::write(dir.join("sub").join(VERSION_FILE), "bc").expect("write");160161		// sha256("abc"): the nested .version is a regular file for jsonnet-bundler162		assert_eq!(163			hash_dir(&dir, &no_exclusions(), Some("deadbeef"), Mode::Correct).expect("hash"),164			"ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0="165		);166	}167168	#[test]169	fn shipped_version_file_is_hashed() {170		let dir = tempdir("version-shipped");171		fs::write(dir.join(VERSION_FILE), "a").expect("write");172		fs::write(dir.join("b.libsonnet"), "bc").expect("write");173174		// sha256("abc")175		assert_eq!(176			hash_dir(&dir, &no_exclusions(), Some("deadbeef"), Mode::Correct).expect("hash"),177			"ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0="178		);179	}180181	#[test]182	fn excluded_paths_are_skipped() {183		let dir = tempdir("exclude");184		fs::write(dir.join("a.libsonnet"), "abc").expect("write");185		fs::create_dir(dir.join("nested")).expect("mkdir");186		fs::write(dir.join("nested/d.libsonnet"), "d").expect("write");187188		let exclude = Exclusions(std::iter::once(dir.join("nested")).collect());189		assert_eq!(190			hash_dir(&dir, &exclude, None, Mode::Correct).expect("hash"),191			"ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0="192		);193	}194195	#[test]196	#[cfg(unix)]197	fn symlink_to_file_is_followed() {198		let dir = tempdir("symlink-file");199		fs::write(dir.join("a.libsonnet"), "abc").expect("write");200		std::os::unix::fs::symlink("a.libsonnet", dir.join("b.libsonnet")).expect("symlink");201202		// sha256("abcabc")203		assert_eq!(204			hash_dir(&dir, &no_exclusions(), None, Mode::Correct).expect("hash"),205			"u7Wdo6+Tn3r182DyzrgKSW47rhzYfd5CbbCuQGd+HCw="206		);207	}208209	#[test]210	#[cfg(unix)]211	fn symlink_to_dir_truncates_only_in_compat() {212		let dir = tempdir("symlink-dir");213		fs::write(dir.join("a.libsonnet"), "ab").expect("write");214		fs::create_dir(dir.join("sub")).expect("mkdir");215		fs::write(dir.join("sub/z.libsonnet"), "c").expect("write");216		std::os::unix::fs::symlink("sub", dir.join("b")).expect("symlink");217218		// the symlink is skipped, sha256("abc")219		assert_eq!(220			hash_dir(&dir, &no_exclusions(), None, Mode::Correct).expect("hash"),221			"ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0="222		);223		// jsonnet-bundler stops at the symlink, never reaching sub/: sha256("ab")224		assert_eq!(225			hash_dir(&dir, &no_exclusions(), None, Mode::JsonnetBundler).expect("hash"),226			"+44g/C5MPySMYMOb1lLzwTRymLuXe4tNWQO4UFViBgM="227		);228	}229}