initial mess
This commit is contained in:
parent
97ba567886
commit
2c372b1038
8 changed files with 1913 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
.vscode/
|
||||
/target
|
||||
|
|
|
|||
1516
Cargo.lock
generated
1516
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
21
Cargo.toml
21
Cargo.toml
|
|
@ -1,6 +1,27 @@
|
|||
[package]
|
||||
name = "lockman"
|
||||
description = "Management of dependencies: Downloads files and verifies integrity by hashes"
|
||||
license = "MIT"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["C0ffeeCode", "Satoqz"]
|
||||
repository = "https://github.com/satoqz/lockman"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.16", features = ["derive"] }
|
||||
reqwest = "0.12.7"
|
||||
serde = { version = "1.0.208", features = ["derive"] }
|
||||
sha2 = "0.10.8"
|
||||
tokio = { version = "1.39.3", features = ["macros"] }
|
||||
toml = "0.8.19"
|
||||
|
||||
[lints.clippy]
|
||||
# uninlined_format_args = { level = "warn", priority = -1 }
|
||||
correctness = "warn"
|
||||
suspicious = "warn"
|
||||
complexity = "warn"
|
||||
perf = "warn"
|
||||
style = "warn"
|
||||
pedantic = "warn"
|
||||
# restriction = "warn"
|
||||
# cargo = "warn"
|
||||
|
|
|
|||
75
src/check_command.rs
Normal file
75
src/check_command.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
use std::{
|
||||
fs::{self, File},
|
||||
io,
|
||||
path::Path,
|
||||
process::exit,
|
||||
};
|
||||
|
||||
use sha2::{Digest, Sha512};
|
||||
|
||||
use crate::{
|
||||
cli::{
|
||||
colors::{GREEN, RED, RESET, YELLOW},
|
||||
CheckArgs,
|
||||
},
|
||||
lockfile::LockFileV1,
|
||||
};
|
||||
|
||||
pub fn check_command(args: CheckArgs) {
|
||||
let file = fs::read_to_string("Lockfile").expect("Lockfile not found");
|
||||
let lf: LockFileV1 = toml::from_str(&file).unwrap();
|
||||
let lf = lf.locks;
|
||||
// TODO: Check files in lockfile are still specified in project file
|
||||
|
||||
let results = lf.iter().map(|i| (i.0, i.1, check_item(i)));
|
||||
|
||||
for item in results {
|
||||
match item.2 {
|
||||
CheckResult::Ok => {
|
||||
if !args.only_report_mismatches {
|
||||
println!("{GREEN}OK{RESET}\t{}", item.0);
|
||||
}
|
||||
}
|
||||
CheckResult::Invalid => {
|
||||
println!("{RED}INVALID{RESET}\t{}", item.0);
|
||||
if args.fast_fail {
|
||||
eprintln!("Quitting as an invalid file was found and fast-fail is enabled.");
|
||||
exit(5);
|
||||
}
|
||||
}
|
||||
CheckResult::Absent => {
|
||||
println!("{YELLOW}ABSENT{RESET}\t{}", item.0);
|
||||
}
|
||||
CheckResult::NotAFile => todo!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_item(item: (&String, &String)) -> CheckResult {
|
||||
let path = Path::new(item.0);
|
||||
if !path.exists() {
|
||||
return CheckResult::Absent;
|
||||
} else if !path.is_file() {
|
||||
return CheckResult::NotAFile;
|
||||
}
|
||||
|
||||
let mut file = File::open(path).unwrap();
|
||||
let mut hasher = Sha512::new();
|
||||
|
||||
io::copy(&mut file, &mut hasher).unwrap();
|
||||
let hash = hasher.finalize();
|
||||
let hash = format!("{hash:x}");
|
||||
|
||||
if hash == *item.1 {
|
||||
CheckResult::Ok
|
||||
} else {
|
||||
CheckResult::Invalid
|
||||
}
|
||||
}
|
||||
|
||||
pub enum CheckResult {
|
||||
Ok,
|
||||
Invalid,
|
||||
Absent,
|
||||
NotAFile,
|
||||
}
|
||||
95
src/cli.rs
Normal file
95
src/cli.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
#![warn(clippy::restriction)]
|
||||
|
||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
|
||||
pub mod colors {
|
||||
pub const RESET: &str = "\x1b[0m";
|
||||
pub const RED: &str = "\x1b[31m";
|
||||
pub const GREEN: &str = "\x1b[32m";
|
||||
pub const YELLOW: &str = "\x1b[33m";
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about, long_about = None)]
|
||||
#[command(propagate_version = true)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
}
|
||||
|
||||
// #[derive(Args, Clone)]
|
||||
// pub struct GlobalArgs {
|
||||
// /// Path to the project file
|
||||
// #[arg(long, default_value = "Project.toml")]
|
||||
// pub project_file: String,
|
||||
|
||||
// /// Path to the lockfile
|
||||
// #[arg(long, default_value = "Lockfile.toml")]
|
||||
// pub lockfile: String,
|
||||
|
||||
// #[arg(short = 'f', long = "format", default_value = "human")]
|
||||
// pub output_format: OutputFormat,
|
||||
// }
|
||||
|
||||
/// Format of the output which is printed to standard output
|
||||
#[derive(Clone, ValueEnum)]
|
||||
pub enum OutputFormat {
|
||||
/// Human readable output
|
||||
Human,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Downloads files as specified in the project file and validates according to the lockfile
|
||||
Download(DownloadArgs),
|
||||
/// Checks the integrity of the local files to be in accordance with the lockfile
|
||||
Check(CheckArgs),
|
||||
/// Checks if the remote resources are the same as specified in the lockfile
|
||||
CheckAvailability,
|
||||
/// Adds a new file to the project, pinning its hash
|
||||
Add,
|
||||
AddDownload,
|
||||
}
|
||||
|
||||
#[derive(Args, Copy, Clone)]
|
||||
pub struct DownloadArgs {
|
||||
/// Quit on the first hash mismatch or unavailable resource
|
||||
#[arg(default_value_t = false, long = "fast-fail")]
|
||||
pub fast_fail: bool,
|
||||
|
||||
/// If true, directly write to the designated file, otherwise
|
||||
/// the downloaded data is stored at a temporary place
|
||||
/// and copied to the designated place once its verified
|
||||
/// TODO: Perhaps in the future, files may be hold in memory if they are not expected to be huge
|
||||
#[arg(default_value_t = false, long = "direct-write")]
|
||||
pub direct_write: bool,
|
||||
|
||||
/// How are present files ought to be handled?
|
||||
#[arg(value_enum, default_value_t = DownloadExistingTreatment::ValidateFail, long = "clash-behavior")]
|
||||
pub existing_file_behavior: DownloadExistingTreatment,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
|
||||
pub enum DownloadExistingTreatment {
|
||||
/// Validate existing files and attempt to replace them if they are invalid
|
||||
ValidateReplace,
|
||||
/// Validate existing files and abort if they are invalid
|
||||
/// TODO: Atomic? Not implemented
|
||||
ValidateFail,
|
||||
/// Validate existing files; ignore invalid ones and report them later on
|
||||
ValidateReport,
|
||||
/// Skip existing files; they are not validated
|
||||
/// TODO: Not implemented
|
||||
Ignore,
|
||||
}
|
||||
|
||||
#[derive(Args, Copy, Clone)]
|
||||
pub struct CheckArgs {
|
||||
/// Panic on first hash mismatch (not impacted by absent files)
|
||||
#[arg(default_value_t = false, long = "fast-fail")]
|
||||
pub fast_fail: bool,
|
||||
|
||||
/// Omit files with valid files in output
|
||||
#[arg(default_value_t = true, long = "list-invalid-only")]
|
||||
pub only_report_mismatches: bool,
|
||||
}
|
||||
165
src/download_command.rs
Normal file
165
src/download_command.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
use std::{
|
||||
fs::{self, File}, io::Write, path::PathBuf, process::exit, time::SystemTime
|
||||
};
|
||||
|
||||
use reqwest::StatusCode;
|
||||
use sha2::{Digest, Sha512};
|
||||
|
||||
use crate::{
|
||||
check_command::{check_item, CheckResult},
|
||||
cli::{DownloadArgs, DownloadExistingTreatment},
|
||||
lockfile::{LockFileV1, ProjectFileV1},
|
||||
};
|
||||
|
||||
pub fn download_command(args: DownloadArgs) {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.expect("Failed to build tokio runtime");
|
||||
|
||||
rt.block_on(download_command_async(args));
|
||||
}
|
||||
|
||||
pub async fn download_command_async(args: DownloadArgs) {
|
||||
let lf = fs::read_to_string("Lockfile").expect("Lockfile not found");
|
||||
let lf: LockFileV1 = toml::from_str(&lf).unwrap();
|
||||
let lf = lf.locks;
|
||||
|
||||
let pf = fs::read_to_string("Projectfile").expect("Projectfile not found");
|
||||
let pf: ProjectFileV1 = toml::from_str(&pf).unwrap();
|
||||
let pf = pf.files;
|
||||
|
||||
let unlocked_files = pf
|
||||
.iter()
|
||||
.filter(|(path, _)| !lf.contains_key(*path))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let locked_files = pf
|
||||
.iter()
|
||||
.filter_map(|(path, url)| {
|
||||
lf.get(path).map(|eh| LockedFile3 {
|
||||
path: path.clone(),
|
||||
url: url.clone(),
|
||||
expected_hash: eh.clone(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
println!(
|
||||
"There are {} files on record, {} are without lock, {} are locked.",
|
||||
pf.len(),
|
||||
unlocked_files.len(),
|
||||
locked_files.len(),
|
||||
);
|
||||
|
||||
for record in locked_files {
|
||||
let check_res = check_item((&record.path, &record.expected_hash));
|
||||
match check_res {
|
||||
CheckResult::Ok => {
|
||||
println!("OK:\t {}", &record.path);
|
||||
}
|
||||
CheckResult::Invalid => match args.existing_file_behavior {
|
||||
DownloadExistingTreatment::ValidateReplace => {
|
||||
println!(
|
||||
"Downloading and replacing invalid file:\n\t{}",
|
||||
&record.path
|
||||
);
|
||||
let _ = download_file(args, &record).await;
|
||||
println!("Replaced invalid file:\n\t{}", &record.path);
|
||||
}
|
||||
DownloadExistingTreatment::ValidateFail => {
|
||||
println!("Existing file has an invalid hash:\n\t{}\n\texpected: {}\n\tfound: TODO ;)", record.path, record.expected_hash);
|
||||
}
|
||||
DownloadExistingTreatment::ValidateReport => todo!(),
|
||||
DownloadExistingTreatment::Ignore => todo!(),
|
||||
},
|
||||
CheckResult::NotAFile => todo!(),
|
||||
CheckResult::Absent => {
|
||||
println!("Downloading absent file:\n\t{}", &record.path);
|
||||
match download_file(args, &record).await {
|
||||
Ok(_) => {
|
||||
println!("Downloaded absent file:\n\t{}", &record.path);
|
||||
}
|
||||
Err(download_error) => {
|
||||
match download_error {
|
||||
DownloadError::ErrorResponse(sc) => {
|
||||
println!("Received an error HTTP status code ({sc}) upon downloading file: \n\t{}", record.path);
|
||||
exit(5);
|
||||
},
|
||||
DownloadError::HashMismatch { calculated, expected } => {
|
||||
println!("Downloaded file has a different hash:\n\tfile:{} \n\texpected: {}\n\treceived: {}", record.path, expected, calculated);
|
||||
exit(5);
|
||||
},
|
||||
DownloadError::IoError(err) => {
|
||||
println!("An I/O error occurred while attempting to download a file: {}\n{err}", record.path);
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_file(args: DownloadArgs, record: &LockedFile3) -> Result<String, DownloadError> {
|
||||
let mut res = reqwest::get(&record.url).await.unwrap();
|
||||
if res.status() != StatusCode::OK {
|
||||
return Err(DownloadError::ErrorResponse(res.status()));
|
||||
}
|
||||
let mut hasher = Sha512::new();
|
||||
|
||||
let mut file = if args.direct_write {
|
||||
// TODO: Handle not-existent paths
|
||||
File::create(&record.path).unwrap()
|
||||
} else {
|
||||
todo!()
|
||||
};
|
||||
|
||||
while let Some(chunk) = res.chunk().await.unwrap() {
|
||||
hasher.write_all(&chunk).unwrap();
|
||||
file.write_all(&chunk).unwrap();
|
||||
}
|
||||
|
||||
let hash = hasher.finalize();
|
||||
let hash = format!("{hash:x}");
|
||||
if hash != record.expected_hash {
|
||||
return Err(DownloadError::HashMismatch {
|
||||
calculated: hash,
|
||||
expected: record.expected_hash.clone(),
|
||||
});
|
||||
}
|
||||
if let Err(err) = file.flush() {
|
||||
return Err(DownloadError::IoError(err));
|
||||
}
|
||||
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
fn get_temp_dir() -> PathBuf {
|
||||
let particle = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let particle = format!("{}-{particle}", env!("CARGO_BIN_NAME"));
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(particle);
|
||||
path
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct LockedFile3 {
|
||||
pub path: String,
|
||||
pub url: String,
|
||||
pub expected_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum DownloadError {
|
||||
ErrorResponse(StatusCode),
|
||||
HashMismatch {
|
||||
calculated: String,
|
||||
expected: String,
|
||||
},
|
||||
IoError(std::io::Error),
|
||||
}
|
||||
21
src/lockfile.rs
Normal file
21
src/lockfile.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct ProjectFileV1 {
|
||||
#[serde(rename = "lockman")]
|
||||
pub version: String,
|
||||
|
||||
/// Map of file -> URL
|
||||
pub files: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct LockFileV1 {
|
||||
#[serde(rename = "lockfile")]
|
||||
pub version: String,
|
||||
|
||||
/// Map of file -> URL
|
||||
pub locks: HashMap<String, String>,
|
||||
}
|
||||
20
src/main.rs
20
src/main.rs
|
|
@ -1,3 +1,21 @@
|
|||
use clap::Parser;
|
||||
use cli::Commands;
|
||||
|
||||
mod check_command;
|
||||
mod cli;
|
||||
mod download_command;
|
||||
mod lockfile;
|
||||
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
let cli = cli::Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Download(args) => download_command::download_command(args),
|
||||
Commands::Check(args) => check_command::check_command(args),
|
||||
Commands::CheckAvailability => todo!(),
|
||||
Commands::Add => todo!(),
|
||||
Commands::AddDownload => todo!(),
|
||||
}
|
||||
|
||||
// TODO: Check project/lock schema version support
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue