init: first cut.

library only. tests provide coverage of path for local file inclusion for a package build.
This commit is contained in:
elliot speck 2024-09-30 03:56:26 +10:00
parent ad532a2818
commit 0037bca017
Signed by: arcayr
SSH key fingerprint: SHA256:ACNNWlqwQA5pfEvX1dnTlr8r4fdg1taXA0lae2FSjto
21 changed files with 3788 additions and 2 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
/.vscode

2609
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

3
Cargo.toml Normal file
View file

@ -0,0 +1,3 @@
[workspace]
resolver = "2"
members = ["crates/*"]

24
crates/ia/Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "ia"
version = "0.1.0"
edition = "2021"
[dependencies]
base16ct = { version = "0.2.0", features = ["alloc", "std"] }
bytes = "1.7.2"
cap-std = "3.2.0"
cap-tempfile = "3.3.0"
copy_on_write = "0.1.3"
decompress = "0.6.0"
digest = { version = "0.10.7", features = ["alloc", "std"] }
hex = "0.4.3"
reqwest = { version = "0.12.7", features = ["blocking"] }
serde = { version = "1.0.210", features = ["derive"] }
sha2 = "0.10.8"
sha3 = "0.10.8"
strum = { version = "0.26.3", features = ["derive"] }
tar = "0.4.42"
thiserror = "1.0.63"
toml = "0.8.19"
tracing = "0.1.40"
url = "2.5.2"

View file

@ -0,0 +1,81 @@
use std::{io::Write, path::PathBuf};
use cap_std::ambient_authority;
use cap_tempfile::TempDir;
use tracing::info;
use crate::{error, phase, Dir, File, Manifest};
/// a build pipeline manager that handles creation of the build
/// environment, execution of each phase in turn, and production of the final
/// build output in the desired format.
pub struct Composer {
manifest: Manifest,
composition_prefix: Dir,
source_files: Vec<File>,
}
impl Composer {
/// creates a new composer from a parsed manifest.
/// this function creates a temporary directory backing the composition.
/// when the composer goes out of scope, the temporary direcytory and all
/// of its contents are removed from disk.
pub fn new(manifest: Manifest) -> Result<Self, error::Composer> {
info!("creating new composer for package '{0}'…", manifest.name);
let composition_prefix = TempDir::new(ambient_authority())
.map(|d| {
d.into_dir()
.map_err(|_| error::Composer::IO(error::IO::TempDirCreate))
})
.map_err(|_| error::Composer::IO(error::IO::TempDirCreate))??;
let mut prefix_manifest_file = composition_prefix
.create("manifest.toml")
.map(File)
.map_err(|_| error::Composer::IO(error::IO::TempCreate))?;
let manifest_content = toml::to_string(&manifest)
.map_err(|_| error::Composer::Fetch(error::Fetch::ManifestParse))?;
info!("copying manifest file to composition directory…");
prefix_manifest_file
.write(manifest_content.as_bytes())
.and_then(|_| prefix_manifest_file.flush())
.map_err(|_| error::Composer::IO(error::IO::TempWrite))?;
Ok(Self {
manifest,
source_files: vec![],
composition_prefix: Dir(composition_prefix),
})
}
/// executes the fetch phase of the composition.
pub fn fetch_phase(self) -> Result<Self, error::Composer> {
info!("executing fetch phase for '{0}'…", self.manifest.name);
let fetch_phase = phase::fetch::Fetch::new(
self.manifest.sources.iter().collect(),
&self.composition_prefix,
)
.map_err(error::Composer::Fetch)?;
let source_files = fetch_phase.fetch().map_err(error::Composer::Fetch)?;
Ok(Self {
manifest: self.manifest,
source_files,
composition_prefix: self.composition_prefix,
})
}
/// returns an array of all files fetched in the fetch phase.
pub fn fetched_source_files(&self) -> &[File] {
&self.source_files
}
/// executes the full composition pipeline and returns the path to the
/// final packaged output.
pub fn compose(&self) -> Result<PathBuf, error::Composer> {
todo!()
}
}

102
crates/ia/src/error.rs Normal file
View file

