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};131415pub struct Exclusions(pub BTreeSet<PathBuf>);1617#[derive(Debug, Clone, Copy, PartialEq, Eq)]18pub enum Mode {19 20 Correct,21 22 23 JsonnetBundler,24}2526272829303132pub 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}42434445fn 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 73 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}99100101102fn 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 147 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 162 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 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 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 219 assert_eq!(220 hash_dir(&dir, &no_exclusions(), None, Mode::Correct).expect("hash"),221 "ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0="222 );223 224 assert_eq!(225 hash_dir(&dir, &no_exclusions(), None, Mode::JsonnetBundler).expect("hash"),226 "+44g/C5MPySMYMOb1lLzwTRymLuXe4tNWQO4UFViBgM="227 );228 }229}