use crate::{
auth::get_token,
errors::{AuthError, PublishError},
registry::{api_url, get_project_id},
};
use ignore::{WalkBuilder, WalkState};
use log::debug;
use path_slash::{PathBufExt as _, PathExt as _};
use regex::Regex;
use reqwest::{
Client, StatusCode,
header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue},
multipart::{Form, Part},
};
use std::{
fs,
io::{Read as _, Write as _},
path::{Path, PathBuf},
sync::mpsc,
};
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
pub type Result<T> = std::result::Result<T, PublishError>;
pub async fn push_version(
dependency_name: &str,
dependency_version: &str,
root_directory_path: impl AsRef<Path>,
files_to_copy: &[PathBuf],
dry_run: bool,
) -> Result<Option<PathBuf>> {
let file_name =
root_directory_path.as_ref().file_name().expect("path should have a last component");
let zip_archive = match zip_file(&root_directory_path, files_to_copy, file_name) {
Ok(zip) => zip,
Err(err) => {
return Err(err);
}
};
debug!(root:? = root_directory_path.as_ref(), zip_archive:?; "created zip file from folder");
if dry_run {
debug!(zip_archive:?; "push dry run, zip file created but not uploading");
return Ok(Some(PathBuf::from_slash_lossy(&zip_archive)));
}
if let Err(error) = push_to_repo(&zip_archive, dependency_name, dependency_version).await {
let _ = fs::remove_file(&zip_archive);
debug!(zip_archive:?; "zip file deleted");
return Err(error);
}
let _ = fs::remove_file(&zip_archive);
debug!(zip_archive:?; "zip file deleted");
Ok(None)
}
pub fn validate_name(name: &str) -> Result<()> {
let regex = Regex::new(r"^[@|a-z0-9][a-z0-9-]*[a-z0-9]$").expect("regex should compile");
if !regex.is_match(name) {
debug!(name; "package name contains unauthorized characters");
return Err(PublishError::InvalidName);
}
if !(3..=100).contains(&name.len()) {
debug!(name; "package name is too short or too long");
return Err(PublishError::InvalidName);
}
Ok(())
}
pub fn validate_version(version: &str) -> Result<()> {
if version.is_empty() {
return Err(PublishError::EmptyVersion);
}
Ok(())
}
pub fn zip_file(
root_directory_path: impl AsRef<Path>,
files_to_copy: &[PathBuf],
file_name: impl Into<PathBuf>,
) -> Result<PathBuf> {
let mut file_name: PathBuf = file_name.into();
file_name.set_extension("zip");
let zip_file_path = root_directory_path.as_ref().join(file_name);
let file = fs::File::create(&zip_file_path)
.map_err(|e| PublishError::IOError { path: zip_file_path.clone(), source: e })?;
debug!(path:? = zip_file_path; "zip file handle created");
let mut zip = ZipWriter::new(file);
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
if files_to_copy.is_empty() {
return Err(PublishError::NoFiles);
}
let mut added_dirs = Vec::new();
for file_path in files_to_copy {
let path = file_path.as_path();
if !path.is_file() {
debug!(path:?; "skipping non-file entry");
continue;
}
let relative_file_path = file_path.strip_prefix(root_directory_path.as_ref())?;
debug!(relative_path:? = relative_file_path; "resolved relative file path for zip archive");
if let Some(parent) = relative_file_path.parent() &&
!parent.as_os_str().is_empty() &&
!added_dirs.contains(&parent)
{
zip.add_directory(parent.to_slash_lossy(), options)?;
debug!(folder:? = parent; "added parent directory in zip archive");
added_dirs.push(parent);
}
let mut f = fs::File::open(file_path.clone())
.map_err(|e| PublishError::IOError { path: file_path.clone(), source: e })?;
let mut buffer = Vec::new();
zip.start_file(relative_file_path.to_slash_lossy(), options)?;
f.read_to_end(&mut buffer)
.map_err(|e| PublishError::IOError { path: file_path.clone(), source: e })?;
zip.write_all(&buffer)
.map_err(|e| PublishError::IOError { path: zip_file_path.clone(), source: e })?;
debug!(file:? = path; "file added to zip archive");
}
zip.finish()?;
debug!(path:? = zip_file_path; "zip archive written");
Ok(zip_file_path)
}
pub fn filter_ignored_files(root_directory_path: impl AsRef<Path>) -> Vec<PathBuf> {
let (tx, rx) = mpsc::channel::<PathBuf>();
let walker = WalkBuilder::new(root_directory_path)
.add_custom_ignore_filename(".soldeerignore")
.hidden(false)
.filter_entry(|entry| {
!(entry.path().is_dir() && entry.path().file_name().unwrap_or_default() == ".git")
})
.build_parallel();
walker.run(|| {
let tx = tx.clone();
Box::new(move |result| {
let Ok(entry) = result else {
return WalkState::Continue;
};
let path = entry.path();
if path.is_dir() {
debug!(path:?; "ignoring dir entry");
return WalkState::Continue;
}
debug!(path:?; "adding file to list");
tx.send(path.to_path_buf())
.expect("Channel receiver should never be dropped before end of function scope");
WalkState::Continue
})
});
drop(tx);
let mut files = Vec::new();
while let Ok(path) = rx.recv() {
files.push(path);
}
files
}
async fn push_to_repo(
zip_file: &Path,
dependency_name: &str,
dependency_version: &str,
) -> Result<()> {
debug!(zip_file:?; "uploading zip archive to registry");
let token = get_token()?;
let client = Client::new();
let url = api_url("v1", "revision/upload", &[]);
let mut headers: HeaderMap = HeaderMap::new();
let header_string = format!("Bearer {token}");
let header_value = HeaderValue::from_str(&header_string);
headers.insert(AUTHORIZATION, header_value.expect("Could not set auth header"));
let file_fs = fs::read(zip_file)
.map_err(|e| PublishError::IOError { path: zip_file.to_path_buf(), source: e })?;
let mut part = Part::bytes(file_fs).file_name(
zip_file
.file_name()
.expect("path should have a last component")
.to_string_lossy()
.into_owned(),
);
part = part.mime_str("application/zip").expect("Could not set mime type");
let project_id = get_project_id(dependency_name).await?;
debug!(project_id; "project ID fetched from registry");
let form = Form::new()
.text("project_id", project_id)
.text("revision", dependency_version.to_string())
.part("zip_name", part);
headers.insert(
CONTENT_TYPE,
HeaderValue::from_str(&("multipart/form-data; boundary=".to_owned() + form.boundary()))
.expect("Could not set content type"),
);
let response = client.post(url).headers(headers.clone()).multipart(form).send().await?;
match response.status() {
StatusCode::OK => Ok(()),
StatusCode::NO_CONTENT => Err(PublishError::ProjectNotFound),
StatusCode::ALREADY_REPORTED => Err(PublishError::AlreadyExists),
StatusCode::UNAUTHORIZED => Err(PublishError::AuthError(AuthError::InvalidCredentials)),
StatusCode::PAYLOAD_TOO_LARGE => Err(PublishError::PayloadTooLarge),
s if s.is_server_error() || s.is_client_error() => Err(PublishError::HttpError(
response.error_for_status().expect_err("result should be an error"),
)),
_ => Err(PublishError::UnknownError),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::download::unzip_file;
use std::fs;
use testdir::testdir;
#[test]
fn test_validate_name() {
assert!(validate_name("foo").is_ok());
assert!(validate_name("test").is_ok());
assert!(validate_name("test-123").is_ok());
assert!(validate_name("@test-123").is_ok());
assert!(validate_name("t").is_err());
assert!(validate_name("te").is_err());
assert!(validate_name("@t").is_err());
assert!(validate_name("test@123").is_err());
assert!(validate_name("test-123-").is_err());
assert!(validate_name("foo.bar").is_err());
assert!(validate_name("mypäckage").is_err());
assert!(validate_name(&"a".repeat(101)).is_err());
}
#[test]
fn test_empty_version() {
assert!(validate_version("").is_err());
}
#[test]
fn test_filter_files_to_copy() {
let dir = testdir!();
fs::write(
dir.join(".soldeerignore"),
"*.toml\n!/broadcast\n/broadcast/31337/\n/broadcast/*/dry_run/\n",
)
.unwrap();
let mut ignored = Vec::new();
let mut included = vec![dir.join(".soldeerignore")];
fs::create_dir(dir.join("random_dir")).unwrap();
fs::create_dir(dir.join("broadcast")).unwrap();
fs::create_dir(dir.join("broadcast/31337")).unwrap();
fs::create_dir(dir.join("broadcast/random_dir_in_broadcast")).unwrap();
fs::create_dir(dir.join("broadcast/random_dir_in_broadcast/dry_run")).unwrap();
ignored.push(dir.join("random_dir/random.toml"));
fs::write(ignored.last().unwrap(), "ignored").unwrap();
included.push(dir.join("random_dir/random.zip"));
fs::write(included.last().unwrap(), "included").unwrap();
ignored.push(dir.join("broadcast/random.toml"));
fs::write(ignored.last().unwrap(), "ignored").unwrap();
included.push(dir.join("broadcast/random.zip"));
fs::write(included.last().unwrap(), "included").unwrap();
ignored.push(dir.join("broadcast/31337/random.toml"));
fs::write(ignored.last().unwrap(), "ignored").unwrap();
ignored.push(dir.join("broadcast/31337/random.zip"));
fs::write(ignored.last().unwrap(), "ignored").unwrap();
included.push(dir.join("broadcast/random_dir_in_broadcast/random.zip"));
fs::write(included.last().unwrap(), "included").unwrap();
ignored.push(dir.join("broadcast/random_dir_in_broadcast/random.toml"));
fs::write(ignored.last().unwrap(), "ignored").unwrap();
ignored.push(dir.join("broadcast/random_dir_in_broadcast/dry_run/zip"));
fs::write(ignored.last().unwrap(), "ignored").unwrap();
ignored.push(dir.join("broadcast/random_dir_in_broadcast/dry_run/toml"));
fs::write(ignored.last().unwrap(), "ignored").unwrap();
let res = filter_ignored_files(&dir);
assert_eq!(res.len(), included.len());
for r in res {
assert!(included.contains(&r));
}
}
#[tokio::test]
async fn test_zip_file() {
let dir = testdir!().join("test_zip");
fs::create_dir(&dir).unwrap();
let mut files = Vec::new();
files.push(dir.join("a.txt"));
fs::write(files.last().unwrap(), "test").unwrap();
files.push(dir.join("b.txt"));
fs::write(files.last().unwrap(), "test").unwrap();
fs::create_dir(dir.join("sub")).unwrap();
files.push(dir.join("sub/c.txt"));
fs::write(files.last().unwrap(), "test").unwrap();
fs::create_dir(dir.join("sub/sub")).unwrap();
files.push(dir.join("sub/sub/d.txt"));
fs::write(files.last().unwrap(), "test").unwrap();
fs::create_dir(dir.join("empty")).unwrap();
let res = zip_file(&dir, &files, "test");
assert!(res.is_ok(), "{res:?}");
fs::copy(dir.join("test.zip"), testdir!().join("test.zip")).unwrap();
fs::remove_dir_all(&dir).unwrap();
fs::create_dir(&dir).unwrap();
unzip_file(testdir!().join("test.zip"), &dir).await.unwrap();
for f in files {
assert!(f.exists());
}
}
}