@ -0,0 +1,102 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error<T: AsRef<dyn std::error::Error>> {
#[error("manifest error: {0}")]
Manifest(T),
#[error("hash error: {0}")]
Hash(T),
#[error("source error: {0}")]
Source(T),
#[error("fetch phase error: {0}")]
Fetch(T),
}
#[derive(Debug, Error)]
pub enum File {
#[error("unsupported file type")]
UnsupportedType(String),
#[error("io error: {0}")]
IO(IO),
}
#[derive(Debug, Error)]
pub enum Fetch {
#[error("unable to parse manifest file")]
ManifestParse,
#[error("no fetcher implemented for scheme '{0}'")]
UnimplementedScheme(String),
#[error("source url '{0}' invalid")]
InvalidSourceUrl(String),
#[error("io error: {0}")]
IO(IO),
#[error("no local working directory identified")]
NoCwd,
}
#[derive(Debug, Error)]
pub enum Source {
#[error("source url does not exist: {0}")]
Nonexistent(String),
#[error("source url does not possess a file base name: {0}")]
NoBaseName(String),
#[error("source url is an absolute path: {0}")]
AbsolutePath(String), // due to sandboxing, this error should never occur.
// any function returning this error must panic.
#[error("permission denied attempting to access source url: {0}")]
PermissionDenied(String),
#[error("unable to parse source url: {0}")]
Parse(String),
}
#[derive(Debug, Error)]
pub enum Manifest {
#[error("unable to parse manifest")]
Parse,
}
#[derive(Debug, Error)]
pub enum Hash {
#[error("unable to parse hash: {0}")]
Parse(String),
#[error("invalid hash: {0}")]
Invalid(String),
#[error("invalid algorithm: {0}")]
Algorithm(String),
#[error("internal error")]
Internal,
}
#[derive(Debug, Error)]
pub enum IO {
#[error("could not create file: {0}")]
Create(String),
#[error("could not read file")]
Read,
#[error("could not read file: {0}")]
ReadNamed(String),
#[error("could not write file: {0}")]
WriteNamed(String),
#[error("could not copy file")]
Copy,
#[error("could not create reflink: {0}:{1}")]
Reflink(String, String),
#[error("no unpacker found for archive: {0}")]
NoUnpacker(String),
#[error("could not create temporary file")]
TempCreate,
#[error("could not create temporary directory")]
TempDirCreate,
#[error("could not read temporary file")]
TempRead,
#[error("could not write to temporary file")]
TempWrite,
}
#[derive(Debug, Error)]
pub enum Composer {
#[error("fetch phase error: {0}")]
Fetch(Fetch),
#[error("io error: {0}")]
IO(IO),
}

140
crates/ia/src/hash.rs Normal file
View file

@ -0,0 +1,140 @@
//! an ia hash is made up of two parts: the hash algorithm and the hash itself.
//! this is simply to allow forward-compatibility.
//! hashes can be deserialised from strings representing the format `algorithm:value`.
use crate::error;
use digest::{Digest, DynDigest};
use serde::{
de::Visitor,
Deserialize, Deserializer, Serialize, Serializer,
};
use std::{
fmt::Display,
io::Write,
str::FromStr,
};
/// a hash algorithm supported by ia.
#[derive(
strum::Display, strum::EnumString, Clone, Debug, Deserialize, Serialize, PartialEq, Eq,
)]
pub enum HashAlgorithm {
#[strum(to_string = "sha2", ascii_case_insensitive)]
Sha2,
#[strum(to_string = "sha3", ascii_case_insensitive)]
Sha3,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HashValue(Vec<u8>);
impl HashValue {
pub fn new(val: Box<[u8]>) -> Self {
Self(val.as_ref().to_vec())
}
}
impl Display for HashValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", hex::encode(self.0.as_slice()))
}
}
/// an individual hash for a package, input, or output.
/// hashes of package inputs are used to verify their authenticity.
/// hashes of the package outputs allow the content-addressed storage model to work.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Hash {
pub algorithm: HashAlgorithm,
pub value: HashValue,
}
impl Display for Hash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.algorithm, self.value)
}
}
pub trait Hasher: DynDigest + Write {}
impl Hasher for sha2::Sha512 {}
impl Hasher for sha3::Sha3_512 {}
impl Hash {
pub fn hasher_for(alg: &HashAlgorithm) -> Result<Box<dyn Hasher>, error::Hash> {
match alg {
HashAlgorithm::Sha2 => Ok(Box::new(sha2::Sha512::default())),
HashAlgorithm::Sha3 => Ok(Box::new(sha3::Sha3_512::default())),
}
}
// creates a new hash with the provided algorithm and value.
pub fn new(alg: HashAlgorithm, val: String) -> Result<Self, error::Hash> {
Ok(Self {
algorithm: alg,
value: HashValue(val.as_bytes().to_owned()),
})
}
}
impl FromStr for Hash {
type Err = error::Hash;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.to_lowercase()
.split_once(':')
.ok_or(error::Hash::Parse(s.to_owned()))
.map(|(alg, val)| {
HashAlgorithm::from_str(alg)
.map_err(|_| error::Hash::Algorithm(alg.to_owned()))
.map(|alg| (alg, val))
})?
.map(|(alg, val)| Hash::new(alg, val.to_owned()))?
}
}
struct HashVisitor;
impl<'de> Visitor<'de> for HashVisitor {
type Value = Hash;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a hash string (`algorithm:value`)")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Hash::from_str(v).map_err(|_| E::invalid_type(serde::de::Unexpected::Str(v), &self))
}
fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
// all hashes are represented in lowercase.
// this generally handles unicode, but
// hashes currently used by ia use ascii only.
self.visit_str(&v.to_lowercase())
}
}
impl<'de> Deserialize<'de> for Hash {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_string(HashVisitor)
}
}
impl Serialize for Hash {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&format!("{}:{}", self.algorithm, self.value))
}
}

