From 55b40267ff51ec328c15394ae3889d3b4fed502ccd7270ab631d7a2d0919bf68 Mon Sep 17 00:00:00 2001 From: sisungo Date: Fri, 21 Aug 2026 10:01:06 +0000 Subject: [PATCH] initial commit Signed-off-by: sisungo --- .gitignore | 7 + Cargo.toml | 32 +++ INSTALL.md | 13 ++ LICENSE | 16 ++ README.md | 3 + cli/Cargo.toml | 20 ++ cli/src/clean.rs | 78 +++++++ cli/src/common.rs | 80 +++++++ cli/src/info.rs | 102 +++++++++ cli/src/initdb.rs | 10 + cli/src/install.rs | 106 +++++++++ cli/src/main.rs | 130 +++++++++++ cli/src/print.rs | 46 ++++ cli/src/remove.rs | 55 +++++ cli/src/repo_admin.rs | 57 +++++ cli/src/select.rs | 69 ++++++ cli/src/update.rs | 40 ++++ locales/libpackie/main.yml | 73 +++++++ locales/packie-cli/main.yml | 173 +++++++++++++++ src/common/checksum.rs | 41 ++++ src/common/fs.rs | 197 +++++++++++++++++ src/common/macros.rs | 23 ++ src/common/mod.rs | 46 ++++ src/config/mod.rs | 21 ++ src/config/repo.rs | 57 +++++ src/install.rs | 219 +++++++++++++++++++ src/lib.rs | 113 ++++++++++ src/link.rs | 105 +++++++++ src/local.rs | 304 ++++++++++++++++++++++++++ src/package.rs | 335 ++++++++++++++++++++++++++++ src/profile.rs | 43 ++++ src/remove.rs | 63 ++++++ src/repo/download.rs | 117 ++++++++++ src/repo/mod.rs | 424 ++++++++++++++++++++++++++++++++++++ src/repo/serve.rs | 105 +++++++++ src/version.rs | 265 ++++++++++++++++++++++ 36 files changed, 3588 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 INSTALL.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cli/Cargo.toml create mode 100644 cli/src/clean.rs create mode 100644 cli/src/common.rs create mode 100644 cli/src/info.rs create mode 100644 cli/src/initdb.rs create mode 100644 cli/src/install.rs create mode 100644 cli/src/main.rs create mode 100644 cli/src/print.rs create mode 100644 cli/src/remove.rs create mode 100644 cli/src/repo_admin.rs create mode 100644 cli/src/select.rs create mode 100644 cli/src/update.rs create mode 100644 locales/libpackie/main.yml create mode 100644 locales/packie-cli/main.yml create mode 100644 src/common/checksum.rs create mode 100644 src/common/fs.rs create mode 100644 src/common/macros.rs create mode 100644 src/common/mod.rs create mode 100644 src/config/mod.rs create mode 100644 src/config/repo.rs create mode 100644 src/install.rs create mode 100644 src/lib.rs create mode 100644 src/link.rs create mode 100644 src/local.rs create mode 100644 src/package.rs create mode 100644 src/profile.rs create mode 100644 src/remove.rs create mode 100644 src/repo/download.rs create mode 100644 src/repo/mod.rs create mode 100644 src/repo/serve.rs create mode 100644 src/version.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1099ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +/Cargo.lock +/.zed +/.idea +/.vscode +.DS_Store +._.DS_Store diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..54f00f1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "packie" +version = "0.1.0" +edition = "2024" + +[features] +default = ["zstd", "gzip"] +zstd = ["dep:zstd"] +gzip = ["dep:flate2"] + +[workspace] +members = ["cli"] + +[dependencies] +const-hex = "1" +digest = "0.11" +flate2 = { version = "1", optional = true } +rustc-hash = "2" +rust-i18n = "4" +rusqlite = { version = "0.40", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha3 = "0.12" +tar = "0.4" +tempfile = "3" +thiserror = "2" +toml = "1" +itertools = "0.15" +ureq = { version = "3", features = ["socks-proxy"] } +url = { version = "2", features = ["serde"] } +nix = { version = "0.31", features = ["fs"] } +zstd = { version = "0.13", optional = true } diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..741ca78 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,13 @@ +# Building & Installing Packie + +## Building Packie + +### Environment Variables + +These environment variables affect building of Packie: + +- `PACKIE_HOST_ARCH`: Host architecture. +- `PACKIE_PKG_DIR`: Package bundle directory. +- `PACKIE_DATA_DIR`: Packie data directory. +- `PACKIE_CONFIG_DIR`: Packie config directory. +- `PACKIE_CACHE_DIR`: Packie cache directory. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..842db23 --- /dev/null +++ b/LICENSE @@ -0,0 +1,16 @@ +Copyright © 2026 sisungo + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without +restriction, including without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e66b712 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Packie + +Packie is the SemiOS package manager. diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..1ff9603 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "packie-cli" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "packie" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +chrono = "0.4" +clap = { version = "4", features = ["derive"] } +console = "0.16" +nix = { version = "0.31", features = ["sched"] } +itertools = "0.15" +packie = { path = "../" } +rust-i18n = "4" +indicatif = "0.18" +dialoguer = "0.12" diff --git a/cli/src/clean.rs b/cli/src/clean.rs new file mode 100644 index 0000000..71bc360 --- /dev/null +++ b/cli/src/clean.rs @@ -0,0 +1,78 @@ +use clap::Parser; +use packie::PackieBuilder; +use rust_i18n::t; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +pub struct Cli { + #[arg(short, long, default_value = "true")] + packages: bool, + + #[arg(short, long)] + verbose: bool, +} + +pub fn main(cli: Cli) -> Result<(), crate::Error> { + let packie = PackieBuilder::new().build()?; + + let repos_dir = packie.cache().repos_dir(); + + if cli.packages { + clean_packages(&cli, &repos_dir); + } + + Ok(()) +} + +fn clean_packages(cli: &Cli, repos_dir: &PathBuf) { + let read_repos_dir_errx = + |e: &std::io::Error| eprintln!("{}", t!("clean.warn_repos_dir", error = e)); + let Ok(repos) = std::fs::read_dir(&repos_dir).inspect_err(read_repos_dir_errx) else { + return; + }; + for repo in repos { + let Ok(repo) = repo.inspect_err(read_repos_dir_errx) else { + break; + }; + let read_repo_dir_errx = |e: &std::io::Error| { + eprintln!( + "{}", + t!( + "clean.warn_repo_dir", + name = repo.file_name().display().to_string(), + error = e + ) + ) + }; + let Ok(repo) = std::fs::read_dir(repo.path()).inspect_err(read_repo_dir_errx) else { + continue; + }; + for file in repo { + let Ok(file) = file.inspect_err(read_repo_dir_errx) else { + break; + }; + if file.file_name().as_encoded_bytes().ends_with(b".pkg") { + match std::fs::remove_file(file.path()) { + Ok(()) => { + if cli.verbose { + eprintln!( + "{}", + t!("clean.info_remove_file", path = file.path().display()) + ); + } + } + Err(err) => { + eprintln!( + "{}", + t!( + "clean.warn_remove_file", + path = file.path().display(), + error = err + ) + ); + } + }; + } + } + } +} diff --git a/cli/src/common.rs b/cli/src/common.rs new file mode 100644 index 0000000..a15cfde --- /dev/null +++ b/cli/src/common.rs @@ -0,0 +1,80 @@ +use chrono::{Local, TimeZone}; +use console::{Term, style}; +use indicatif::ProgressStyle; +use rust_i18n::t; + +pub fn progress_style_multidownload() -> ProgressStyle { + let mut lines = Vec::with_capacity(3); + let (_, cols) = Term::stderr().size(); + + let short_first_line = format!("{{spinner}} {prefix}", prefix = style("{prefix}").green()); + let info_line = format!( + "{speed}{{bytes_per_sec}} {current}{{bytes}} {total}{{total_bytes}} {eta}{{eta}}", + speed = t!("common.progress_style.speed"), + current = t!("common.progress_style.current"), + total = t!("common.progress_style.total"), + eta = t!("common.progress_style.eta"), + ); + let long_first_line = format!("{short_first_line} {info_line}"); + if cols > 100 { + lines.push(&long_first_line[..]); + } else { + lines.push(&short_first_line[..]); + lines.push(&info_line[..]); + } + + lines.push("{wide_bar}"); + + ProgressStyle::with_template(&lines.join("\n")).unwrap() +} + +pub fn yesno(prompt: &str, default: bool) -> bool { + if default { + eprint!("{prompt} [Y/n] "); + } else { + eprint!("{prompt} [y/N] "); + } + let line = std::io::stdin() + .lines() + .next() + .transpose() + .unwrap_or_default() + .unwrap_or_default(); + let line = line.trim(); + ["y", "Y"].contains(&line) || (line.is_empty() && default) +} + +pub fn print_list(indent: u32, list: impl Iterator>) { + let (_, cols) = Term::stdout().size(); + let mut line_used: usize = 0; + for i in list { + let mut i = i.as_ref().to_string(); + if line_used >= cols as _ { + line_used = 0; + println!(); + } + if i.contains(' ') { + i = format!("'{i}'"); + } + let to_print = format!("{}{}", " ".repeat(indent as _), i); + print!("{to_print}"); + line_used += to_print.len(); + } + println!(); +} + +pub fn format_size(bytes: u64) -> String { + match bytes { + ..1000 => format!("{bytes} B"), + ..1_000_000 => format!("{} kB", bytes / 1000), + ..1_000_000_000 => format!("{:.2} MB", (bytes as f64) / 1000000.), + _ => format!("{:.3} GB", (bytes as f64) / 1000000000.), + } +} + +pub fn format_datetime(timestamp: i64) -> String { + let Some(datetime) = Local.timestamp_opt(timestamp, 0).earliest() else { + return "?".into(); + }; + datetime.to_string() +} diff --git a/cli/src/info.rs b/cli/src/info.rs new file mode 100644 index 0000000..c653bd7 --- /dev/null +++ b/cli/src/info.rs @@ -0,0 +1,102 @@ +use crate::{common::format_size, error}; +use clap::Parser; +use packie::{ + PackieBuilder, + local::Installation, + package::{PkgManifest, PkgSpec}, +}; +use rust_i18n::t; +use std::fmt::Write; + +#[derive(Debug, Parser)] +pub struct Cli { + pkgspec: PkgSpec, +} + +pub fn main(cli: Cli) -> Result<(), crate::Error> { + let mut packie = PackieBuilder::new().readonly(true).build()?; + let mut found = packie.search_installation(&cli.pkgspec)?; + if found.len() == 0 { + return Err(error!("info.no_package_found")); + } + if found.len() != 1 { + let mut note = String::new(); + writeln!(&mut note, "{}", t!("info.note_pkgspec_not_unique")).unwrap(); + for i in found.iter() { + writeln!(&mut note, " - {}", i.pkg_manifest.pkg_ident()).unwrap(); + } + let note = note.trim().into(); + + return Err(crate::Error { + message: t!("info.error_pkgspec_not_unique").into(), + note: Some(note), + }); + } + let found = found.remove(0); + + print_pkgmanifest(&found.pkg_manifest); + print_install_misc(&found); + + Ok(()) +} + +fn print_pkgmanifest(pkg_manifest: &PkgManifest) { + println!( + "{}: {}", + t!("info.print_pkgmanifest.name"), + pkg_manifest.name + ); + println!( + "{}: {}", + t!("info.print_pkgmanifest.version"), + pkg_manifest.version + ); + println!( + "{}: {}", + t!("info.print_pkgmanifest.arch"), + pkg_manifest.arch + ); + if !pkg_manifest.description.is_empty() { + println!( + "{}: {}", + t!("info.print_pkgmanifest.description"), + pkg_manifest.arch + ); + } + if !pkg_manifest.maintainers.is_empty() { + println!("{}:", t!("info.print_pkgmanifest.maintainers")); + for i in pkg_manifest.maintainers.iter() { + println!(" - {i}"); + } + } + if !pkg_manifest.dependencies.is_empty() { + println!("{}:", t!("info.print_pkgmanifest.dependencies")); + for i in pkg_manifest.dependencies.iter() { + println!(" - {i}"); + } + } + if !pkg_manifest.recommendations.is_empty() { + println!("{}:", t!("info.print_pkgmanifest.recommendations")); + for i in pkg_manifest.recommendations.iter() { + println!(" - {i}"); + } + } +} + +fn print_install_misc(installation: &Installation) { + println!( + "{}: {}", + t!("info.print_install_misc.install_pkgspec"), + installation.install_pkgspec + ); + println!( + "{}: {}", + t!("info.print_install_misc.install_date"), + crate::common::format_datetime(installation.install_date), + ); + println!( + "{}: {}", + t!("info.print_install_misc.installed_size"), + format_size(installation.installed_size), + ); +} diff --git a/cli/src/initdb.rs b/cli/src/initdb.rs new file mode 100644 index 0000000..ba968fd --- /dev/null +++ b/cli/src/initdb.rs @@ -0,0 +1,10 @@ +use clap::Parser; +use packie::PackieBuilder; + +#[derive(Debug, Parser)] +pub struct Cli {} + +pub fn main(_: Cli) -> Result<(), crate::Error> { + PackieBuilder::new().build()?; + Ok(()) +} diff --git a/cli/src/install.rs b/cli/src/install.rs new file mode 100644 index 0000000..8e09d15 --- /dev/null +++ b/cli/src/install.rs @@ -0,0 +1,106 @@ +use crate::common::{format_size, print_list, yesno}; +use clap::Parser; +use console::style; +use indicatif::ProgressBar; +use itertools::Itertools; +use packie::{ + PackieBuilder, install::InstallOptions, package::PkgSpec, repo::DownloadPackageEvent, +}; +use rust_i18n::t; + +#[derive(Debug, Parser)] +pub struct Cli { + /// Force the specified items to be files + #[arg(short, long)] + file: bool, + + /// Don't require manual confirmation + #[arg(short, long)] + yes: bool, + + /// Items to be installed + items: Vec, +} + +pub fn main(cli: Cli) -> Result<(), crate::Error> { + let mut packie = PackieBuilder::new().build()?; + if cli.file { + for item in cli.items { + packie.install_package_file(item, &InstallOptions::default())?; + } + return Ok(()); + } + + let mut packages = Vec::with_capacity(cli.items.len() * 4); + for i in cli.items { + packages.append(&mut packie.calculate_depgraph(i.parse::()?)?); + } + let packages: Vec<_> = packages + .into_iter() + .unique_by(|x| x.repo_pkg.pkg_manifest.pkg_ident()) + .collect(); + + if packages.is_empty() { + eprintln!("{}", t!("install.no_package_to_install")); + return Ok(()); + } + + let download_size: u64 = packages.iter().map(|x| x.repo_pkg.download_size).sum(); + let installed_size: u64 = packages.iter().map(|x| x.repo_pkg.installed_size).sum(); + + println!("{}", t!("install.prompt_to_install")); + print_list( + 4, + packages + .iter() + .map(|x| style(&x.pkgspec).green().to_string()), + ); + println!( + "{}", + t!("install.download_size", size = format_size(download_size)) + ); + println!( + "{}", + t!("install.installed_size", size = format_size(installed_size)) + ); + if cli.yes { + eprintln!("y"); + } else if !yesno(&t!("install.confirm_installation"), true) { + eprintln!("{}", t!("install.aborting")); + return Ok(()); + } + + let progress_bar = + ProgressBar::new(download_size).with_style(crate::common::progress_style_multidownload()); + let mut package_files = Vec::with_capacity(packages.len()); + for package in packages.iter() { + let init_pos = progress_bar.position(); + progress_bar.println(format!("Downloading \"{}\"...", package.pkgspec)); + progress_bar.set_prefix(package.repo_pkg.pkg_manifest.pkg_ident().to_string()); + let path = packie.download_package( + &package.repo_name, + &package.repo_pkg.pkg_manifest.pkg_ident(), + |ev| match ev { + DownloadPackageEvent::Progress(_, sum, _) => { + progress_bar.set_position(init_pos + sum); + } + DownloadPackageEvent::Error(url, err) => { + progress_bar + .println(format!("error: failed to download from \"{url}\": {err}")); + } + }, + )?; + package_files.push(path); + progress_bar.set_position(init_pos + package.repo_pkg.download_size); + } + drop(progress_bar); + + let progress_bar = ProgressBar::new(package_files.len() as _); + for package in package_files.iter() { + progress_bar.println(format!("Installing \"{}\"...", package.display())); + packie.install_package_file(package, &InstallOptions::default())?; + progress_bar.inc(1); + } + + Ok(()) +} diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 0000000..9c120e4 --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,130 @@ +mod clean; +mod common; +mod info; +mod initdb; +mod install; +mod print; +mod remove; +mod repo_admin; +mod select; +mod update; + +use clap::Parser; +use console::style; +use rust_i18n::t; +use std::path::{Path, PathBuf}; + +rust_i18n::i18n!("../locales/packie-cli"); + +#[derive(Debug, Parser)] +struct Cli { + #[arg(long)] + chroot: Option, + + #[command(subcommand)] + subcommand: Subcommand, +} + +#[derive(Debug, Parser)] +enum Subcommand { + /// Clean cache data + Clean(clean::Cli), + + /// Get package information + Info(info::Cli), + + /// Explicitly initialize the database + InitDb(initdb::Cli), + + /// Install one or more packages + Install(install::Cli), + + /// Print configuration information + Print(print::Cli), + + /// Remove one or more packages + Remove(remove::Cli), + + /// Administrate repository service + RepoAdmin(repo_admin::Cli), + + /// Update repository indexes + Update(update::Cli), + + /// Select global link + Select(select::Cli), +} + +fn main() { + rust_i18n::set_locale(match std::env::var("LANG").as_deref() { + Ok("zh_CN.UTF-8") => "zh-CN", + Ok("ja_JP.UTF-8") => "ja", + _ => "en", + }); + + let cli = Cli::parse(); + if let Some(new_root) = cli.chroot + && let Err(err) = chroot(&new_root) + { + eprintln!("{}", style(t!("main.error", error = err)).red()); + std::process::exit(1); + } + let result = match cli.subcommand { + Subcommand::Clean(cli) => clean::main(cli), + Subcommand::Info(cli) => info::main(cli), + Subcommand::InitDb(cli) => initdb::main(cli), + Subcommand::Install(cli) => install::main(cli), + Subcommand::Print(cli) => print::main(cli), + Subcommand::Remove(cli) => remove::main(cli), + Subcommand::RepoAdmin(cli) => repo_admin::main(cli), + Subcommand::Update(cli) => update::main(cli), + Subcommand::Select(cli) => select::main(cli), + }; + if let Err(err) = result { + eprintln!("{}", style(t!("main.error", error = err.message)).red()); + if let Some(note) = err.note { + eprintln!("{}", style(t!("main.note", note = note)).blue()); + } + std::process::exit(1); + } +} + +#[cfg(target_os = "linux")] +fn chroot(new_root: &Path) -> std::io::Result<()> { + nix::sched::unshare(nix::sched::CloneFlags::CLONE_NEWUSER)?; + nix::sched::unshare(nix::sched::CloneFlags::CLONE_NEWNS)?; + std::os::unix::fs::chroot(new_root)?; + std::env::set_current_dir("/")?; + Ok(()) +} + +#[cfg(all(target_family = "unix", not(target_os = "linux")))] +fn chroot(new_root: &Path) -> std::io::Result<()> { + std::os::unix::fs::chroot(new_root)?; + std::env::set_current_dir("/")?; + Ok(()) +} + +#[derive(Debug)] +struct Error { + message: String, + note: Option, +} +impl From for Error { + fn from(value: E) -> Self { + Self { + message: value.to_string(), + note: None, + } + } +} + +#[macro_export] +macro_rules! error { + ($($all:tt)*) => { + $crate::Error { + message: ::rust_i18n::t!($($all)*).into(), + note: ::std::option::Option::None, + } + }; +} diff --git a/cli/src/print.rs b/cli/src/print.rs new file mode 100644 index 0000000..47123a6 --- /dev/null +++ b/cli/src/print.rs @@ -0,0 +1,46 @@ +use anyhow::anyhow; +use clap::Parser; +use packie::PackieBuilder; + +#[derive(Debug, Parser)] +pub struct Cli { + key: String, +} + +pub fn main(cli: Cli) -> Result<(), crate::Error> { + let key: Vec<&str> = cli.key.split('.').collect(); + let result = match key.get(0).copied().unwrap() { + "profile" => profile(&key[1..]), + "package" => package(&key[1..]), + unknown => Err(anyhow!("unknown key {unknown} to print")), + }; + println!("{}", result?); + Ok(()) +} + +fn profile(other: &[&str]) -> anyhow::Result { + let packie = PackieBuilder::new().readonly(true).build()?; + match other { + ["host_arch"] => Ok(packie.profile().host_arch.clone()), + _ => Err(anyhow!("unsupported profile key")), + } +} + +fn package(other: &[&str]) -> anyhow::Result { + let mut packie = PackieBuilder::new().readonly(true).build()?; + match other { + ["prefix", ..] => { + let mut found = packie.search_installation(&other[1..].join(".").parse()?)?; + if found.is_empty() { + return Err(anyhow!("package not found")); + } + Ok(packie + .profile() + .pkg_dir + .join(found.remove(0).pkg_manifest.pkg_ident().prefix_name()) + .to_string_lossy() + .into()) + } + _ => Err(anyhow!("unsupported profile key")), + } +} diff --git a/cli/src/remove.rs b/cli/src/remove.rs new file mode 100644 index 0000000..3a144a6 --- /dev/null +++ b/cli/src/remove.rs @@ -0,0 +1,55 @@ +use crate::{ + common::{print_list, yesno}, + error, +}; +use anyhow::anyhow; +use clap::Parser; +use console::style; +use packie::{PackieBuilder, package::PkgSpec}; +use rust_i18n::t; + +#[derive(Debug, Parser)] +pub struct Cli { + #[arg(short, long)] + yes: bool, + + items: Vec, +} + +pub fn main(cli: Cli) -> Result<(), crate::Error> { + let mut packie = PackieBuilder::new().build()?; + let mut packages = Vec::with_capacity(cli.items.len()); + for pkgspec in cli.items { + let mut found = packie.search_installation(&pkgspec)?; + if found.is_empty() { + return Err(error!("remove.package_not_found")); + } + if found.len() != 1 { + return Err(error!("remove.pkgspec_not_unique")); + } + packages.push(found.remove(0)); + } + println!("{}", t!("remove.prompt_to_remove")); + print_list( + 4, + packages + .iter() + .map(|x| style(&x.install_pkgspec).green().to_string()), + ); + if cli.yes { + println!("y"); + } else if !yesno(&t!("remove.confirm_removal"), false) { + println!("{}", t!("remove.aborting")); + return Ok(()); + } + for i in packages { + println!( + "{}", + t!("remove.action_hint", package = i.pkg_manifest.pkg_ident()) + ); + packie + .remove(&i.pkg_manifest.pkg_ident().into()) + .map_err(|error| anyhow!("{}", t!("remove.action_failure", error = error)))?; + } + Ok(()) +} diff --git a/cli/src/repo_admin.rs b/cli/src/repo_admin.rs new file mode 100644 index 0000000..406741d --- /dev/null +++ b/cli/src/repo_admin.rs @@ -0,0 +1,57 @@ +use anyhow::anyhow; +use clap::Parser; +use indicatif::ProgressBar; +use packie::{ + package::{Package, PkgIdent}, + repo::RepoServeDir, +}; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +pub struct Cli { + /// Path of the repository serve directory + repo: PathBuf, + + /// Add package(s) to the repository + #[arg(long, short)] + add_package: Option>, + + /// Remove package(s) from the repository + #[arg(long, short)] + remove_package: Option>, + + /// Clone or pull remote repository + #[arg(long, short)] + clone: Option, +} + +pub fn main(cli: Cli) -> Result<(), crate::Error> { + if let Some(clone) = cli.clone { + let progress_bar = ProgressBar::new(0); + RepoServeDir::clone(&clone.parse()?, &cli.repo, |pkg, url, sum, total| { + progress_bar.set_length(total); + progress_bar.set_position(sum); + if let Some(pkg) = pkg { + progress_bar.set_prefix(pkg); + } + progress_bar.set_message(url.to_string()); + }) + .map_err(|x| anyhow!("{x}"))?; + } + + let mut repo = RepoServeDir::open(cli.repo)?; + + for pkg in cli.remove_package.into_iter().flatten() { + repo.remove_package(&pkg) + .map_err(|err| anyhow!("\"{pkg}\": {err}"))?; + } + + for file in cli.add_package.into_iter().flatten() { + let pkg_ident = Package::open(&file)?.manifest()?.pkg_ident(); + _ = repo.remove_package(&pkg_ident); + repo.add_package(&file) + .map_err(|err| anyhow!("\"{}\": {}", file.display(), err))?; + } + + Ok(()) +} diff --git a/cli/src/select.rs b/cli/src/select.rs new file mode 100644 index 0000000..e074869 --- /dev/null +++ b/cli/src/select.rs @@ -0,0 +1,69 @@ +use crate::error; +use clap::Parser; +use console::style; +use dialoguer::Select; +use packie::PackieBuilder; +use rust_i18n::t; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +pub struct Cli { + file: PathBuf, +} + +pub fn main(cli: Cli) -> Result<(), crate::Error> { + let mut packie = PackieBuilder::new().build()?; + let file = if cli.file.is_absolute() { + cli.file.clone() + } else { + std::env::current_dir()?.join(&cli.file) + }; + let mut all_pkgs = Vec::with_capacity(1024); + let mut candidates = Vec::new(); + packie.for_each_installation::(|inst| { + all_pkgs.push(inst.pkg_manifest.pkg_ident()); + Ok(()) + })?; + for pkgident in all_pkgs { + let links = packie.links_of_pkg(&pkgident); + let Ok(links) = links.iter() else { + return Ok(()); + }; + if let Some(link) = links + .filter_map(|x| x.ok()) + .find(|x| x.to == file.to_string_lossy()) + { + candidates.push((pkgident, link)); + } + } + if candidates.is_empty() { + return Err(crate::Error { + message: t!("select.empty_list").into(), + note: Some(t!("select.note_relative_path").into()), + }); + } + let items = std::iter::once(t!("select.disabled").into()) + .chain(candidates.iter().map(|x| x.0.to_string())) + .map(|x| style(x).green()); + let choice = Select::new() + .with_prompt(t!("select.prompt_select_package")) + .items(items) + .interact()?; + if choice == 0 { + if let Err(err) = packie::link::remove(&file) { + if err.kind() == std::io::ErrorKind::InvalidData { + return Err(error!("select.not_a_link")); + } + if err.kind() == std::io::ErrorKind::NotFound { + return Ok(()); + } + Err(err)?; + } + return Ok(()); + } + let (pkgident, linkdes) = candidates.remove(choice - 1); + _ = packie::link::remove(&file); + _ = packie::link::deactivate_by_description(&mut packie, &pkgident, &linkdes); + packie::link::activate_by_description(&mut packie, &pkgident, &linkdes)?; + Ok(()) +} diff --git a/cli/src/update.rs b/cli/src/update.rs new file mode 100644 index 0000000..3b77089 --- /dev/null +++ b/cli/src/update.rs @@ -0,0 +1,40 @@ +use clap::Parser; +use indicatif::ProgressBar; +use packie::{PackieBuilder, repo::SyncEvent}; +use rust_i18n::t; + +#[derive(Debug, Parser)] +pub struct Cli {} + +pub fn main(cli: Cli) -> Result<(), crate::Error> { + let mut packie = PackieBuilder::new().build()?; + let progress_bar = + ProgressBar::new(0).with_style(crate::common::progress_style_multidownload()); + packie + .sync_repo() + .on_event(|ev| match ev { + SyncEvent::BeginRepo(repo) => { + progress_bar.set_prefix(repo.clone()); + progress_bar.println(t!("update.begin_repo", repo = repo)); + } + SyncEvent::Progress(progress) => { + progress_bar.set_length(progress.total_bytes); + progress_bar.set_position(progress.downloaded_bytes); + } + SyncEvent::Error(repo, url, err) => match (repo, url) { + (Some(repo), Some(url)) => { + progress_bar.println(format!( + "failed to update repository \"{repo}\" from \"{url}\": {err}" + )); + } + (Some(repo), None) => { + progress_bar.println(format!("failed to update repository \"{repo}\": {err}")); + } + _ => { + progress_bar.println(format!("failed to update repositories: {err}")); + } + }, + }) + .run(); + Ok(()) +} diff --git a/locales/libpackie/main.yml b/locales/libpackie/main.yml new file mode 100644 index 0000000..3666055 --- /dev/null +++ b/locales/libpackie/main.yml @@ -0,0 +1,73 @@ +_version: 2 +common.CopyError: + en: 'failed to copy from "%{src}" to "%{dst}": %{error}' + zh-CN: '无法将 "%{src}" 复制到 "%{dst}":%{error}' + ja: '"%{src}" から "%{dst}" へのコピーに失敗しました:%{error}' +install.InstallError.Database: + en: "local database error: %{error}" + zh-CN: "本地数据库错误:%{error}" + ja: "ローカルデータベースエラー:%{error}" +install.InstallError.NotFound: + en: "item not found: %{item}" + zh-CN: "找不到项目:%{item}" + ja: "項目が見つかりません:%{item}" +install.InstallError.DependencyRing: + en: "dependency ring detected" + zh-CN: "检测到依赖环" + ja: "依存関係の循環が検出されました" +remove.RemoveError.NotUniquePkgSpec: + en: "not unique package spec" + zh-CN: "指定的包范围匹配到了多个包" + ja: "パッケージ指定が一意ではありません" +remove.RemoveError.Database: + en: "local database error: %{error}" + zh-CN: "本地数据库错误:%{error}" + ja: "ローカルデータベースエラー:%{error}" +remove.RemoveError.NotFound: + en: "package not found" + zh-CN: "找不到包" + ja: "パッケージが見つかりません" +remove.RemoveError.RemoveFiles: + en: "failed to remove package files: %{error}" + zh-CN: "无法删除包文件:%{error}" + ja: "パッケージファイルの削除に失敗しました:%{error}" +install.InstallError.AlreadyInstalled: + en: "package already installed" + zh-CN: "此包已经安装" + ja: "パッケージはすでにインストールされています" +repo.RepoError.Database: + en: "repository database error: %{error}" + zh-CN: "软件包仓库数据库错误:%{error}" + ja: "リポジトリデータベースエラー:%{error}" +repo.RepoError.NoSuchRepo: + en: "no such repository" + zh-CN: "找不到仓库" + ja: "指定されたリポジトリが存在しません" +repo.RepoError.NoSuchPackage: + en: "no such package" + zh-CN: "找不到包" + ja: "指定されたパッケージが存在しません" +repo.RepoError.AllTriesFailed: + en: "all tries failed" + zh-CN: "所有尝试均失败" + ja: "すべての試行に失敗しました" +repo.download.DownloadError.Checksum: + en: "checksum failed" + zh-CN: "文件完整性检查失败" + ja: "チェックサム検証に失敗しました" +BuildPackieError.Lock: + en: "failed to lock packie database: %{error}" + zh-CN: "无法锁定 Packie 数据库:%{error}" + ja: "Packie データベースのロックに失敗しました:%{error}" +version.VersionError.ParseNamespace: + en: "invalid version namespace" + zh-CN: "无效的版本命名空间" + ja: "無効なバージョン名前空間です" +version.VersionError.ParseMain: + en: "invalid version number" + zh-CN: "无效的版本号" + ja: "無効なバージョン番号です" +version.VersionError.ParsePrerelease: + en: "invalid version prerelease" + zh-CN: "无效的预发布版本" + ja: "無効なプレリリースバージョンです" diff --git a/locales/packie-cli/main.yml b/locales/packie-cli/main.yml new file mode 100644 index 0000000..2b342ac --- /dev/null +++ b/locales/packie-cli/main.yml @@ -0,0 +1,173 @@ +_version: 2 +main.error: + en: "error: %{error}" + zh-CN: "错误:%{error}" + ja: "エラー:%{error}" +main.note: + en: "note: %{note}" + zh-CN: "备注:%{note}" + ja: "備考:%{note}" +update.begin_repo: + en: "Synchronizing repository \"%{repo}\"..." + zh-CN: "正在同步软件包仓库 \"%{repo}\"..." + ja: "リポジトリ \"%{repo}\" を同期中..." +install.prompt_to_install: + en: "The following packages are to be installed:" + zh-CN: "将要安装如下的包:" + ja: "以下のパッケージをインストールします:" +install.download_size: + en: "Download size: %{size}" + zh-CN: "下载体积:%{size}" + ja: "ダウンロードサイズ:%{size}" +install.installed_size: + en: "Installed size: %{size}" + zh-CN: "安装体积:%{size}" + ja: "インストール後サイズ:%{size}" +install.confirm_installation: + en: "Confirm to install?" + zh-CN: "确认安装?" + ja: "インストールを続行しますか?" +install.aborting: + en: "Aborting." + zh-CN: "中止。" + ja: "中止します。" +install.no_package_to_install: + en: "No packages to install." + zh-CN: "没有要安装的包。" + ja: "インストールするパッケージがありません。" +info.no_package_found: + en: "The specified package was not found." + zh-CN: "找不到指定的包。" + ja: "指定されたパッケージが見つかりません。" +info.error_pkgspec_not_unique: + en: "The specified package spec was not unique." + zh-CN: "指定的包规范匹配了多个包。" + ja: "指定されたパッケージ指定が複数のパッケージに一致しました。" +info.note_pkgspec_not_unique: + en: "The following packages were matched:" + zh-CN: "匹配到了下面的包:" + ja: "以下のパッケージが一致しました:" +info.print_pkgmanifest.name: + en: "Package name" + zh-CN: "包名" + ja: "パッケージ名" +info.print_pkgmanifest.version: + en: "Package version" + zh-CN: "版本" + ja: "バージョン" +info.print_pkgmanifest.arch: + en: "Package architecture" + zh-CN: "架构" + ja: "アーキテクチャ" +info.print_pkgmanifest.dependencies: + en: "Dependencies" + zh-CN: "依赖" + ja: "依存関係" +info.print_pkgmanifest.recommendations: + en: "Recommendations" + zh-CN: "建议安装" + ja: "推奨パッケージ" +info.print_pkgmanifest.description: + en: "Description" + zh-CN: "描述" + ja: "説明" +info.print_pkgmanifest.maintainers: + en: "Maintainers" + zh-CN: "维护者" + ja: "メンテナー" +info.print_install_misc.install_pkgspec: + en: "Install package spec" + zh-CN: "安装请求" + ja: "インストール要求" +info.print_install_misc.installed_size: + en: "Installed size" + zh-CN: "安装后大小" + ja: "インストール後サイズ" +info.print_install_misc.install_date: + en: "Install date" + zh-CN: "安装日期" + ja: "インストール日時" +remove.package_not_found: + en: "The specified package \"%{pkgspec}\" was not found." + zh-CN: "找不到指定的包 \"%{pkgspec}\"。" + ja: "指定されたパッケージ \"%{pkgspec}\" が見つかりません。" +remove.error_pkgspec_not_unique: + en: "The specified package spec \"%{pkgspec}\" was not unique." + zh-CN: "指定的包规范 \"%{pkgspec}\" 匹配了多个包。" + ja: "指定されたパッケージ指定 \"%{pkgspec}\" が複数のパッケージに一致しました。" +remove.note_pkgspec_not_unique: + en: "The following packages were matched:" + zh-CN: "匹配到了下面的包:" + ja: "以下のパッケージが一致しました:" +remove.action_failure: + en: "Failed to remove the specified package: %{error}" + zh-CN: "无法移除指定的包:%{error}" + ja: "指定されたパッケージの削除に失敗しました:%{error}" +remove.prompt_to_remove: + en: "The following packages are to be removed:" + zh-CN: "将要移除下面的包:" + ja: "以下のパッケージを削除します:" +remove.confirm_removal: + en: "Confirm to remove?" + zh-CN: "确认移除?" + ja: "削除を続行しますか?" +remove.aborting: + en: "Aborting." + zh-CN: "中止。" + ja: "中止します。" +remove.action_hint: + en: "Removing package %{package} ..." + zh-CN: "正在移除包 %{package} ..." + ja: "パッケージ %{package} を削除中 ..." +common.progress_style.speed: + en: "Speed: " + zh-CN: "速度:" + ja: "速度:" +common.progress_style.current: + en: "Current: " + zh-CN: "已下载:" + ja: "現在:" +common.progress_style.total: + en: "Total: " + zh-CN: "总共:" + ja: "合計:" +common.progress_style.eta: + en: "ETA: " + zh-CN: "剩余时间:" + ja: "残り時間:" +clean.warn_repos_dir: + en: "warning: failed to read repository cache directory: %{error}" + zh-CN: "警告:无法读取软件包仓库缓存目录:%{error}" + ja: "警告:リポジトリキャッシュディレクトリの読み取りに失敗しました:%{error}" +clean.warn_repo_dir: + en: "warning: failed to read cache directory of repository \"%{name}\": %{error}" + zh-CN: "警告:无法读取软件包仓库 \"%{name}\" 的缓存目录:%{error}" + ja: "警告:リポジトリ \"%{name}\" のキャッシュディレクトリの読み取りに失敗しました:%{error}" +clean.info_remove_file: + en: "Removing file \"%{path}\"." + zh-CN: "正在删除文件 \"%{path}\"。" + ja: "ファイル \"%{path}\" を削除中。" +clean.warn_remove_file: + en: "warning: failed to remove file \"%{path}\": %{error}" + zh-CN: "警告:无法删除文件 \"%{path}\":%{error}" + ja: "警告:ファイル \"%{path}\" の削除に失敗しました:%{error}" +select.prompt_select_package: + en: "Select the package to use for the link" + zh-CN: "选择要为此链接使用的包" + ja: "リンクに使用するパッケージを選択してください" +select.empty_list: + en: "No package provided the given link" + zh-CN: "没有包提供要求的链接" + ja: "指定されたリンクを提供するパッケージはありません" +select.note_relative_path: + en: "A relative path is specified, and Packie resolves path based on your current directory. If this is not expected, provide an absolute path instead." + zh-CN: "指定了相对路径,Packie 会根据您当前目录解析路径。如果不符合预期,请改用绝对路径。" + ja: "相対パスが指定されています。Packieはカレントディレクトリに基づいてパスを解決します。意図と異なる場合は、絶対パスを指定してください。" +select.disabled: + en: "Disabled" + zh-CN: "禁用" + ja: "無効" +select.not_a_link: + en: "The existing file is not a link managed by Packie. Please remove it manually." + zh-CN: "已存在的文件不是一个被 Packie 管理的链接。请手动删除该文件。" + ja: "既存のファイルはPackieで管理されているリンクではありません。手動で削除してください。" diff --git a/src/common/checksum.rs b/src/common/checksum.rs new file mode 100644 index 0000000..99cda26 --- /dev/null +++ b/src/common/checksum.rs @@ -0,0 +1,41 @@ +use digest::{Digest, DynDigest}; +use std::{fs::File, io::Read, path::Path}; + +pub const DEFAULT_HASHER: &str = "sha3-512"; + +pub fn find_hasher(s: &str) -> std::io::Result> { + match s { + "sha3-512" => Ok(Box::new(sha3::Sha3_512::new())), + _ => Err(std::io::ErrorKind::InvalidInput.into()), + } +} + +pub fn hash_reader(hasher_name: &str, reader: &mut R) -> std::io::Result { + let mut buf = [0u8; 1024]; + let mut hasher = find_hasher(hasher_name)?; + loop { + let n = reader.read(&mut buf)?; + hasher.update(&buf[..n]); + if n == 0 { + break; + } + } + Ok(format!( + "{hasher_name}:{}", + const_hex::encode(hasher.finalize()), + )) +} + +pub fn verify_reader(reader: &mut R, expected: &str) -> std::io::Result { + let hasher = expected.split(':').next().unwrap(); + let hash = hash_reader(hasher, reader)?; + Ok(hash == expected) +} + +pub fn hash_file>(hasher: &str, path: P) -> std::io::Result { + hash_reader(hasher, &mut File::open(path)?) +} + +pub fn verify_file>(path: P, expected: &str) -> std::io::Result { + verify_reader(&mut File::open(path)?, expected) +} diff --git a/src/common/fs.rs b/src/common/fs.rs new file mode 100644 index 0000000..fac7a52 --- /dev/null +++ b/src/common/fs.rs @@ -0,0 +1,197 @@ +use rust_i18n::t; +use rustc_hash::FxHashSet; +use std::{ + fs::{FileType, ReadDir}, + path::{Path, PathBuf}, + time::SystemTime, +}; + +#[derive(Debug)] +pub struct TreeDir { + stack: Vec<(ReadDir, PathBuf)>, +} +impl TreeDir { + pub fn new(path: impl Into) -> std::io::Result { + let root_path = path.into(); + let read_dir = std::fs::read_dir(&root_path)?; + Ok(Self { + stack: vec![(read_dir, PathBuf::new())], + }) + } +} +impl Iterator for TreeDir { + type Item = std::io::Result; + + fn next(&mut self) -> Option { + while let Some((read_dir, current_relpath)) = self.stack.last_mut() { + match read_dir.next() { + Some(Ok(entry)) => { + let file_name = entry.file_name(); + let relpath = current_relpath.join(file_name); + + let ty = match entry.file_type() { + Ok(ty) => ty, + Err(e) => return Some(Err(e)), + }; + + if ty.is_dir() { + match std::fs::read_dir(entry.path()) { + Ok(sub_read_dir) => { + self.stack.push((sub_read_dir, relpath.clone())); + } + Err(e) => { + return Some(Err(e)); + } + } + } + + return Some(Ok(TreeDirEntry { relpath, ty })); + } + Some(Err(e)) => { + return Some(Err(e)); + } + None => { + self.stack.pop(); + } + } + } + None + } +} + +#[derive(Debug)] +pub struct TreeDirEntry { + pub relpath: PathBuf, + pub ty: FileType, +} + +pub fn du_dir(path: impl AsRef) -> std::io::Result { + use std::os::unix::fs::MetadataExt; + + let mut size = 0; + let mut inodes = FxHashSet::default(); + let path = path.as_ref(); + for ent in TreeDir::new(path)? { + let ent = ent?; + let metadata = std::fs::symlink_metadata(path.join(&ent.relpath))?; + let inode = (metadata.dev(), metadata.ino()); + if inodes.contains(&inode) { + continue; + } + size += metadata.len(); + inodes.insert(inode); + } + Ok(size) +} + +pub fn copy_dir(src: impl Into, dst: impl AsRef) -> Result<(), CopyError> { + let src = src.into(); + std::fs::create_dir(dst.as_ref()).map_err(|error| CopyError { + src: src.clone(), + dst: dst.as_ref().into(), + error, + })?; + match copy_dir_raw(src, dst.as_ref()) { + Ok(()) => Ok(()), + Err(err) => { + _ = std::fs::remove_dir_all(dst.as_ref()); + Err(err) + } + } +} + +fn copy_dir_raw(src: PathBuf, dst: &Path) -> Result<(), CopyError> { + let global_err = |error| CopyError { + src: src.clone(), + dst: dst.into(), + error, + }; + let src_tree = TreeDir::new(src.clone()).map_err(global_err)?; + for ent in src_tree { + let ent = ent.map_err(global_err)?; + let src_full_path = src.join(&ent.relpath); + let dst_full_path = dst.join(&ent.relpath); + + copy_fs_node(&src_full_path, &dst_full_path).map_err(|error| CopyError { + src: src_full_path.clone(), + dst: dst_full_path.clone(), + error, + })?; + } + Ok(()) +} + +fn copy_fs_node(src: &Path, dst: &Path) -> std::io::Result<()> { + let metadata = std::fs::symlink_metadata(src)?; + + if metadata.file_type().is_dir() { + std::fs::create_dir(dst)?; + } else if metadata.is_symlink() { + let link_to = std::fs::read_link(src)?; + symlink(&link_to, dst)?; + return Ok(()); + } else { + std::fs::copy(&src, &dst)?; + } + + std::fs::set_permissions(dst, metadata.permissions())?; + set_modified_time(dst, metadata.modified()?)?; + + Ok(()) +} + +#[cfg(target_family = "unix")] +pub fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(src, dst) +} + +#[cfg(target_family = "unix")] +fn set_modified_time(path: &Path, modified: SystemTime) -> std::io::Result<()> { + let modified_duration = modified + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|_| std::io::ErrorKind::InvalidData)?; + unsafe { + nix::sys::stat::utimensat( + std::os::fd::BorrowedFd::borrow_raw(nix::libc::AT_FDCWD), + path, + &nix::sys::time::TimeSpec::UTIME_NOW, + &nix::sys::time::TimeSpec::from_duration(modified_duration), + nix::sys::stat::UtimensatFlags::NoFollowSymlink, + ) + .map_err(|errno| std::io::Error::from_raw_os_error(errno as _)) + } +} + +#[derive(Debug, thiserror::Error)] +#[error("{}", t!("common.CopyError", src = src.display(), dst = dst.display(), error = error))] +pub struct CopyError { + src: PathBuf, + dst: PathBuf, + error: std::io::Error, +} + +#[cfg(target_family = "unix")] +#[derive(Debug)] +pub struct LockGuard { + _inner: Option>, +} +#[cfg(target_family = "unix")] +impl LockGuard { + pub fn noop() -> Self { + Self { _inner: None } + } + + pub fn open(path: impl AsRef, nonblocking: bool) -> std::io::Result { + let file = std::fs::File::options() + .create(true) + .write(true) + .open(path)?; + let flags = if nonblocking { + nix::fcntl::FlockArg::LockExclusiveNonblock + } else { + nix::fcntl::FlockArg::LockExclusive + }; + let lock = nix::fcntl::Flock::lock(file, flags).map_err(|x| x.1)?; + Ok(Self { _inner: Some(lock) }) + } +} diff --git a/src/common/macros.rs b/src/common/macros.rs new file mode 100644 index 0000000..0323b1f --- /dev/null +++ b/src/common/macros.rs @@ -0,0 +1,23 @@ +#[macro_export] +macro_rules! impl_serde_str { + ($t:ty) => { + impl serde::Serialize for $t { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } + } + impl<'de> serde::Deserialize<'de> for $t { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(|err| serde::de::Error::custom(err)) + } + } + }; +} diff --git a/src/common/mod.rs b/src/common/mod.rs new file mode 100644 index 0000000..9e42455 --- /dev/null +++ b/src/common/mod.rs @@ -0,0 +1,46 @@ +pub mod checksum; +pub mod fs; +pub mod macros; + +pub use fs::*; + +use std::{ + io::{Read, Seek, SeekFrom}, + time::SystemTime, +}; + +pub fn decompress( + mut raw: impl Read + Seek + Send + Sync + 'static, +) -> std::io::Result> { + #[cfg(feature = "zstd")] + if stream_starts_with(&mut raw, &[0x28, 0xb5, 0x2f, 0xfd])? { + return Ok(Box::new(zstd::Decoder::new(raw)?)); + } + + #[cfg(feature = "gzip")] + if stream_starts_with(&mut raw, &[0x1F, 0x8B])? { + return Ok(Box::new(flate2::read::GzDecoder::new(raw))); + } + + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Unknown compression algorithm", + )) +} + +fn stream_starts_with( + stream: &mut (impl Read + Seek + Send + Sync + 'static), + magic: &[u8], +) -> std::io::Result { + let mut magic_buf = vec![0u8; magic.len()]; + stream.read_exact(&mut magic_buf)?; + stream.seek(SeekFrom::Start(0))?; + Ok(magic_buf == magic) +} + +pub fn timestamp_secs() -> i64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or_else(|e| -(e.duration().as_secs() as i64)) +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..4a85561 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,21 @@ +mod repo; + +pub use repo::RepoEntry; + +use rustc_hash::FxHashMap; +use std::path::Path; + +/// A collection of all loaded Packie config files. +#[derive(Debug, Clone)] +pub struct AllConfig { + pub repos: FxHashMap, +} +impl AllConfig { + pub fn open>(path: P) -> Self { + let path = path.as_ref(); + + Self { + repos: repo::read_repos(&path.join("repos.d")), + } + } +} diff --git a/src/config/repo.rs b/src/config/repo.rs new file mode 100644 index 0000000..6b3c0a9 --- /dev/null +++ b/src/config/repo.rs @@ -0,0 +1,57 @@ +use rustc_hash::FxHashMap; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use url::Url; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct RepoConfig(FxHashMap); +impl RepoConfig { + pub fn read>(path: P) -> Result> { + let s = std::fs::read_to_string(path)?; + let result: Self = toml::from_str(&s)?; + for ent in result.0.values() { + if ent.url.is_some() && ent.urls.is_some() { + return Err(Box::from("cannot specify both `url` and `urls`")); + } + if ent.url.is_none() && ent.urls.is_none() { + return Err(Box::from("no url specified")); + } + } + Ok(result) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepoEntry { + url: Option, + urls: Option>, +} +impl RepoEntry { + pub fn urls(&self) -> &[Url] { + self.urls + .as_deref() + .unwrap_or_else(|| std::slice::from_ref(self.url.as_ref().unwrap())) + } +} + +pub fn read_repos(repos_dir: &Path) -> FxHashMap { + let mut repos = FxHashMap::default(); + if let Ok(tree_dir) = crate::common::TreeDir::new(repos_dir) { + for ent in tree_dir { + let Ok(ent) = ent else { + continue; + }; + if !ent.ty.is_file() { + continue; + } + let Ok(repo_config) = RepoConfig::read(repos_dir.join(&ent.relpath)) else { + continue; + }; + for (key, val) in repo_config.0 { + repos.insert(key, val); + } + } + } + repos +} diff --git a/src/install.rs b/src/install.rs new file mode 100644 index 0000000..9b3a851 --- /dev/null +++ b/src/install.rs @@ -0,0 +1,219 @@ +use crate::{ + local::{InstallFlags, Installation}, + package::{Package, PackageError, PkgIdent, PkgSpec}, + repo::{RepoError, RepoPackage}, +}; +use rust_i18n::t; +use std::path::Path; + +#[derive(Debug, Default, Clone)] +pub struct InstallOptions { + /// Package spec to record for this installation. + pub install_pkgspec: Option, +} +impl InstallOptions { + pub fn new() -> Self { + Self::default() + } +} + +impl super::Packie { + pub fn calculate_depgraph( + &mut self, + pkgspec: PkgSpec, + ) -> Result, InstallError> { + let prior_arch = self.profile.host_arch.clone(); + + let mut query = self.query_repo(); + + let mut depgraph = Vec::new(); + let mut stack = vec![pkgspec]; + + loop { + let Some(pkgspec) = stack.pop() else { + break; + }; + let mut found = query.search_package(&pkgspec); + if found.is_empty() { + return Err(InstallError::NotFound(pkgspec.to_string())); + } + let prior_pkg = prior_pkg( + found.iter().map(|(_, x)| x.pkg_manifest.pkg_ident()), + &prior_arch, + ); + let prior_pkg = found + .iter() + .enumerate() + .find(|(_, (_, p))| p.pkg_manifest.pkg_ident() == prior_pkg) + .unwrap() + .0; + let (repo_name, repo_pkg) = found.remove(prior_pkg); + + if !self + .search_installation(&pkgspec) + .unwrap_or_default() + .is_empty() + { + continue; + } + + depgraph.push(DepgraphNode { + repo_name, + pkgspec, + repo_pkg: repo_pkg.clone(), + }); + + for dep in &repo_pkg.pkg_manifest.dependencies { + stack.push(dep.clone()); + } + } + + depgraph.reverse(); + Ok(depgraph) + } + + /// Installs a package, from a local package file. + pub fn install_package_file>( + &mut self, + path: P, + options: &InstallOptions, + ) -> Result<(), InstallError> { + let package = Package::open(path)?; + self.install_package(&package, options) + } + + /// Installs a package, from a constructed [`Package`]. + pub fn install_package( + &mut self, + package: &Package, + options: &InstallOptions, + ) -> Result<(), InstallError> { + // Collect necessary information + let pkg_manifest = package.manifest()?; + let pkg_ident = pkg_manifest.pkg_ident(); + let installed_size = package.installed_size()?; + let install_pkgspec = options + .install_pkgspec + .clone() + .unwrap_or_else(|| default_install_pkgspec(pkg_ident.clone())); + + // Check if the package is previously installed + if !self + .search_installation(&pkg_ident.clone().into())? + .is_empty() + { + return Err(InstallError::AlreadyInstalled); + } + + // Check if the package spec is valid + if !install_pkgspec.matches(&pkg_ident) { + return Err(InstallError::PkgSpec); + } + + // Copy package files + let dest_dir = self.profile.pkg_dir.join(pkg_ident.prefix_name()); + crate::common::copy_dir(package.bundle_dir(), dest_dir).map_err(InstallError::Copy)?; + + // Record installation in the database + let installation = Installation { + pkg_manifest: pkg_manifest.clone(), + install_pkgspec, + install_date: crate::common::timestamp_secs(), + update_date: crate::common::timestamp_secs(), + installed_size, + install_flags: InstallFlags::default(), + }; + self.local_db.insert_installation(&installation)?; + + // Record abstract package provision in the database + for i in installation.pkg_manifest.provides.iter() { + self.local_db.insert_abspkg(&i, &pkg_ident)?; + } + + // Copy links file + if let Some(links) = package.links() { + std::fs::copy(&links.0, self.local_data.links_file(&pkg_ident)) + .map_err(|err| InstallError::Database(Box::new(err)))?; + } + + // Enable default links + if let Some(links) = package.links() { + for linkdes in links.iter().ok().into_iter().flatten() { + let Ok(linkdes) = linkdes else { + continue; + }; + if !linkdes.default { + continue; + } + _ = crate::link::activate_by_description(self, &pkg_ident, &linkdes); + } + } + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum InstallError { + #[error("{}", t!("install.InstallError.NotFound", item = .0))] + NotFound(String), + + #[error("{}", t!("install.InstallError.DependencyRing"))] + DependencyRing, + + #[error("{0}")] + Package(PackageError), + + #[error("incompatible pkgspec")] + PkgSpec, + + #[error("{0}")] + Copy(crate::common::CopyError), + + #[error("{}", t!("install.InstallError.AlreadyInstalled"))] + AlreadyInstalled, + + #[error("{0}")] + Repo(RepoError), + + #[error("{}", t!("install.InstallError.Database", error = .0))] + Database(Box), +} +impl From for InstallError { + fn from(value: PackageError) -> Self { + Self::Package(value) + } +} +impl From for InstallError { + fn from(value: rusqlite::Error) -> Self { + Self::Database(Box::new(value)) + } +} + +fn default_install_pkgspec(pkg_ident: PkgIdent) -> PkgSpec { + PkgSpec { + name: pkg_ident.name, + version: crate::version::VersionFilter::Any, + arch: None, + } +} + +#[derive(Debug, Clone)] +pub struct DepgraphNode { + pub repo_name: String, + pub pkgspec: PkgSpec, + pub repo_pkg: RepoPackage, +} + +/// Gets prior package that is installed by default in package candidates. +fn prior_pkg(all: impl Iterator, prior_arch: &str) -> PkgIdent { + let mut all = all.collect::>(); + all.sort_by(|x, y| x.version.cmp(&y.version)); + all.reverse(); + for i in &all { + if i.arch == prior_arch { + return i.clone(); + } + } + all.remove(0) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c5a3dbc --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,113 @@ +mod common; +pub mod config; +pub mod install; +pub mod link; +pub mod local; +pub mod package; +pub mod profile; +pub mod remove; +pub mod repo; +pub mod version; + +use common::fs::LockGuard; +use config::AllConfig; +use local::{Cache, DataDir, LocalDb}; +use profile::Profile; +use rust_i18n::t; + +rust_i18n::i18n!("locales/libpackie", fallback = "en"); + +/// Main Packie state. +#[derive(Debug)] +pub struct Packie { + profile: Profile, + local_db: LocalDb, + config: AllConfig, + local_data: DataDir, + cache: Cache, + _lock_guard: LockGuard, +} +impl Packie { + /// Returns profile of the Packie instance. + pub fn profile(&self) -> &Profile { + &self.profile + } + + /// Returns cache manager for the Packie instance. + pub fn cache(&self) -> &Cache { + &self.cache + } +} + +#[derive(Debug, Clone)] +pub struct PackieBuilder { + profile: Profile, + readonly: bool, + nonblocking: bool, +} +impl PackieBuilder { + pub fn new() -> Self { + Self { + profile: Profile::builtin(), + readonly: false, + nonblocking: false, + } + } + + pub fn readonly(mut self, val: bool) -> Self { + self.readonly = val; + self + } + + pub fn nonblocking(mut self, val: bool) -> Self { + self.nonblocking = val; + self + } + + pub fn profile(mut self, val: Profile) -> Self { + self.profile = val; + self + } + + pub fn build(self) -> Result { + let mut _lock_guard = LockGuard::noop(); + if !self.readonly { + _ = std::fs::create_dir_all(&self.profile.packie_data_dir); + _ = std::fs::create_dir_all(&self.profile.packie_cache_dir); + _ = std::fs::create_dir_all(&self.profile.packie_config_dir); + _ = std::fs::create_dir_all(&self.profile.pkg_dir); + + let lock_path = self.profile.packie_data_dir.join("packie.lock"); + _lock_guard = + LockGuard::open(lock_path, self.nonblocking).map_err(BuildPackieError::Lock)?; + } + let local_db_path = self.profile.packie_data_dir.join(LocalDb::FILENAME); + let open_local_db = if self.readonly { + LocalDb::open_ro + } else { + LocalDb::open_rw + }; + let local_db = open_local_db(local_db_path).map_err(BuildPackieError::LocalDb)?; + let config = AllConfig::open(&self.profile.packie_config_dir); + let cache = Cache(self.profile.packie_cache_dir.clone()); + let local_data = DataDir(self.profile.packie_data_dir.clone()); + + Ok(Packie { + profile: self.profile, + local_db, + config, + cache, + local_data, + _lock_guard, + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum BuildPackieError { + #[error("{0}")] + LocalDb(rusqlite::Error), + + #[error("{}", t!("BuildPackieError.Lock", error = .0))] + Lock(std::io::Error), +} diff --git a/src/link.rs b/src/link.rs new file mode 100644 index 0000000..a535f1e --- /dev/null +++ b/src/link.rs @@ -0,0 +1,105 @@ +use crate::{Packie, package::PkgIdent}; +use serde::{Deserialize, Serialize}; +use std::{ + fs::File, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, +}; + +impl super::Packie { + pub fn links_of_pkg(&self, pkgident: &PkgIdent) -> LinksFile { + LinksFile(self.local_data.links_file(pkgident)) + } +} + +/// Iterator over link descriptions in a links file. +#[derive(Debug)] +pub struct LinksFile(pub PathBuf); +impl LinksFile { + pub const FILENAME: &str = "_links"; + + pub fn iter(&self) -> std::io::Result>> { + Ok(BufReader::new(File::open(&self.0)?) + .lines() + .map(|s| { + Ok::<_, std::io::Error>( + serde_json::from_str(&s?) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err)), + ) + }) + .flatten()) + } +} + +/// Description of a link. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LinkDescription { + /// Path of the link source, relative to the package bundle. + pub from: String, + + /// Path of the link destination, must be absolute. + pub to: String, + + /// True if the link is enabled on installation, by default. + #[serde(default)] + pub default: bool, +} + +pub fn activate_by_description( + packie: &mut Packie, + pkg_ident: &PkgIdent, + description: &LinkDescription, +) -> std::io::Result<()> { + let bundle_path = packie.profile.pkg_dir.join(pkg_ident.prefix_name()); + let from = bundle_path.join(&description.from); + create(from, &description.to) +} + +pub fn deactivate_by_description( + packie: &mut Packie, + pkg_ident: &PkgIdent, + description: &LinkDescription, +) -> std::io::Result<()> { + let bundle_path = packie.profile.pkg_dir.join(pkg_ident.prefix_name()); + let from = bundle_path.join(&description.from); + let link_from = std::fs::read_link(&description.to)?; + if link_from != from { + return Err(std::io::ErrorKind::InvalidData.into()); + } + remove(&description.to) +} + +pub fn create(from: impl AsRef, to: impl AsRef) -> std::io::Result<()> { + std::fs::create_dir_all( + to.as_ref() + .parent() + .ok_or_else(|| std::io::ErrorKind::InvalidInput)?, + )?; + crate::common::symlink(from.as_ref(), to.as_ref())?; + Ok(()) +} + +/// Tries to remove a link at a given path. Also removes all its parent directories if they become empty after +/// this deletion. +/// +/// Note that this is not an atomic operation. Take care of TOCTOU and race condition. +/// +/// # Errors +/// It would return an standard library IO error in given error kind: +/// +/// - `NotFound`: The file could not be located. +/// - `InvalidData`: The file is not a link. +pub fn remove(path: impl Into) -> std::io::Result<()> { + let mut path = path.into(); + if !path.exists() { + return Err(std::io::ErrorKind::NotFound.into()); + } + if std::fs::read_link(&path).is_err() { + return Err(std::io::ErrorKind::InvalidData.into()); + } + std::fs::remove_file(&path)?; + while path.pop() { + _ = std::fs::remove_dir(&path); + } + Ok(()) +} diff --git a/src/local.rs b/src/local.rs new file mode 100644 index 0000000..d95012f --- /dev/null +++ b/src/local.rs @@ -0,0 +1,304 @@ +use crate::{ + package::{AbsPkgIdent, PkgIdent, PkgManifest, PkgSpec}, + version::VersionFilter, +}; +use itertools::Itertools; +use rusqlite::{OpenFlags, Row, params}; +use std::{ + convert::Infallible, + fmt::Display, + path::{Path, PathBuf}, + str::FromStr, +}; + +impl super::Packie { + pub fn search_installation( + &mut self, + pkgspec: &PkgSpec, + ) -> rusqlite::Result> { + let mut real = self + .local_db + .select_installation_by_name(&pkgspec.name)? + .into_iter() + .filter(|x| pkgspec.matches(&x.pkg_manifest.pkg_ident())) + .collect::>(); + if !matches!(pkgspec.version, VersionFilter::Any) { + return Ok(real); + } + let abspkg = AbsPkgIdent { + name: pkgspec.name.clone(), + arch: pkgspec.arch.clone(), + }; + let abs = self.local_db.select_abspkg_by_ident(&abspkg)?; + for (_, provider) in abs { + real.push(self.local_db.select_installation_by_ident(&provider)?); + } + Ok(real + .into_iter() + .unique_by(|x| x.pkg_manifest.pkg_ident()) + .collect()) + } + + pub fn for_each_installation>( + &mut self, + f: impl FnMut(Installation) -> Result<(), E>, + ) -> Result<(), E> { + self.local_db.for_each_installation(f) + } +} + +#[derive(Debug)] +pub struct DataDir(pub PathBuf); +impl DataDir { + const LINKS_FILES_DIR_NAME: &str = "links_files"; + + pub fn links_files_dir(&self) -> PathBuf { + self.dir(&[Self::LINKS_FILES_DIR_NAME]) + } + + pub fn links_file(&self, name: &PkgIdent) -> PathBuf { + self.links_files_dir().join(name.to_string()) + } + + fn dir(&self, relative: &[&str]) -> PathBuf { + let mut path = self.0.clone(); + for i in relative { + path.push(i); + } + _ = std::fs::create_dir_all(&path); + path + } +} + +#[derive(Debug)] +pub struct Cache(pub PathBuf); +impl Cache { + const REPO_DIR_NAME: &str = "repo"; + + pub fn repos_dir(&self) -> PathBuf { + self.dir(&[Self::REPO_DIR_NAME]) + } + + pub fn repo_dir(&self, name: &str) -> PathBuf { + self.dir(&[Self::REPO_DIR_NAME, name]) + } + + fn dir(&self, relative: &[&str]) -> PathBuf { + let mut path = self.0.clone(); + for i in relative { + path.push(i); + } + _ = std::fs::create_dir_all(&path); + path + } +} + +#[derive(Debug)] +pub struct LocalDb(rusqlite::Connection); +impl LocalDb { + pub const FILENAME: &str = "local.db"; + + pub const SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS "installation"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "pkg_name" TEXT NOT NULL, + "pkg_version" TEXT NOT NULL, + "pkg_arch" TEXT NOT NULL, + "pkg_manifest" TEXT NOT NULL, + "install_pkgspec" TEXT NOT NULL, + "install_date" INTEGER NOT NULL, + "update_date" INTEGER NOT NULL, + "installed_size" INTEGER NOT NULL, + "install_flags" TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS "abspkg"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "abspkg_ident" TEXT NOT NULL, + "provider" TEXT NOT NULL +); + "#; + + pub fn open_ro>(path: P) -> rusqlite::Result { + rusqlite::Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).map(Self) + } + + pub fn open_rw>(path: P) -> rusqlite::Result { + let conn = rusqlite::Connection::open(path)?; + conn.execute_batch(Self::SCHEMA)?; + Ok(Self(conn)) + } + + pub fn insert_installation(&mut self, installation: &Installation) -> rusqlite::Result<()> { + let pkg_manifest = serde_json::to_string(&installation.pkg_manifest) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + self.0 + .prepare_cached( + r#" + INSERT INTO "installation"( + "pkg_name", "pkg_version", "pkg_arch", "pkg_manifest", + "install_pkgspec", "install_date", "update_date", "installed_size", + "install_flags" + ) + VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + "#, + )? + .execute(params![ + installation.pkg_manifest.name, + installation.pkg_manifest.version.to_string(), + installation.pkg_manifest.arch, + pkg_manifest, + installation.install_pkgspec.to_string(), + installation.install_date, + installation.update_date, + installation.installed_size as i64, + installation.install_flags.to_string(), + ])?; + Ok(()) + } + + pub fn insert_abspkg( + &mut self, + abspkg: &AbsPkgIdent, + provider: &PkgIdent, + ) -> rusqlite::Result<()> { + self.0 + .prepare_cached(r#"INSERT INTO "abspkg"("abspkg_ident", "provider") VALUES(?1, ?2)"#)? + .execute(params![abspkg.to_string(), provider.to_string()])?; + Ok(()) + } + + pub fn select_installation_by_name( + &mut self, + pkg_name: &str, + ) -> rusqlite::Result> { + let mut stmt = self + .0 + .prepare_cached(r#"SELECT * FROM "installation" WHERE "pkg_name" = ?1"#)?; + let mapped_rows = stmt.query_map(params![pkg_name], map_installation)?; + let mut ret = Vec::with_capacity(16); + for mr in mapped_rows { + ret.push(mr?); + } + Ok(ret) + } + + pub fn select_installation_by_ident( + &mut self, + pkg_ident: &PkgIdent, + ) -> rusqlite::Result { + self.0.prepare_cached(r#" + SELECT * FROM "installation" WHERE "pkg_name" = ?1 AND "pkg_version" = ?2 AND "pkg_arch" = ?3 + "#)?.query_one(params![pkg_ident.name, pkg_ident.version.to_string(), pkg_ident.arch], map_installation) + } + + pub fn select_abspkg_by_ident( + &mut self, + ident: &AbsPkgIdent, + ) -> rusqlite::Result> { + let mut stmt = self + .0 + .prepare_cached(r#"SELECT * FROM "abspkg" WHERE "abspkg_ident" = ?1"#)?; + let rows = stmt.query_map(params![ident.to_string()], map_abspkg)?; + let mut abspkg = Vec::new(); + for i in rows { + abspkg.push(i?); + } + Ok(abspkg) + } + + pub fn for_each_installation>( + &mut self, + mut f: impl FnMut(Installation) -> Result<(), E>, + ) -> Result<(), E> { + let mut stmt = self.0.prepare_cached("SELECT * FROM \"installation\"")?; + for i in stmt.query_map(params![], map_installation)? { + f(i?)?; + } + Ok(()) + } + + pub fn remove_installation(&mut self, pkg_ident: &PkgIdent) -> rusqlite::Result { + self + .0 + .prepare_cached(r#"DELETE FROM "installation" WHERE "pkg_name" = ?1 AND "pkg_version" = ?2 AND "pkg_arch" = ?3"#)? + .execute(params![pkg_ident.name, pkg_ident.version.to_string(), pkg_ident.arch]) + .map(|x| x > 0) + } + + pub fn remove_abspkg_provided_by(&mut self, provider: &PkgIdent) -> rusqlite::Result { + self.0 + .prepare_cached(r#"DELETE FROM "abspkg" WHERE "provider" = ?1"#)? + .execute(params![provider.to_string()]) + .map(|x| x > 0) + } +} + +#[derive(Debug, Clone)] +pub struct Installation { + pub pkg_manifest: PkgManifest, + pub install_pkgspec: PkgSpec, + pub install_date: i64, + pub update_date: i64, + pub installed_size: u64, + pub install_flags: InstallFlags, +} + +#[derive(Debug, Clone, Default)] +pub struct InstallFlags(Vec); +impl InstallFlags { + pub const AUTO_INSTALL: &str = "auto-install"; + + pub fn new(v: &[&str]) -> Self { + Self(v.iter().map(ToString::to_string).collect()) + } + + pub fn contains(&self, s: &str) -> bool { + self.0.iter().any(|x| x == s) + } +} +impl FromStr for InstallFlags { + type Err = Infallible; + + fn from_str(s: &str) -> Result { + Ok(Self(s.split(' ').map(Into::into).collect())) + } +} +impl Display for InstallFlags { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for i in self.0.iter() { + write!(f, "{i} ")?; + } + Ok(()) + } +} + +fn map_installation(row: &Row) -> rusqlite::Result { + Ok(Installation { + pkg_manifest: serde_json::from_str(&row.get::<_, String>("pkg_manifest")?) + .map_err(from_sql_error)?, + install_pkgspec: row + .get::<_, String>("install_pkgspec")? + .parse() + .map_err(from_sql_error)?, + install_date: row.get("install_date")?, + update_date: row.get("update_date")?, + installed_size: row.get::<_, i64>("installed_size")? as _, + install_flags: row.get::<_, String>("install_flags")?.parse().unwrap(), + }) +} + +fn map_abspkg(row: &Row) -> rusqlite::Result<(AbsPkgIdent, PkgIdent)> { + let provider = row + .get::<_, String>("provider")? + .parse() + .map_err(from_sql_error)?; + let abspkg_name = row + .get::<_, String>("abspkg_ident")? + .parse() + .map_err(from_sql_error)?; + Ok((abspkg_name, provider)) +} + +fn from_sql_error(error: E) -> rusqlite::Error { + rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(error)) +} diff --git a/src/package.rs b/src/package.rs new file mode 100644 index 0000000..413f35c --- /dev/null +++ b/src/package.rs @@ -0,0 +1,335 @@ +use crate::{ + link::LinksFile, + version::{Version, VersionFilter}, +}; +use serde::{Deserialize, Serialize}; +use sha3::{Digest, Sha3_224}; +use std::{ + fmt::Display, + fs::File, + io::Read, + path::{Path, PathBuf}, + str::FromStr, +}; +use tempfile::TempDir; + +#[derive(Debug)] +pub struct Package { + file_path: PathBuf, + extracted_dir: TempDir, +} +impl Package { + /// Opens a package file. + pub fn open>(path: P) -> Result { + let open_file = File::open(path.as_ref()).map_err(PackageError::Open)?; + let decompressed_file = crate::common::decompress(open_file).map_err(PackageError::Open)?; + let mut archive = tar::Archive::new(decompressed_file); + + let extracted_dir = TempDir::new().map_err(PackageError::Open)?; + archive.unpack(&extracted_dir).map_err(PackageError::Open)?; + + Ok(Self { + file_path: path.as_ref().into(), + extracted_dir, + }) + } + + /// Returns [`PkgManifest`] for this package. + pub fn manifest(&self) -> Result { + PkgManifest::read_at(self.extracted_dir.path().join(PkgManifest::FILENAME)) + .map_err(PackageError::Manifest) + } + + pub fn links(&self) -> Option { + let path = self.extracted_dir.path().join(LinksFile::FILENAME); + if path.exists() { + Some(LinksFile(path)) + } else { + None + } + } + + /// Returns path of the bundle directory of this package. + pub fn bundle_dir(&self) -> PathBuf { + self.extracted_dir.path().join("bundle") + } + + /// Returns installed size of this package. + pub fn installed_size(&self) -> Result { + crate::common::du_dir(self.bundle_dir()).map_err(PackageError::Bundle) + } +} + +/// Representation of the `PkgManifest.json` file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PkgManifest { + /// Package name. + pub name: String, + + /// Package version. + pub version: Version, + + /// Package architecture. + pub arch: String, + + /// Package description. + #[serde(default)] + pub description: String, + + /// Package maintainers. + #[serde(default)] + pub maintainers: Vec, + + /// Package dependencies. + #[serde(default)] + pub dependencies: Vec, + + /// Package recommended (soft) dependencies. + #[serde(default)] + pub recommendations: Vec, + + /// Provided abstract packages. + #[serde(default)] + pub provides: Vec, +} +impl PkgManifest { + pub const FILENAME: &str = "PkgManifest.json"; + + pub const SIZE_MAX: u64 = 2 * 1024 * 1024; + + pub fn read_at>(path: P) -> std::io::Result { + let mut data = Vec::with_capacity(1024); + let mut file = File::open(path)?.take(PkgManifest::SIZE_MAX); + file.read_to_end(&mut data)?; + serde_json::from_slice(&data) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err)) + } + + pub fn pkg_ident(&self) -> PkgIdent { + PkgIdent { + name: self.name.clone(), + version: self.version.clone(), + arch: self.arch.clone(), + } + } +} + +const PKGNAME_FORBIDDEN_CHARS: &str = "~!@#$%^&*()={}[]\\|:;'\"<>,/?\r\n "; + +/// A package specifier. +#[derive(Debug, Clone)] +pub struct PkgSpec { + pub name: String, + pub version: VersionFilter, + pub arch: Option, +} +impl PkgSpec { + pub fn matches(&self, pkg_ident: &PkgIdent) -> bool { + self.name == pkg_ident.name + && self.version.matches(&pkg_ident.version) + && match self.arch.as_deref() { + Some(arch) => pkg_ident.arch == arch, + None => true, + } + } +} +impl From for PkgSpec { + fn from(value: PkgIdent) -> Self { + Self { + name: value.name, + version: VersionFilter::Eq(value.version), + arch: Some(value.arch), + } + } +} +impl FromStr for PkgSpec { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + if let Ok(pkgident) = PkgIdent::from_str(s) { + return Ok(Self { + name: pkgident.name, + version: VersionFilter::Eq(pkgident.version), + arch: Some(pkgident.arch), + }); + } + + let s = s.trim(); + let mut name = String::new(); + let mut version = None; + let mut arch = None; + let mut chars = s.chars().peekable(); + + while let Some(&c) = chars.peek() { + if c == '(' { + break; + } + if c == '@' { + break; + } + name.push(chars.next().unwrap()); + } + + if chars.peek() == Some(&'(') { + chars.next(); + let mut ver = String::new(); + while let Some(&c) = chars.peek() { + if c == ')' { + chars.next(); + break; + } + ver.push(chars.next().unwrap()); + } + version = Some(ver); + + while chars.peek() == Some(&' ') { + chars.next(); + } + } + + if chars.peek() == Some(&'@') { + chars.next(); + let mut arch_str = String::new(); + while chars.peek().is_some() { + arch_str.push(chars.next().unwrap()); + } + arch = Some(arch_str); + } + + let name = name.trim_end().to_string(); + if name.chars().any(|ch| PKGNAME_FORBIDDEN_CHARS.contains(|x| x == ch)) { + return Err(ParseError::Pkgname); + } + + Ok(Self { + name, + version: version.as_deref().unwrap_or("*").parse()?, + arch, + }) + } +} +impl Display for PkgSpec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name)?; + if self.version != VersionFilter::Any { + write!(f, " ({})", self.version)?; + } + if let Some(arch) = self.arch.as_ref() { + write!(f, " @{}", arch)?; + } + Ok(()) + } +} +crate::impl_serde_str!(PkgSpec); + +/// A package identifier that specifies a unique package. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PkgIdent { + pub name: String, + pub version: Version, + pub arch: String, +} +impl PkgIdent { + pub fn prefix_name(&self) -> String { + let mut hasher = Sha3_224::new(); + hasher.update(self.to_string().as_bytes()); + let hash = const_hex::encode(hasher.finalize()); + format!("{}-{}", self.name, hash) + } +} +impl Display for PkgIdent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}={}@{}", self.name, self.version, self.arch) + } +} +impl FromStr for PkgIdent { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + let mut name = String::new(); + let mut version = String::new(); + let mut arch = String::new(); + let mut handling = "name"; + for c in s.chars() { + if c == '=' { + handling = "version"; + } else if c == '@' { + handling = "arch"; + } else if handling == "name" { + name.push(c); + } else if handling == "version" { + version.push(c); + } else if handling == "arch" { + arch.push(c); + } + } + + let name = name.trim().to_string(); + if name.chars().any(|ch| PKGNAME_FORBIDDEN_CHARS.contains(|x| x == ch)) { + return Err(ParseError::Pkgname); + } + + Ok(Self { + name, + version: version.trim().parse()?, + arch: arch.trim().into(), + }) + } +} +crate::impl_serde_str!(PkgIdent); + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AbsPkgIdent { + pub name: String, + pub arch: Option, +} +impl Display for AbsPkgIdent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.arch { + Some(arch) => write!(f, "{}@{}", self.name, arch), + None => write!(f, "{}", self.name), + } + } +} +impl FromStr for AbsPkgIdent { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + let (name, arch) = s + .split_once('@') + .map(|(a, b)| (a, Some(b))) + .unwrap_or((s, None)); + Ok(Self { + name: name.into(), + arch: arch.map(Into::into), + }) + } +} +crate::impl_serde_str!(AbsPkgIdent); + +#[derive(Debug, thiserror::Error)] +pub enum ParseError { + #[error("invalid package name")] + Pkgname, + + #[error("{0}")] + Version(crate::version::VersionError), +} +impl From for ParseError { + fn from(error: crate::version::VersionError) -> Self { + Self::Version(error) + } +} + +/// An error caused by dealing with package files. +#[derive(Debug, thiserror::Error)] +pub enum PackageError { + #[error("failed to open package file: {0}")] + Open(std::io::Error), + + #[error("failed to read package manifest: {0}")] + Manifest(std::io::Error), + + #[error("failed to read package bundle: {0}")] + Bundle(std::io::Error), +} diff --git a/src/profile.rs b/src/profile.rs new file mode 100644 index 0000000..ca8aeb1 --- /dev/null +++ b/src/profile.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Profile { + /// Directory for storing package bundles, e.g. `/pkg`. + pub pkg_dir: PathBuf, + + /// Packie data directory, e.g. `/var/db/packie`. + pub packie_data_dir: PathBuf, + + /// Packie cache directory, e.g. `/var/cache/packie`. + pub packie_cache_dir: PathBuf, + + /// Packie config directory, e.g. `/var/config/packie`. + pub packie_config_dir: PathBuf, + + /// Host architecture, e.g. `riscv64-semios-linux`. + pub host_arch: String, +} +impl Profile { + pub fn builtin() -> Self { + Self { + pkg_dir: option_env!("PACKIE_PKG_DIR").unwrap_or("/pkg").into(), + packie_data_dir: option_env!("PACKIE_DATA_DIR") + .unwrap_or("/var/lib/packie") + .into(), + packie_cache_dir: option_env!("PACKIE_CACHE_DIR") + .unwrap_or("/var/cache/packie") + .into(), + packie_config_dir: option_env!("PACKIE_CONFIG_DIR") + .unwrap_or("/var/config/packie") + .into(), + host_arch: option_env!("PACKIE_HOST_ARCH") + .unwrap_or(&format!( + "{}-semios-{}", + std::env::consts::ARCH, + std::env::consts::OS + )) + .into(), + } + } +} diff --git a/src/remove.rs b/src/remove.rs new file mode 100644 index 0000000..87e36ce --- /dev/null +++ b/src/remove.rs @@ -0,0 +1,63 @@ +use crate::{link::LinksFile, package::PkgSpec}; +use rust_i18n::t; + +impl super::Packie { + pub fn remove(&mut self, pkgspec: &PkgSpec) -> Result<(), RemoveError> { + // Find the installation to remove + let mut found = self + .search_installation(pkgspec) + .map_err(RemoveError::Database)?; + if found.is_empty() { + return Err(RemoveError::NotFound); + } + if found.len() != 1 { + return Err(RemoveError::NotUniquePkgSpec); + } + assert_eq!(found.len(), 1); + let found = found.remove(0); + let pkg_ident = found.pkg_manifest.pkg_ident(); + + // Remove all links + let links = LinksFile(self.local_data.links_file(&pkg_ident)); + if let Ok(links) = links.iter() { + for link in links { + let Ok(link) = link else { + continue; + }; + _ = crate::link::deactivate_by_description(self, &pkg_ident, &link); + } + } + + // Remove package files + let bundle_dir = self.profile.pkg_dir.join(pkg_ident.prefix_name()); + std::fs::remove_dir_all(&bundle_dir).map_err(RemoveError::RemoveFiles)?; + + // Remove database records + self.local_db + .remove_installation(&pkg_ident) + .map_err(RemoveError::Database)?; + self.local_db + .remove_abspkg_provided_by(&pkg_ident) + .map_err(RemoveError::Database)?; + + // Remove the links file + _ = std::fs::remove_file(self.local_data.links_file(&pkg_ident)); + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum RemoveError { + #[error("{}", t!("remove.RemoveError.NotFound"))] + NotFound, + + #[error("{}", t!("remove.RemoveError.NotUniquePkgSpec"))] + NotUniquePkgSpec, + + #[error("{}", t!("remove.RemoveError.RemoveFiles", error = .0))] + RemoveFiles(std::io::Error), + + #[error("{}", t!("remove.RemoveError.Database", error = .0))] + Database(rusqlite::Error), +} diff --git a/src/repo/download.rs b/src/repo/download.rs new file mode 100644 index 0000000..6d68cd2 --- /dev/null +++ b/src/repo/download.rs @@ -0,0 +1,117 @@ +use rust_i18n::t; +use std::{ + fs::File, + io::{Read, Seek, Write}, + path::PathBuf, +}; + +pub struct Download

{ + from: String, + to: PathBuf, + on_progress: P, + no_existence_check: bool, + checksum_required: Option, +} +impl Download { + pub fn new(from: String, to: PathBuf) -> Self { + Self { + from, + to, + no_existence_check: false, + on_progress: |_, _| (), + checksum_required: None, + } + } +} +impl

Download

{ + pub fn on_progress(self, on_progress: P2) -> Download { + Download { + from: self.from, + to: self.to, + no_existence_check: self.no_existence_check, + on_progress, + checksum_required: self.checksum_required, + } + } + + pub fn no_existence_check(mut self) -> Self { + self.no_existence_check = true; + self + } + + pub fn checksum_required(mut self, val: String) -> Self { + self.checksum_required = Some(val); + self + } +} +impl Download

{ + pub fn run(&mut self) -> Result<(), DownloadError> { + if self.to.exists() && !self.no_existence_check { + return Ok(()); + } + + let mut temp_file_path = self.to.clone(); + temp_file_path.add_extension("tmp"); + let mut temp_file = File::options() + .create(true) + .append(true) + .open(&temp_file_path) + .map_err(DownloadError::Filesystem)?; + let seek = temp_file + .seek(std::io::SeekFrom::Current(0)) + .map_err(DownloadError::Filesystem)?; + + let mut resp = ureq::get(&self.from) + .header(ureq::http::header::RANGE, format!("bytes={seek}-")) + .call() + .map_err(|e| DownloadError::Http(Box::new(e)))?; + let body = resp.body_mut(); + let total = body.content_length().unwrap_or_default() + seek; + let mut sum = seek; + + let mut buf = [0; 1024]; + let mut body_reader = body.as_reader(); + + loop { + let len = body_reader + .read(&mut buf) + .map_err(|e| DownloadError::Http(Box::new(e)))?; + if len == 0 { + break; + } + temp_file + .write_all(&buf[..len]) + .map_err(DownloadError::Filesystem)?; + sum += len as u64; + (self.on_progress)(sum, total); + } + + if let Some(expected) = self.checksum_required.as_ref() { + let verified = + crate::common::checksum::verify_file(&temp_file_path, expected).unwrap_or_default(); + if !verified { + _ = std::fs::remove_file(&temp_file_path); + return Err(DownloadError::Checksum); + } + } + + std::fs::rename(temp_file_path, &self.to).map_err(DownloadError::Filesystem)?; + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum DownloadError { + #[error("INVALID_URL")] + InvalidUrl, + + #[error("{0}")] + Http(Box), + + #[error("{0}")] + Filesystem(std::io::Error), + + #[error("{}", t!("repo.download.DownloadError.Checksum"))] + Checksum, +} diff --git a/src/repo/mod.rs b/src/repo/mod.rs new file mode 100644 index 0000000..283ebe6 --- /dev/null +++ b/src/repo/mod.rs @@ -0,0 +1,424 @@ +mod download; +mod serve; + +#[doc(inline)] +pub use download::DownloadError; +#[doc(inline)] +pub use serve::RepoServeDir; + +use crate::{ + Packie, + package::{AbsPkgIdent, PkgIdent, PkgManifest, PkgSpec}, +}; +use download::Download; +use itertools::Itertools; +use rusqlite::{OpenFlags, Row, params}; +use rust_i18n::t; +use rustc_hash::FxHashMap; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use url::Url; + +impl super::Packie { + /// Creates a session for syncing repositories. + pub fn sync_repo(&mut self) -> SyncSession<'_> { + SyncSession::new(self) + } + + /// Create a session for querying repositories. + pub fn query_repo(&mut self) -> QuerySession { + QuerySession::new(self) + } + + /// Download a package. + pub fn download_package( + &self, + repo: &str, + pkg_ident: &PkgIdent, + mut on_event: impl FnMut(DownloadPackageEvent), + ) -> Result { + let repo_cache_dir = self.cache.repo_dir(repo); + + let repo = self.config.repos.get(repo).ok_or(RepoError::NoSuchRepo)?; + let repo_db = repo_cache_dir.join(RepoDb::FILENAME); + let mut repo_db = RepoDb::open_ro(repo_db).map_err(RepoError::Database)?; + let repo_package = repo_db + .select_package_by_ident(pkg_ident) + .map_err(|_| RepoError::NoSuchPackage)?; + + let standard_filename = format!("{pkg_ident}.pkg"); + + let download_path = repo_cache_dir.join(&standard_filename); + + for repo_url in repo.urls() { + let download_url = match repo_url.join(&standard_filename) { + Ok(x) => x, + Err(_) => { + on_event(DownloadPackageEvent::Error( + repo_url.clone(), + RepoError::Download(DownloadError::InvalidUrl), + )); + continue; + } + }; + let download_result = Download::new(download_url.to_string(), download_path.clone()) + .on_progress(|sum, total| { + on_event(DownloadPackageEvent::Progress(repo_url.clone(), sum, total)) + }) + .checksum_required(repo_package.checksum.clone()) + .run(); + if let Err(err) = download_result { + on_event(DownloadPackageEvent::Error( + download_url.clone(), + RepoError::Download(err), + )); + continue; + } + return Ok(download_path); + } + + Err(RepoError::AllTriesFailed) + } +} + +#[derive(Debug)] +pub struct RepoDb(rusqlite::Connection); +impl RepoDb { + const FILENAME: &str = "repo.db"; + + const SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS "package"( + "pkg_name" TEXT NOT NULL, + "pkg_version" TEXT NOT NULL, + "pkg_arch" TEXT NOT NULL, + "pkg_manifest" TEXT NOT NULL, + "download_size" INTEGER NOT NULL, + "installed_size" INTEGER NOT NULL, + "checksum" TEXT NOT NULL, + PRIMARY KEY ("pkg_name", "pkg_version", "pkg_arch") +); +CREATE TABLE IF NOT EXISTS "abspkg"( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "abspkg_ident" TEXT NOT NULL, + "provider" TEXT NOT NULL +); +"#; + + pub fn open_rw>(path: P) -> rusqlite::Result { + let conn = rusqlite::Connection::open(path)?; + conn.execute_batch(Self::SCHEMA)?; + Ok(Self(conn)) + } + + pub fn open_ro>(path: P) -> rusqlite::Result { + rusqlite::Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).map(Self) + } + + pub fn select_package_by_ident( + &mut self, + pkg_ident: &PkgIdent, + ) -> rusqlite::Result { + self.0.prepare_cached( + r#"SELECT * FROM "package" WHERE "pkg_name" = ?1 AND "pkg_version" = ?2 AND "pkg_arch" = ?3"#, + )?.query_one(params![pkg_ident.name, pkg_ident.version.to_string(), pkg_ident.arch], map_repo_package) + } + + pub fn select_package_by_name(&mut self, pkgname: &str) -> rusqlite::Result> { + let mut stmt = self + .0 + .prepare_cached(r#"SELECT * FROM "package" WHERE "pkg_name" = ?1"#)?; + let mapped_rows = stmt.query_map(params![pkgname], map_repo_package)?; + let mut ret = Vec::with_capacity(16); + for mr in mapped_rows { + ret.push(mr?); + } + Ok(ret) + } + + pub fn select_abspkg_by_ident( + &mut self, + ident: &AbsPkgIdent, + ) -> rusqlite::Result> { + let mut stmt = self + .0 + .prepare_cached(r#"SELECT * FROM "abspkg" WHERE "abspkg_ident" = ?1"#)?; + let rows = stmt.query_map(params![ident.to_string()], map_abspkg)?; + let mut ret = Vec::with_capacity(16); + for row in rows { + ret.push(row?); + } + Ok(ret) + } + + pub fn for_each_package>( + &mut self, + mut f: impl FnMut(RepoPackage) -> Result<(), E>, + ) -> Result<(), E> { + let mut stmt = self.0.prepare_cached("SELECT * FROM \"package\"")?; + let mapped_rows = stmt.query_map(params![], map_repo_package)?; + for mr in mapped_rows { + f(mr?)?; + } + Ok(()) + } + + pub fn insert_package(&mut self, package: &RepoPackage) -> rusqlite::Result<()> { + let pkg_manifest = serde_json::to_string(&package.pkg_manifest) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + self.0 + .prepare_cached( + r#" + INSERT INTO "package"( + "pkg_name", "pkg_version", "pkg_arch", "pkg_manifest", + "download_size", "installed_size", "checksum" + ) + VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7) + "#, + )? + .execute(params![ + package.pkg_manifest.name, + package.pkg_manifest.version.to_string(), + package.pkg_manifest.arch, + pkg_manifest, + package.download_size as i64, + package.installed_size as i64, + package.checksum + ]) + .map(|_| ()) + } + + pub fn insert_abspkg(&mut self, abs: &AbsPkgIdent, real: &PkgIdent) -> rusqlite::Result<()> { + self.0 + .prepare_cached(r#"INSERT INTO "abspkg"("abspkg_ident", "provider") VALUES(?1, ?2)"#)? + .execute(params![abs.to_string(), real.to_string()])?; + Ok(()) + } + + pub fn delete_package_by_ident(&mut self, ident: &PkgIdent) -> rusqlite::Result { + self.0 + .prepare_cached( + r#"DELETE FROM "package" WHERE "pkg_name" = ?1 AND "pkg_version" = ?2 AND "pkg_arch" = ?3"#, + )? + .execute(params![ident.name, ident.version.to_string(), ident.arch]) + .map(|x| x > 0) + } + + pub fn delete_abspkg_by_provider(&mut self, ident: &PkgIdent) -> rusqlite::Result { + self.0 + .prepare_cached(r#"DELETE FROM "abspkg" WHERE "provider" = ?1"#)? + .execute(params![ident.to_string()]) + .map(|x| x > 0) + } +} + +fn map_repo_package(row: &Row) -> rusqlite::Result { + let pkg_manifest: String = row.get("pkg_manifest")?; + let pkg_manifest = serde_json::from_str(&pkg_manifest).map_err(from_sql_error)?; + Ok(RepoPackage { + pkg_manifest, + download_size: row.get::<_, i64>("download_size")? as u64, + installed_size: row.get::<_, i64>("installed_size")? as u64, + checksum: row.get("checksum")?, + }) +} + +fn map_abspkg(row: &Row) -> rusqlite::Result<(AbsPkgIdent, PkgIdent)> { + let provider = row + .get::<_, String>("provider")? + .parse() + .map_err(from_sql_error)?; + let abspkg_name = row + .get::<_, String>("abspkg_ident")? + .parse() + .map_err(from_sql_error)?; + Ok((abspkg_name, provider)) +} + +fn from_sql_error(error: E) -> rusqlite::Error { + rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(error)) +} + +/// A record representing to a package in a repository. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepoPackage { + /// Package manifest. + pub pkg_manifest: PkgManifest, + + /// Download size of the package. + pub download_size: u64, + + /// Installed size of the package. + pub installed_size: u64, + + /// Checksum of the package file. + pub checksum: String, +} + +/// Session of syncing repositories. +pub struct SyncSession<'a, E = fn(SyncEvent)> { + packie: &'a mut Packie, + repo: Option, + on_event: E, +} +impl<'a> SyncSession<'a> { + pub fn new(packie: &'a mut Packie) -> Self { + Self { + packie, + repo: None, + on_event: |_| (), + } + } +} +impl<'a, E> SyncSession<'a, E> { + pub fn repo(mut self, repo: String) -> Self { + self.repo = Some(repo); + self + } + + pub fn on_event(self, on_event: E2) -> SyncSession<'a, E2> { + SyncSession { + packie: self.packie, + repo: self.repo, + on_event, + } + } +} +impl SyncSession<'_, E> { + pub fn run(mut self) { + let repos = match self.repo.clone() { + Some(x) => vec![x], + None => self.packie.config.repos.keys().cloned().collect(), + }; + for repo_name in repos { + let Some(repo) = self.packie.config.repos.get(&repo_name).cloned() else { + self.raise_error(Some(repo_name), None, RepoError::NoSuchRepo); + continue; + }; + (self.on_event)(SyncEvent::BeginRepo(repo_name.clone())); + for url in repo.urls() { + match self.sync_url(&repo_name, &url) { + Ok(()) => break, + Err(err) => self.raise_error(Some(repo_name.clone()), Some(url.clone()), err), + } + } + } + } + + fn sync_url(&mut self, repo_name: &str, url: &Url) -> Result<(), RepoError> { + let repo_db_url = url + .join(RepoDb::FILENAME) + .map_err(|_| DownloadError::InvalidUrl) + .map_err(RepoError::Download)?; + + let repo_cache_dir = self.packie.cache.repo_dir(repo_name); + Download::new( + repo_db_url.to_string(), + repo_cache_dir.join(RepoDb::FILENAME), + ) + .no_existence_check() + .on_progress(Box::new(|sum, total| { + self.raise_progress(repo_name.into(), repo_db_url.clone(), sum, total) + })) + .run() + .map_err(RepoError::Download)?; + + Ok(()) + } + + fn raise_error(&mut self, repo: Option, url: Option, error: RepoError) { + (self.on_event)(SyncEvent::Error(repo, url, error)); + } + + fn raise_progress(&mut self, repo: String, url: Url, downloaded_bytes: u64, total_bytes: u64) { + (self.on_event)(SyncEvent::Progress(SyncProgress { + repo: Some(repo), + url: Some(url), + downloaded_bytes, + total_bytes, + })); + } +} + +/// An event during running [`SyncSession`], which may report progress changes or errors. +#[derive(Debug)] +pub enum SyncEvent { + BeginRepo(String), + Progress(SyncProgress), + Error(Option, Option, RepoError), +} + +/// A progress while syncing repositories. +#[derive(Debug)] +pub struct SyncProgress { + pub repo: Option, + pub url: Option, + pub downloaded_bytes: u64, + pub total_bytes: u64, +} + +/// An event occurred while downloading a package. +#[derive(Debug)] +pub enum DownloadPackageEvent { + Progress(Url, u64, u64), + Error(Url, RepoError), +} + +#[derive(Debug)] +pub struct QuerySession { + repos: FxHashMap, +} +impl QuerySession { + pub fn new(packie: &mut Packie) -> Self { + let mut repos = FxHashMap::default(); + for name in packie.config.repos.keys() { + let repo_db = packie.cache.repo_dir(name).join(RepoDb::FILENAME); + let Ok(repo_db) = RepoDb::open_ro(repo_db) else { + continue; + }; + repos.insert(name.into(), repo_db); + } + Self { repos } + } + + pub fn search_package(&mut self, pkgspec: &PkgSpec) -> Vec<(String, RepoPackage)> { + let mut all: Vec<(String, RepoPackage)> = Vec::new(); + for (name, db) in self.repos.iter_mut() { + for pkg in db.select_package_by_name(&pkgspec.name).unwrap_or_default() { + if pkgspec.matches(&pkg.pkg_manifest.pkg_ident()) { + all.push((name.into(), pkg)); + } + } + let abspkg_ident = AbsPkgIdent { + name: pkgspec.name.clone(), + arch: pkgspec.arch.clone(), + }; + for (_, provider) in db.select_abspkg_by_ident(&abspkg_ident).unwrap_or_default() { + let Ok(real) = db.select_package_by_ident(&provider) else { + continue; + }; + all.push((name.into(), real)); + } + } + all.into_iter() + .unique_by(|(repo, pkg)| (repo.to_string(), pkg.pkg_manifest.pkg_ident())) + .collect() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum RepoError { + #[error("{}", t!("repo.RepoError.NoSuchRepo"))] + NoSuchRepo, + + #[error("{}", t!("repo.RepoError.NoSuchPackage"))] + NoSuchPackage, + + #[error("{}", t!("repo.RepoError.Database", error = 0))] + Database(rusqlite::Error), + + #[error("{0}")] + Download(DownloadError), + + #[error("{}", t!("repo.RepoError.AllTriesFailed"))] + AllTriesFailed, +} diff --git a/src/repo/serve.rs b/src/repo/serve.rs new file mode 100644 index 0000000..dd9323e --- /dev/null +++ b/src/repo/serve.rs @@ -0,0 +1,105 @@ +use crate::{ + common::checksum::DEFAULT_HASHER, + package::{Package, PkgIdent}, + repo::{RepoDb, RepoPackage, download::Download}, +}; +use std::path::{Path, PathBuf}; +use url::Url; + +/// A helper type to deal with repository serve directories. +#[derive(Debug)] +pub struct RepoServeDir { + path: PathBuf, + db: RepoDb, +} +impl RepoServeDir { + /// Open a directory. + pub fn open>(path: P) -> rusqlite::Result { + let path = path.into(); + Ok(Self { + db: RepoDb::open_rw(path.join(RepoDb::FILENAME))?, + path, + }) + } + + pub fn clone>( + url: &Url, + path: P, + mut on_event: impl FnMut(Option, Url, u64, u64), + ) -> Result<(), Box> { + let path = path.into(); + std::fs::create_dir_all(&path)?; + + let repo_db_url = url.join(RepoDb::FILENAME)?; + Download::new(repo_db_url.to_string(), path.join(RepoDb::FILENAME)) + .on_progress(|sum, total| on_event(None, repo_db_url.clone(), sum, total)) + .run()?; + + let mut db = RepoDb::open_ro(path.join(RepoDb::FILENAME))?; + db.for_each_package::>(|pkg| { + let pkg_ident = pkg.pkg_manifest.pkg_ident(); + let standard_filename = format!("{}.pkg", pkg_ident.to_string()); + let pkgfile_url = url.join(&standard_filename)?; + let pkgfile_path = path.join(&standard_filename); + if crate::common::checksum::verify_file(&pkgfile_path, &pkg.checksum) + .unwrap_or_default() + { + return Ok(()); + } + Download::new(pkgfile_url.to_string(), pkgfile_path) + .on_progress(|sum, total| { + on_event(Some(pkg_ident.to_string()), pkgfile_url.clone(), sum, total) + }) + .run() + .map_err::, _>(|e| { + Box::from(format!("failed to download \"{pkgfile_url}\": {e}")) + })?; + Ok(()) + })?; + + Ok(()) + } + + /// Add a package to the repository. + pub fn add_package>( + &mut self, + path: P, + ) -> Result<(), Box> { + let path = path.as_ref(); + let package = Package::open(path)?; + + let pkg_manifest = package.manifest()?; + let download_size = std::fs::metadata(path)?.len(); + let installed_size = package.installed_size()?; + let checksum = crate::common::checksum::hash_file(DEFAULT_HASHER, path)?; + + let repo_package = RepoPackage { + pkg_manifest, + download_size, + installed_size, + checksum, + }; + + let standard_filename = + format!("{}.pkg", repo_package.pkg_manifest.pkg_ident().to_string()); + std::fs::copy(path, self.path.join(standard_filename))?; + + self.db.insert_package(&repo_package)?; + for abs in repo_package.pkg_manifest.provides.iter() { + self.db + .insert_abspkg(abs, &repo_package.pkg_manifest.pkg_ident())?; + } + Ok(()) + } + + pub fn remove_package( + &mut self, + ident: &PkgIdent, + ) -> Result<(), Box> { + let path = self.path.join(format!("{ident}.pkg")); + std::fs::remove_file(path)?; + self.db.delete_package_by_ident(ident)?; + self.db.delete_abspkg_by_provider(ident)?; + Ok(()) + } +} diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 0000000..8711d00 --- /dev/null +++ b/src/version.rs @@ -0,0 +1,265 @@ +//! Parsing and matching of software versions. + +use std::{cmp::Ordering, fmt::Display, str::FromStr}; +use rust_i18n::t; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Version { + /// Version namespace. By default, the value is `0`. The value needs to be changed if the versioning + /// rule has an incompatible change. + pub namespace: u32, + + /// Main version. This is usually determined by the upstream. + pub main: Vec, + + /// Pre-release version suffix. This is usually determined by the upstream. + pub prerelease: Option, + + /// Meta version. + pub meta: Option, +} +impl FromStr for Version { + type Err = VersionError; + + fn from_str(s: &str) -> Result { + let (namespace, s) = match s.split_once(':') { + Some((a, b)) => (a.parse().map_err(|_| VersionError::ParseNamespace)?, b), + None => (0, s), + }; + let (s, meta) = match s.split_once('+') { + Some((a, b)) => (a, Some(b.into())), + None => (s, None), + }; + let (s, prerelease) = match s.split_once('-') { + Some((a, b)) => (a, Some(b.parse()?)), + None => (s, None), + }; + let mut main = Vec::new(); + for i in s.split('.') { + main.push(i.parse().map_err(|_| VersionError::ParseMain)?); + } + Ok(Self { + namespace, + main, + prerelease, + meta, + }) + } +} +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + let namespace = self.namespace.cmp(&other.namespace); + if namespace != Ordering::Equal { + return Some(namespace); + } + let main = self.main.cmp(&other.main); + if main != Ordering::Equal { + return Some(main); + } + let prerelease = match (&self.prerelease, &other.prerelease) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(a), Some(b)) => a.cmp(b), + }; + if prerelease != Ordering::Equal { + return Some(prerelease); + } + match (&self.meta, &other.meta) { + (None, None) => Some(Ordering::Equal), + (None, Some(_)) => Some(Ordering::Less), + (Some(_), None) => Some(Ordering::Greater), + (Some(a), Some(b)) => Some(a.cmp(&b)), + } + } +} +impl Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + self.partial_cmp(other).unwrap() + } +} +impl Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.namespace != 0 { + write!(f, "{}:", self.namespace)?; + } + let main = self + .main + .iter() + .map(ToString::to_string) + .collect::>() + .join("."); + write!(f, "{main}",)?; + if let Some(prerelease) = &self.prerelease { + write!(f, "-{prerelease}")?; + } + if let Some(meta) = &self.meta { + write!(f, "+{meta}")?; + } + Ok(()) + } +} +crate::impl_serde_str!(Version); +impl Version {} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Prerelease { + pub tag: String, + pub number: u32, +} +impl FromStr for Prerelease { + type Err = VersionError; + + fn from_str(s: &str) -> Result { + let Some((tag, number)) = s.split_once('.') else { + return Err(VersionError::ParsePrerelease); + }; + Ok(Self { + tag: tag.into(), + number: number.parse().map_err(|_| VersionError::ParsePrerelease)?, + }) + } +} +impl Display for Prerelease { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.{}", self.tag, self.number) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum VersionFilter { + /// The '*' filter, matching any versions. + #[default] + Any, + + /// The '..., ..., ...' filter. + All(Vec), + + /// The '<=' filter. + Lte(Version), + + /// The '>=' filter. + Gte(Version), + + /// The '=' filter. + Eq(Version), + + /// The '~' (or default) filter. + Compatible(Version), + + /// The '+' filter. + WithMeta(String), +} +impl VersionFilter { + pub fn matches(&self, version: &Version) -> bool { + match self { + Self::Any => true, + Self::Lte(other) => version <= other, + Self::Gte(other) => version >= other, + Self::Eq(other) => version == other, + Self::All(cond) => cond.iter().all(|x| x.matches(version)), + Self::Compatible(other) => semver_is_compatible(version, other), + Self::WithMeta(meta) => version.meta.as_deref() == Some(meta), + } + } +} +impl FromStr for VersionFilter { + type Err = VersionError; + + fn from_str(s: &str) -> Result { + if s == "*" { + return Ok(Self::Any); + } + if s.contains(',') { + let mut all = Vec::new(); + for i in s.split(',') { + all.push(i.trim().parse()?); + } + return Ok(Self::All(all)); + } + if let Some(version) = s.strip_prefix('=') { + return Ok(Self::Eq(version.parse()?)); + } + if let Some(version) = s.strip_prefix(">=") { + return Ok(Self::Gte(version.parse()?)); + } + if let Some(version) = s.strip_prefix("<=") { + return Ok(Self::Lte(version.parse()?)); + } + if let Some(meta) = s.strip_prefix("+") { + return Ok(Self::WithMeta(meta.into())); + } + if let Some(version) = s.strip_prefix("~") { + return Ok(Self::Compatible(version.parse()?)); + } + return Ok(Self::Compatible(s.parse()?)); + } +} +impl Display for VersionFilter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Any => write!(f, "*"), + Self::Lte(ver) => write!(f, "<={ver}"), + Self::Gte(ver) => write!(f, ">={ver}"), + Self::Eq(ver) => write!(f, "={ver}"), + Self::All(cond) => write!( + f, + "{}", + cond.iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + ), + Self::Compatible(ver) => write!(f, "~{ver}"), + Self::WithMeta(meta) => write!(f, "+{meta}"), + } + } +} +crate::impl_serde_str!(VersionFilter); + +/// An error caused by dealing with version or version filter strings. +#[derive(Debug, Clone, thiserror::Error)] +pub enum VersionError { + #[error("{}", t!("version.VersionError.ParseNamespace"))] + ParseNamespace, + + #[error("{}", t!("version.VersionError.ParseMain"))] + ParseMain, + + #[error("{}", t!("version.VersionError.ParsePrerelease"))] + ParsePrerelease, +} + +fn semver_is_compatible(a: &Version, b: &Version) -> bool { + if a.namespace != b.namespace { + return false; + } + if a.main.first().copied() != b.main.first().copied() { + return false; + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cmp_prerelease() -> Result<(), Box> { + assert!(Prerelease::from_str("rc.1")? > Prerelease::from_str("beta.2")?); + assert!(Prerelease::from_str("beta.1")? > Prerelease::from_str("alpha.1")?); + Ok(()) + } + + #[test] + fn cmp_version() -> Result<(), Box> { + assert!(Version::from_str("100:1.0.0")? > Version::from_str("99:1.0.0")?); + assert!(Version::from_str("1:1.0.0")? > Version::from_str("1.0.0")?); + assert!(Version::from_str("0:1.0.0")? == Version::from_str("1.0.0")?); + assert!(Version::from_str("1.0.0-rc.1")? < Version::from_str("1.0.0")?); + assert!(Version::from_str("1.0.0-rc.1")? > Version::from_str("0.5.1")?); + assert!(Version::from_str("1.0.0-rc.1+1")? > Version::from_str("1.0.0-rc.1")?); + assert!(Version::from_str("1.0.0+1")? > Version::from_str("1.0.0")?); + Ok(()) + } +}