mirror of
https://github.com/mii443/usls.git
synced 2025-08-22 15:45:41 +00:00
* bump ureq to 3 * bump rand to 0.9 * cargo fmt --------- Co-authored-by: jamjamjon <xxyydzml@outlook.com>
532 lines
19 KiB
Rust
532 lines
19 KiB
Rust
use anyhow::{Context, Result};
|
|
use indicatif::ProgressStyle;
|
|
use regex::Regex;
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::io::{Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Duration;
|
|
|
|
use crate::{retry, Dir};
|
|
|
|
/// Represents a downloadable asset in a release
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub(crate) struct Asset {
|
|
pub name: String,
|
|
pub browser_download_url: String,
|
|
pub size: u64,
|
|
}
|
|
|
|
/// Represents a GitHub release
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub(crate) struct Release {
|
|
pub tag_name: String,
|
|
pub assets: Vec<Asset>,
|
|
}
|
|
|
|
// / Manages interactions with a GitHub repository's releases
|
|
/// Provides an interface for managing GitHub releases, including downloading assets,
|
|
/// fetching release tags and file information, and handling caching.
|
|
///
|
|
/// The `Hub` struct simplifies interactions with a GitHub repository by allowing users
|
|
/// to specify a repository owner and name, download files from releases, and manage
|
|
/// cached data to reduce redundant network requests.
|
|
///
|
|
/// # Fields
|
|
/// - `owner`: The owner of the GitHub repository (e.g., `"jamjamjon"`).
|
|
/// - `repo`: The name of the GitHub repository (e.g., `"assets"`).
|
|
/// - `to`: The directory where downloaded files are stored, determined from a prioritized list
|
|
/// of available directories (e.g., cache, home, config, or current directory).
|
|
/// - `timeout`: Timeout duration for network requests, in seconds.
|
|
/// - `ttl`: Time-to-live duration for cached data, defining how long cache files remain valid.
|
|
/// - `max_attempts`: The maximum number of retry attempts for failed downloads or network operations.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ## 1. Download from a default GitHub release
|
|
/// Download a file by specifying its path relative to the release:
|
|
/// ```rust,ignore
|
|
/// let path = Hub::default().try_fetch("images/bus.jpg")?;
|
|
/// println!("Fetched image to: {:?}", path);
|
|
/// ```
|
|
///
|
|
/// ## 2. Download from a specific GitHub release URL
|
|
/// Fetch a file directly using its full GitHub release URL:
|
|
/// ```rust,ignore
|
|
/// let path = Hub::default()
|
|
/// .try_fetch("https://github.com/jamjamjon/assets/releases/download/images/bus.jpg")?;
|
|
/// println!("Fetched file to: {:?}", path);
|
|
/// ```
|
|
///
|
|
/// ## 3. Fetch available tags and files in a repository
|
|
/// List all release tags and the files associated with each tag:
|
|
/// ```rust,ignore
|
|
/// let hub = Hub::default().with_owner("jamjamjon").with_repo("usls");
|
|
/// for tag in hub.tags().iter() {
|
|
/// let files = hub.files(tag);
|
|
/// println!("Tag: {}, Files: {:?}", tag, files);
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// # Default Behavior
|
|
/// By default, `Hub` interacts with the `jamjamjon/assets` repository, stores downloads in
|
|
/// an accessible directory, and applies a 10-minute cache expiration time. These settings
|
|
/// can be customized using the builder-like methods `with_owner`, `with_repo`, `with_ttl`,
|
|
/// `with_timeout`, and `with_max_attempts`.
|
|
///
|
|
/// # Errors
|
|
/// Methods in `Hub` return `Result` types. Errors may occur due to invalid paths, failed
|
|
/// network requests, cache write failures, or mismatched file sizes during downloads.
|
|
///
|
|
#[derive(Debug)]
|
|
pub struct Hub {
|
|
/// GitHub repository owner
|
|
owner: String,
|
|
|
|
/// GitHub repository name
|
|
repo: String,
|
|
|
|
/// Directory to store the downloaded file
|
|
to: Dir,
|
|
|
|
/// Download timeout in seconds
|
|
timeout: u64,
|
|
|
|
/// Time to live (cache duration)
|
|
ttl: Duration,
|
|
|
|
/// The maximum number of retry attempts for failed downloads or network operations
|
|
max_attempts: u32,
|
|
}
|
|
|
|
impl Default for Hub {
|
|
fn default() -> Self {
|
|
let owner = "jamjamjon".to_string();
|
|
let repo = "assets".to_string();
|
|
let to = [Dir::Cache, Dir::Home, Dir::Config, Dir::Current]
|
|
.into_iter()
|
|
.find(|dir| dir.crate_dir_default().is_ok())
|
|
.expect(
|
|
"Unable to get cache directory, home directory, config directory, and current directory. Possible reason: \
|
|
\n1. Unsupported OS \
|
|
\n2. Directory does not exist \
|
|
\n3. Insufficient permissions to access"
|
|
);
|
|
|
|
Self {
|
|
owner,
|
|
repo,
|
|
to,
|
|
timeout: 3000,
|
|
max_attempts: 3,
|
|
ttl: Duration::from_secs(10 * 60),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Hub {
|
|
pub fn new(owner: &str, repo: &str) -> Self {
|
|
Self {
|
|
owner: owner.into(),
|
|
repo: repo.into(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Attempts to fetch a file from a local path or a GitHub release.
|
|
///
|
|
/// The `try_fetch` method supports three main scenarios:
|
|
/// 1. **Local file**: If the provided string is a valid file path, the file is returned without downloading.
|
|
/// 2. **GitHub release URL**: If the input matches a valid GitHub release URL, the corresponding file is downloaded.
|
|
/// 3. **Default repository**: If no explicit URL is provided, the method uses the default or configured repository.
|
|
///
|
|
/// # Parameters
|
|
/// - `s`: A string representing the file to fetch. This can be:
|
|
/// - A local file path.
|
|
/// - A GitHub release URL (e.g., `https://github.com/owner/repo/releases/download/tag/file`).
|
|
/// - A `<tag>/<file>` format for fetching from the default repository.
|
|
///
|
|
/// # Returns
|
|
/// - `Result<String>`: On success, returns the path to the fetched file.
|
|
///
|
|
/// # Errors
|
|
/// - Returns an error if:
|
|
/// - The file cannot be found locally.
|
|
/// - The URL or tag is invalid.
|
|
/// - Network operations fail after the maximum retry attempts.
|
|
///
|
|
/// # Example
|
|
/// ```rust,ignore
|
|
/// let mut hub = Hub::default();
|
|
///
|
|
/// // Fetch a file from a local path
|
|
/// let local_path = hub.try_fetch("local/path/to/file").expect("File not found");
|
|
///
|
|
/// // Fetch a file from a GitHub release URL
|
|
/// let url_path = hub.try_fetch("https://github.com/owner/repo/releases/download/tag/file")
|
|
/// .expect("Failed to fetch file");
|
|
///
|
|
/// // Fetch a file using the default repository
|
|
/// let default_repo_path = hub.try_fetch("v1.0.0/file").expect("Failed to fetch file");
|
|
/// ```
|
|
pub fn try_fetch(&mut self, s: &str) -> Result<String> {
|
|
#[derive(Default, Debug, aksr::Builder)]
|
|
struct Pack {
|
|
// owner: String,
|
|
// repo: String,
|
|
url: String,
|
|
tag: String,
|
|
file_name: String,
|
|
file_size: Option<u64>,
|
|
}
|
|
let mut pack = Pack::default();
|
|
|
|
// saveout
|
|
let p = PathBuf::from(s);
|
|
let saveout = if p.exists() {
|
|
// => Local file
|
|
p
|
|
} else if let Some((owner_, repo_, tag_, file_name_)) = Self::is_valid_github_release_url(s)
|
|
{
|
|
// => Valid GitHub release URL
|
|
// keep original owner, repo and tag
|
|
let saveout = self
|
|
.to
|
|
.crate_dir_default_with_subs(&[&owner_, &repo_, &tag_])?
|
|
.join(&file_name_);
|
|
|
|
pack = pack.with_url(s).with_tag(&tag_).with_file_name(&file_name_);
|
|
if let Some(n) = retry!(self.max_attempts, Self::fetch_get_response(s))?
|
|
.headers()
|
|
.get(http::header::CONTENT_LENGTH)
|
|
.and_then(|v| v.to_str().ok()?.parse::<u64>().ok())
|
|
{
|
|
pack = pack.with_file_size(n);
|
|
}
|
|
|
|
saveout
|
|
} else {
|
|
// => Default hub
|
|
|
|
// Fetch releases
|
|
let releases = match Self::get_releases(&self.owner, &self.repo, &self.to, &self.ttl) {
|
|
Err(err) => anyhow::bail!(
|
|
"Failed to download: No releases found in this repo. Error: {}",
|
|
err
|
|
),
|
|
Ok(releases) => releases,
|
|
};
|
|
|
|
// Check remote
|
|
match s.split_once('/') {
|
|
Some((tag_, file_name_)) => {
|
|
// Validate the tag
|
|
let tags: Vec<String> = releases.iter().map(|x| x.tag_name.clone()).collect();
|
|
if !tags.contains(&tag_.to_string()) {
|
|
anyhow::bail!(
|
|
"Failed to download: Tag `{}` not found in GitHub releases. Available tags: {:?}",
|
|
tag_,
|
|
tags
|
|
);
|
|
} else {
|
|
// Validate the file
|
|
if let Some(release) = releases.iter().find(|r| r.tag_name == tag_) {
|
|
let files: Vec<&str> =
|
|
release.assets.iter().map(|x| x.name.as_str()).collect();
|
|
if !files.contains(&file_name_) {
|
|
anyhow::bail!(
|
|
"Failed to download: The file `{}` is missing in tag `{}`. Available files: {:?}",
|
|
file_name_,
|
|
tag_,
|
|
files
|
|
);
|
|
} else {
|
|
for f_ in release.assets.iter() {
|
|
if f_.name.as_str() == file_name_ {
|
|
pack = pack
|
|
.with_url(&f_.browser_download_url)
|
|
.with_tag(tag_)
|
|
.with_file_name(file_name_)
|
|
.with_file_size(f_.size);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
self.to
|
|
.crate_dir_default_with_subs(&[tag_])?
|
|
.join(file_name_)
|
|
}
|
|
}
|
|
_ => anyhow::bail!(
|
|
"Failed to download file from github releases due to invalid format. Expected: <tag>/<file>, got: {}",
|
|
s
|
|
),
|
|
}
|
|
};
|
|
|
|
// Commit the downloaded file, downloading if necessary
|
|
if !pack.url.is_empty() {
|
|
// Download if the file does not exist or if the size of file does not match
|
|
if saveout.is_file() {
|
|
match pack.file_size {
|
|
None => {
|
|
log::warn!(
|
|
"Failed to retrieve the remote file size. \
|
|
Download will be skipped, which may cause issues. \
|
|
Please verify your network connection or ensure the local file is valid and complete."
|
|
);
|
|
}
|
|
Some(file_size) => {
|
|
if std::fs::metadata(&saveout)?.len() != file_size {
|
|
log::debug!(
|
|
"Local file size does not match remote. Starting download."
|
|
);
|
|
retry!(
|
|
self.max_attempts,
|
|
1000,
|
|
3000,
|
|
Self::download(
|
|
&pack.url,
|
|
&saveout,
|
|
Some(&format!("{}/{}", pack.tag, pack.file_name)),
|
|
)
|
|
)?;
|
|
} else {
|
|
log::debug!("Local file size matches remote. No download required.");
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
log::debug!("Starting remote file download...");
|
|
retry!(
|
|
self.max_attempts,
|
|
1000,
|
|
3000,
|
|
Self::download(
|
|
&pack.url,
|
|
&saveout,
|
|
Some(&format!("{}/{}", pack.tag, pack.file_name)),
|
|
)
|
|
)?;
|
|
}
|
|
}
|
|
|
|
saveout
|
|
.to_str()
|
|
.map(|s| s.to_string())
|
|
.with_context(|| format!("Failed to convert PathBuf: {:?} to String", saveout))
|
|
}
|
|
|
|
/// Fetch releases from GitHub and cache them
|
|
fn fetch_and_cache_releases(url: &str, cache_path: &Path) -> Result<String> {
|
|
let response = retry!(3, Self::fetch_get_response(url))?;
|
|
let body = response
|
|
.into_body()
|
|
.read_to_string()
|
|
.context("Failed to read response body")?;
|
|
|
|
// Ensure cache directory exists
|
|
let parent_dir = cache_path
|
|
.parent()
|
|
.context("Invalid cache path: no parent directory found")?;
|
|
std::fs::create_dir_all(parent_dir)
|
|
.with_context(|| format!("Failed to create cache directory: {:?}", parent_dir))?;
|
|
|
|
// Create temporary file
|
|
let mut temp_file = tempfile::NamedTempFile::new_in(parent_dir)
|
|
.context("Failed to create temporary cache file")?;
|
|
|
|
// Write data to temporary file
|
|
temp_file
|
|
.write_all(body.as_bytes())
|
|
.context("Failed to write to temporary cache file")?;
|
|
|
|
// Persist temporary file as the cache
|
|
temp_file.persist(cache_path).with_context(|| {
|
|
format!("Failed to persist temporary cache file to {:?}", cache_path)
|
|
})?;
|
|
|
|
Ok(body)
|
|
}
|
|
|
|
pub fn tags(&self) -> Vec<String> {
|
|
Self::get_releases(&self.owner, &self.repo, &self.to, &self.ttl)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|x| x.tag_name)
|
|
.collect()
|
|
}
|
|
|
|
pub fn files(&self, tag: &str) -> Vec<String> {
|
|
Self::get_releases(&self.owner, &self.repo, &self.to, &self.ttl)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.find(|r| r.tag_name == tag)
|
|
.map(|a| a.assets.iter().map(|x| x.name.clone()).collect())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn is_file_expired<P: AsRef<Path>>(file: P, ttl: &Duration) -> Result<bool> {
|
|
let file = file.as_ref();
|
|
let y = if !file.exists() {
|
|
log::debug!("No cache found, fetching data from GitHub");
|
|
true
|
|
} else {
|
|
match std::fs::metadata(file)?.modified() {
|
|
Err(_) => {
|
|
log::debug!("Cannot get file modified time, fetching new data from GitHub");
|
|
true
|
|
}
|
|
Ok(modified_time) => {
|
|
if std::time::SystemTime::now().duration_since(modified_time)? < *ttl {
|
|
log::debug!("Using cached data");
|
|
false
|
|
} else {
|
|
log::debug!("Cache expired, fetching new data from GitHub");
|
|
true
|
|
}
|
|
}
|
|
}
|
|
};
|
|
Ok(y)
|
|
}
|
|
|
|
/// Download a file from a github release to a specified path with a progress bar
|
|
pub fn download<P: AsRef<Path> + std::fmt::Debug>(
|
|
src: &str,
|
|
dst: P,
|
|
message: Option<&str>,
|
|
) -> Result<()> {
|
|
let resp = Self::fetch_get_response(src)?;
|
|
let ntotal = resp
|
|
.headers()
|
|
.get(http::header::CONTENT_LENGTH)
|
|
.and_then(|v| v.to_str().ok()?.parse::<u64>().ok())
|
|
.context("Content-Length header is missing or invalid")?;
|
|
let pb = crate::build_progress_bar(
|
|
ntotal,
|
|
"Fetching",
|
|
Some(message.unwrap_or_default()),
|
|
"{prefix:.cyan.bold} {msg} |{bar}| ({percent_precise}%, {binary_bytes}/{binary_total_bytes}, {binary_bytes_per_sec})",
|
|
)?;
|
|
|
|
let mut reader = resp.into_body().into_reader();
|
|
let mut buffer = [0; 2048];
|
|
let mut downloaded_bytes = 0usize;
|
|
let mut file = std::fs::File::create(&dst)
|
|
.with_context(|| format!("Failed to create destination file: {:?}", dst))?;
|
|
|
|
loop {
|
|
let bytes_read = reader.read(&mut buffer)?;
|
|
if bytes_read == 0 {
|
|
break;
|
|
}
|
|
file.write_all(&buffer[..bytes_read])
|
|
.context("Failed to write to file")?;
|
|
downloaded_bytes += bytes_read;
|
|
pb.inc(bytes_read as u64);
|
|
}
|
|
|
|
if downloaded_bytes as u64 != ntotal {
|
|
anyhow::bail!("The downloaded file is incomplete.");
|
|
}
|
|
|
|
// Update the progress bar
|
|
pb.set_prefix("Downloaded");
|
|
pb.set_style(ProgressStyle::with_template(
|
|
crate::PROGRESS_BAR_STYLE_FINISH_3,
|
|
)?);
|
|
pb.finish();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn fetch_get_response(url: &str) -> anyhow::Result<http::Response<ureq::Body>> {
|
|
let config = ureq::Agent::config_builder()
|
|
.proxy(ureq::Proxy::try_from_env())
|
|
.build();
|
|
let agent = ureq::Agent::new_with_config(config);
|
|
|
|
let response = agent
|
|
.get(url)
|
|
.call()
|
|
.map_err(|err| anyhow::anyhow!("Failed to GET response from {}: {}", url, err))?;
|
|
if response.status() != 200 {
|
|
anyhow::bail!("Failed to fetch data from remote due to: {:?}", response);
|
|
}
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
fn cache_file(owner: &str, repo: &str) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(format!("{}-{}", owner, repo));
|
|
format!(".{:x}", hasher.finalize())
|
|
}
|
|
|
|
fn get_releases(owner: &str, repo: &str, to: &Dir, ttl: &Duration) -> Result<Vec<Release>> {
|
|
let cache = to.crate_dir_default()?.join(Self::cache_file(owner, repo));
|
|
let is_file_expired = Self::is_file_expired(&cache, ttl)?;
|
|
let body = if is_file_expired {
|
|
let gh_api_release = format!(
|
|
"https://api.github.com/repos/{}/{}/releases?per_page=100",
|
|
owner, repo
|
|
);
|
|
Self::fetch_and_cache_releases(&gh_api_release, &cache)?
|
|
} else {
|
|
std::fs::read_to_string(&cache)?
|
|
};
|
|
|
|
Ok(serde_json::from_str(&body)?)
|
|
}
|
|
|
|
pub fn is_valid_github_release_url(url: &str) -> Option<(String, String, String, String)> {
|
|
let re =
|
|
Regex::new(r"^https://github\.com/([^/]+)/([^/]+)/releases/download/([^/]+)/([^/]+)$")
|
|
.expect("Failed to compile the regex for GitHub release URL pattern");
|
|
|
|
if let Some(caps) = re.captures(url) {
|
|
let owner = caps.get(1).map_or("", |m| m.as_str());
|
|
let repo = caps.get(2).map_or("", |m| m.as_str());
|
|
let tag = caps.get(3).map_or("", |m| m.as_str());
|
|
let file = caps.get(4).map_or("", |m| m.as_str());
|
|
|
|
Some((
|
|
owner.to_string(),
|
|
repo.to_string(),
|
|
tag.to_string(),
|
|
file.to_string(),
|
|
))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn with_owner(mut self, owner: &str) -> Self {
|
|
self.owner = owner.to_string();
|
|
self
|
|
}
|
|
|
|
pub fn with_repo(mut self, repo: &str) -> Self {
|
|
self.repo = repo.to_string();
|
|
self
|
|
}
|
|
|
|
pub fn with_ttl(mut self, x: u64) -> Self {
|
|
self.ttl = std::time::Duration::from_secs(x);
|
|
self
|
|
}
|
|
|
|
pub fn with_timeout(mut self, x: u64) -> Self {
|
|
self.timeout = x;
|
|
self
|
|
}
|
|
|
|
pub fn with_max_attempts(mut self, x: u32) -> Self {
|
|
self.max_attempts = x;
|
|
self
|
|
}
|
|
}
|