163
crates/ia/src/lib.rs Normal file
View file

@ -0,0 +1,163 @@
mod error;
mod hash;
use std::{
io::{self, BufReader, BufWriter, Read, Seek, SeekFrom, Write},
path::PathBuf,
};
use cap_std::{ambient_authority, fs::OpenOptions};
use hash::HashValue;
pub use hash::{Hash, HashAlgorithm};
pub mod manifest;
pub use manifest::Manifest;
use tracing::{debug, warn};
pub mod phase;
#[derive(strum::Display)]
enum ArchiveType {
#[strum(to_string = "bz2", ascii_case_insensitive)]
Bz2,
#[strum(to_string = "gz", ascii_case_insensitive)]
Gz,
#[strum(to_string = "xz", ascii_case_insensitive)]
Xz,
#[strum(to_string = "zstd", ascii_case_insensitive)]
Zstd,
#[strum(to_string = "tar", ascii_case_insensitive)]
Tar,
#[strum(to_string = "7z", ascii_case_insensitive)]
SevenZip,
#[strum(to_string = "zip", ascii_case_insensitive)]
Zip,
#[strum(to_string = "rar", ascii_case_insensitive)]
Rar, // oh no
}
#[derive(strum::Display)]
enum FileType {
Archive(ArchiveType),
Other,
}
pub struct Dir(pub cap_std::fs::Dir);
impl Dir {
fn open_file(&self, file_name: String) -> Result<File, error::IO> {
self.0
.open_with(
file_name.clone(),
OpenOptions::new().create(true).write(true).read(true),
)
.map_err(|_| error::IO::Create(file_name))
.map(File)
}
/// creates a new file named `dst_basename` within `self.prefix`.
/// this function uses capabilities. writing to files outside of
/// `self.prefix` will fail.
pub fn write_file(&self, src_file: &mut File, dst_basename: String) -> Result<File, error::IO> {
// ensure we are at the start of the file stream.
// in rare cases, the file is written after being otherwise
// at least partially read.
src_file.reset().map_err(|_| error::IO::Read)?;
let mut dst_file = self.open_file(dst_basename.clone())?;
debug!("writing to file: {0}", dst_basename);
io::copy(src_file, &mut dst_file)
.and_then(|_| dst_file.flush())
.map_err(|_| error::IO::Copy)
.and_then(|_| dst_file.reset())
.map(|_| dst_file)
}
}
#[derive(Debug)]
pub struct File(cap_std::fs::File);
impl Read for File {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.0.read(buf)
}
}
impl Write for File {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.0.flush()
}
}
impl File {
pub fn hash(&mut self, alg: HashAlgorithm) -> Result<Hash, error::Hash> {
self.reset().map_err(|_| error::Hash::Internal)?;
let mut buf = String::new();
self.read_to_string(&mut buf).unwrap();
self.reset().unwrap();
let mut hasher = Hash::hasher_for(&alg)?;
io::copy(self, &mut hasher).map_err(|_| error::Hash::Internal)?;
let hash_value = hasher.finalize_reset();
hasher.flush().map_err(|_| error::Hash::Internal)?;
let hash_value = HashValue::new(hash_value);
Ok(Hash {
algorithm: alg,
value: hash_value,
})
}
fn seek(&mut self, pos: SeekFrom) -> Result<u64, error::IO> {
self.0.seek(pos).map_err(|_| error::IO::Read)
}
fn reset(&mut self) -> Result<(), error::IO> {
self.seek(SeekFrom::Start(0)).map(|_| ())
}
fn read_ambient(path: PathBuf) -> Result<BufReader<File>, error::IO> {
warn!("ia::File::read_ambient() called! this function is not sandboxed.");
cap_std::fs::File::open_ambient_with(
path.clone(),
OpenOptions::new().read(true),
ambient_authority(),
)
.map(File)
.map(BufReader::new)
.map_err(|_| error::IO::ReadNamed(path.to_string_lossy().to_string()))
}
// this truly _should_ never happen, but may be useful in some rare cases.
fn write_ambient(path: PathBuf) -> Result<BufWriter<File>, error::IO> {
warn!("ia::File::write_ambient() called. this function is not sandboxed.");
cap_std::fs::File::open_ambient_with(
path.clone(),
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true),
ambient_authority(),
)
.map(|f| BufWriter::new(File(f)))
.map_err(|_| error::IO::WriteNamed(path.to_string_lossy().to_string()))
}
/// copies the contents of `self` to `dst`, consuming `self` and returning
/// `dst`.
fn copy_to(self, dst: File) -> Result<File, error::IO> {
let mut read = BufReader::new(self);
let mut write = BufWriter::new(dst);
io::copy(&mut read, &mut write)
.and_then(|_| write.flush())
.map_err(|_| error::IO::Copy)
.and_then(|_| write.into_inner().map_err(|_| error::IO::Copy))
}
}

