#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(rustdoc::broken_intra_doc_links)]
#[cfg(all(feature = "async", not(feature = "reqwest")))]
compile_error!("feature `async` requires the `reqwest` client - `ureq` has no async API");
pub use http;
#[cfg(feature = "reqwest")]
#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
pub use reqwest;
#[cfg(feature = "async")]
#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
pub use update::{AsyncReleaseSource, AsyncReleaseUpdate};
pub use update::{
Release, ReleaseAsset, ReleaseBuilder, ReleaseSource, ReleaseStatus, ReleaseUpdate, Releases,
UpdateConfig,
};
#[cfg(feature = "ureq")]
#[cfg_attr(docsrs, doc(cfg(feature = "ureq")))]
pub use ureq;
#[cfg(feature = "signatures")]
#[cfg_attr(docsrs, doc(cfg(feature = "signatures")))]
pub use zipsign_api;
#[cfg(feature = "signatures")]
#[cfg_attr(docsrs, doc(cfg(feature = "signatures")))]
pub type VerifyingKey = [u8; zipsign_api::PUBLIC_KEY_LENGTH];
#[cfg(feature = "compression-tar-gz")]
use either::Either;
#[cfg(feature = "progress-bar")]
use indicatif::{ProgressBar, ProgressStyle as IndicatifProgressStyle};
use log::debug;
#[cfg(feature = "progress-bar")]
use std::cmp::min;
use std::fs;
use std::io;
use std::path;
#[macro_use]
mod macros;
pub mod backends;
#[cfg(feature = "checksums")]
mod checksum;
pub mod errors;
pub mod http_client;
mod tls;
pub mod update;
pub mod version;
pub use tls::Certificate;
pub use errors::{Error, Result};
#[cfg(feature = "checksums")]
#[cfg_attr(docsrs, doc(cfg(feature = "checksums")))]
pub use checksum::Checksum;
use http_client::header;
#[cfg(feature = "progress-bar")]
pub(crate) const DEFAULT_PROGRESS_TEMPLATE: &str =
"[{elapsed_precise}] [{bar:40}] {bytes}/{total_bytes} ({eta}) {msg}";
#[cfg(feature = "progress-bar")]
pub(crate) const DEFAULT_PROGRESS_CHARS: &str = "=>-";
#[cfg(feature = "progress-bar")]
#[cfg_attr(docsrs, doc(cfg(feature = "progress-bar")))]
#[derive(Clone, Debug)]
pub struct ProgressStyle {
pub template: String,
pub chars: String,
}
#[cfg(feature = "progress-bar")]
impl ProgressStyle {
pub fn new(template: impl Into<String>, chars: impl Into<String>) -> Self {
Self {
template: template.into(),
chars: chars.into(),
}
}
}
pub fn get_target() -> &'static str {
env!("TARGET")
}
fn confirm(msg: &str) -> Result<()> {
print_flush!("{}", msg);
let mut s = String::new();
if io::stdin().read_line(&mut s)? == 0 {
return Err(Error::Aborted);
}
let s = s.trim().to_lowercase();
if !s.is_empty() && s != "y" {
return Err(Error::Aborted);
}
Ok(())
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum VersionStatus {
UpToDate(String),
Updated(String),
}
impl VersionStatus {
pub fn version(&self) -> &str {
use VersionStatus::*;
match *self {
UpToDate(ref s) => s,
Updated(ref s) => s,
}
}
pub fn is_up_to_date(&self) -> bool {
matches!(*self, VersionStatus::UpToDate(_))
}
pub fn is_updated(&self) -> bool {
matches!(*self, VersionStatus::Updated(_))
}
}
impl std::fmt::Display for VersionStatus {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
use VersionStatus::*;
match *self {
UpToDate(ref s) => write!(f, "UpToDate({})", s),
Updated(ref s) => write!(f, "Updated({})", s),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum ArchiveKind {
#[cfg(feature = "archive-tar")]
#[cfg_attr(docsrs, doc(cfg(feature = "archive-tar")))]
Tar(Option<Compression>),
Plain(Option<Compression>),
#[cfg(feature = "archive-zip")]
#[cfg_attr(docsrs, doc(cfg(feature = "archive-zip")))]
Zip,
}
impl std::fmt::Display for ArchiveKind {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
#[cfg(feature = "archive-tar")]
ArchiveKind::Tar(Some(Compression::Gz)) => write!(f, "tar.gz"),
#[cfg(feature = "archive-tar")]
ArchiveKind::Tar(None) => write!(f, "tar"),
ArchiveKind::Plain(Some(Compression::Gz)) => write!(f, "gz"),
ArchiveKind::Plain(None) => write!(f, "plain"),
#[cfg(feature = "archive-zip")]
ArchiveKind::Zip => write!(f, "zip"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum Compression {
Gz,
}
fn detect_archive(path: &path::Path) -> Result<ArchiveKind> {
let ext = path.extension();
debug!("Detecting archive type using extension: {:?}", ext);
let res = match ext {
Some(extension) if extension == std::ffi::OsStr::new("zip") => {
#[cfg(feature = "archive-zip")]
{
debug!("Detected .zip archive");
Ok(ArchiveKind::Zip)
}
#[cfg(not(feature = "archive-zip"))]
{
Err(Error::ArchiveNotEnabled("zip".to_string()))
}
}
Some(extension) if extension == std::ffi::OsStr::new("tar") => {
#[cfg(feature = "archive-tar")]
{
debug!("Detected .tar archive");
Ok(ArchiveKind::Tar(None))
}
#[cfg(not(feature = "archive-tar"))]
{
Err(Error::ArchiveNotEnabled("tar".to_string()))
}
}
Some(extension) if extension == std::ffi::OsStr::new("tgz") => {
#[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))]
{
debug!("Detected .tgz archive");
Ok(ArchiveKind::Tar(Some(Compression::Gz)))
}
#[cfg(all(feature = "archive-tar", not(feature = "compression-tar-gz")))]
{
Err(Error::CompressionNotEnabled("gz".to_string()))
}
#[cfg(not(feature = "archive-tar"))]
{
Err(Error::ArchiveNotEnabled("tar".to_string()))
}
}
Some(extension) if extension == std::ffi::OsStr::new("gz") => match path
.file_stem()
.map(path::Path::new)
.and_then(|f| f.extension())
{
Some(extension) if extension == std::ffi::OsStr::new("tar") => {
#[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))]
{
debug!("Detected .tar.gz archive");
Ok(ArchiveKind::Tar(Some(Compression::Gz)))
}
#[cfg(all(feature = "archive-tar", not(feature = "compression-tar-gz")))]
{
Err(Error::CompressionNotEnabled("gz".to_string()))
}
#[cfg(not(feature = "archive-tar"))]
{
Err(Error::ArchiveNotEnabled("tar".to_string()))
}
}
_ => {
#[cfg(feature = "compression-tar-gz")]
{
Ok(ArchiveKind::Plain(Some(Compression::Gz)))
}
#[cfg(not(feature = "compression-tar-gz"))]
{
Err(Error::CompressionNotEnabled("gz".to_string()))
}
}
},
_ => Ok(ArchiveKind::Plain(None)),
};
debug!("Detected archive type: {:?}", res);
res
}
#[derive(Debug)]
#[non_exhaustive]
pub struct Extract {
source: path::PathBuf,
archive: Option<ArchiveKind>,
}
#[cfg(feature = "compression-tar-gz")]
type GetArchiveReaderResult = Either<fs::File, flate2::read::GzDecoder<fs::File>>;
#[cfg(not(feature = "compression-tar-gz"))]
type GetArchiveReaderResult = fs::File;
impl Extract {
pub fn from_source(source: impl AsRef<path::Path>) -> Extract {
Self {
source: source.as_ref().to_path_buf(),
archive: None,
}
}
pub fn archive(&mut self, kind: ArchiveKind) -> &mut Self {
self.archive = Some(kind);
self
}
#[allow(unused_variables)]
fn get_archive_reader(
source: fs::File,
compression: Option<Compression>,
) -> GetArchiveReaderResult {
#[cfg(feature = "compression-tar-gz")]
match compression {
Some(Compression::Gz) => Either::Right(flate2::read::GzDecoder::new(source)),
None => Either::Left(source),
}
#[cfg(not(feature = "compression-tar-gz"))]
source
}
pub fn extract_into(&self, into_dir: impl AsRef<path::Path>) -> Result<()> {
let into_dir = into_dir.as_ref();
let source = fs::File::open(&self.source)?;
let archive = match self.archive {
Some(archive) => archive,
None => detect_archive(&self.source)?,
};
let extract_into_plain_or_tar = |source: fs::File, compression: Option<Compression>| {
let mut reader = Self::get_archive_reader(source, compression);
match archive {
ArchiveKind::Plain(_) => {
match fs::create_dir_all(into_dir) {
Ok(_) => (),
Err(e) => {
if e.kind() != io::ErrorKind::AlreadyExists {
return Err(Error::Io(e));
}
}
}
let file_name = self.source.file_name().ok_or_else(|| Error::Internal {
message: "Extractor source has no file-name".to_string(),
source: None,
})?;
let mut out_path = into_dir.join(file_name);
out_path.set_extension("");
let mut out_file = fs::File::create(&out_path)?;
io::copy(&mut reader, &mut out_file)?;
}
#[cfg(feature = "archive-tar")]
ArchiveKind::Tar(_) => {
let mut archive = tar::Archive::new(reader);
archive.unpack(into_dir)?;
}
#[allow(unreachable_patterns)]
_ => unreachable!(
"detect_archive() returns in case the proper feature flag is not enabled"
),
};
Ok(())
};
match archive {
#[cfg(feature = "archive-tar")]
ArchiveKind::Plain(compression) | ArchiveKind::Tar(compression) => {
extract_into_plain_or_tar(source, compression)?;
}
#[cfg(not(feature = "archive-tar"))]
ArchiveKind::Plain(compression) => {
extract_into_plain_or_tar(source, compression)?;
}
#[cfg(feature = "archive-zip")]
ArchiveKind::Zip => {
let mut archive = zip::ZipArchive::new(source)?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let Some(rel_path) = file.enclosed_name() else {
return Err(Error::Internal {
message: format!("zip entry has an unsafe path: {:?}", file.name()),
source: None,
});
};
let output_path = into_dir.join(rel_path);
if file.is_dir() {
fs::create_dir_all(&output_path)?;
continue;
}
if let Some(parent_dir) = output_path.parent()
&& let Err(e) = fs::create_dir_all(parent_dir)
&& e.kind() != io::ErrorKind::AlreadyExists
{
return Err(Error::Io(e));
}
let mut output = fs::File::create(&output_path)?;
io::copy(&mut file, &mut output)?;
#[cfg(unix)]
if let Some(mode) = file.unix_mode() {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(
&output_path,
fs::Permissions::from_mode(mode & 0o777),
)?;
}
}
}
};
Ok(())
}
pub fn extract_file<T: AsRef<path::Path>>(
&self,
into_dir: impl AsRef<path::Path>,
file_to_extract: T,
) -> Result<()> {
let into_dir = into_dir.as_ref();
let file_to_extract = file_to_extract.as_ref();
let source = fs::File::open(&self.source)?;
let archive = match self.archive {
Some(archive) => archive,
None => detect_archive(&self.source)?,
};
debug!(
"Attempting to extract {:?} file from {:?}",
file_to_extract, self.source
);
let extract_file_plain_or_tar = |source: fs::File, compression: Option<Compression>| {
let mut reader = Self::get_archive_reader(source, compression);
match archive {
ArchiveKind::Plain(_) => {
debug!("Copying file directly");
match fs::create_dir_all(into_dir) {
Ok(_) => (),
Err(e) => {
if e.kind() != io::ErrorKind::AlreadyExists {
return Err(Error::Io(e));
}
}
}
let file_name = file_to_extract.file_name().ok_or_else(|| Error::Internal {
message: "Extractor source has no file-name".to_string(),
source: None,
})?;
let out_path = into_dir.join(file_name);
let mut out_file = fs::File::create(out_path)?;
io::copy(&mut reader, &mut out_file)?;
}
#[cfg(feature = "archive-tar")]
ArchiveKind::Tar(_) => {
debug!("Extracting from tar");
let mut archive = tar::Archive::new(reader);
let mut entry = archive
.entries()?
.filter_map(|e| e.ok())
.find(|e| {
let p = e.path();
debug!("Archive path: {:?}", p);
p.ok().filter(|p| p == file_to_extract).is_some()
})
.ok_or_else(|| Error::Internal {
message: format!(
"Could not find the required path in the archive: {:?}",
file_to_extract
),
source: None,
})?;
entry.unpack_in(into_dir)?;
}
#[allow(unreachable_patterns)]
_ => unreachable!(
"detect_archive() returns in case the proper feature flag is not enabled"
),
};
Ok(())
};
match archive {
#[cfg(feature = "archive-tar")]
ArchiveKind::Plain(compression) | ArchiveKind::Tar(compression) => {
extract_file_plain_or_tar(source, compression)?;
}
#[cfg(not(feature = "archive-tar"))]
ArchiveKind::Plain(compression) => {
extract_file_plain_or_tar(source, compression)?;
}
#[cfg(feature = "archive-zip")]
ArchiveKind::Zip => {
let mut archive = zip::ZipArchive::new(source)?;
let file_name = file_to_extract.to_str().ok_or_else(|| Error::Internal {
message: format!(
"cannot extract file with a non-UTF-8 path: {:?}",
file_to_extract
),
source: None,
})?;
let mut file = archive.by_name(file_name)?;
let Some(rel_path) = file.enclosed_name() else {
return Err(Error::Internal {
message: format!("zip entry has an unsafe path: {:?}", file.name()),
source: None,
});
};
let output_path = into_dir.join(rel_path);
if let Some(parent_dir) = output_path.parent()
&& let Err(e) = fs::create_dir_all(parent_dir)
&& e.kind() != io::ErrorKind::AlreadyExists
{
return Err(Error::Io(e));
}
let mut output = fs::File::create(&output_path)?;
io::copy(&mut file, &mut output)?;
#[cfg(unix)]
if let Some(mode) = file.unix_mode() {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&output_path, fs::Permissions::from_mode(mode & 0o777))?;
}
}
};
Ok(())
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct Move {
source: path::PathBuf,
temp: Option<path::PathBuf>,
}
impl Move {
pub fn from_source(source: impl AsRef<path::Path>) -> Move {
Self {
source: source.as_ref().to_path_buf(),
temp: None,
}
}
pub fn replace_using_temp(&mut self, temp: impl AsRef<path::Path>) -> &mut Self {
self.temp = Some(temp.as_ref().to_path_buf());
self
}
pub fn to_dest(&self, dest: impl AsRef<path::Path>) -> Result<()> {
let dest = dest.as_ref();
match self.temp.as_deref() {
Some(temp) if dest.exists() => {
fs::rename(dest, temp)?;
if let Err(e) = fs::rename(&self.source, dest) {
fs::rename(temp, dest)?;
return Err(Error::from(e));
}
}
_ => {
rename_or_copy(&self.source, dest)?;
}
};
Ok(())
}
}
fn rename_or_copy(source: &path::Path, dest: &path::Path) -> Result<()> {
match fs::rename(source, dest) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::CrossesDevices => {
let tmp = match dest.file_name() {
Some(name) => {
let mut n = name.to_os_string();
n.push(".self_update.tmp");
dest.with_file_name(n)
}
None => return Err(Error::from(e)),
};
fs::copy(source, &tmp)?;
if let Err(rename_err) = fs::rename(&tmp, dest) {
let _ = fs::remove_file(&tmp);
return Err(Error::from(rename_err));
}
let _ = fs::remove_file(source);
Ok(())
}
Err(e) => Err(Error::from(e)),
}
}
#[derive(Debug)]
#[must_use = "queued moves are only applied when `.commit()` is called"]
#[non_exhaustive]
pub struct MoveAll {
temp: path::PathBuf,
moves: Vec<(path::PathBuf, path::PathBuf)>,
}
impl MoveAll {
pub fn from_temp(temp: impl AsRef<path::Path>) -> Self {
Self {
temp: temp.as_ref().to_path_buf(),
moves: Vec::new(),
}
}
pub fn add(
&mut self,
source: impl AsRef<path::Path>,
dest: impl AsRef<path::Path>,
) -> &mut Self {
self.moves
.push((source.as_ref().to_path_buf(), dest.as_ref().to_path_buf()));
self
}
pub fn commit(&mut self) -> Result<()> {
let moves = std::mem::take(&mut self.moves);
let mut applied: Vec<Applied> = Vec::with_capacity(moves.len());
for (i, (source, dest)) in moves.iter().enumerate() {
let stash = if dest.exists() {
let stash = self.temp.join(format!("self_update-stash-{i}"));
if let Err(e) = fs::rename(dest, &stash) {
rollback(&applied);
return Err(Error::from(e));
}
Some(stash)
} else {
None
};
if let Err(e) = fs::rename(source, dest) {
if let Some(stash) = &stash
&& let Err(restore_err) = fs::rename(stash, dest)
{
log::error!(
"failed to restore {:?} from stash {:?} during rollback: {}",
dest,
stash,
restore_err
);
}
rollback(&applied);
return Err(Error::from(e));
}
applied.push(Applied {
dest: dest.clone(),
stash,
});
}
Ok(())
}
}
#[derive(Debug)]
struct Applied {
dest: path::PathBuf,
stash: Option<path::PathBuf>,
}
fn rollback(applied: &[Applied]) {
for entry in applied.iter().rev() {
match &entry.stash {
Some(stash) => {
if let Err(e) = fs::rename(stash, &entry.dest) {
log::error!(
"failed to restore {:?} from stash {:?} during rollback: {}",
entry.dest,
stash,
e
);
}
}
None => {
if let Err(e) = fs::remove_file(&entry.dest) {
log::error!("failed to remove {:?} during rollback: {}", entry.dest, e);
}
}
}
}
}
pub(crate) type DynProgressFn = dyn Fn(u64, Option<u64>) + Send + Sync;
#[derive(Clone)]
pub(crate) struct ProgressCallback(pub(crate) std::sync::Arc<DynProgressFn>);
impl std::fmt::Debug for ProgressCallback {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("ProgressCallback(..)")
}
}
pub(crate) type DynVerifyFn = dyn Fn(&std::path::Path) -> Result<()> + Send + Sync;
#[derive(Clone)]
pub(crate) struct VerifyCallback(pub(crate) std::sync::Arc<DynVerifyFn>);
impl std::fmt::Debug for VerifyCallback {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("VerifyCallback(..)")
}
}
pub(crate) type DynAssetMatcher = dyn Fn(&[ReleaseAsset]) -> Option<ReleaseAsset> + Send + Sync;
#[derive(Clone)]
pub(crate) struct AssetMatcher(pub(crate) std::sync::Arc<DynAssetMatcher>);
impl std::fmt::Debug for AssetMatcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("AssetMatcher(..)")
}
}
#[non_exhaustive]
pub struct Download {
show_progress: bool,
url: String,
headers: http_client::header::HeaderMap,
#[cfg(feature = "progress-bar")]
progress_template: String,
#[cfg(feature = "progress-bar")]
progress_chars: String,
timeout: Option<std::time::Duration>,
on_progress: Option<ProgressCallback>,
max_download_size: Option<u64>,
retries: u32,
retry_base_delay: std::time::Duration,
retry_max_delay: std::time::Duration,
client: Option<std::sync::Arc<dyn http_client::HttpClient>>,
#[cfg(feature = "async")]
async_client: Option<std::sync::Arc<dyn http_client::AsyncHttpClient>>,
root_certificates: Vec<Certificate>,
header_error: Option<String>,
}
impl std::fmt::Debug for Download {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut s = f.debug_struct("Download");
s.field("show_progress", &self.show_progress)
.field("url", &self.url)
.field("headers", &self.headers);
#[cfg(feature = "progress-bar")]
s.field("progress_template", &self.progress_template)
.field("progress_chars", &self.progress_chars);
s.field("timeout", &self.timeout)
.field(
"on_progress",
&self.on_progress.as_ref().map(|_| "<callback>"),
)
.field("max_download_size", &self.max_download_size)
.field("client", &self.client.as_ref().map(|_| "<http_client>"));
#[cfg(feature = "async")]
s.field(
"async_client",
&self.async_client.as_ref().map(|_| "<async_http_client>"),
);
s.field(
"root_certificates",
&format_args!("<{} root_certificates>", self.root_certificates.len()),
);
s.finish()
}
}
fn max_download_size_exceeded(cap: u64) -> Error {
Error::Io(io::Error::other(format!(
"download exceeded the configured max_download_size cap of {cap} bytes"
)))
}
impl Download {
pub fn from_url(url: impl Into<String>) -> Self {
Self {
show_progress: false,
url: url.into(),
headers: http_client::header::HeaderMap::new(),
#[cfg(feature = "progress-bar")]
progress_template: DEFAULT_PROGRESS_TEMPLATE.to_string(),
#[cfg(feature = "progress-bar")]
progress_chars: DEFAULT_PROGRESS_CHARS.to_string(),
timeout: None,
on_progress: None,
max_download_size: None,
retries: 0,
retry_base_delay: std::time::Duration::from_millis(100),
retry_max_delay: std::time::Duration::from_millis(3200),
client: None,
#[cfg(feature = "async")]
async_client: None,
root_certificates: vec![],
header_error: None,
}
}
pub fn show_download_progress(&mut self, b: bool) -> &mut Self {
self.show_progress = b;
self
}
pub fn timeout(&mut self, timeout: std::time::Duration) -> &mut Self {
self.timeout = Some(timeout);
self
}
pub fn max_download_size(&mut self, max_bytes: u64) -> &mut Self {
self.max_download_size = Some(max_bytes);
self
}
pub fn progress_callback(
&mut self,
callback: impl Fn(u64, Option<u64>) + Send + Sync + 'static,
) -> &mut Self {
self.on_progress = Some(ProgressCallback(std::sync::Arc::new(callback)));
self
}
pub(crate) fn set_progress_callback_arc(
&mut self,
callback: std::sync::Arc<DynProgressFn>,
) -> &mut Self {
self.on_progress = Some(ProgressCallback(callback));
self
}
#[cfg(feature = "progress-bar")]
pub fn progress_style(&mut self, style: ProgressStyle) -> &mut Self {
self.progress_template = style.template;
self.progress_chars = style.chars;
self
}
pub fn replace_headers(&mut self, headers: http_client::header::HeaderMap) -> &mut Self {
self.headers = headers;
self
}
pub(crate) fn set_retries(
&mut self,
retries: u32,
base: std::time::Duration,
max: std::time::Duration,
) -> &mut Self {
self.retries = retries;
self.retry_base_delay = base;
self.retry_max_delay = max;
self
}
pub(crate) fn set_http_client(
&mut self,
client: Option<std::sync::Arc<dyn http_client::HttpClient>>,
#[cfg(feature = "async")] async_client: Option<
std::sync::Arc<dyn http_client::AsyncHttpClient>,
>,
) -> &mut Self {
self.client = client;
#[cfg(feature = "async")]
{
self.async_client = async_client;
}
self
}
pub fn add_root_certificate(&mut self, cert: Certificate) -> &mut Self {
self.root_certificates.push(cert);
self
}
#[cfg(test)]
pub(crate) fn root_certificates(&self) -> &[Certificate] {
&self.root_certificates
}
pub fn request_header<N, V>(&mut self, name: N, value: V) -> &mut Self
where
N: ::core::convert::TryInto<http_client::header::HeaderName>,
V: ::core::convert::TryInto<http_client::header::HeaderValue>,
{
match (name.try_into(), value.try_into()) {
(Ok(name), Ok(value)) => {
self.headers.insert(name, value);
}
_ => {
if self.header_error.is_none() {
self.header_error =
Some("invalid HTTP header passed to `request_header`".to_string());
}
}
}
self
}
fn check_header_error(&self) -> Result<()> {
if let Some(msg) = &self.header_error {
return Err(Error::InvalidHeader {
source: Box::new(errors::MessageError(msg.clone())),
});
}
Ok(())
}
pub fn download_to<T: io::Write>(&self, mut dest: T) -> Result<()> {
use io::BufRead;
self.check_header_error()?;
let mut headers = self.headers.clone();
if !headers.contains_key(header::USER_AGENT) {
headers.insert(
header::USER_AGENT,
"rust-reqwest/self-update"
.parse()
.expect("invalid user-agent"),
);
}
let default;
let built;
let client: &dyn http_client::HttpClient = match self.client.as_deref() {
Some(c) => c,
None if !self.root_certificates.is_empty() => {
built = http_client::client_with_root_certs(&self.root_certificates)
.map_err(|source| Error::InvalidCertificate { source })?;
&*built
}
None => {
default = http_client::default_client();
&*default
}
};
let resp = backends::retry(
self.retries,
self.retry_base_delay,
self.retry_max_delay,
|| client.get(&self.url, &headers, self.timeout),
|e, backoff| {
log::warn!(
"self_update: download request to {} failed ({e}); retrying in {backoff}ms",
crate::errors::redact_url(&self.url)
);
std::thread::sleep(std::time::Duration::from_millis(backoff));
},
)?;
let size = resp
.headers()
.get(http_client::header::CONTENT_LENGTH)
.map(|val| {
val.to_str()
.map(|s| s.parse::<u64>().unwrap_or(0))
.unwrap_or(0)
})
.unwrap_or(0);
let total = if size == 0 { None } else { Some(size) };
#[cfg(feature = "progress-bar")]
let show_progress = if size == 0 { false } else { self.show_progress };
let mut src = io::BufReader::new(resp.body());
let mut downloaded: u64 = 0;
#[cfg(feature = "progress-bar")]
let mut bar = if show_progress {
let style = IndicatifProgressStyle::default_bar()
.template(&self.progress_template)
.map_err(|e| Error::InvalidProgressStyle {
source: Box::new(e),
})?
.progress_chars(&self.progress_chars);
let pb = ProgressBar::new(size);
pb.set_style(style);
Some(pb)
} else {
None
};
loop {
let n = {
let buf = src.fill_buf()?;
dest.write_all(buf)?;
buf.len()
};
if n == 0 {
break;
}
src.consume(n);
downloaded += n as u64;
if let Some(cap) = self.max_download_size
&& downloaded > cap
{
return Err(max_download_size_exceeded(cap));
}
#[cfg(feature = "progress-bar")]
if let Some(ref mut bar) = bar {
bar.set_position(min(downloaded, size));
}
if let Some(ref cb) = self.on_progress {
(cb.0)(downloaded, total);
}
}
#[cfg(feature = "progress-bar")]
if let Some(ref mut bar) = bar {
bar.finish_with_message("Done");
}
Ok(())
}
#[cfg(feature = "async")]
pub async fn download_to_async<T: io::Write>(&self, mut dest: T) -> Result<()> {
use futures_util::StreamExt;
self.check_header_error()?;
let mut headers = self.headers.clone();
if !headers.contains_key(header::USER_AGENT) {
headers.insert(
header::USER_AGENT,
"rust-reqwest/self-update"
.parse()
.expect("invalid user-agent"),
);
}
let default;
let built;
let client: &dyn http_client::AsyncHttpClient = match self.async_client.as_deref() {
Some(c) => c,
None if !self.root_certificates.is_empty() => {
built = http_client::async_client_with_root_certs(&self.root_certificates)
.map_err(|source| Error::InvalidCertificate { source })?;
&*built
}
None => {
default = http_client::default_async_client();
&*default
}
};
let resp = backends::retry_async(
self.retries,
self.retry_base_delay,
self.retry_max_delay,
|| client.get(&self.url, &headers, self.timeout),
|e, backoff| {
log::warn!(
"self_update: download request to {} failed ({e}); retrying in {backoff}ms",
crate::errors::redact_url(&self.url)
);
},
|backoff| tokio::time::sleep(std::time::Duration::from_millis(backoff)),
)
.await?;
let size = resp
.headers()
.get(http_client::header::CONTENT_LENGTH)
.map(|val| {
val.to_str()
.map(|s| s.parse::<u64>().unwrap_or(0))
.unwrap_or(0)
})
.unwrap_or(0);
let total = if size == 0 { None } else { Some(size) };
#[cfg(feature = "progress-bar")]
let show_progress = if size == 0 { false } else { self.show_progress };
let mut downloaded: u64 = 0;
#[cfg(feature = "progress-bar")]
let mut bar = if show_progress {
let style = IndicatifProgressStyle::default_bar()
.template(&self.progress_template)
.map_err(|e| Error::InvalidProgressStyle {
source: Box::new(e),
})?
.progress_chars(&self.progress_chars);
let pb = ProgressBar::new(size);
pb.set_style(style);
Some(pb)
} else {
None
};
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
dest.write_all(&chunk)?;
downloaded += chunk.len() as u64;
if let Some(cap) = self.max_download_size
&& downloaded > cap
{
return Err(max_download_size_exceeded(cap));
}
#[cfg(feature = "progress-bar")]
if let Some(ref mut bar) = bar {
bar.set_position(min(downloaded, size));
}
if let Some(ref cb) = self.on_progress {
(cb.0)(downloaded, total);
}
}
#[cfg(feature = "progress-bar")]
if let Some(ref mut bar) = bar {
bar.finish_with_message("Done");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
#![allow(dead_code, unused_mut, unused_variables)]
use super::*;
#[cfg(feature = "compression-tar-gz")]
use flate2::{self, write::GzEncoder};
#[allow(unused_imports)]
use std::{
fs::{self, File},
io::{self, Read, Write},
path::{Path, PathBuf},
};
#[test]
fn version_status_is_up_to_date() {
assert!(VersionStatus::UpToDate("1.2.3".to_string()).is_up_to_date());
assert!(!VersionStatus::Updated("1.2.3".to_string()).is_up_to_date());
assert!(VersionStatus::Updated("1.2.3".to_string()).is_updated());
assert!(!VersionStatus::UpToDate("1.2.3".to_string()).is_updated());
}
#[test]
fn version_status_version_accessor() {
assert_eq!(
VersionStatus::UpToDate("1.0.0".to_string()).version(),
"1.0.0"
);
assert_eq!(
VersionStatus::Updated("2.0.0".to_string()).version(),
"2.0.0"
);
}
#[test]
fn version_status_display() {
assert_eq!(
VersionStatus::UpToDate("1.0.0".to_string()).to_string(),
"UpToDate(1.0.0)"
);
assert_eq!(
VersionStatus::Updated("2.0.0".to_string()).to_string(),
"Updated(2.0.0)"
);
}
#[test]
fn archive_kind_display_is_human_readable() {
assert_eq!(ArchiveKind::Plain(None).to_string(), "plain");
assert_eq!(ArchiveKind::Plain(Some(Compression::Gz)).to_string(), "gz");
#[cfg(feature = "archive-tar")]
{
assert_eq!(ArchiveKind::Tar(None).to_string(), "tar");
assert_eq!(
ArchiveKind::Tar(Some(Compression::Gz)).to_string(),
"tar.gz"
);
}
#[cfg(feature = "archive-zip")]
assert_eq!(ArchiveKind::Zip.to_string(), "zip");
}
#[test]
fn ergonomic_constructors_accept_owned_and_borrowed_paths_and_strings() {
let _ = Download::from_url("https://example.com/a.bin");
let _ = Download::from_url(String::from("https://example.com/b.bin"));
let _: Extract = Extract::from_source("some/path.tar.gz");
let _: Extract = Extract::from_source(PathBuf::from("some/path.tar.gz"));
let owned = PathBuf::from("some/path.tar.gz");
let _: Extract = Extract::from_source(owned.as_path());
let mut mv: Move = Move::from_source("src");
mv.replace_using_temp("tmp");
let _: MoveAll = MoveAll::from_temp("tmp-dir");
}
#[cfg(feature = "progress-bar")]
#[test]
fn progress_style_newtype_threads_template_and_chars() {
let style = ProgressStyle::new("[{bar:40}] {bytes}", "#>-");
assert_eq!(style.template, "[{bar:40}] {bytes}");
assert_eq!(style.chars, "#>-");
let mut dl = Download::from_url("https://example.com/app.tar.gz");
dl.progress_style(style);
assert_eq!(dl.progress_template, "[{bar:40}] {bytes}");
assert_eq!(dl.progress_chars, "#>-");
}
#[test]
fn download_header_accepts_str_name_and_value() {
let mut dl = Download::from_url("https://example.com/app.tar.gz");
dl.request_header("x-custom-header", "custom-value");
let stored = dl
.headers
.get("x-custom-header")
.expect("header should be inserted");
assert_eq!(stored, "custom-value");
}
#[test]
fn download_header_accepts_typed_name_and_value() {
let mut dl = Download::from_url("https://example.com/app.tar.gz");
dl.request_header(http_client::header::ACCEPT, "application/octet-stream");
assert_eq!(
dl.headers.get(http_client::header::ACCEPT).unwrap(),
"application/octet-stream"
);
}
#[test]
fn download_header_overwrites_on_repeated_name() {
let mut dl = Download::from_url("https://example.com/app.tar.gz");
dl.request_header("x-dup", "first");
dl.request_header("x-dup", "second");
assert_eq!(dl.headers.get("x-dup").unwrap(), "second");
assert_eq!(
dl.headers.get_all("x-dup").iter().count(),
1,
"a repeated header name must overwrite, not accumulate"
);
}
#[test]
fn replace_headers_wholesale_replaces_after_header_calls() {
let mut dl = Download::from_url("https://example.com/app.tar.gz");
dl.request_header("x-old-a", "a");
dl.request_header("x-old-b", "b");
let mut fresh = http_client::header::HeaderMap::new();
fresh.insert("x-new", "n".parse().unwrap());
dl.replace_headers(fresh);
assert!(
dl.headers.get("x-old-a").is_none(),
"replace_headers must drop previously-added headers"
);
assert!(dl.headers.get("x-old-b").is_none());
assert_eq!(dl.headers.get("x-new").unwrap(), "n");
assert_eq!(
dl.headers.len(),
1,
"replace_headers installs exactly the supplied map"
);
dl.request_header("x-after", "y");
assert_eq!(dl.headers.get("x-after").unwrap(), "y");
assert_eq!(dl.headers.get("x-new").unwrap(), "n");
}
#[test]
fn download_header_rejects_invalid_value() {
let mut dl = Download::from_url("https://example.com/app.tar.gz");
dl.request_header("x-ok", "bad\nvalue");
assert!(
dl.headers.get("x-ok").is_none(),
"the bad header must not be inserted"
);
let err = dl
.download_to(Vec::<u8>::new())
.expect_err("a deferred invalid header must surface from download_to");
assert!(
matches!(err, Error::InvalidHeader { .. }),
"expected Error::InvalidHeader, got {:?}",
err
);
}
#[test]
fn download_header_rejects_invalid_name() {
let mut dl = Download::from_url("https://example.com/app.tar.gz");
dl.request_header("inva lid", "ok");
assert!(
dl.headers.is_empty(),
"an invalid header name must not leave a partial value inserted"
);
let err = dl
.download_to(Vec::<u8>::new())
.expect_err("a deferred invalid header name must surface from download_to");
assert!(matches!(err, Error::InvalidHeader { .. }));
}
#[test]
fn detect_plain() {
assert_eq!(
ArchiveKind::Plain(None),
detect_archive(&PathBuf::from("Something.exe")).unwrap()
);
}
#[test]
fn move_all_commits_every_move() {
let dir = tempfile::tempdir().unwrap();
let temp = tempfile::tempdir().unwrap();
let src_a = dir.path().join("src_a");
let src_b = dir.path().join("src_b");
fs::write(&src_a, b"new-a").unwrap();
fs::write(&src_b, b"new-b").unwrap();
let dst_a = dir.path().join("dst_a");
let dst_b = dir.path().join("dst_b");
fs::write(&dst_a, b"old-a").unwrap();
fs::write(&dst_b, b"old-b").unwrap();
MoveAll::from_temp(temp.path())
.add(&src_a, &dst_a)
.add(&src_b, &dst_b)
.commit()
.unwrap();
assert_eq!(fs::read(&dst_a).unwrap(), b"new-a");
assert_eq!(fs::read(&dst_b).unwrap(), b"new-b");
}
#[test]
fn move_all_rolls_back_on_failure() {
let dir = tempfile::tempdir().unwrap();
let temp = tempfile::tempdir().unwrap();
let src_a = dir.path().join("src_a");
let src_b = dir.path().join("src_b");
fs::write(&src_a, b"new-a").unwrap();
fs::write(&src_b, b"new-b").unwrap();
let missing_src = dir.path().join("does_not_exist");
let dst_a = dir.path().join("dst_a");
let dst_b = dir.path().join("dst_b");
let dst_c = dir.path().join("dst_c");
fs::write(&dst_a, b"old-a").unwrap();
fs::write(&dst_b, b"old-b").unwrap();
fs::write(&dst_c, b"old-c").unwrap();
let res = MoveAll::from_temp(temp.path())
.add(&src_a, &dst_a)
.add(&src_b, &dst_b)
.add(&missing_src, &dst_c)
.commit();
assert!(res.is_err(), "a failing move must abort the transaction");
assert_eq!(
fs::read(&dst_a).unwrap(),
b"old-a",
"the first applied move must be rolled back"
);
assert_eq!(
fs::read(&dst_b).unwrap(),
b"old-b",
"the second applied move must be rolled back"
);
assert_eq!(
fs::read(&dst_c).unwrap(),
b"old-c",
"the failed move's stashed destination must be restored"
);
}
#[test]
fn move_all_installs_fresh_destinations() {
let dir = tempfile::tempdir().unwrap();
let temp = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
fs::write(&src, b"fresh").unwrap();
let dst = dir.path().join("new_dst");
MoveAll::from_temp(temp.path())
.add(&src, &dst)
.commit()
.unwrap();
assert_eq!(fs::read(&dst).unwrap(), b"fresh");
}
#[test]
fn move_all_second_commit_is_a_noop() {
let dir = tempfile::tempdir().unwrap();
let temp = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
fs::write(&src, b"new").unwrap();
let dst = dir.path().join("dst");
fs::write(&dst, b"old").unwrap();
let mut mover = MoveAll::from_temp(temp.path());
mover.add(&src, &dst);
mover.commit().unwrap();
assert_eq!(fs::read(&dst).unwrap(), b"new");
mover.commit().unwrap();
assert_eq!(fs::read(&dst).unwrap(), b"new");
}
#[test]
fn download_invokes_progress_callback() {
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
let body = "x".repeat(20_000);
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let served = body.clone();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
served.len(),
served
);
let _ = stream.write_all(resp.as_bytes());
});
let progress = Arc::new(Mutex::new(Vec::<(u64, Option<u64>)>::new()));
let sink_progress = progress.clone();
let mut out = Vec::new();
Download::from_url(format!("http://{addr}/file"))
.progress_callback(move |downloaded, total| {
sink_progress.lock().unwrap().push((downloaded, total));
})
.download_to(&mut out)
.unwrap();
assert_eq!(out.len(), 20_000);
let calls = progress.lock().unwrap();
assert!(!calls.is_empty(), "callback should have been invoked");
assert!(calls.iter().all(|(_, total)| *total == Some(20_000)));
let mut last = 0u64;
for (downloaded, _) in calls.iter() {
assert!(*downloaded >= last);
last = *downloaded;
}
assert_eq!(calls.last().unwrap().0, 20_000);
}
struct DlResponse {
body: Vec<u8>,
headers: http_client::header::HeaderMap,
}
impl http_client::HttpResponse for DlResponse {
fn headers(&self) -> &http_client::header::HeaderMap {
&self.headers
}
fn body(self: Box<Self>) -> Box<dyn io::Read> {
Box::new(io::Cursor::new(self.body))
}
}
struct DlClient {
body: Vec<u8>,
content_length: Option<u64>,
requested: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
}
impl http_client::HttpClient for DlClient {
fn get(
&self,
url: &str,
_headers: &http_client::header::HeaderMap,
_timeout: Option<std::time::Duration>,
) -> Result<Box<dyn http_client::HttpResponse>> {
self.requested.lock().unwrap().push(url.to_string());
let mut headers = http_client::header::HeaderMap::new();
if let Some(len) = self.content_length {
headers.insert(
http_client::header::CONTENT_LENGTH,
len.to_string().parse().unwrap(),
);
}
Ok(Box::new(DlResponse {
body: self.body.clone(),
headers,
}))
}
}
struct FlakyDlClient {
body: Vec<u8>,
fail_times: std::sync::atomic::AtomicU32,
attempts: std::sync::Arc<std::sync::atomic::AtomicU32>,
}
impl http_client::HttpClient for FlakyDlClient {
fn get(
&self,
_url: &str,
_headers: &http_client::header::HeaderMap,
_timeout: Option<std::time::Duration>,
) -> Result<Box<dyn http_client::HttpResponse>> {
use std::sync::atomic::Ordering;
self.attempts.fetch_add(1, Ordering::SeqCst);
if self.fail_times.load(Ordering::SeqCst) > 0 {
self.fail_times.fetch_sub(1, Ordering::SeqCst);
return Err(Error::HttpStatus {
status: 503,
url: "u".into(),
});
}
let mut headers = http_client::header::HeaderMap::new();
headers.insert(
http_client::header::CONTENT_LENGTH,
self.body.len().to_string().parse().unwrap(),
);
Ok(Box::new(DlResponse {
body: self.body.clone(),
headers,
}))
}
}
#[test]
fn download_retries_request_establishment_with_configured_budget() {
use std::sync::atomic::{AtomicU32, Ordering};
let body = b"payload-after-retries".to_vec();
let attempts = std::sync::Arc::new(AtomicU32::new(0));
let client = std::sync::Arc::new(FlakyDlClient {
body: body.clone(),
fail_times: AtomicU32::new(2),
attempts: attempts.clone(),
});
let mut out = Vec::new();
let mut dl = Download::from_url("https://nonroutable.invalid/asset.bin");
dl.set_http_client(
Some(client),
#[cfg(feature = "async")]
None,
);
dl.set_retries(
3,
std::time::Duration::from_millis(1),
std::time::Duration::from_millis(2),
);
dl.download_to(&mut out).unwrap();
assert_eq!(out, body, "the download succeeds after retrying");
assert_eq!(
attempts.load(Ordering::SeqCst),
3,
"two failed attempts plus the successful third"
);
}
#[test]
fn download_without_retry_budget_does_not_retry() {
use std::sync::atomic::{AtomicU32, Ordering};
let attempts = std::sync::Arc::new(AtomicU32::new(0));
let client = std::sync::Arc::new(FlakyDlClient {
body: b"never-reached".to_vec(),
fail_times: AtomicU32::new(5),
attempts: attempts.clone(),
});
let mut out = Vec::new();
let mut dl = Download::from_url("https://nonroutable.invalid/asset.bin");
dl.set_http_client(
Some(client),
#[cfg(feature = "async")]
None,
);
let res = dl.download_to(&mut out);
assert!(
res.is_err(),
"no retry budget => the first failure is fatal"
);
assert_eq!(
attempts.load(Ordering::SeqCst),
1,
"exactly one attempt with retries == 0"
);
}
#[test]
fn download_to_uses_injected_http_client_through_the_trait() {
let requested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let body = b"injected-binary-payload".to_vec();
let client = std::sync::Arc::new(DlClient {
body: body.clone(),
content_length: Some(body.len() as u64),
requested: requested.clone(),
});
let mut out = Vec::new();
let mut dl = Download::from_url("https://nonroutable.invalid/asset.bin");
dl.set_http_client(
Some(client),
#[cfg(feature = "async")]
None,
);
dl.download_to(&mut out).unwrap();
assert_eq!(out, body, "download_to streamed the injected client's body");
let urls = requested.lock().unwrap();
assert_eq!(
urls.len(),
1,
"exactly one GET went through the injected client"
);
assert_eq!(urls[0], "https://nonroutable.invalid/asset.bin");
}
#[test]
fn download_to_handles_injected_client_without_content_length() {
let requested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let body = b"no-length-body".to_vec();
let client = std::sync::Arc::new(DlClient {
body: body.clone(),
content_length: None,
requested: requested.clone(),
});
let totals = std::sync::Arc::new(std::sync::Mutex::new(Vec::<Option<u64>>::new()));
let sink = totals.clone();
let mut out = Vec::new();
let mut dl = Download::from_url("https://nonroutable.invalid/asset.bin");
dl.set_http_client(
Some(client),
#[cfg(feature = "async")]
None,
);
dl.progress_callback(move |_d, total| sink.lock().unwrap().push(total));
dl.download_to(&mut out).unwrap();
assert_eq!(
out, body,
"the full body is streamed even with no Content-Length"
);
let totals = totals.lock().unwrap();
assert!(
totals.iter().all(|t| t.is_none()),
"with no Content-Length the callback's total must be None, got {:?}",
totals
);
}
struct CaptureLogger;
static CAPTURE_LOGGER: CaptureLogger = CaptureLogger;
fn log_capture() -> &'static std::sync::Mutex<Vec<String>> {
static BUF: std::sync::OnceLock<std::sync::Mutex<Vec<String>>> = std::sync::OnceLock::new();
BUF.get_or_init(|| std::sync::Mutex::new(Vec::new()))
}
impl log::Log for CaptureLogger {
fn enabled(&self, _: &log::Metadata) -> bool {
true
}
fn log(&self, record: &log::Record) {
log_capture()
.lock()
.unwrap()
.push(format!("{}", record.args()));
}
fn flush(&self) {}
}
fn install_capture_logger() {
static INIT: std::sync::OnceLock<()> = std::sync::OnceLock::new();
INIT.get_or_init(|| {
let _ = log::set_logger(&CAPTURE_LOGGER);
log::set_max_level(log::LevelFilter::Warn);
});
}
#[test]
fn download_retry_warning_redacts_presigned_signature() {
use std::sync::atomic::AtomicU32;
install_capture_logger();
let sig = "abc123-secret-signature-value";
let cred = "AKIAREDACTTESTONLY";
let host = "s3-redact-retry-test.invalid";
let url = format!(
"https://{host}/app.tar.gz?X-Amz-Credential={cred}%2F20260101\
&X-Amz-Expires=300&X-Amz-Signature={sig}&X-Amz-SignedHeaders=host"
);
let attempts = std::sync::Arc::new(AtomicU32::new(0));
let client = std::sync::Arc::new(FlakyDlClient {
body: b"ok".to_vec(),
fail_times: AtomicU32::new(1),
attempts: attempts.clone(),
});
let mut out = Vec::new();
let mut dl = Download::from_url(url);
dl.set_http_client(
Some(client),
#[cfg(feature = "async")]
None,
);
dl.set_retries(
1,
std::time::Duration::from_millis(1),
std::time::Duration::from_millis(2),
);
dl.download_to(&mut out).unwrap();
let lines: Vec<String> = log_capture()
.lock()
.unwrap()
.iter()
.filter(|l| l.contains(host))
.cloned()
.collect();
assert!(
!lines.is_empty(),
"the retry closure should have logged a warning for {host}"
);
for line in &lines {
assert!(
!line.contains(sig),
"presigned signature leaked into the retry warning: {line}"
);
assert!(
!line.contains(cred),
"presigned credential leaked into the retry warning: {line}"
);
}
}
#[cfg(all(unix, feature = "archive-zip"))]
#[test]
fn extract_zip_masks_setuid_setgid_sticky_bits() {
use std::io::Write as _;
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let zip_path = tmp.path().join("archive.zip");
{
let file = fs::File::create(&zip_path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored)
.unix_permissions(0o4755);
zip.start_file("payload", opts).unwrap();
zip.write_all(b"#!/bin/sh\n").unwrap();
zip.finish().unwrap();
}
let out_dir = tmp.path().join("out");
fs::create_dir_all(&out_dir).unwrap();
let mut ex = Extract::from_source(&zip_path);
ex.archive(ArchiveKind::Zip);
ex.extract_into(&out_dir).unwrap();
let extracted = out_dir.join("payload");
let mode = fs::metadata(&extracted).unwrap().permissions().mode();
assert_eq!(
mode & 0o7000,
0,
"extracted file must carry no setuid/setgid/sticky bits, got mode {mode:o}"
);
assert_eq!(
mode & 0o777,
0o755,
"the ordinary rwx bits should be preserved, got mode {mode:o}"
);
}
#[test]
fn download_max_download_size_aborts_when_body_exceeds_cap() {
let body = vec![0u8; 4096];
let client = std::sync::Arc::new(DlClient {
body: body.clone(),
content_length: Some(body.len() as u64),
requested: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
});
let mut out = Vec::new();
let mut dl = Download::from_url("https://nonroutable.invalid/big.bin");
dl.set_http_client(
Some(client),
#[cfg(feature = "async")]
None,
);
dl.max_download_size(1024);
let res = dl.download_to(&mut out);
assert!(res.is_err(), "a body over the cap must error");
let msg = res.unwrap_err().to_string();
assert!(
msg.contains("max_download_size"),
"the error should name the cap: {msg}"
);
}
#[test]
fn download_max_download_size_allows_body_under_cap() {
let body = vec![7u8; 512];
let client = std::sync::Arc::new(DlClient {
body: body.clone(),
content_length: Some(body.len() as u64),
requested: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
});
let mut out = Vec::new();
let mut dl = Download::from_url("https://nonroutable.invalid/small.bin");
dl.set_http_client(
Some(client),
#[cfg(feature = "async")]
None,
);
dl.max_download_size(1024);
dl.download_to(&mut out).unwrap();
assert_eq!(out, body, "a body under the cap downloads in full");
}
#[cfg(feature = "async")]
struct DlAsyncResponse {
body: Vec<u8>,
headers: http_client::header::HeaderMap,
}
#[cfg(feature = "async")]
impl http_client::AsyncHttpResponse for DlAsyncResponse {
fn headers(&self) -> &http_client::header::HeaderMap {
&self.headers
}
fn text(self: Box<Self>) -> futures_util::future::BoxFuture<'static, Result<String>> {
Box::pin(async move { Ok(String::from_utf8_lossy(&self.body).into_owned()) })
}
fn bytes_stream(
self: Box<Self>,
) -> futures_util::stream::BoxStream<'static, Result<bytes::Bytes>> {
Box::pin(futures_util::stream::once(async move {
Ok(bytes::Bytes::from(self.body))
}))
}
}
#[cfg(feature = "async")]
struct DlAsyncClient {
body: Vec<u8>,
content_length: Option<u64>,
requested: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
}
#[cfg(feature = "async")]
impl http_client::AsyncHttpClient for DlAsyncClient {
fn get<'a>(
&'a self,
url: &'a str,
_headers: &'a http_client::header::HeaderMap,
_timeout: Option<std::time::Duration>,
) -> futures_util::future::BoxFuture<'a, Result<Box<dyn http_client::AsyncHttpResponse>>>
{
self.requested.lock().unwrap().push(url.to_string());
let mut headers = http_client::header::HeaderMap::new();
if let Some(len) = self.content_length {
headers.insert(
http_client::header::CONTENT_LENGTH,
len.to_string().parse().unwrap(),
);
}
let body = self.body.clone();
Box::pin(async move {
Ok(Box::new(DlAsyncResponse { body, headers })
as Box<dyn http_client::AsyncHttpResponse>)
})
}
}
#[cfg(feature = "async")]
#[tokio::test]
async fn download_to_async_uses_injected_async_client_through_the_trait() {
let requested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let body = b"async-injected-payload".to_vec();
let client = std::sync::Arc::new(DlAsyncClient {
body: body.clone(),
content_length: Some(body.len() as u64),
requested: requested.clone(),
});
let mut out = Vec::new();
let mut dl = Download::from_url("https://nonroutable.invalid/asset.bin");
dl.set_http_client(None, Some(client));
dl.download_to_async(&mut out).await.unwrap();
assert_eq!(
out, body,
"download_to_async streamed the injected client's body"
);
let urls = requested.lock().unwrap();
assert_eq!(
urls.len(),
1,
"exactly one async GET went through the injected client"
);
assert_eq!(urls[0], "https://nonroutable.invalid/asset.bin");
}
#[cfg(feature = "async")]
#[tokio::test]
async fn sync_and_async_injection_are_independent() {
let requested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let body = b"only-async".to_vec();
let async_client = std::sync::Arc::new(DlAsyncClient {
body: body.clone(),
content_length: Some(body.len() as u64),
requested: requested.clone(),
});
let mut dl = Download::from_url("https://nonroutable.invalid/asset.bin");
dl.set_http_client(None, Some(async_client));
assert!(
dl.client.is_none(),
"injecting an async client must not populate the sync client slot"
);
assert!(dl.async_client.is_some(), "the async slot is populated");
let mut out = Vec::new();
dl.download_to_async(&mut out).await.unwrap();
assert_eq!(out, body);
}
#[cfg(not(feature = "progress-bar"))]
#[test]
fn progress_callback_fires_without_progress_bar_feature() {
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
let body = "y".repeat(8_000);
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let served = body.clone();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
served.len(),
served
);
let _ = stream.write_all(resp.as_bytes());
});
let calls = Arc::new(Mutex::new(Vec::<(u64, Option<u64>)>::new()));
let sink = calls.clone();
let mut out = Vec::new();
Download::from_url(format!("http://{addr}/file"))
.show_download_progress(true)
.progress_callback(move |downloaded, total| {
sink.lock().unwrap().push((downloaded, total));
})
.download_to(&mut out)
.unwrap();
assert_eq!(out.len(), 8_000);
let calls = calls.lock().unwrap();
assert!(
!calls.is_empty(),
"progress_callback must fire even with progress-bar feature disabled"
);
assert!(
calls.iter().all(|(_, total)| *total == Some(8_000)),
"total should reflect Content-Length"
);
assert_eq!(
calls.last().unwrap().0,
8_000,
"final byte count should equal body length"
);
}
#[cfg(feature = "compression-tar-gz")]
#[test]
fn detect_plain_gz() {
assert_eq!(
ArchiveKind::Plain(Some(Compression::Gz)),
detect_archive(&PathBuf::from("Something.exe.gz")).unwrap()
);
}
#[cfg(not(feature = "compression-tar-gz"))]
#[test]
fn detect_plain_gz_without_feature_errors() {
assert!(matches!(
detect_archive(&PathBuf::from("Something.exe.gz")),
Err(Error::CompressionNotEnabled(_))
));
}
#[cfg(not(feature = "archive-tar"))]
#[test]
#[ignore]
fn detect_tar_gz() {
println!("WARNING: Please enable 'archive-tar' feature!");
}
#[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))]
#[test]
fn detect_tar_gz() {
assert_eq!(
ArchiveKind::Tar(Some(Compression::Gz)),
detect_archive(&PathBuf::from("Something.tar.gz")).unwrap()
);
}
#[cfg(all(feature = "archive-tar", not(feature = "compression-tar-gz")))]
#[test]
fn detect_tar_gz_without_compression_errors() {
assert!(matches!(
detect_archive(&PathBuf::from("Something.tar.gz")),
Err(Error::CompressionNotEnabled(_))
));
}
#[cfg(not(feature = "archive-tar"))]
#[test]
#[ignore]
fn detect_plain_tar() {
println!("WARNING: Please enable 'archive-tar' feature!");
}
#[cfg(feature = "archive-tar")]
#[test]
fn detect_plain_tar() {
assert_eq!(
ArchiveKind::Tar(None),
detect_archive(&PathBuf::from("Something.tar")).unwrap()
);
}
#[cfg(not(feature = "archive-zip"))]
#[test]
#[ignore]
fn detect_zip() {
println!("WARNING: Please enable 'archive-zip' feature!");
}
#[cfg(feature = "archive-zip")]
#[test]
fn detect_zip() {
assert_eq!(
ArchiveKind::Zip,
detect_archive(&PathBuf::from("Something.zip")).unwrap()
);
}
#[allow(dead_code)]
fn cmp_content<T: AsRef<Path>>(path: T, s: &str) {
let mut content = String::new();
let mut f = File::open(&path).unwrap();
f.read_to_string(&mut content).unwrap();
assert!(s == content);
}
#[cfg(not(feature = "compression-tar-gz"))]
#[test]
#[ignore]
fn unpack_plain_gzip() {
println!("WARNING: Please enable 'compression-tar-gz' feature!");
}
#[cfg(feature = "compression-tar-gz")]
#[test]
fn unpack_plain_gzip() {
let tmp_dir = tempfile::Builder::new()
.prefix("self_update_unpack_plain_gzip_src")
.tempdir()
.expect("tempdir fail");
let fp = tmp_dir.path().with_file_name("temp.gz");
let mut tmp_file = File::create(&fp).expect("temp file create fail");
let mut e = GzEncoder::new(&mut tmp_file, flate2::Compression::default());
e.write_all(b"This is a test!").expect("gz encode fail");
e.finish().expect("gz finish fail");
let out_tmp = tempfile::Builder::new()
.prefix("self_update_unpack_plain_gzip_outdir")
.tempdir()
.expect("tempdir fail");
let out_path = out_tmp.path();
Extract::from_source(&fp)
.extract_into(out_path)
.expect("extract fail");
let out_file = out_path.join("temp");
assert!(out_file.exists());
cmp_content(out_file, "This is a test!");
}
#[cfg(not(feature = "compression-tar-gz"))]
#[test]
#[ignore]
fn unpack_plain_gzip_double_ext() {
println!("WARNING: Please enable 'compression-tar-gz' feature!");
}
#[cfg(feature = "compression-tar-gz")]
#[test]
fn unpack_plain_gzip_double_ext() {
let tmp_dir = tempfile::Builder::new()
.prefix("self_update_unpack_plain_gzip_double_ext_src")
.tempdir()
.expect("tempdir fail");
let fp = tmp_dir.path().with_file_name("temp.txt.gz");
let mut tmp_file = File::create(&fp).expect("temp file create fail");
let mut e = GzEncoder::new(&mut tmp_file, flate2::Compression::default());
e.write_all(b"This is a test!").expect("gz encode fail");
e.finish().expect("gz finish fail");
let out_tmp = tempfile::Builder::new()
.prefix("self_update_unpack_plain_gzip_double_ext_outdir")
.tempdir()
.expect("tempdir fail");
let out_path = out_tmp.path();
Extract::from_source(&fp)
.extract_into(out_path)
.expect("extract fail");
let out_file = out_path.join("temp.txt");
assert!(out_file.exists());
cmp_content(out_file, "This is a test!");
}
#[cfg(not(all(feature = "archive-tar", feature = "compression-tar-gz")))]
#[test]
#[ignore]
fn unpack_tar_gzip() {
println!("WARNING: Please enable 'archive-tar compression-tar-gz' features!");
}
#[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))]
#[test]
fn unpack_tar_gzip() {
test_extract_into(
"self_update_unpack_tar_gzip_src",
"archive.tar.gz",
ArchiveKind::Tar(Some(Compression::Gz)),
);
}
#[cfg(not(feature = "compression-tar-gz"))]
#[test]
#[ignore]
fn unpack_file_plain_gzip() {
println!("WARNING: Please enable 'compression-tar-gz' feature!");
}
#[cfg(feature = "compression-tar-gz")]
#[test]
fn unpack_file_plain_gzip() {
let tmp_dir = tempfile::Builder::new()
.prefix("self_update_unpack_file_plain_gzip_src")
.tempdir()
.expect("tempdir fail");
let fp = tmp_dir.path().with_file_name("temp.gz");
let mut tmp_file = File::create(&fp).expect("temp file create fail");
let mut e = GzEncoder::new(&mut tmp_file, flate2::Compression::default());
e.write_all(b"This is a test!").expect("gz encode fail");
e.finish().expect("gz finish fail");
let out_tmp = tempfile::Builder::new()
.prefix("self_update_unpack_file_plain_gzip_outdir")
.tempdir()
.expect("tempdir fail");
let out_path = out_tmp.path();
Extract::from_source(&fp)
.extract_file(out_path, "renamed_file")
.expect("extract fail");
let out_file = out_path.join("renamed_file");
assert!(out_file.exists());
cmp_content(out_file, "This is a test!");
}
#[cfg(not(all(feature = "archive-tar", feature = "compression-tar-gz")))]
#[test]
#[ignore]
fn unpack_file_tar_gzip() {
println!("WARNING: Please enable 'archive-tar compression-tar-gz' features!");
}
#[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))]
#[test]
fn unpack_file_tar_gzip() {
test_extract_file(
"self_update_unpack_file_tar_gzip_src",
"archive.tar.gz",
ArchiveKind::Tar(Some(Compression::Gz)),
);
}
#[cfg(not(feature = "archive-zip"))]
#[test]
#[ignore]
fn unpack_zip() {
println!("WARNING: Please enable 'archive-zip' feature!");
}
#[cfg(feature = "archive-zip")]
#[test]
fn unpack_zip() {
test_extract_into(
"self_update_unpack_zip_src",
"archive.zip",
ArchiveKind::Zip,
);
}
#[cfg(not(feature = "archive-zip"))]
#[test]
#[ignore]
fn unpack_zip_file() {
println!("WARNING: Please enable 'archive-zip' feature!");
}
#[cfg(feature = "archive-zip")]
#[test]
fn unpack_zip_file() {
test_extract_file(
"self_update_unpack_zip_src",
"archive.zip",
ArchiveKind::Zip,
);
}
fn test_extract_into(tmpfile_prefix: &str, src_archive_path: &str, archive_kind: ArchiveKind) {
let tmp_dir = tempfile::Builder::new()
.prefix(tmpfile_prefix)
.tempdir()
.expect("Failed to create temp dir");
let tmp_path = tmp_dir.path();
let archive_file_path = tmp_path.join(src_archive_path);
let archive_file = File::create(&archive_file_path).expect("Failed to create archive file");
build_test_archive(archive_file, &archive_file_path, archive_kind);
let out_tmp = tempfile::Builder::new()
.prefix(&format!("{}_outdir", tmpfile_prefix))
.tempdir()
.expect("tempdir fail");
let out_path = out_tmp.path();
Extract::from_source(&archive_file_path)
.extract_into(out_path)
.expect("extract fail");
let out_file = out_path.join("temp.txt");
assert!(out_file.exists());
cmp_content(&out_file, "This is a test!");
let out_file = out_path.join("inner_archive/temp2.txt");
assert!(out_file.exists());
cmp_content(&out_file, "This is a second test!");
}
fn test_extract_file(tmpfile_prefix: &str, src_archive_path: &str, archive_kind: ArchiveKind) {
let tmp_dir = tempfile::Builder::new()
.prefix(tmpfile_prefix)
.tempdir()
.expect("Failed to create temp dir");
let tmp_path = tmp_dir.path();
let archive_file_path = tmp_path.join(src_archive_path);
let archive_file = File::create(&archive_file_path).expect("Failed to create archive file");
build_test_archive(archive_file, &archive_file_path, archive_kind);
let out_tmp = tempfile::Builder::new()
.prefix(&format!("{}_outdir", tmpfile_prefix))
.tempdir()
.expect("tempdir fail");
let out_path = out_tmp.path();
Extract::from_source(&archive_file_path)
.extract_file(out_path, "temp.txt")
.expect("extract fail");
let out_file = out_path.join("temp.txt");
assert!(out_file.exists());
cmp_content(&out_file, "This is a test!");
Extract::from_source(&archive_file_path)
.extract_file(out_path, "inner_archive/temp2.txt")
.expect("extract fail");
let out_file = out_path.join("inner_archive/temp2.txt");
assert!(out_file.exists());
cmp_content(&out_file, "This is a second test!");
}
#[cfg(feature = "archive-zip")]
#[test]
fn extract_into_rejects_zip_slip() {
let staging = tempfile::tempdir().expect("tempdir");
let archive_path = staging.path().join("evil.zip");
{
let f = File::create(&archive_path).expect("create zip");
let mut zip = zip::ZipWriter::new(f);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
zip.start_file("../escape.txt", options).expect("start");
zip.write_all(b"pwned").expect("write");
zip.finish().expect("finish");
}
let out_tmp = tempfile::tempdir().expect("tempdir");
let out_dir = out_tmp.path().join("into");
fs::create_dir_all(&out_dir).expect("mkdir");
let res = Extract::from_source(&archive_path).extract_into(&out_dir);
assert!(res.is_err(), "a zip-slip entry must be rejected");
assert!(
!out_tmp.path().join("escape.txt").exists(),
"nothing must be written outside the extraction dir"
);
}
#[cfg(all(feature = "archive-zip", unix))]
#[test]
fn extract_into_preserves_zip_unix_mode() {
use std::os::unix::fs::PermissionsExt;
let staging = tempfile::tempdir().expect("tempdir");
let archive_path = staging.path().join("app.zip");
{
let f = File::create(&archive_path).expect("create zip");
let mut zip = zip::ZipWriter::new(f);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored)
.unix_permissions(0o755);
zip.start_file("app", options).expect("start");
zip.write_all(b"#!/bin/sh\n").expect("write");
zip.finish().expect("finish");
}
let out_tmp = tempfile::tempdir().expect("tempdir");
Extract::from_source(&archive_path)
.extract_into(out_tmp.path())
.expect("extract");
let mode = fs::metadata(out_tmp.path().join("app"))
.expect("stat")
.permissions()
.mode();
assert!(
mode & 0o111 != 0,
"the executable bit must be preserved, got mode {:o}",
mode
);
}
fn build_test_archive<T: AsRef<Path>>(
mut archive_file: fs::File,
archive_file_path: T,
archive_kind: ArchiveKind,
) {
let archive_file_path = archive_file_path.as_ref();
match archive_kind {
#[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))]
ArchiveKind::Tar(Some(Compression::Gz)) => {
let tmp_tar_path = archive_file_path
.parent()
.expect("Missing archive file path parent")
.join("tar_contents");
let tmp_tar_inner_path = tmp_tar_path.join("inner_archive");
fs::create_dir_all(&tmp_tar_inner_path).expect("Failed to create temp tar path");
let fp = tmp_tar_path.join("temp.txt");
let mut tmp_file = File::create(fp).expect("temp file create fail");
tmp_file.write_all(b"This is a test!").unwrap();
let fp = tmp_tar_inner_path.join("temp2.txt");
let mut tmp_file = File::create(fp).expect("temp file create fail");
tmp_file.write_all(b"This is a second test!").unwrap();
let mut ar = tar::Builder::new(vec![]);
ar.append_dir_all(".", &tmp_tar_path)
.expect("tar append dir all fail");
let tar_writer = ar.into_inner().expect("failed getting tar writer");
let mut e = GzEncoder::new(&mut archive_file, flate2::Compression::default());
io::copy(&mut tar_writer.as_slice(), &mut e)
.expect("failed writing from tar archive to gz encoder");
e.finish().expect("gz finish fail");
}
#[cfg(feature = "archive-zip")]
ArchiveKind::Zip => {
let mut zip = zip::ZipWriter::new(archive_file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
zip.start_file("temp.txt", options)
.expect("failed starting zip file");
zip.write_all(b"This is a test!")
.expect("failed writing to zip");
zip.start_file("inner_archive/temp2.txt", options)
.expect("failed starting second zip file");
zip.write_all(b"This is a second test!")
.expect("failed writing to second zip");
zip.finish().expect("failed finishing zip");
}
_ => {
unimplemented!("{:?} not handled", archive_kind);
}
}
}
#[test]
fn extract_file_plain_no_file_name_routes_to_internal_without_source() {
use std::error::Error as _;
let src_dir = tempfile::tempdir().expect("tempdir");
let src = src_dir.path().join("payload.bin");
fs::write(&src, b"hello").expect("write source");
let out_dir = tempfile::tempdir().expect("out tempdir");
let err = Extract::from_source(&src)
.archive(ArchiveKind::Plain(None))
.extract_file(out_dir.path(), "..")
.expect_err("a file_to_extract with no file name must error");
match err {
Error::Internal {
ref message,
ref source,
} => {
assert!(
source.is_none(),
"the no-file-name invariant carries no source, got {:?}",
source
);
assert!(
message.contains("file-name"),
"message must describe the missing file name, got: {}",
message
);
}
other => panic!("expected Error::Internal, got {:?}", other),
}
let err = Extract::from_source(&src)
.archive(ArchiveKind::Plain(None))
.extract_file(out_dir.path(), "..")
.unwrap_err();
assert!(err.source().is_none());
}
#[cfg(all(feature = "archive-tar", feature = "compression-tar-gz"))]
#[test]
fn extract_file_tar_missing_path_routes_to_internal_without_source() {
let tmp_dir = tempfile::Builder::new()
.prefix("self_update_ws3_tar_missing_src")
.tempdir()
.expect("tempdir");
let archive_file_path = tmp_dir.path().join("archive.tar.gz");
let archive_file = File::create(&archive_file_path).expect("create archive");
build_test_archive(
archive_file,
&archive_file_path,
ArchiveKind::Tar(Some(Compression::Gz)),
);
let out_tmp = tempfile::tempdir().expect("out tempdir");
let err = Extract::from_source(&archive_file_path)
.extract_file(out_tmp.path(), "does/not/exist.txt")
.expect_err("a path absent from the tar must error");
match err {
Error::Internal {
ref message,
ref source,
} => {
assert!(
source.is_none(),
"the path-not-found invariant carries no source, got {:?}",
source
);
assert!(
message.contains("Could not find the required path"),
"message must describe the missing archive path, got: {}",
message
);
}
other => panic!("expected Error::Internal, got {:?}", other),
}
}
#[cfg(all(feature = "archive-zip", unix))]
#[test]
fn extract_file_zip_non_utf8_path_routes_to_internal_without_source() {
use std::os::unix::ffi::OsStrExt;
let tmp_dir = tempfile::Builder::new()
.prefix("self_update_ws3_zip_nonutf8_src")
.tempdir()
.expect("tempdir");
let archive_file_path = tmp_dir.path().join("archive.zip");
let archive_file = File::create(&archive_file_path).expect("create archive");
build_test_archive(archive_file, &archive_file_path, ArchiveKind::Zip);
let out_tmp = tempfile::tempdir().expect("out tempdir");
let bad = std::ffi::OsStr::from_bytes(b"bad\xFFname");
let err = Extract::from_source(&archive_file_path)
.extract_file(out_tmp.path(), bad)
.expect_err("a non-UTF-8 zip path must error");
match err {
Error::Internal {
ref message,
ref source,
} => {
assert!(
source.is_none(),
"the non-UTF-8-path invariant carries no source, got {:?}",
source
);
assert!(
message.contains("non-UTF-8 path"),
"message must describe the non-UTF-8 path, got: {}",
message
);
}
other => panic!("expected Error::Internal, got {:?}", other),
}
}
}