use std::collections::{HashMap, HashSet};
use std::fmt;
use std::fs::{self, File};
use std::path::{Component, Path, PathBuf};
use crate::error::{Error, Result};
use crate::stage::{PreparedTransaction, Stage};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ProductId(String);
impl ProductId {
pub fn new(value: impl Into<String>) -> Result<Self> {
Ok(Self(validate_identifier("product", value.into())?))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ProductId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ReleaseId(String);
impl ReleaseId {
pub fn new(value: impl Into<String>) -> Result<Self> {
Ok(Self(validate_identifier("release", value.into())?))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ReleaseId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MemberId(String);
impl MemberId {
pub fn new(value: impl Into<String>) -> Result<Self> {
Ok(Self(validate_identifier("member", value.into())?))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for MemberId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
fn validate_identifier(kind: &str, value: String) -> Result<String> {
if value.is_empty() || value.chars().any(char::is_control) {
return Err(Error::invalid(format!(
"{kind} identifier is empty or contains control characters"
)));
}
Ok(value)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FileKind {
Regular,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum PermissionsIntent {
Preserve,
Executable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ownership {
Absent,
Owned,
Foreign,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum IntegrityRequirement {
None,
Sha256([u8; 32]),
}
pub trait OwnershipVerifier {
fn verify(&self, member: &MemberId, destination: &Path) -> Ownership;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AbsentPolicy {
AllowCreate,
DenyCreate,
}
#[derive(Clone, Copy)]
pub struct CommitOwnership<'a> {
pub verifier: &'a dyn OwnershipVerifier,
pub absent: AbsentPolicy,
}
impl<'a> CommitOwnership<'a> {
pub fn new(verifier: &'a dyn OwnershipVerifier, absent: AbsentPolicy) -> Self {
Self { verifier, absent }
}
}
impl std::fmt::Debug for CommitOwnership<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CommitOwnership")
.field("absent", &self.absent)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct AbsentOnlyVerifier;
impl OwnershipVerifier for AbsentOnlyVerifier {
fn verify(&self, _member: &MemberId, destination: &Path) -> Ownership {
match std::fs::symlink_metadata(destination) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ownership::Absent,
Ok(_) => Ownership::Foreign,
Err(_) => Ownership::Unknown,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ExistingAsOwnedVerifier;
impl OwnershipVerifier for ExistingAsOwnedVerifier {
fn verify(&self, _member: &MemberId, destination: &Path) -> Ownership {
match std::fs::symlink_metadata(destination) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ownership::Absent,
Ok(m) if m.is_file() => Ownership::Owned,
Ok(_) => Ownership::Foreign,
Err(_) => Ownership::Unknown,
}
}
}
#[derive(Debug, Clone)]
pub struct ExactDigestVerifier {
expected: std::collections::HashMap<MemberId, [u8; 32]>,
}
impl ExactDigestVerifier {
pub fn new(expected: Vec<(MemberId, [u8; 32])>) -> Self {
Self {
expected: expected.into_iter().collect(),
}
}
}
impl OwnershipVerifier for ExactDigestVerifier {
fn verify(&self, member: &MemberId, destination: &Path) -> Ownership {
let expected = match self.expected.get(member) {
Some(d) => d,
None => return Ownership::Unknown,
};
match std::fs::symlink_metadata(destination) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ownership::Absent,
Err(_) => Ownership::Unknown,
Ok(m) => {
if !m.is_file() {
return Ownership::Foreign;
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if m.nlink() != 1 {
return Ownership::Foreign;
}
}
match crate::integrity::hash_file(destination) {
Ok(d) if &d == expected => Ownership::Owned,
Ok(_) => Ownership::Foreign,
Err(_) => Ownership::Unknown,
}
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArtifactMember {
id: MemberId,
source: PathBuf,
destination: PathBuf,
file_kind: FileKind,
permissions: PermissionsIntent,
integrity: IntegrityRequirement,
}
impl ArtifactMember {
pub fn new(
id: MemberId,
source: impl Into<PathBuf>,
destination: impl Into<PathBuf>,
) -> Result<Self> {
let destination = normalize_relative_path(&destination.into())?;
Ok(Self {
id,
source: source.into(),
destination,
file_kind: FileKind::Regular,
permissions: PermissionsIntent::Preserve,
integrity: IntegrityRequirement::None,
})
}
pub fn with_permissions(mut self, permissions: PermissionsIntent) -> Self {
self.permissions = permissions;
self
}
pub fn with_integrity(mut self, integrity: IntegrityRequirement) -> Self {
self.integrity = integrity;
self
}
pub fn id(&self) -> &MemberId {
&self.id
}
pub fn source(&self) -> &Path {
&self.source
}
pub fn destination(&self) -> &Path {
&self.destination
}
pub fn file_kind(&self) -> FileKind {
self.file_kind
}
pub fn permissions(&self) -> PermissionsIntent {
self.permissions
}
pub fn integrity(&self) -> IntegrityRequirement {
self.integrity
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArtifactSet(Vec<ArtifactMember>);
impl ArtifactSet {
pub fn new(members: Vec<ArtifactMember>) -> Result<Self> {
if members.is_empty() {
return Err(Error::invalid(
"artifact set must contain at least one member",
));
}
let mut ids = HashSet::new();
for member in &members {
if !ids.insert(member.id.clone()) {
return Err(Error::invalid(format!(
"duplicate artifact member: {}",
member.id
)));
}
}
Ok(Self(members))
}
pub fn single(member: ArtifactMember) -> Result<Self> {
Self::new(vec![member])
}
pub fn iter(&self) -> impl ExactSizeIterator<Item = &ArtifactMember> {
self.0.iter()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub(crate) fn as_slice(&self) -> &[ArtifactMember] {
&self.0
}
}
#[derive(Debug, Default)]
pub struct BoundSources {
handles: HashMap<MemberId, File>,
}
impl BoundSources {
pub fn new() -> Self {
Self {
handles: HashMap::new(),
}
}
pub fn insert(&mut self, member: MemberId, handle: File) -> Option<File> {
self.handles.insert(member, handle)
}
pub(crate) fn is_empty(&self) -> bool {
self.handles.is_empty()
}
pub(crate) fn take(&mut self, member: &MemberId) -> Option<File> {
self.handles.remove(member)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallPlan {
product: ProductId,
release: ReleaseId,
installation_root: PathBuf,
artifacts: ArtifactSet,
}
impl InstallPlan {
pub fn new(
product: ProductId,
release: ReleaseId,
installation_root: impl Into<PathBuf>,
artifacts: ArtifactSet,
) -> Result<Self> {
let installation_root = installation_root.into();
validate_installation_root(&installation_root)?;
let canonical_root = fs::canonicalize(&installation_root)
.map_err(|source| Error::io("canonicalizing installation root", source))?;
let mut destinations = HashSet::new();
for member in artifacts.as_slice() {
if !destinations.insert(member.destination.clone()) {
return Err(Error::invalid(format!(
"duplicate destination: {}",
member.destination.display()
)));
}
validate_source(member, &canonical_root, &installation_root)?;
}
Ok(Self {
product,
release,
installation_root,
artifacts,
})
}
pub fn product(&self) -> &ProductId {
&self.product
}
pub fn release(&self) -> &ReleaseId {
&self.release
}
pub fn installation_root(&self) -> &Path {
&self.installation_root
}
pub fn artifacts(&self) -> &ArtifactSet {
&self.artifacts
}
pub fn destination(&self, member: &MemberId) -> Result<PathBuf> {
let artifact = self
.artifacts
.iter()
.find(|candidate| candidate.id == *member)
.ok_or_else(|| Error::UnknownMember(member.to_string()))?;
Ok(self.installation_root.join(&artifact.destination))
}
pub fn prepare(self) -> Result<PreparedTransaction> {
Stage::prepare(self)
}
pub fn prepare_with_bound_sources(self, bound: BoundSources) -> Result<PreparedTransaction> {
Stage::prepare_bound(self, bound)
}
#[cfg(test)]
pub(crate) fn prepare_with_failure(
self,
point: crate::test_support::FailurePoint,
) -> Result<PreparedTransaction> {
Stage::prepare_with_failure(self, point)
}
}
fn validate_installation_root(root: &Path) -> Result<()> {
if !root.is_absolute() {
return Err(Error::invalid("installation root must be absolute"));
}
let metadata = fs::symlink_metadata(root)
.map_err(|source| Error::io("reading installation root", source))?;
if !metadata.is_dir() {
return Err(Error::invalid("installation root must be a directory"));
}
Ok(())
}
fn validate_source(
member: &ArtifactMember,
canonical_root: &Path,
installation_root: &Path,
) -> Result<()> {
if !member.source.is_absolute() {
return Err(Error::invalid(format!(
"source for {} must be absolute",
member.id
)));
}
let metadata = fs::symlink_metadata(&member.source)
.map_err(|source| Error::io("reading artifact source", source))?;
if member.file_kind == FileKind::Regular && !metadata.is_file() {
return Err(Error::invalid(format!(
"source for {} is not a regular file",
member.id
)));
}
let canonical_source = fs::canonicalize(&member.source)
.map_err(|source| Error::io("canonicalizing artifact source", source))?;
if canonical_source.starts_with(canonical_root) {
return Err(Error::invalid(format!(
"source for {} is inside the installation root",
member.id
)));
}
let destination = installation_root.join(&member.destination);
if canonical_source == destination {
return Err(Error::invalid(format!(
"source for {} aliases its destination",
member.id
)));
}
Ok(())
}
pub(crate) fn normalize_relative_path(path: &Path) -> Result<PathBuf> {
if path.as_os_str().is_empty() || path.is_absolute() {
return Err(Error::invalid(
"destination must be a non-empty relative path",
));
}
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(value) => {
if value.to_string_lossy().chars().any(char::is_control) {
return Err(Error::invalid("destination contains control characters"));
}
normalized.push(value);
}
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(Error::invalid("destination escapes the installation root"));
}
}
}
if normalized.as_os_str().is_empty() {
return Err(Error::invalid("destination must name a file"));
}
Ok(normalized)
}