View file

@ -0,0 +1,320 @@
//! a package manifest is the 'recipe' for constructing an [ia::PackageLock].
//! a full package manifest can be found in the `docs` directory.
use std::fmt::{Debug, Display};
use std::str::FromStr;
use serde::de::Visitor;
use serde::{Deserialize, Deserializer, Serialize};
use crate::{error, ArchiveType, FileType, Hash};
// an architecture supported by ia packages.
// for the sake of easier future expansion,
// the 'other' value allows for architectures
// that may not be completely supported presently.
#[derive(strum::Display, Debug, Deserialize, Serialize)]
pub enum Architecture {
#[strum(to_string = "all")]
#[serde(rename = "all")]
All,
#[strum(to_string = "aarch64")]
#[serde(rename = "aarch64")]
Aarch64,
#[strum(to_string = "amd64")]
#[serde(rename = "amd64")]
Amd64,
#[strum(to_string = "riscv")]
#[serde(rename = "riscv")]
RiscV,
#[strum(to_string = "other:{0}")]
Other(String),
}
// a license supported by ia packages. these are represented
// as a spdx license expression.
#[derive(strum::Display, Debug, Deserialize, Serialize)]
pub enum License {
#[strum(to_string = "gpl-3.0-only")]
#[serde(rename = "gpl-3.0-only")]
Gpl3Only,
#[strum(to_string = "gpl-3.0-or-later")]
#[serde(rename = "gpl-3.0-or-later")]
Gpl3OrLater,
#[strum(to_string = "gpl-2.0-only")]
#[serde(rename = "gpl-2.0-only")]
Gpl2Only,
#[strum(to_string = "gpl-2.0-or-later")]
#[serde(rename = "gpl-2.0-or-later")]
Gpl2OrLater,
#[strum(to_string = "lgpl-3.0-only")]
#[serde(rename = "lgpl-3.0-only")]
Lgpl3Only,
#[strum(to_string = "lgpl-3.0-or-later")]
#[serde(rename = "lgpl-3.0-or-later")]
Lgpl3OrLater,
#[strum(to_string = "bsd-2-clause")]
#[serde(rename = "bsd-2-clause")]
Bsd2,
#[strum(to_string = "bsd-3-clause")]
#[serde(rename = "bsd-3-clause")]
Bsd3,
#[strum(to_string = "mit")]
#[serde(rename = "mit")]
Mit,
#[strum(to_string = "mpl-2.0")]
#[serde(rename = "mpl-2.0")]
Mozilla2,
#[strum(to_string = "other:{0}")]
#[serde(rename = "other:{0}")]
Other(String),
}
/// a package's name, usually defined by upstream.
#[derive(Debug, Deserialize, PartialEq, Serialize)]
pub struct Name(String);
impl Name {
pub fn new<T: AsRef<str>>(n: T) -> Self {
Self(n.as_ref().to_owned())
}
}
impl Display for Name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// a package's description, usually defined by upstream.
#[derive(Debug, Deserialize, PartialEq, Serialize)]
pub struct Description(String);
#[derive(strum::Display, Debug, Deserialize, PartialEq, Serialize)]
pub enum VersionOper {
#[strum(to_string = "<")]
LessThan,
#[strum(to_string = "<=")]
LessThanOrEqual,
#[strum(to_string = "=")]
Equal,
#[strum(to_string = ">=")]
GreaterThanOrEqual,
#[strum(to_string = ">")]
GreaterThan,
}
impl FromStr for VersionOper {
type Err = error::Manifest;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"<=" => Ok(VersionOper::LessThanOrEqual),
">=" => Ok(VersionOper::GreaterThanOrEqual),
"=" => Ok(VersionOper::Equal),
"<" => Ok(VersionOper::LessThan),
">" => Ok(VersionOper::GreaterThan),
_ => Err(error::Manifest::Parse),
}
}
}
// a package revision.
// this is used by maintainers where package updates are required
// that do not include changes from upstream.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, Eq)]
pub struct Revision(u8);
impl Revision {
pub fn new(rev: u8) -> Self {
Self(rev)
}
}
/// a package's version.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct Version(String);
impl Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl Version {
pub fn new<T: AsRef<str>>(upstream: T) -> Self {
Version(upstream.as_ref().to_string())
}
}
/// a dependency entry.
// these may be represented in two variants:
// a version-free name, which will pull the latest version
// of the package as a dependency, or
// a versioned name, which may use the following operators:
// - `<=`, which indicates a package version of the provided value or below
// - `>=`, which indicates a package version of the provided value or above
// - `=`, which indicates a package version of the provided value exactly
// - `<`, which indicates a package version below the provided value
// - `>`, which indicates a package version above the provided value
// version operators are searched in the following order:
// `<=`, `>=`, `=`, `<`, `>`
#[derive(Debug, PartialEq, Serialize)]
pub struct Dependency(Name, Option<(VersionOper, Version)>);
impl Dependency {
fn new(name: Name, opped_version: Option<(VersionOper, Version)>) -> Self {
Self(name, opped_version)
}
}
impl Display for Dependency {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.1 {
None => write!(f, "{}", self.0),
Some((op, version)) => write!(f, "{}{}{}", self.0, op, version),
}
}
}
struct DependencyVisitor;
impl<'de> Visitor<'de> for DependencyVisitor {
type Value = Dependency;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a version string (e.g., `package_name=1.0`)")
}
fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
["<=", ">=", "=", "<", ">"]
.into_iter()
.find_map(|oper| {
s.rsplit_once(oper)
.map(|(name, version)| {
(
Name::new(name),
VersionOper::from_str(oper).unwrap_or(VersionOper::Equal),
Version::new(version),
)
})
.map(|(n, o, v)| Dependency::new(n, Some((o, v))))
})
.or(Some(Dependency::new(Name::new(s), None)))
.ok_or(E::invalid_value(serde::de::Unexpected::Str(s), &self))
}
fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
// all hashes are represented in lowercase.
// this generally handles unicode, but
// hashes currently used by ia use ascii only.
self.visit_str(&v.to_lowercase())
}
}
impl<'de> Deserialize<'de> for Dependency {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_string(DependencyVisitor)
}
}
/// a configuration phase construct which either uses a helper,
/// or executes a collection of commands.
#[derive(Debug, Deserialize, PartialEq, Serialize)]
pub struct ConfigurePhase {
handler: Option<String>,
exec: Option<String>,
}
/// a build phase construct which either uses a helper,
/// or executes a collection of commands.
#[derive(Debug, Deserialize, PartialEq, Serialize)]
pub struct BuildPhase {
handler: Option<String>,
exec: Option<String>,
}
/// a test phase construct which either uses a helper,
/// or executes a collection of commands.
#[derive(Debug, Deserialize, PartialEq, Serialize)]
pub struct TestPhase {
handler: Option<String>,
exec: Option<String>,
}
/// a source entry, or "input". a url pointing to a source file of some
/// description.
/// the currently-supported scheme handlers are:
/// - `file:`, which is a local file
/// - `http://` and `https://`, which interact over HTTP/HTTPS protocols
/// respectively
/// - `git://`, which interacts over the git DVCS protocol
/// - `sftp://`, which interacts over the SSH File Transfer Protocol
#[derive(Debug, Deserialize, PartialEq, Serialize)]
pub struct Source {
pub name: String,
pub url: String,
pub hash: Hash,
}
impl Source {
pub fn new(name: String, url: String, hash: Hash) -> Self {
Self { name, url, hash }
}
}
impl Source {
/// attempts to match the filetype using the extension of the source
/// destination's basename.
///
/// this isn't a particularly resilient heuristic against naming files
/// with invalid or no extension, but it's reasonable enough to fix in the
/// manifest and increases clarity of the lockfile.
fn dst_file_type(&self) -> Result<FileType, error::File> {
match self
.name
.clone()
.rsplit_once('.')
.ok_or(error::File::UnsupportedType("none".to_owned()))?
.1
{
"gz" => Ok(FileType::Archive(ArchiveType::Gz)),
"xz" => Ok(FileType::Archive(ArchiveType::Xz)),
"zstd" => Ok(FileType::Archive(ArchiveType::Zstd)),
"bz2" => Ok(FileType::Archive(ArchiveType::Bz2)),
"rar" => Ok(FileType::Archive(ArchiveType::Rar)),
"zip" => Ok(FileType::Archive(ArchiveType::Zip)),
"7z" => Ok(FileType::Archive(ArchiveType::SevenZip)),
_ => Ok(FileType::Other),
}
}
}
/// a full package manifest.
/// manifests are a representation of the "recipe" used to build the package.
/// a manifest is used to build an ia package and generate its lockfile thereafter.
#[derive(Debug, Deserialize, PartialEq, Serialize)]
pub struct Manifest {
pub name: Name,
pub description: Description,
pub version: Version,
pub revision: Revision,
pub sources: Vec<Source>,
pub patches: Option<Vec<Source>>,
pub dependencies: Vec<Dependency>,
pub build_dependencies: Vec<Dependency>,
#[serde(rename = "configure")]
pub configure_phase: ConfigurePhase,
#[serde(rename = "build")]
pub build_phase: BuildPhase,
#[serde(rename = "test")]
pub test_phase: TestPhase,
}

