use crate::error::{self, Result};
use crate::io::{is_file, DigestAdapter};
use crate::key_source::KeySource;
use crate::schema::{
DelegatedTargets, KeyHolder, Role, RoleType, Root, Signature, Signed, Snapshot, Target,
Targets, Timestamp,
};
use async_trait::async_trait;
use aws_lc_rs::digest::{digest, SHA256, SHA256_OUTPUT_LEN};
use aws_lc_rs::rand::SecureRandom;
use futures::TryStreamExt;
use olpc_cjson::CanonicalFormatter;
use serde::{Deserialize, Serialize};
use serde_plain::derive_fromstr_from_deserialize;
use snafu::{ensure, OptionExt, ResultExt};
use std::collections::HashMap;
use std::future::{ready, Future};
use tokio::fs::{canonicalize, copy, create_dir_all, remove_file, symlink_metadata};
use tokio::io::AsyncWriteExt;
#[cfg(not(target_os = "windows"))]
use tokio::fs::symlink;
#[cfg(target_os = "windows")]
use tokio::fs::symlink_file as symlink;
use crate::{FilesystemTransport, TargetName, Transport};
use std::borrow::Cow;
use std::path::{Component, Path, PathBuf};
use url::Url;
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub struct SignedRole<T> {
pub(crate) signed: Signed<T>,
pub(crate) buffer: Vec<u8>,
pub(crate) sha256: [u8; SHA256_OUTPUT_LEN],
pub(crate) length: u64,
}
impl<T> SignedRole<T>
where
T: Role + Serialize,
{
pub async fn new(
role: T,
key_holder: &KeyHolder,
keys: &[Box<dyn KeySource>],
rng: &(dyn SecureRandom + Sync),
) -> Result<Self> {
let root_keys = key_holder.get_keys(keys).await?;
let role_keys = key_holder.role_keys(role.role_id())?;
let valid_keys = root_keys
.iter()
.filter(|(keyid, _signing_key)| role_keys.keyids.contains(keyid));
let mut role = Signed {
signed: role,
signatures: Vec::new(),
};
let mut data = Vec::new();
let mut ser = serde_json::Serializer::with_formatter(&mut data, CanonicalFormatter::new());
role.signed
.serialize(&mut ser)
.context(error::SerializeRoleSnafu {
role: T::TYPE.to_string(),
})?;
for (signing_key_id, signing_key) in valid_keys {
let sig = signing_key
.sign(&data, rng)
.await
.context(error::SignMessageSnafu)?;
role.signatures.push(Signature {
keyid: signing_key_id.clone(),
sig: sig.into(),
});
}
if T::TYPE != RoleType::Root && role_keys.threshold.get() > role.signatures.len() as u64 {
return Err(error::Error::SigningKeysNotFound {
role: T::TYPE.to_string(),
});
}
SignedRole::from_signed(role)
}
pub(crate) fn from_signed(role: Signed<T>) -> Result<SignedRole<T>> {
let mut buffer =
serde_json::to_vec_pretty(&role).context(error::SerializeSignedRoleSnafu {
role: T::TYPE.to_string(),
})?;
buffer.push(b'\n');
let length = buffer.len() as u64;
let mut sha256 = [0; SHA256_OUTPUT_LEN];
sha256.copy_from_slice(digest(&SHA256, &buffer).as_ref());
let signed_role = SignedRole {
signed: role,
buffer,
sha256,
length,
};
Ok(signed_role)
}
pub fn signed(&self) -> &Signed<T> {
&self.signed
}
pub fn buffer(&self) -> &Vec<u8> {
&self.buffer
}
pub fn sha256(&self) -> &[u8] {
&self.sha256
}
pub fn length(&self) -> &u64 {
&self.length
}
pub async fn write<P>(&self, outdir: P, consistent_snapshot: bool) -> Result<()>
where
P: AsRef<Path>,
{
let outdir = outdir.as_ref();
tokio::fs::create_dir_all(outdir)
.await
.context(error::DirCreateSnafu { path: outdir })?;
let outdir = tokio::fs::canonicalize(outdir)
.await
.context(error::AbsolutePathSnafu { path: outdir })?;
let filename = self.signed.signed.filename(consistent_snapshot);
let path = outdir.join(filename);
let mut file = tokio::fs::File::create(&path)
.await
.context(error::FileWriteSnafu { path: &path })?;
file.write_all(&self.buffer)
.await
.context(error::FileWriteSnafu { path: &path })?;
file.flush().await.context(error::FileWriteSnafu { path })
}
pub fn add_old_signatures(mut self, old_signatures: Vec<Signature>) -> Result<Self> {
for old_signature in old_signatures {
if !self
.signed
.signatures
.iter()
.any(|new_sig| new_sig.keyid == old_signature.keyid)
{
self.signed.signatures.push(Signature {
keyid: old_signature.keyid,
sig: old_signature.sig,
});
}
}
SignedRole::from_signed(self.signed)
}
}
#[derive(Debug, Deserialize, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
pub enum PathExists {
Skip,
Replace,
Fail,
}
derive_fromstr_from_deserialize!(PathExists);
#[derive(Debug, Clone)]
enum TargetPath {
New { path: PathBuf },
File { path: PathBuf },
Symlink { path: PathBuf },
}
#[derive(Debug)]
pub struct SignedRepository {
pub(crate) root: SignedRole<Root>,
pub(crate) targets: SignedRole<Targets>,
pub(crate) snapshot: SignedRole<Snapshot>,
pub(crate) timestamp: SignedRole<Timestamp>,
pub(crate) delegated_targets: Option<SignedDelegatedTargets>,
}
impl SignedRepository {
pub async fn write<P>(&self, outdir: P) -> Result<()>
where
P: AsRef<Path>,
{
let consistent_snapshot = self.root.signed.signed.consistent_snapshot;
self.root.write(&outdir, consistent_snapshot).await?;
self.targets.write(&outdir, consistent_snapshot).await?;
self.snapshot.write(&outdir, consistent_snapshot).await?;
self.timestamp.write(&outdir, consistent_snapshot).await?;
if let Some(delegated_targets) = &self.delegated_targets {
delegated_targets
.write(&outdir, consistent_snapshot)
.await?;
}
Ok(())
}
pub async fn link_targets<P1, P2>(
&self,
indir: P1,
outdir: P2,
replace_behavior: PathExists,
) -> Result<()>
where
P1: AsRef<Path>,
P2: AsRef<Path>,
{
self.walk_targets(
indir.as_ref(),
outdir.as_ref(),
Self::link_target,
replace_behavior,
)
.await
}
pub async fn copy_targets<P1, P2>(
&self,
indir: P1,
outdir: P2,
replace_behavior: PathExists,
) -> Result<()>
where
P1: AsRef<Path>,
P2: AsRef<Path>,
{
self.walk_targets(
indir.as_ref(),
outdir.as_ref(),
Self::copy_target,
replace_behavior,
)
.await
}
pub async fn link_target(
&self,
input_path: &Path,
outdir: &Path,
replace_behavior: PathExists,
target_filename: Option<&TargetName>,
) -> Result<()> {
ensure!(
is_file(input_path).await,
error::PathIsNotFileSnafu { path: input_path }
);
match self
.target_path(input_path, outdir, target_filename)
.await?
{
TargetPath::New { path } => {
symlink(input_path, &path)
.await
.context(error::LinkCreateSnafu { path })?;
}
TargetPath::Symlink { path } => match replace_behavior {
PathExists::Skip => {}
PathExists::Fail => error::PathExistsFailSnafu { path }.fail()?,
PathExists::Replace => {
remove_file(&path)
.await
.context(error::RemoveTargetSnafu { path: &path })?;
symlink(input_path, &path)
.await
.context(error::LinkCreateSnafu { path })?;
}
},
TargetPath::File { path } => {
error::TargetFileTypeMismatchSnafu {
expected: "symlink",
found: "regular file",
path,
}
.fail()?;
}
}
Ok(())
}
pub async fn copy_target(
&self,
input_path: &Path,
outdir: &Path,
replace_behavior: PathExists,
target_filename: Option<&TargetName>,
) -> Result<()> {
ensure!(
is_file(input_path).await,
error::PathIsNotFileSnafu { path: input_path }
);
match self
.target_path(input_path, outdir, target_filename)
.await?
{
TargetPath::New { path } => {
copy(input_path, &path)
.await
.context(error::FileWriteSnafu { path })?;
}
TargetPath::File { path } => match replace_behavior {
PathExists::Skip => {}
PathExists::Fail => error::PathExistsFailSnafu { path }.fail()?,
PathExists::Replace => {
remove_file(&path)
.await
.context(error::RemoveTargetSnafu { path: &path })?;
copy(input_path, &path)
.await
.context(error::FileWriteSnafu { path })?;
}
},
TargetPath::Symlink { path } => {
error::TargetFileTypeMismatchSnafu {
expected: "regular file",
found: "symlink",
path,
}
.fail()?;
}
}
Ok(())
}
}
impl TargetsWalker for SignedRepository {
fn targets(&self) -> HashMap<TargetName, &Target> {
self.targets.signed.signed.targets_map()
}
fn consistent_snapshot(&self) -> bool {
self.root.signed.signed.consistent_snapshot
}
}
#[derive(Debug)]
pub struct SignedDelegatedTargets {
pub(crate) roles: Vec<SignedRole<DelegatedTargets>>,
pub(crate) consistent_snapshot: bool,
}
impl SignedDelegatedTargets {
pub async fn write<P>(&self, outdir: P, consistent_snapshot: bool) -> Result<()>
where
P: AsRef<Path>,
{
for targets in &self.roles {
targets.write(&outdir, consistent_snapshot).await?;
}
Ok(())
}
pub fn roles(self) -> Vec<SignedRole<DelegatedTargets>> {
self.roles
}
pub async fn link_targets<P1, P2>(
&self,
indir: P1,
outdir: P2,
replace_behavior: PathExists,
) -> Result<()>
where
P1: AsRef<Path>,
P2: AsRef<Path>,
{
self.walk_targets(
indir.as_ref(),
outdir.as_ref(),
Self::link_target,
replace_behavior,
)
.await
}
pub async fn copy_targets<P1, P2>(
&self,
indir: P1,
outdir: P2,
replace_behavior: PathExists,
) -> Result<()>
where
P1: AsRef<Path>,
P2: AsRef<Path>,
{
self.walk_targets(
indir.as_ref(),
outdir.as_ref(),
Self::copy_target,
replace_behavior,
)
.await
}
pub async fn link_target(
&self,
input_path: &Path,
outdir: &Path,
replace_behavior: PathExists,
target_filename: Option<&TargetName>,
) -> Result<()> {
ensure!(
is_file(input_path).await,
error::PathIsNotFileSnafu { path: input_path }
);
match self
.target_path(input_path, outdir, target_filename)
.await?
{
TargetPath::New { path } => {
symlink(input_path, &path)
.await
.context(error::LinkCreateSnafu { path })?;
}
TargetPath::Symlink { path } => match replace_behavior {
PathExists::Skip => {}
PathExists::Fail => error::PathExistsFailSnafu { path }.fail()?,
PathExists::Replace => {
remove_file(&path)
.await
.context(error::RemoveTargetSnafu { path: &path })?;
symlink(input_path, &path)
.await
.context(error::LinkCreateSnafu { path })?;
}
},
TargetPath::File { path } => {
error::TargetFileTypeMismatchSnafu {
expected: "symlink",
found: "regular file",
path,
}
.fail()?;
}
}
Ok(())
}
pub async fn copy_target(
&self,
input_path: &Path,
outdir: &Path,
replace_behavior: PathExists,
target_filename: Option<&TargetName>,
) -> Result<()> {
ensure!(
is_file(input_path).await,
error::PathIsNotFileSnafu { path: input_path }
);
match self
.target_path(input_path, outdir, target_filename)
.await?
{
TargetPath::New { path } => {
copy(input_path, &path)
.await
.context(error::FileWriteSnafu { path })?;
}
TargetPath::File { path } => match replace_behavior {
PathExists::Skip => {}
PathExists::Fail => error::PathExistsFailSnafu { path }.fail()?,
PathExists::Replace => {
remove_file(&path)
.await
.context(error::RemoveTargetSnafu { path: &path })?;
copy(input_path, &path)
.await
.context(error::FileWriteSnafu { path })?;
}
},
TargetPath::Symlink { path } => {
error::TargetFileTypeMismatchSnafu {
expected: "regular file",
found: "symlink",
path,
}
.fail()?;
}
}
Ok(())
}
}
impl TargetsWalker for SignedDelegatedTargets {
fn targets(&self) -> HashMap<TargetName, &Target> {
let mut targets_map = HashMap::new();
for targets in &self.roles {
targets_map.extend(targets.signed.signed.targets_map());
}
targets_map
}
fn consistent_snapshot(&self) -> bool {
self.consistent_snapshot
}
}
trait WalkOperator<S, In, Out, TN>:
FnMut(S, In, Out, PathExists, Option<TN>) -> <Self as WalkOperator<S, In, Out, TN>>::Fut
{
type Fut: Future<Output = <Self as WalkOperator<S, In, Out, TN>>::Output> + Send;
type Output;
}
impl<S, In, Out, TN, F, Fut> WalkOperator<S, In, Out, TN> for F
where
F: FnMut(S, In, Out, PathExists, Option<TN>) -> Fut,
Fut: Future + Send,
{
type Fut = Fut;
type Output = Fut::Output;
}
#[async_trait]
#[allow(clippy::too_many_lines)]
trait TargetsWalker {
fn targets(&self) -> HashMap<TargetName, &Target>;
fn consistent_snapshot(&self) -> bool;
async fn walk_targets<F>(
&self,
indir: &Path,
outdir: &Path,
mut f: F,
replace_behavior: PathExists,
) -> Result<()>
where
F: for<'a, 'b, 'c, 'd> WalkOperator<
&'a Self,
&'b Path,
&'c Path,
&'d TargetName,
Output = Result<()>,
> + Send,
{
create_dir_all(outdir)
.await
.context(error::DirCreateSnafu { path: outdir })?;
let abs_indir = canonicalize(indir)
.await
.context(error::AbsolutePathSnafu { path: indir })?;
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
let root = abs_indir.clone();
tokio::task::spawn_blocking(move || {
let walker = WalkDir::new(&root).follow_links(true);
for entry in walker {
if tx.blocking_send(entry).is_err() {
break;
}
}
});
while let Some(entry) = rx.recv().await {
let entry = entry.context(error::WalkDirSnafu {
directory: &abs_indir,
})?;
if !entry.file_type().is_file() {
continue;
}
if let Err(e) = f(self, entry.path(), outdir, replace_behavior, None).await {
match e {
error::Error::PathIsNotTarget { .. } => {}
_ => return Err(e),
}
}
}
Ok(())
}
async fn target_path(
&self,
input: &Path,
outdir: &Path,
target_filename: Option<&TargetName>,
) -> Result<TargetPath> {
let outdir = tokio::fs::canonicalize(outdir)
.await
.context(error::AbsolutePathSnafu { path: outdir })?;
let target_name = if let Some(target_filename) = target_filename {
Cow::Borrowed(target_filename)
} else {
Cow::Owned(TargetName::new(
input
.file_name()
.context(error::NoFileNameSnafu { path: input })?
.to_str()
.context(error::PathUtf8Snafu { path: input })?,
)?)
};
let target_from_path = Target::from_path(input)
.await
.context(error::TargetFromPathSnafu { path: input })?;
let repo_targets = &self.targets();
let repo_target = repo_targets
.get(&target_name)
.context(error::PathIsNotTargetSnafu { path: input })?;
ensure!(
target_from_path.hashes.sha256 == repo_target.hashes.sha256,
error::HashMismatchSnafu {
context: "target",
calculated: hex::encode(target_from_path.hashes.sha256),
expected: hex::encode(&repo_target.hashes.sha256),
}
);
let dest = if self.consistent_snapshot() {
outdir.join(format!(
"{}.{}",
hex::encode(&target_from_path.hashes.sha256),
target_name.resolved()
))
} else {
outdir.join(target_name.resolved())
};
ensure!(
!dest.components().any(|c| matches!(c, Component::ParentDir)),
error::SaveTargetUnsafePathSnafu {
name: target_name.clone().into_owned(),
outdir: outdir.clone(),
filepath: &dest,
}
);
let dest_parent = dest
.parent()
.map_or_else(|| dest.clone(), Path::to_path_buf);
ensure!(
dest_parent.starts_with(&outdir),
error::SaveTargetUnsafePathSnafu {
name: target_name.clone().into_owned(),
outdir: outdir.clone(),
filepath: &dest,
}
);
create_dir_all(&dest_parent)
.await
.context(error::DirCreateSnafu { path: &dest_parent })?;
let real_parent = canonicalize(&dest_parent)
.await
.context(error::AbsolutePathSnafu { path: &dest_parent })?;
let real_outdir = canonicalize(&outdir)
.await
.context(error::AbsolutePathSnafu { path: &outdir })?;
ensure!(
real_parent.starts_with(&real_outdir),
error::SaveTargetUnsafePathSnafu {
name: target_name.into_owned(),
outdir,
filepath: &dest,
}
);
if !dest.exists() {
return Ok(TargetPath::New { path: dest });
}
if !self.consistent_snapshot() {
let url = Url::from_file_path(&dest)
.ok() .context(error::FileUrlSnafu { path: &dest })?;
let stream = FilesystemTransport
.fetch(url.clone())
.await
.with_context(|_| error::TransportSnafu { url: url.clone() })?;
let stream = DigestAdapter::sha256(stream, &repo_target.hashes.sha256, url.clone());
stream
.try_for_each(|_| ready(Ok(())))
.await
.context(error::TransportSnafu { url })?;
}
let metadata = symlink_metadata(&dest)
.await
.context(error::FileMetadataSnafu { path: &dest })?;
if metadata.file_type().is_file() {
Ok(TargetPath::File { path: dest })
} else if metadata.file_type().is_symlink() {
Ok(TargetPath::Symlink { path: dest })
} else {
error::InvalidFileTypeSnafu { path: dest }.fail()
}
}
}