diff --git a/Cargo.lock b/Cargo.lock index 5a804fa..3486d9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -188,7 +188,7 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "l-s" -version = "0.1.1" +version = "0.2.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index d66a955..c5abdd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "l-s" -version = "0.1.1" +version = "0.2.0" authors = ["licsber "] edition = "2021" diff --git a/README.md b/README.md index fc196d8..c35fe49 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,29 @@ # l-s -Summary any file‘s meta. +为任意文件或文件夹生成和校验元数据(meta 信息)的工具。 ## 使用说明 -接收1个命令行参数,为文件/文件夹路径,如命令行参数为空,则默认为当前路径。 +程序接受一个命令行参数,作为文件或文件夹路径。如果未提供参数,则默认处理当前目录。 -元数据均以json格式保存,且经过pretty打印,以方便人类阅读。 +元数据信息以 json 格式保存,且采用美观(pretty)打印以方便阅读。 -对于输入参数是文件的情况: +**如果输入的是文件:** -文件的元数据命名与原文件相同,仅新增json拓展名。 +- 元数据文件与原文件同名,仅添加 `.json` 扩展名,存放在文件所在目录下的 `meta` 子文件夹内(文件夹不存在则自动创建)。 +- 若不存在相应元数据文件,则自动计算并保存。 +- 若元数据文件已存在,则仅校验其中的 `xxh128` 哈希值。 + - 校验通过将提示“校验通过”。 + - 校验不通过时,会重新计算并输出当前元数据到标准输出,同时保留原元数据文件不变。 -文件的元数据保存路径是其所在路径下的meta子文件夹,如果元数据保存路径不存在,则新建。 +**如果输入的是文件夹:** -如果文件的元数据不存在,则计算元数据并保存。 - -如果文件的元数据存在,则对元数据中的xxh128部分进行校验。 - -如果校验通过,则打印校验成功。 - -如果校验不通过,计算当前的元数据并打印到标准输出,保留原元数据文件不动。 - -对于输入参数是文件夹的情况: - -遍历这个文件夹,以字母序遍历遇到的每一个子文件夹和文件。 - -文件夹的元数据文件就保存在其中,命名为`meta.json`。 - -如果文件夹的元数据不存在,则遍历计算并保存。 - -如果文件夹的元数据存在,则对原元数据文件重命名为`meta-old.json`,并对其中所有文件的xxh128部分校验。 - -遍历如果发现有新增文件或删除文件,都应打印到标准输出提示用户。 - -如果全部校验一致,则重新命名回`meta.json`。 +- 若不存在`meta.json`,则按字母序遍历目录树,计算全部文件元数据并写入`meta.json`。 +- 若`meta.json`已存在: + - 程序会先将其重命名为`meta-old.json`,提示用户正在校验旧数据; + - 遍历当前目录结构,仅计算每个文件的`xxh128`并与旧元数据对比; + - 发现校验失败、新增或缺失的文件都会即时打印到标准输出; + - 若全部一致,则将`meta-old.json`重命名回`meta.json`;如有异常则保留`meta-old.json`供排查(不会生成新的`meta.json`)。 标准`meta.json`的格式如下: diff --git a/src/main.rs b/src/main.rs index 906b225..4d04172 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,7 @@ use std::time::Instant; use anyhow::{Context, Result}; use clap::Parser; -use meta::{DirSnapshot, FileMeta}; +use meta::{calc_xxh128, scan_dir_xxh128, DirSnapshot, FileMeta}; fn main() -> Result<()> { let started = Instant::now(); @@ -30,7 +30,6 @@ fn main() -> Result<()> { } fn process_file(path: &Path) -> Result<()> { - let meta = FileMeta::from_path(path)?; let meta_dir = path .parent() .map(Path::to_path_buf) @@ -39,8 +38,13 @@ fn process_file(path: &Path) -> Result<()> { fs::create_dir_all(&meta_dir) .with_context(|| format!("无法创建目录: {}", meta_dir.display()))?; - let save_path = meta_dir.join(format!("{}.json", meta.basename)); + let basename = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let save_path = meta_dir.join(format!("{basename}.json")); if !save_path.exists() { + let meta = FileMeta::from_path(path)?; let json = meta.to_pretty_json()?; println!("{}", json); fs::write(&save_path, json)?; @@ -50,48 +54,79 @@ fn process_file(path: &Path) -> Result<()> { let existing = File::open(&save_path) .with_context(|| format!("无法读取历史元数据: {}", save_path.display()))?; let old_meta = FileMeta::from_reader(existing)?; - if meta.matches(&old_meta) { + let fast_hash = calc_xxh128(path)?; + if fast_hash == old_meta.xxh128 { println!("校验通过."); - } else { - println!("校验失败!"); - println!("现校验文件:"); - println!("{}", meta.to_pretty_json()?); - println!("原校验文件:"); - println!("{}", old_meta.to_pretty_json()?); + return Ok(()); } + println!("校验失败!"); + println!("现校验文件:"); + let meta = FileMeta::from_path(path)?; + println!("{}", meta.to_pretty_json()?); + println!("原校验文件:"); + println!("{}", old_meta.to_pretty_json()?); + Ok(()) } fn process_dir(path: &Path) -> Result<()> { - let save_path = path.join("meta.json"); - let old_path = path.join("meta-old.json"); - let has_old = save_path.exists(); + let meta_path = path.join("meta.json"); + let backup_path = path.join("meta-old.json"); - if has_old { - if old_path.exists() { - fs::remove_file(&old_path)?; - } - fs::rename(&save_path, &old_path) - .with_context(|| format!("无法备份旧文件: {}", save_path.display()))?; + if !meta_path.exists() { + let snapshot = DirSnapshot::build_root(path)?; + let json = serde_json::to_string_pretty(&snapshot)?; + let mut file = File::create(&meta_path) + .with_context(|| format!("无法写入: {}", meta_path.display()))?; + file.write_all(json.as_bytes())?; + return Ok(()); } - let snapshot = DirSnapshot::build_root(path)?; - let json = serde_json::to_string_pretty(&snapshot)?; - let mut file = - File::create(&save_path).with_context(|| format!("无法写入: {}", save_path.display()))?; - file.write_all(json.as_bytes())?; + if backup_path.exists() { + fs::remove_file(&backup_path)?; + } - if has_old { - let old_meta = FileMeta::from_path(&old_path)?; - let new_meta = FileMeta::from_path(&save_path)?; - if old_meta.matches(&new_meta) { - println!("校验通过."); - fs::remove_file(&old_path)?; + fs::rename(&meta_path, &backup_path) + .with_context(|| format!("无法重命名旧meta: {}", meta_path.display()))?; + println!("发现旧元数据,已暂存为 meta-old.json,开始校验..."); + + let meta_file = + File::open(&backup_path).with_context(|| format!("无法读取: {}", backup_path.display()))?; + let snapshot = DirSnapshot::from_reader(meta_file)?; + let mut stored = snapshot.collect_file_map(path); + let current = scan_dir_xxh128(path)?; + let mut issues = false; + + for (file_path, hash) in current { + if let Some(meta) = stored.remove(&file_path) { + if hash != meta.xxh128 { + println!( + "校验失败: {}\n 期望: {}\n 当前: {}", + file_path.display(), + meta.xxh128, + hash + ); + issues = true; + } } else { - println!("校验失败!"); + println!("文件新增: {}", file_path.display()); + issues = true; } } + for (missing_path, _) in stored { + println!("文件缺失: {}", missing_path.display()); + issues = true; + } + + if issues { + println!("校验存在异常,已保留 meta-old.json 供排查。"); + } else { + println!("校验通过."); + fs::rename(&backup_path, &meta_path) + .with_context(|| format!("无法恢复meta: {}", meta_path.display()))?; + } + Ok(()) } diff --git a/src/meta/file.rs b/src/meta/file.rs index e1b6983..d1f8c25 100644 --- a/src/meta/file.rs +++ b/src/meta/file.rs @@ -111,13 +111,20 @@ impl FileMeta { pub fn to_pretty_json(&self) -> Result { Ok(serde_json::to_string_pretty(self)?) } - - pub fn matches(&self, other: &Self) -> bool { - self.size == other.size - && self.ed2k == other.ed2k - && self.md5 == other.md5 - && self.sha1 == other.sha1 - && self.sha256 == other.sha256 - && self.xxh128 == other.xxh128 - } +} + +pub fn calc_xxh128(path: &Path) -> Result { + let mut file = File::open(path).with_context(|| format!("无法打开文件: {}", path.display()))?; + let mut buffer = vec![0u8; DEFAULT_BUFFER_SIZE]; + let mut hasher = Xxh3::new(); + + loop { + let read_len = file.read(&mut buffer)?; + if read_len == 0 { + break; + } + hasher.update(&buffer[..read_len]); + } + + Ok(hex_upper(hasher.digest128().to_be_bytes())) } diff --git a/src/meta/mod.rs b/src/meta/mod.rs index d521503..016495f 100644 --- a/src/meta/mod.rs +++ b/src/meta/mod.rs @@ -1,5 +1,5 @@ mod file; mod tree; -pub use file::FileMeta; -pub use tree::DirSnapshot; +pub use file::{calc_xxh128, FileMeta}; +pub use tree::{scan_dir_xxh128, DirSnapshot}; diff --git a/src/meta/tree.rs b/src/meta/tree.rs index 3f7d5aa..ffe3a4e 100644 --- a/src/meta/tree.rs +++ b/src/meta/tree.rs @@ -1,10 +1,11 @@ +use std::collections::BTreeMap; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -use super::file::FileMeta; +use super::file::{calc_xxh128, FileMeta}; use crate::constants::META_VERSION; use crate::utils::{basename, should_skip_dir, should_skip_file}; @@ -24,6 +25,10 @@ impl DirSnapshot { Ok(node) } + pub fn from_reader(reader: R) -> Result { + Ok(serde_json::from_reader(reader)?) + } + fn build_node(path: &Path) -> Result { let dir_name = path .file_name() @@ -73,4 +78,61 @@ impl DirSnapshot { v: None, }) } + + pub fn collect_file_map(&self, root: &Path) -> BTreeMap { + let mut map = BTreeMap::new(); + self.collect_into(root.to_path_buf(), &mut map); + map + } + + fn collect_into(&self, current: PathBuf, map: &mut BTreeMap) { + for file in &self.files { + map.insert(current.join(&file.basename), file.clone()); + } + + for dir in &self.dirs { + let next = current.join(&dir.dir_name); + dir.collect_into(next, map); + } + } +} + +pub fn scan_dir_xxh128(path: &Path) -> Result> { + let mut map = BTreeMap::new(); + walk_dir(path, &mut map)?; + Ok(map) +} + +fn walk_dir(path: &Path, map: &mut BTreeMap) -> Result<()> { + let mut entries = fs::read_dir(path) + .with_context(|| format!("无法遍历目录: {}", path.display()))? + .collect::, _>>() + .with_context(|| format!("读取目录失败: {}", path.display()))?; + entries.sort_by(|a, b| a.file_name().cmp(&b.file_name())); + + for entry in entries { + let file_name = entry.file_name(); + let name = file_name.to_string_lossy().to_string(); + let full_path = entry.path(); + let file_type = entry + .file_type() + .with_context(|| format!("无法读取类型: {}", full_path.display()))?; + + if file_type.is_dir() { + if should_skip_dir(&name) { + continue; + } + walk_dir(&full_path, map)?; + continue; + } + + if should_skip_file(&name) { + continue; + } + + let hash = calc_xxh128(&full_path)?; + map.insert(full_path, hash); + } + + Ok(()) }