View file

@ -0,0 +1,62 @@
//! the local file implementation of [super::Fetcher], handling the fetching of
//! source files from a local filesystem.
use crate::{error, manifest::Source, Dir, File};
use std::io::{self, BufWriter, Write};
pub(crate) struct Fetcher {}
/// the local file implementation of [super::Fetcher].
/// usage of this fetcher in application code is discouraged, as source file
/// retrieval cannot be sandboxed and therefore can be used to access files
/// outside of the build environment.
impl super::Fetcher for Fetcher {
fn new() -> Result<Self, error::Fetch> {
Ok(Self {})
}
fn name(&self) -> &'static str {
"file"
}
/// opens an arbitrary file with read-only permissions, creates a file
/// within the provided `dst_dir`, and copies the contents of the source
/// file to the destination file.
///
/// this function mutates data, and must never be used implicitly in
/// application code.
fn fetch(&self, src: &Source, dst_dir: &Dir) -> Result<File, error::Fetch> {
let src_path_string = src
.url
.split_once("file:")
.map(|(_, p)| p.to_string())
.ok_or(error::Fetch::InvalidSourceUrl(src.url.clone()))?;
let src_path = std::env::current_dir()
.map_err(|_| error::Fetch::NoCwd)?
.join(src_path_string.clone());
let mut src_buf = File::read_ambient(src_path)
.map_err(|_| error::Fetch::IO(error::IO::ReadNamed(src_path_string.clone())))?;
let dst_file = dst_dir
.open_file(src.name.clone())
.map_err(error::Fetch::IO)?;
let mut dst_buf = BufWriter::new(dst_file);
io::copy(&mut src_buf, &mut dst_buf)
// reports of flushing on copy are
// greatly exaggerated.
// enforce this to ensure all data
// is written to disk or explicitly
// fails.
.and_then(|_| dst_buf.flush())
.map(|_| {
dst_buf
.into_inner()
.map_err(|_| error::Fetch::IO(error::IO::WriteNamed(src_path_string.clone())))
})
.map_err(|_| error::Fetch::IO(error::IO::WriteNamed(src_path_string.clone())))?
.map(|mut f| f.reset().map_err(error::Fetch::IO).map(|_| f))?
}
}

