pub mod registry_build;
use std::io::Write;
use sha2::{Digest, Sha256};
use crate::{
catalog::{Catalog, CatalogRelease},
error::{LogoxideError, Result},
manifest::Package,
storage::Storage,
};
pub fn sha256(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
format!("sha256:{hex}")
}
pub fn archive_name(info: &Package) -> String {
format!("{}-{}.tar.gz", info.id, info.version)
}
pub fn pack(storage: &dyn Storage, info: &Package) -> Result<Vec<u8>> {
let top = format!("{}-{}", info.id, info.version);
let fail = |e: std::io::Error| LogoxideError::Io {
path: top.clone(),
message: format!("packing: {e}"),
source: Some(Box::new(e)),
};
let gzip = flate2::GzBuilder::new()
.mtime(0)
.write(Vec::new(), flate2::Compression::best());
let mut tar = tar::Builder::new(gzip);
tar.mode(tar::HeaderMode::Deterministic);
for path in crate::package::declared_files(storage)? {
let bytes = storage.read(&path)?;
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Regular);
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_mtime(0);
header.set_uid(0);
header.set_gid(0);
tar.append_data(&mut header, format!("{top}/{path}"), bytes.as_slice())
.map_err(fail)?;
}
let mut gzip = tar.into_inner().map_err(fail)?;
gzip.flush().map_err(fail)?;
gzip.finish().map_err(fail)
}
pub fn add_release(
text: Option<&str>,
name: &str,
info: &Package,
path: Option<&str>,
release: &CatalogRelease,
) -> Result<Option<String>> {
let mut doc: toml_edit::DocumentMut = match text {
Some(text) => text
.parse()
.map_err(|e| LogoxideError::Catalog(format!("catalog.toml: {e}")))?,
None => {
let mut doc = toml_edit::DocumentMut::new();
let format = i64::from(crate::catalog::CATALOG_FORMAT);
doc.insert("format", toml_edit::value(format));
doc.insert("name", toml_edit::value(name));
doc
}
};
let packages = doc
.entry("package")
.or_insert(toml_edit::Item::ArrayOfTables(
toml_edit::ArrayOfTables::new(),
))
.as_array_of_tables_mut()
.ok_or_else(|| LogoxideError::Catalog(String::from("`package` isn't [[package]]")))?;
let index =
(packages.iter()).position(|t| t.get("id").and_then(|i| i.as_str()) == Some(&info.id));
let index = match index {
Some(index) => index,
None => {
packages.push(toml_edit::Table::new());
packages.len() - 1
}
};
let Some(table) = packages.get_mut(index) else {
return Err(LogoxideError::Catalog(format!("{}: no entry", info.id)));
};
let mut releases = match table.remove("release") {
Some(item) => item.into_array_of_tables().map_err(|_| {
LogoxideError::Catalog(format!("{}: `release` isn't [[package.release]]", info.id))
})?,
None => toml_edit::ArrayOfTables::new(),
};
let version = info.version.to_string();
let mut newest = true;
for existing in releases.iter() {
let other = existing
.get("version")
.and_then(|v| v.as_str())
.unwrap_or_default();
if other == version {
return match existing.get("sha256").and_then(|s| s.as_str()) == Some(&release.sha256) {
true => Ok(None),
false => Err(LogoxideError::Catalog(format!(
"{} {version} is published already, as another archive: a published version \
never changes, so release a new one",
info.id
))),
};
}
if other
.parse::<semver::Version>()
.is_ok_and(|v| v > info.version)
{
newest = false;
}
}
if newest || table.is_empty() {
let mut fresh = Catalog::entry_table(info, "");
fresh.remove("git");
if let Some(path) = path.or_else(|| table.get("path").and_then(|p| p.as_str())) {
fresh.insert("path", toml_edit::value(path));
}
*table = fresh;
}
let mut added = toml_edit::Table::new();
added.insert("version", toml_edit::value(&version));
added.insert("url", toml_edit::value(&release.url));
if !release.mirrors.is_empty() {
let mirrors: toml_edit::Array = release.mirrors.iter().collect();
added.insert("mirrors", toml_edit::value(mirrors));
}
added.insert("sha256", toml_edit::value(&release.sha256));
if let Some(checksum) = &release.checksum {
added.insert("checksum", toml_edit::value(checksum));
}
if let Some(size) = release.size {
added.insert("size", toml_edit::value(size as i64));
}
releases.push(added);
table.insert("release", toml_edit::Item::ArrayOfTables(releases));
Ok(Some(doc.to_string()))
}
pub fn yank(text: &str, id: &str, version: &str, yanked: bool) -> Result<String> {
let mut doc: toml_edit::DocumentMut = text
.parse()
.map_err(|e| LogoxideError::Catalog(format!("catalog.toml: {e}")))?;
let release = (doc
.get_mut("package")
.and_then(|p| p.as_array_of_tables_mut()))
.and_then(|packages| {
(packages.iter_mut()).find(|t| t.get("id").and_then(|i| i.as_str()) == Some(id))
})
.and_then(|package| package.get_mut("release"))
.and_then(|r| r.as_array_of_tables_mut())
.and_then(|releases| {
(releases.iter_mut()).find(|r| r.get("version").and_then(|v| v.as_str()) == Some(version))
})
.ok_or_else(|| LogoxideError::Catalog(format!("no release {id} {version} here")))?;
match yanked {
true => {
release.insert("yanked", toml_edit::value(true));
}
false => {
release.remove("yanked");
}
}
Ok(doc.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::MemoryStorage;
fn package(version: &str) -> (MemoryStorage, Package) {
let manifest = format!(
"[package]\nid = \"romans\"\nname = \"Romans\"\nabbreviation = \"ROM\"\n\
version = \"{version}\"\nlanguage = \"en\"\nkind = \"book\"\n\
[formats]\nepub = {{ path = \"epub/\" }}\n"
);
let storage = MemoryStorage::new();
storage.write("logoxide.toml", manifest.as_bytes()).unwrap();
storage
.write("epub/romans.epub", b"not really an epub")
.unwrap();
storage
.write("notes.md", b"a reader's, not the package's")
.unwrap();
let info = crate::manifest::Manifest::parse(&manifest)
.unwrap()
.package
.unwrap();
(storage, info)
}
#[test]
fn archives_are_the_same_bytes_and_hold_only_the_package() {
let (storage, info) = package("1.0.0");
let a = pack(&storage, &info).unwrap();
let b = pack(&storage, &info).unwrap();
assert_eq!(sha256(&a), sha256(&b));
let unpacked = MemoryStorage::new();
crate::net::archive::unpack(&a, &unpacked).unwrap();
let mut files = unpacked.walk("").unwrap();
files.sort();
assert_eq!(files, ["epub/romans.epub", "logoxide.toml"]);
assert_eq!(
crate::package::checksum(&unpacked).unwrap(),
crate::package::checksum(&storage).unwrap()
);
}
#[test]
fn releases_are_added_once_and_never_changed() {
let release = |version: &str, sha: &str| CatalogRelease {
version: version.into(),
url: format!("romans/romans-{version}.tar.gz"),
mirrors: vec![],
sha256: sha.into(),
checksum: Some("sha256:00".into()),
size: Some(10),
yanked: false,
};
let (_, one) = package("1.0.0");
let (_, two) = package("1.1.0");
let text = add_release(
None,
"mine",
&one,
Some("books/romans"),
&release("1.0.0", "sha256:aa"),
)
.unwrap()
.unwrap();
let text = add_release(
Some(&text),
"mine",
&two,
None,
&release("1.1.0", "sha256:bb"),
)
.unwrap()
.unwrap();
assert!(
add_release(
Some(&text),
"mine",
&two,
None,
&release("1.1.0", "sha256:bb")
)
.unwrap()
.is_none()
);
assert!(
add_release(
Some(&text),
"mine",
&two,
None,
&release("1.1.0", "sha256:cc")
)
.is_err()
);
let catalog = Catalog::parse("https://example.org/r/catalog.toml", &text).unwrap();
assert_eq!(catalog.name, "mine");
let entry = catalog.find("books/romans").unwrap().unwrap();
assert_eq!(entry.version.as_deref(), Some("1.1.0"));
assert_eq!(
entry.source,
crate::catalog::CatalogSource::Registry {
catalog: "https://example.org/r/catalog.toml".into()
}
);
let newest = entry.release(None).unwrap();
assert_eq!(
newest.url,
"https://example.org/r/romans/romans-1.1.0.tar.gz"
);
let req: semver::VersionReq = "~1.0".parse().unwrap();
assert_eq!(entry.release(Some(&req)).unwrap().version, "1.0.0");
let yanked = yank(&text, "romans", "1.1.0", true).unwrap();
let catalog = Catalog::parse("catalog.toml", &yanked).unwrap();
assert_eq!(catalog.entries[0].release(None).unwrap().version, "1.0.0");
let back = yank(&yanked, "romans", "1.1.0", false).unwrap();
assert_eq!(back, text);
assert!(yank(&text, "romans", "9.0.0", true).is_err());
}
}