From e6fa0df9a55d85cc635d44466298f1cb82b87d5e Mon Sep 17 00:00:00 2001 From: sisungo Date: Wed, 10 Jun 2026 00:33:24 +0800 Subject: [PATCH] Initial commit Signed-off-by: sisungo --- .gitignore | 7 + Cargo.toml | 25 ++++ INSTALL.md | 13 ++ cli/Cargo.toml | 14 ++ cli/src/info.rs | 28 ++++ cli/src/initdb.rs | 10 ++ cli/src/install.rs | 49 +++++++ cli/src/main.rs | 48 +++++++ cli/src/print.rs | 19 +++ cli/src/remove.rs | 17 +++ locales/libpackie/main.yml | 22 +++ locales/packie-cli/main.yml | 4 + src/common.rs | 206 +++++++++++++++++++++++++++ src/install.rs | 103 ++++++++++++++ src/installation.rs | 22 +++ src/lib.rs | 77 ++++++++++ src/link.rs | 28 ++++ src/local_db.rs | 104 ++++++++++++++ src/package.rs | 216 ++++++++++++++++++++++++++++ src/profile.rs | 43 ++++++ src/remove.rs | 46 ++++++ src/version.rs | 277 ++++++++++++++++++++++++++++++++++++ 22 files changed, 1378 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 INSTALL.md create mode 100644 cli/Cargo.toml 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 locales/libpackie/main.yml create mode 100644 locales/packie-cli/main.yml create mode 100644 src/common.rs create mode 100644 src/install.rs create mode 100644 src/installation.rs create mode 100644 src/lib.rs create mode 100644 src/link.rs create mode 100644 src/local_db.rs create mode 100644 src/package.rs create mode 100644 src/profile.rs create mode 100644 src/remove.rs create mode 100644 src/version.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c55d613 --- /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..4566bd4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "packie" +version = "0.1.0" +edition = "2024" + +[features] +default = ["zstd", "gzip"] +zstd = ["dep:zstd"] +gzip = ["dep:flate2"] + +[workspace] +members = ["cli"] + +[dependencies] +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" +tar = "0.4" +tempfile = "3" +thiserror = "2" +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..6bd145c --- /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/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..477db79 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "packie-cli" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "packie" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +packie = { path = "../" } +rust-i18n = "4" diff --git a/cli/src/info.rs b/cli/src/info.rs new file mode 100644 index 0000000..8381d5c --- /dev/null +++ b/cli/src/info.rs @@ -0,0 +1,28 @@ +use anyhow::anyhow; +use clap::Parser; +use packie::{PackieBuilder, package::PkgSpec}; + +#[derive(Debug, Parser)] +pub struct Cli { + pkgspec: PkgSpec, +} + +pub fn main(cli: Cli) -> anyhow::Result<()> { + let mut packie = PackieBuilder::new().readonly(true).build()?; + let mut found = packie::installation::by_pkgspec(&mut packie, &cli.pkgspec)?; + if found.len() == 0 { + return Err(anyhow!("no package found")); + } + if found.len() != 1 { + return Err(anyhow!("not unique package spec")); + } + let found = found.remove(0); + + println!("Package name: {}", found.pkg_manifest.name); + println!("Package version: {}", found.pkg_manifest.version); + println!("Package architecture: {}", found.pkg_manifest.arch); + println!("Install package spec: {}", found.install_pkgspec); + println!("Installed size: {}", found.installed_size); + + Ok(()) +} diff --git a/cli/src/initdb.rs b/cli/src/initdb.rs new file mode 100644 index 0000000..489b188 --- /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: Cli) -> anyhow::Result<()> { + PackieBuilder::new().build()?; + Ok(()) +} diff --git a/cli/src/install.rs b/cli/src/install.rs new file mode 100644 index 0000000..dc3bdce --- /dev/null +++ b/cli/src/install.rs @@ -0,0 +1,49 @@ +use clap::Parser; +use packie::{PackieBuilder, install::InstallOptions, package::PkgSpec}; +use std::{ + path::{Path, PathBuf}, + str::FromStr, +}; + +#[derive(Debug, Parser)] +pub struct Cli { + items: Vec, +} + +#[derive(Debug, Clone)] +enum Item { + LocalFile(PathBuf), + PkgSpec(PkgSpec), +} +impl FromStr for Item { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + if Path::new(s).exists() { + Ok(Self::LocalFile(s.into())) + } else { + Ok(Self::PkgSpec(s.parse()?)) + } + } +} + +pub fn main(cli: Cli) -> anyhow::Result<()> { + let mut packie = PackieBuilder::new().build()?; + for i in cli.items { + match i { + Item::LocalFile(path) => { + packie::install::install_package_file( + &mut packie, + path, + &InstallOptions { + install_pkgspec: None, + }, + )?; + } + Item::PkgSpec(pkgspec) => { + todo!() + } + } + } + Ok(()) +} diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 0000000..e444364 --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,48 @@ +mod info; +mod initdb; +mod install; +mod print; +mod remove; + +use clap::Parser; +use rust_i18n::t; + +rust_i18n::i18n!("../locales/packie-cli"); + +#[derive(Debug, Parser)] +enum 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), +} + +fn main() { + rust_i18n::set_locale(match std::env::var("LANG").as_deref() { + Ok("zh_CN.UTF-8") => "zh-CN", + _ => "en", + }); + + let cli = Cli::parse(); + let result = match cli { + Cli::Info(cli) => info::main(cli), + Cli::InitDb(cli) => initdb::main(cli), + Cli::Install(cli) => install::main(cli), + Cli::Print(cli) => print::main(cli), + Cli::Remove(cli) => remove::main(cli), + }; + if let Err(err) = result { + eprintln!("{}", t!("main.error", error = err)); + std::process::exit(1); + } +} diff --git a/cli/src/print.rs b/cli/src/print.rs new file mode 100644 index 0000000..be03ede --- /dev/null +++ b/cli/src/print.rs @@ -0,0 +1,19 @@ +use anyhow::anyhow; +use clap::Parser; +use packie::PackieBuilder; + +#[derive(Debug, Parser)] +pub struct Cli { + key: String, +} + +pub fn main(cli: Cli) -> anyhow::Result<()> { + let packie = PackieBuilder::new().readonly(true).build()?; + match &cli.key[..] { + "profile.host_arch" => { + println!("{}", packie.profile().host_arch); + Ok(()) + } + unknown => Err(anyhow!("unknown key {unknown} to print")), + } +} diff --git a/cli/src/remove.rs b/cli/src/remove.rs new file mode 100644 index 0000000..6293682 --- /dev/null +++ b/cli/src/remove.rs @@ -0,0 +1,17 @@ +use anyhow::anyhow; +use clap::Parser; +use packie::{PackieBuilder, package::PkgSpec}; + +#[derive(Debug, Parser)] +pub struct Cli { + pkgspecs: Vec, +} + +pub fn main(cli: Cli) -> anyhow::Result<()> { + let mut packie = PackieBuilder::new().build()?; + for pkgspec in cli.pkgspecs { + packie::remove::remove(&mut packie, &pkgspec) + .map_err(|err| anyhow!("failed to remove {pkgspec}: {err}"))?; + } + Ok(()) +} diff --git a/locales/libpackie/main.yml b/locales/libpackie/main.yml new file mode 100644 index 0000000..87b30ed --- /dev/null +++ b/locales/libpackie/main.yml @@ -0,0 +1,22 @@ +_version: 2 +common.CopyError: + en: 'failed to copy from "%{src}" to "%{dst}": %{error}' + zh-CN: '无法将 "%{src}" 复制到 "%{dst}":%{error}' +install.InstallError.Database: + en: 'local database error: %{error}' + zh-CN: '本地数据库错误:%{error}' +remove.RemoveError.NotUniquePkgSpec: + en: 'not unique package spec' + zh-CN: '指定的包范围匹配到了多个包' +remove.RemoveError.Database: + en: 'local database error: %{error}' + zh-CN: '本地数据库错误:%{error}' +remove.RemoveError.NotFound: + en: 'package not found' + zh-CN: '找不到包' +remove.RemoveError.RemoveFiles: + en: 'failed to remove package files: %{error}' + zh-CN: '无法删除包文件:%{error}' +install.InstallError.AlreadyInstalled: + en: 'package already installed' + zh-CN: '此包已经安装' diff --git a/locales/packie-cli/main.yml b/locales/packie-cli/main.yml new file mode 100644 index 0000000..f4daf21 --- /dev/null +++ b/locales/packie-cli/main.yml @@ -0,0 +1,4 @@ +_version: 2 +main.error: + en: 'error: %{error}' + zh-CN: '错误:%{error}' diff --git a/src/common.rs b/src/common.rs new file mode 100644 index 0000000..594754d --- /dev/null +++ b/src/common.rs @@ -0,0 +1,206 @@ +use rust_i18n::t; +use std::{ + fs::{FileType, ReadDir}, + io::{Read, Seek, SeekFrom}, + path::{Path, PathBuf}, + 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) +} + +#[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 { + let mut size = 0; + let path = path.as_ref(); + for ent in TreeDir::new(path)? { + let ent = ent?; + let metadata = std::fs::symlink_metadata(path.join(&ent.relpath))?; + size += metadata.len(); + } + 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")] +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, +} + +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)) +} + +pub fn timestamp_millis() -> i64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or_else(|e| -(e.duration().as_millis() as i64)) +} diff --git a/src/install.rs b/src/install.rs new file mode 100644 index 0000000..849dde0 --- /dev/null +++ b/src/install.rs @@ -0,0 +1,103 @@ +use crate::{ + Packie, + installation::Installation, + package::{Package, PackageError, PkgIdent, PkgSpec}, +}; +use rust_i18n::t; +use std::path::Path; + +#[derive(Debug, Clone)] +pub struct InstallOptions { + pub install_pkgspec: Option, +} + +/// Installs a package, from a local package file. +pub fn install_package_file>( + packie: &mut Packie, + path: P, + options: &InstallOptions, +) -> Result<(), InstallError> { + let package = Package::open(path)?; + install_package(packie, &package, options) +} + +/// Installs a package, from a constructed [`Package`]. +pub fn install_package( + packie: &mut Packie, + package: &Package, + options: &InstallOptions, +) -> Result<(), InstallError> { + // Collect necessary information + let pkg_manifest = package.manifest()?; + let installed_size = package.installed_size()?; + let install_pkgspec = options + .install_pkgspec + .clone() + .unwrap_or_else(|| default_install_pkgspec(pkg_manifest.pkg_ident())); + + // Check if the package is previously installed + if !crate::installation::by_pkgspec(packie, &pkg_manifest.pkg_ident().into()) + .map_err(InstallError::Database)? + .is_empty() + { + return Err(InstallError::AlreadyInstalled); + } + + // Check if the package spec is valid + if !install_pkgspec.matches(&pkg_manifest.pkg_ident()) { + return Err(InstallError::PkgSpec); + } + + // Copy package files + let dest_dir = packie + .profile + .pkg_dir + .join(pkg_manifest.pkg_ident().to_string()); + crate::common::copy_dir(package.bundle_dir(), dest_dir).map_err(InstallError::Copy)?; + + // Record installation in the database + let installation = Installation { + pkg_manifest, + install_pkgspec, + install_date: crate::common::timestamp_secs(), + update_date: crate::common::timestamp_secs(), + installed_size, + }; + packie + .local_db + .insert_installation(&installation) + .map_err(InstallError::Database)?; + + Ok(()) +} + +#[derive(Debug, thiserror::Error)] +pub enum InstallError { + #[error("{0}")] + Package(PackageError), + + #[error("incompatible pkgspec")] + PkgSpec, + + #[error("{0}")] + Copy(crate::common::CopyError), + + #[error("{}", t!("install.InstallError.AlreadyInstalled"))] + AlreadyInstalled, + + #[error("{}", t!("install.InstallError.Database", error = 0))] + Database(rusqlite::Error), +} +impl From for InstallError { + fn from(value: PackageError) -> Self { + Self::Package(value) + } +} + +fn default_install_pkgspec(pkg_ident: PkgIdent) -> PkgSpec { + PkgSpec { + name: pkg_ident.name, + version: crate::version::VersionFilter::Any, + arch: None, + } +} diff --git a/src/installation.rs b/src/installation.rs new file mode 100644 index 0000000..6dfbfa2 --- /dev/null +++ b/src/installation.rs @@ -0,0 +1,22 @@ +use crate::{ + Packie, + package::{PkgManifest, PkgSpec}, +}; + +#[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 fn by_pkgspec(packie: &mut Packie, pkgspec: &PkgSpec) -> rusqlite::Result> { + Ok(packie + .local_db + .select_installation_by_pkgname(&pkgspec.name)? + .into_iter() + .filter(|x| pkgspec.matches(&x.pkg_manifest.pkg_ident())) + .collect()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..730b6e4 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,77 @@ +mod common; +pub mod install; +pub mod installation; +pub mod link; +pub mod local_db; +pub mod package; +pub mod profile; +pub mod remove; +pub mod version; + +use local_db::LocalDb; +use profile::Profile; + +rust_i18n::i18n!("locales/libpackie", fallback = "en"); + +/// Main Packie state. +#[derive(Debug)] +pub struct Packie { + profile: Profile, + local_db: LocalDb, +} +impl Packie { + /// Returns profile of the Packie instance. + pub fn profile(&self) -> &Profile { + &self.profile + } +} + +#[derive(Debug)] +pub struct PackieBuilder { + profile: Profile, + readonly: bool, +} +impl PackieBuilder { + pub fn new() -> Self { + Self { + profile: Profile::builtin(), + readonly: false, + } + } + + pub fn readonly(mut self, val: bool) -> Self { + self.readonly = val; + self + } + + pub fn profile(mut self, val: Profile) -> Self { + self.profile = val; + self + } + + pub fn build(self) -> Result { + 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 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)?; + Ok(Packie { + profile: self.profile, + local_db, + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum BuildPackieError { + #[error("{0}")] + LocalDb(rusqlite::Error), +} diff --git a/src/link.rs b/src/link.rs new file mode 100644 index 0000000..d44cacb --- /dev/null +++ b/src/link.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; + +/// 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, +} + +/// Status of a link in an installed package. +#[derive(Debug, Clone)] +pub struct LinkStatus { + /// Absolute path of the link source. + pub from: String, + + /// Absolute path of the link destination. + pub to: String, + + /// True if the link is activated. + pub activated: bool, +} diff --git a/src/local_db.rs b/src/local_db.rs new file mode 100644 index 0000000..4939ffc --- /dev/null +++ b/src/local_db.rs @@ -0,0 +1,104 @@ +use crate::{installation::Installation, package::PkgIdent}; +use rusqlite::{OpenFlags, Row, params}; +use std::path::Path; + +/// Schema of the database. +/// +/// The "installation" table records installed packages. +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 +); +"#; + +#[derive(Debug)] +pub struct LocalDb(rusqlite::Connection); +impl LocalDb { + pub const FILENAME: &str = "local.db"; + + pub fn open_ro>(path: P) -> rusqlite::Result { + let conn = rusqlite::Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + Ok(Self(conn)) + } + + pub fn open_rw>(path: P) -> rusqlite::Result { + let conn = rusqlite::Connection::open(path)?; + conn.execute_batch(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" + ) + VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + "#, + )? + .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, + ])?; + Ok(()) + } + + pub fn select_installation_by_pkgname( + &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 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) + } +} + +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 _, + }) +} + +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..320fb7a --- /dev/null +++ b/src/package.rs @@ -0,0 +1,216 @@ +use crate::{ + link::LinkDescription, + version::{Version, VersionFilter}, +}; +use serde::{Deserialize, Serialize}; +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) + } + + /// 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) + } +} + +/// 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), +} + +/// 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 dependencies. + #[serde(default)] + pub dependencies: Vec, + + /// Links. + #[serde(default)] + pub links: Vec, +} +impl PkgManifest { + pub const FILENAME: &str = "PkgManifest.json"; + + pub const SIZE_MAX: u64 = 64 * 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(), + } + } +} + +/// A package specifier. +#[derive(Debug, Clone, Serialize, Deserialize)] +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 = crate::version::VersionError; + + fn from_str(s: &str) -> Result { + 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 let Some(&c) = chars.peek() { + arch_str.push(chars.next().unwrap()); + } + arch = Some(arch_str); + } + let name = name.trim_end().to_string(); + + 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(()) + } +} + +/// A package identifier that specifies a unique package. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PkgIdent { + pub name: String, + pub version: Version, + pub arch: String, +} +impl Display for PkgIdent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}={}@{}", self.name, self.version, self.arch) + } +} diff --git a/src/profile.rs b/src/profile.rs new file mode 100644 index 0000000..51ebc1b --- /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/db/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..af33720 --- /dev/null +++ b/src/remove.rs @@ -0,0 +1,46 @@ +use crate::{Packie, package::PkgSpec}; +use rust_i18n::t; + +pub fn remove(packie: &mut Packie, pkgspec: &PkgSpec) -> Result<(), RemoveError> { + // Find the installation to remove + let mut found = + crate::installation::by_pkgspec(packie, 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); + + // Remove package files + let bundle_dir = packie + .profile + .pkg_dir + .join(found.pkg_manifest.pkg_ident().to_string()); + std::fs::remove_dir_all(&bundle_dir).map_err(RemoveError::RemoveFiles)?; + + // Remove database records + packie + .local_db + .remove_installation(&found.pkg_manifest.pkg_ident()) + .map_err(RemoveError::Database)?; + + 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/version.rs b/src/version.rs new file mode 100644 index 0000000..4b58070 --- /dev/null +++ b/src/version.rs @@ -0,0 +1,277 @@ +//! Parsing and matching of software versions. + +use std::{cmp::Ordering, fmt::Display, str::FromStr}; + +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)) + } + } + }; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +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 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(()) + } +} +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. + Lte(Version), + + /// The '>=' filter. + Gte(Version), + + /// The '=' filter. + Eq(Version), + + /// The '>=xxx, <=xxx' filter. + Between(Version, Version), + + /// The '~' (or default) filter. + Compatible(Version), +} +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::Between(a, b) => version >= a && version <= b, + Self::Compatible(other) => semver_is_compatible(version, other), + } + } +} +impl FromStr for VersionFilter { + type Err = VersionError; + + fn from_str(s: &str) -> Result { + if s == "*" { + return Ok(Self::Any); + } + 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((a, b)) = s.split_once(',') { + let (mut smaller, mut greater) = (None, None); + for i in [a.trim(), b.trim()] { + if let Some(version) = i.strip_prefix(">=") { + smaller = Some(version.parse()?); + } else if let Some(version) = i.strip_prefix("<=") { + greater = Some(version.parse()?); + } + } + return Ok(Self::Between( + smaller.ok_or(VersionError::InvalidRange)?, + greater.ok_or(VersionError::InvalidRange)?, + )); + } + 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::Between(a, b) => write!(f, ">={a}, <={b}"), + Self::Compatible(ver) => write!(f, "~{ver}"), + } + } +} +impl_serde_str!(VersionFilter); + +/// An error caused by dealing with version or version filter strings. +#[derive(Debug, Clone, thiserror::Error)] +pub enum VersionError { + #[error("invalid namespace")] + ParseNamespace, + + #[error("invalid main part")] + ParseMain, + + #[error("invalid prerelease part")] + ParsePrerelease, + + #[error("invalid version range")] + InvalidRange, +} + +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(()) + } +}