View file

@ -0,0 +1,77 @@
//! the fetch phase is the first stage of the package composition pipeline.
//! it is responsible for fetching the sources of the package from its
//! defined source paths using an automatically determined scheme handler.
//!
//! this phase is transactional:
//! - sources are not linked into the build directory until all sources
//! are successfully obtained.
//! - if the parsing or retrieval of any input fails,
//! the entire phase will return an error and the retrieved sources
//! are removed from disk.
//!
//! fetch handlers generally stream source artifacts in chunks instead
//! of reading the entire artifact into memory, allowing sources of sizes
//! much larger than the available system memory to be used.
mod file;
use crate::{error, manifest::Source, Dir, File, HashAlgorithm};
use tracing::info;
pub trait Fetcher {
fn new() -> Result<Self, error::Fetch>
where
Self: Sized;
fn fetch(&self, src: &Source, dst_dir: &Dir) -> Result<File, error::Fetch>;
fn name(&self) -> &'static str;
}
pub struct Fetch<'a> {
sources: Vec<&'a Source>,
prefix: &'a Dir,
}
impl<'a> Fetch<'a> {
pub fn new(sources: Vec<&'a Source>, prefix: &'a Dir) -> Result<Self, error::Fetch> {
Ok(Self { sources, prefix })
}
pub fn fetcher_for(source: &Source) -> Result<Box<dyn Fetcher>, error::Fetch> {
match source
.url
.split_once(':')
.map(|split| split.0)
.or(Some(""))
.ok_or(error::Fetch::InvalidSourceUrl(source.url.clone()))?
{
"file" => Ok(Box::new(file::Fetcher::new()?)),
"http" | "https" => todo!(),
"ftp" => todo!(),
"git" => todo!(),
"sftp" => todo!(),
_ => Err(error::Fetch::UnimplementedScheme(source.url.clone())),
}
}
fn _fetch(&self, source: &Source) -> Result<File, error::Fetch> {
let fetcher: Box<dyn Fetcher> = Fetch::fetcher_for(source)?;
info!(
"executing '{0}' fetcher for source '{1}'…",
fetcher.name(),
source.name
);
let mut file = fetcher.fetch(source, self.prefix).unwrap();
println!("{:?}", file.hash(HashAlgorithm::Sha2).unwrap());
Ok(file)
}
pub fn fetch(&self) -> Result<Vec<File>, error::Fetch> {
// FIX: this is presently quite inefficient, as it recreates a new
// fetcher for each source even if they are all of the same scheme.
// could cache a fetcher upon its creation and re-use it, but would
// need a 'fetcher pool' for this.
self.sources
.iter()
.map(|source| self._fetch(source))
.collect::<Result<Vec<File>, error::Fetch>>()
}
}

