initial mess

This commit is contained in:
Laurenz 2024-08-24 22:40:09 +02:00
parent 97ba567886
commit 2c372b1038
Signed by: C0ffeeCode
SSH key fingerprint: SHA256:jnEltBNftC3wUZESLSMvM9zVPOkkevGRzqqoW2k2ORI
8 changed files with 1913 additions and 1 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
.vscode/
/target /target

1516
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,27 @@
[package] [package]
name = "lockman" name = "lockman"
description = "Management of dependencies: Downloads files and verifies integrity by hashes"
license = "MIT"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
authors = ["C0ffeeCode", "Satoqz"]
repository = "https://github.com/satoqz/lockman"
[dependencies] [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
View 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
View 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
View 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
View 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>,
}

View file

@ -1,3 +1,21 @@
use clap::Parser;
use cli::Commands;
mod check_command;
mod cli;
mod download_command;
mod lockfile;
fn main() { 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
} }