From 8b042a3e8e7658dbe59f5290ee81a613c004c83b Mon Sep 17 00:00:00 2001 From: sisungo Date: Tue, 16 Jun 2026 21:07:01 +0800 Subject: [PATCH] feat: add basic repository support Signed-off-by: sisungo --- Cargo.toml | 9 +- cli/src/common.rs | 1 + cli/src/info.rs | 2 +- cli/src/main.rs | 1 + cli/src/remove.rs | 3 +- locales/libpackie/main.yml | 40 +-- src/common/checksum.rs | 44 ++++ src/{common.rs => common/fs.rs} | 44 ---- src/common/mod.rs | 45 ++++ src/config/mod.rs | 21 ++ src/config/repo.rs | 57 +++++ src/install.rs | 5 +- src/installation.rs | 22 -- src/lib.rs | 14 +- src/{local_db.rs => local.rs} | 82 +++++-- src/remove.rs | 66 ++--- src/repo.rs | 414 ++++++++++++++++++++++++++++++++ 17 files changed, 731 insertions(+), 139 deletions(-) create mode 100644 cli/src/common.rs create mode 100644 src/common/checksum.rs rename src/{common.rs => common/fs.rs} (78%) create mode 100644 src/common/mod.rs create mode 100644 src/config/mod.rs create mode 100644 src/config/repo.rs delete mode 100644 src/installation.rs rename src/{local_db.rs => local.rs} (66%) create mode 100644 src/repo.rs diff --git a/Cargo.toml b/Cargo.toml index 4566bd4..0cd2f42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,22 +4,29 @@ version = "0.1.0" edition = "2024" [features] -default = ["zstd", "gzip"] +default = ["zstd", "gzip", "sha3"] zstd = ["dep:zstd"] gzip = ["dep:flate2"] +sha3 = ["dep:sha3"] [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 = { version = "0.12", optional = true } tar = "0.4" tempfile = "3" thiserror = "2" +toml = "1" +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/cli/src/common.rs b/cli/src/common.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/cli/src/common.rs @@ -0,0 +1 @@ + diff --git a/cli/src/info.rs b/cli/src/info.rs index 8381d5c..8f1569c 100644 --- a/cli/src/info.rs +++ b/cli/src/info.rs @@ -9,7 +9,7 @@ pub struct Cli { 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)?; + let mut found = packie.search_installation(&cli.pkgspec)?; if found.len() == 0 { return Err(anyhow!("no package found")); } diff --git a/cli/src/main.rs b/cli/src/main.rs index a6856af..80d89aa 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,3 +1,4 @@ +mod common; mod info; mod initdb; mod install; diff --git a/cli/src/remove.rs b/cli/src/remove.rs index 6293682..e493af9 100644 --- a/cli/src/remove.rs +++ b/cli/src/remove.rs @@ -10,7 +10,8 @@ pub struct Cli { pub fn main(cli: Cli) -> anyhow::Result<()> { let mut packie = PackieBuilder::new().build()?; for pkgspec in cli.pkgspecs { - packie::remove::remove(&mut packie, &pkgspec) + packie + .remove(&pkgspec) .map_err(|err| anyhow!("failed to remove {pkgspec}: {err}"))?; } Ok(()) diff --git a/locales/libpackie/main.yml b/locales/libpackie/main.yml index 87b30ed..12afcb6 100644 --- a/locales/libpackie/main.yml +++ b/locales/libpackie/main.yml @@ -1,22 +1,34 @@ _version: 2 common.CopyError: - en: 'failed to copy from "%{src}" to "%{dst}": %{error}' - zh-CN: '无法将 "%{src}" 复制到 "%{dst}":%{error}' + 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}' + en: "local database error: %{error}" + zh-CN: "本地数据库错误:%{error}" remove.RemoveError.NotUniquePkgSpec: - en: 'not unique package spec' - zh-CN: '指定的包范围匹配到了多个包' + en: "not unique package spec" + zh-CN: "指定的包范围匹配到了多个包" remove.RemoveError.Database: - en: 'local database error: %{error}' - zh-CN: '本地数据库错误:%{error}' + en: "local database error: %{error}" + zh-CN: "本地数据库错误:%{error}" remove.RemoveError.NotFound: - en: 'package not found' - zh-CN: '找不到包' + en: "package not found" + zh-CN: "找不到包" remove.RemoveError.RemoveFiles: - en: 'failed to remove package files: %{error}' - zh-CN: '无法删除包文件:%{error}' + en: "failed to remove package files: %{error}" + zh-CN: "无法删除包文件:%{error}" install.InstallError.AlreadyInstalled: - en: 'package already installed' - zh-CN: '此包已经安装' + en: "package already installed" + zh-CN: "此包已经安装" +repo.RepoError.Database: + en: "repository database error: %{error}" + zh-CN: "软件仓库数据库错误:%{error}" +repo.RepoError.NoSuchRepo: + en: "no such repository" + zh-CN: "找不到仓库" +repo.RepoError.NoSuchPackage: + en: "no such package" + zh-CN: "找不到包" +repo.RepoError.AllTriesFailed: + en: "all tries failed" + zh-CN: "所有尝试均失败" diff --git a/src/common/checksum.rs b/src/common/checksum.rs new file mode 100644 index 0000000..6990e52 --- /dev/null +++ b/src/common/checksum.rs @@ -0,0 +1,44 @@ +use digest::{Digest, DynDigest}; +use std::{fs::File, io::Read, path::Path}; + +#[cfg(feature = "sha3")] +pub const DEFAULT_HASHER: &str = "sha3-512"; + +pub fn find_hasher(s: &str) -> std::io::Result> { + match s { + #[cfg(feature = "sha3")] + "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.rs b/src/common/fs.rs similarity index 78% rename from src/common.rs rename to src/common/fs.rs index c4bcd53..fa105d6 100644 --- a/src/common.rs +++ b/src/common/fs.rs @@ -1,40 +1,10 @@ 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)>, @@ -190,17 +160,3 @@ pub struct CopyError { 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/common/mod.rs b/src/common/mod.rs new file mode 100644 index 0000000..c075e0b --- /dev/null +++ b/src/common/mod.rs @@ -0,0 +1,45 @@ +pub mod checksum; +pub mod fs; + +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..2e3b4b2 --- /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..a2cf5cc --- /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 index fcb7c70..973538b 100644 --- a/src/install.rs +++ b/src/install.rs @@ -1,6 +1,6 @@ use crate::{ Packie, - installation::Installation, + local::Installation, package::{Package, PackageError, PkgIdent, PkgSpec}, }; use rust_i18n::t; @@ -36,7 +36,8 @@ pub fn install_package( .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()) + if !packie + .search_installation(&pkg_manifest.pkg_ident().into()) .map_err(InstallError::Database)? .is_empty() { diff --git a/src/installation.rs b/src/installation.rs deleted file mode 100644 index 6dfbfa2..0000000 --- a/src/installation.rs +++ /dev/null @@ -1,22 +0,0 @@ -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 index 730b6e4..cf82ae8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,16 @@ mod common; +pub mod config; pub mod install; -pub mod installation; pub mod link; -pub mod local_db; +pub mod local; pub mod package; pub mod profile; pub mod remove; +pub mod repo; pub mod version; -use local_db::LocalDb; +use config::AllConfig; +use local::{Cache, LocalDb}; use profile::Profile; rust_i18n::i18n!("locales/libpackie", fallback = "en"); @@ -18,6 +20,8 @@ rust_i18n::i18n!("locales/libpackie", fallback = "en"); pub struct Packie { profile: Profile, local_db: LocalDb, + config: AllConfig, + cache: Cache, } impl Packie { /// Returns profile of the Packie instance. @@ -63,9 +67,13 @@ impl PackieBuilder { 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()); Ok(Packie { profile: self.profile, local_db, + config, + cache, }) } } diff --git a/src/local_db.rs b/src/local.rs similarity index 66% rename from src/local_db.rs rename to src/local.rs index 4415025..9a0e14f 100644 --- a/src/local_db.rs +++ b/src/local.rs @@ -1,35 +1,70 @@ -use crate::{installation::Installation, package::PkgIdent}; +use crate::package::{PkgIdent, PkgManifest, PkgSpec}; use rusqlite::{OpenFlags, Row, params}; -use std::path::Path; +use std::path::{Path, PathBuf}; -/// Schema of the database. -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 -); -"#; +impl super::Packie { + pub fn search_installation( + &mut self, + pkgspec: &PkgSpec, + ) -> rusqlite::Result> { + Ok(self + .local_db + .select_installation_by_pkgname(&pkgspec.name)? + .into_iter() + .filter(|x| pkgspec.matches(&x.pkg_manifest.pkg_ident())) + .collect()) + } +} + +#[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 + ); + "#; + pub fn open_ro>(path: P) -> rusqlite::Result { - let conn = rusqlite::Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; - Ok(Self(conn)) + 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(SCHEMA)?; + conn.execute_batch(Self::SCHEMA)?; Ok(Self(conn)) } @@ -92,6 +127,15 @@ impl LocalDb { } } +#[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, +} + fn map_installation(row: &Row) -> rusqlite::Result { Ok(Installation { pkg_manifest: serde_json::from_str(&row.get::<_, String>("pkg_manifest")?) diff --git a/src/remove.rs b/src/remove.rs index 55eb690..22b636b 100644 --- a/src/remove.rs +++ b/src/remove.rs @@ -1,38 +1,40 @@ -use crate::{Packie, package::PkgSpec}; +use crate::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); +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); + + // Remove all links + for link in found.pkg_manifest.links.iter() { + _ = crate::link::deactivate_by_description(self, &found.pkg_manifest.pkg_ident(), link); + } + + // Remove package files + let bundle_dir = self + .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 + self.local_db + .remove_installation(&found.pkg_manifest.pkg_ident()) + .map_err(RemoveError::Database)?; + + Ok(()) } - if found.len() != 1 { - return Err(RemoveError::NotUniquePkgSpec); - } - assert_eq!(found.len(), 1); - let found = found.remove(0); - - // Remove all links - for link in found.pkg_manifest.links.iter() { - _ = crate::link::deactivate_by_description(packie, &found.pkg_manifest.pkg_ident(), link); - } - - // 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)] diff --git a/src/repo.rs b/src/repo.rs new file mode 100644 index 0000000..95dd181 --- /dev/null +++ b/src/repo.rs @@ -0,0 +1,414 @@ +use crate::{ + Packie, + common::checksum::DEFAULT_HASHER, + package::{Package, PkgIdent, PkgManifest}, +}; +use rusqlite::{OpenFlags, Row, params}; +use rust_i18n::t; +use serde::{Deserialize, Serialize}; +use std::{ + fs::File, + io::{Read, Seek, Write}, + 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) + } + + /// 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_pkgident(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() { + match download_package_url(repo_url, &repo_cache_dir, &standard_filename, &mut on_event) + { + Ok(()) => { + return Ok(download_path); + } + Err(err) => { + on_event(DownloadPackageEvent::Error( + repo_url.clone(), + RepoError::Download(err), + )); + } + } + } + todo!() + } +} + +fn download_package_url( + repo_url: &Url, + repo_cache_dir: &Path, + filename: &str, + on_event: &mut dyn FnMut(DownloadPackageEvent), +) -> Result<(), DownloadError> { + let download_url = repo_url + .join(filename) + .map_err(|_| DownloadError::InvalidUrl)?; + let download_path = repo_cache_dir.join(filename); + Download::new(download_url.to_string(), download_path) + .on_progress(|sum, total| { + on_event(DownloadPackageEvent::Progress(repo_url.clone(), sum, total)) + }) + .run()?; + Ok(()) +} + +#[derive(Debug)] +pub struct RepoServeDir { + path: PathBuf, + db: RepoDb, +} +impl RepoServeDir { + pub fn open>(path: P) -> rusqlite::Result { + let path = path.into(); + Ok(Self { + db: RepoDb::open_rw(&path)?, + path, + }) + } + + pub fn add>(&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)?; + Ok(()) + } +} + +#[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") + ); + "#; + + 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_pkgident( + &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_pkgname(&mut self) {} + + 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" + ) + VALUES(?1, ?2, ?3, ?4, ?5, ?6) + "#, + )? + .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 + ]) + .map(|_| ()) + } +} + +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(|error| { + rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(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")?, + }) +} + +/// 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; + }; + 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), + ) + .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 { + 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, 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, +} + +#[derive(Debug, thiserror::Error)] +pub enum DownloadError { + #[error("INVALID_URL")] + InvalidUrl, + + #[error("{0}")] + Http(Box), + + #[error("{0}")] + Filesystem(std::io::Error), +} + +pub struct Download

{ + from: String, + to: PathBuf, + on_progress: P, +} +impl Download { + pub fn new(from: String, to: PathBuf) -> Self { + Self { + from, + to, + on_progress: |_, _| (), + } + } +} +impl

Download

{ + pub fn on_progress(self, on_progress: P2) -> Download { + Download { + from: self.from, + to: self.to, + on_progress, + } + } +} +impl Download

{ + pub fn run(&mut self) -> Result<(), DownloadError> { + if self.to.exists() { + 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); + } + std::fs::rename(temp_file_path, &self.to).map_err(DownloadError::Filesystem)?; + + Ok(()) + } +}