View file

@ -0,0 +1 @@
pub mod fetch;

10
crates/tests/Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[package]
name = "ia-tests"
version = "0.1.0"
edition = "2021"
[dependencies]
cap-tempfile = "3.3.0"
ia = { path = "../ia" }
toml = "0.8.19"
url = { version = "2.5.2", features = ["serde"] }

View file

@ -0,0 +1,28 @@
name = "test-package"
description = "a test package for apm testing"
version = "1.2.3-git"
revision = 42
license = "gpl-3.0-only"
architectures = [ "ppc64" ]
dependencies = [ ]
build_dependencies = [ ]
provides = [ "hello-world" ]
groups = [ "examples" ]
[[sources]]
name = "source-test.txt"
url = "file:./source-test.txt"
hash = "sha2:4cbf04bd094e2bd2eda36cf3561bfa61cb1a83c29427f0dd01d8b071cd8d002f3f46aa1c8b5eef25e7743f629ad4976f59bf7ee07d345b9bdb89c85703e581c7"
[configure]
exec = """
"""
[build]
exec = """
make
"""
[test]
exec = """
"""

View file

@ -0,0 +1 @@
test source file

51
crates/tests/src/hash.rs Normal file
View file

@ -0,0 +1,51 @@
use std::str::FromStr;
use ia::{Hash, HashAlgorithm};
const HASH_PLAINTEXT: &str = "example";
pub(crate) const SHA2_HASH_STR: &str = "sha2:3bb12eda3c298db5de25597f54d924f2e17e78a26ad8953ed8218ee682f0bbbe9021e2f3009d152c911bf1f25ec683a902714166767afbd8e5bd0fb0124ecb8a";
pub(crate) const SHA2_HASH_ALG: HashAlgorithm = HashAlgorithm::Sha2;
pub(crate) const SHA2_HASH_VALUE: &str = "3bb12eda3c298db5de25597f54d924f2e17e78a26ad8953ed8218ee682f0bbbe9021e2f3009d152c911bf1f25ec683a902714166767afbd8e5bd0fb0124ecb8a";
pub(crate) const SHA3_HASH_STR: &str = "sha3:e6da59e7349fb06a3de52ab3a2b383090f80d45ea8489d76b231d580ccf01fb112e509dedd5cc09bead4aa54455ca4c66cd46abe35c061325802d5df12b2a55d";
pub(crate) const SHA3_HASH_ALG: HashAlgorithm = HashAlgorithm::Sha3;
pub(crate) const SHA3_HASH_VALUE: &str = "e6da59e7349fb06a3de52ab3a2b383090f80d45ea8489d76b231d580ccf01fb112e509dedd5cc09bead4aa54455ca4c66cd46abe35c061325802d5df12b2a55d";
pub(crate) const INVALIDALG_HASH_STR: &str = "abcd213:3bb12eda3c298db5de25597f54d924f2e17e78a26ad8953ed8218ee682f0bbbe9021e2f3009d152c911bf1f25ec683a902714166767afbd8e5bd0fb0124ecb8a";
pub(crate) const INVALIDVALUE_HASH_STR: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
#[test]
fn can_parse_sha2_alg() {
let hash = Hash::from_str(SHA2_HASH_STR).unwrap();
assert_eq!(hash.algorithm, SHA2_HASH_ALG)
}
#[test]
fn can_parse_sha2_value() {
let hash = Hash::from_str(SHA2_HASH_STR).unwrap();
assert_eq!(hash.value.to_string(), SHA2_HASH_VALUE.to_owned())
}
#[test]
fn can_parse_sha3_alg() {
let hash = Hash::from_str(SHA3_HASH_STR).unwrap();
assert_eq!(hash.algorithm, SHA3_HASH_ALG)
}
#[test]
fn can_parse_sha3_value() {
let hash = Hash::from_str(SHA3_HASH_STR).unwrap();
assert_eq!(hash.value.to_string(), SHA3_HASH_VALUE.to_owned())
}
#[test]
fn rejects_invalid_alg() {
let hash = Hash::from_str(INVALIDALG_HASH_STR);
assert!(hash.is_err())
}
#[test]
fn rejects_invalid_hash() {
let hash = Hash::from_str(INVALIDVALUE_HASH_STR);
assert!(hash.is_err())
}

3
crates/tests/src/lib.rs Normal file
View file

@ -0,0 +1,3 @@
mod hash;
mod manifest;
mod phase;

View file

@ -0,0 +1,31 @@
use ia::{manifest::{Revision, Version}, Manifest};
pub(crate) const MANIFEST_FILE_STR: &str = include_str!("../../fixtures/package/manifest.toml");
pub(crate) const MANIFEST_PACKAGE_NAME_STR: &str = "test-package";
pub(crate) const MANIFEST_VERSION_STR: &str = "1.2.3-git";
pub(crate) const MANIFEST_REVISION: u8 = 42;
#[test]
fn can_parse_toml() {
let manifest_toml: toml::Table = toml::from_str(MANIFEST_FILE_STR).unwrap();
assert_eq!(
manifest_toml["name"].as_str(),
Some(MANIFEST_PACKAGE_NAME_STR)
);
}
#[test]
fn can_parse_manifest() {
let manifest: Manifest = toml::from_str(MANIFEST_FILE_STR).unwrap();
assert_eq!(manifest.name.to_string(), MANIFEST_PACKAGE_NAME_STR);
}
#[test]
fn can_parse_version() {
let version = Version::new(MANIFEST_VERSION_STR);
let revision = Revision::new(MANIFEST_REVISION);
let manifest: Manifest = toml::from_str(MANIFEST_FILE_STR).unwrap();
assert_eq!(manifest.version, version);
assert_eq!(manifest.revision, revision);
}

View file

@ -0,0 +1,71 @@
use std::io::Read;
use cap_tempfile::ambient_authority;
use cap_tempfile::TempDir;
use ia::manifest::Source;
use ia::phase::fetch::Fetch;
use ia::Dir;
use ia::File;
use ia::Hash;
use ia::Manifest;
const SOURCE_FILE_NAME: &str = "source-test.txt";
const SOURCE_FILE_CONTENT: &str = "test source file\n";
const SOURCE_FILE_HASH: &str = "0c984b1b4ebc120d693d33ebda78d26fd91f27614de387c420960cc67a0e32ffef2a7adfaf132acf411c2606a10e0290822b5ac096471342c05b89d62230e486";
fn test_source() -> Source {
Source::new(
SOURCE_FILE_NAME.to_string(),
String::from("file:./fixtures/package/source-test.txt"),
Hash::new(ia::HashAlgorithm::Sha2, String::new()).unwrap(),
)
}
fn test_source_file() -> File {
Fetch::fetcher_for(&test_source())
.unwrap()
.fetch(&test_source(), &test_build_prefix())
.unwrap()
}
fn test_manifest() -> Manifest {
toml::from_str(crate::manifest::MANIFEST_FILE_STR).unwrap()
}
fn test_build_prefix() -> Dir {
Dir(TempDir::new(ambient_authority())
.unwrap()
.into_dir()
.unwrap())
}
#[test]
fn can_identify_fetcher_from_url() {
assert_eq!(
Fetch::fetcher_for(test_manifest().sources.first().unwrap())
.unwrap()
.name(),
"file"
);
}
#[test]
fn can_fetch() {
let mut buf = String::new();
test_source_file().read_to_string(&mut buf).unwrap();
assert_eq!(buf, SOURCE_FILE_CONTENT);
}
#[test]
fn can_hash_source() {
assert_eq!(
test_source_file()
.hash(ia::HashAlgorithm::Sha2)
.unwrap()
.value
.to_string(),
SOURCE_FILE_HASH
);
}

View file

@ -0,0 +1 @@
mod fetch;

View file

@ -85,11 +85,14 @@ if you really need a longer name to attach to it, i guess 'installation assistan
- ia repository server
- package definition import from:
- apk: apkbuild
- implementation of specific configuration / pre-build handlers
- autoconf
- implementation of specific build handlers
- cmake
- go
- cargo
- meson
- cpan
- python (setup.py)
- implementation of specific test handlers
- cmake
- go
@ -103,6 +106,9 @@ if you really need a longer name to attach to it, i guess 'installation assistan
- implementation of further download handlers
- hg
- bzr
- implementation of further build handlers
- cargo
- python (distutils)
- package definition import from:
- apk: apkbuild
- pacman: pkgbuild
@ -117,7 +123,7 @@ if you really need a longer name to attach to it, i guess 'installation assistan
* deb: debian control
* library package definition import from:
* javascript: npm
* ruby: rubygems
* ruby: rubygems (via git)
* python: pypi
* rust: crates.io
* go: pkg.dev