pub mod mount;
use std::{
borrow::{Borrow, Cow},
fmt::{self, Display, Formatter, Write},
path::{Component, Path, PathBuf},
str::FromStr,
};
use compose_spec_macros::{DeserializeFromStr, DeserializeTryFromString, SerializeDisplay};
use indexmap::IndexSet;
use serde::{
de::{self, Unexpected},
Deserialize, Deserializer, Serialize, Serializer,
};
use thiserror::Error;
use crate::{impl_try_from, Identifier, InvalidIdentifierError, ShortOrLong};
pub use self::mount::Mount;
use self::mount::{Bind, BindOptions, Common, Volume};
pub type Volumes = IndexSet<ShortOrLong<ShortVolume, Mount>>;
pub fn into_short_iter(volumes: Volumes) -> impl Iterator<Item = Result<ShortVolume, Mount>> {
volumes.into_iter().map(|volume| match volume {
ShortOrLong::Short(volume) => Ok(volume),
ShortOrLong::Long(volume) => volume.into_short(),
})
}
pub fn into_long_iter(volumes: Volumes) -> impl Iterator<Item = Mount> {
volumes.into_iter().map(Into::into)
}
pub(crate) fn named_volumes_iter(volumes: &Volumes) -> impl Iterator<Item = &Identifier> {
volumes.iter().filter_map(|volume| match volume {
ShortOrLong::Short(ShortVolume {
options:
Some(ShortOptions {
source: Source::Volume(volume),
..
}),
..
}) => Some(volume),
ShortOrLong::Long(Mount::Volume(Volume { source, .. })) => source.as_ref(),
_ => None,
})
}
#[derive(SerializeDisplay, DeserializeFromStr, Debug, Clone, PartialEq, Eq, Hash)]
#[serde(expecting = "a string in the format \"[{source}:]{container_path}[:{options}]\"")]
pub struct ShortVolume {
pub container_path: AbsolutePath,
pub options: Option<ShortOptions>,
}
impl ShortVolume {
#[must_use]
pub const fn new(container_path: AbsolutePath) -> Self {
Self {
container_path,
options: None,
}
}
#[must_use]
pub fn into_long(self) -> Mount {
let Self {
container_path: target,
options,
} = self;
if let Some(ShortOptions {
source,
read_only,
selinux,
}) = options
{
let common = Common {
read_only,
..target.into()
};
match source {
Source::HostPath(source) => Mount::Bind(Bind {
source,
bind: Some(BindOptions {
create_host_path: true,
selinux,
..BindOptions::default()
}),
common,
}),
Source::Volume(source) => Mount::Volume(Volume {
source: Some(source),
volume: None,
common,
}),
}
} else {
Mount::Volume(Common::new(target).into())
}
}
}
impl From<AbsolutePath> for ShortVolume {
fn from(container_path: AbsolutePath) -> Self {
Self::new(container_path)
}
}
impl FromStr for ShortVolume {
type Err = ParseShortVolumeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut split = s.splitn(3, ':');
let source_or_container = split.next().expect("split has at least one element");
let Some(container_path) = split.next() else {
let container_path = source_or_container;
return parse_container_path(container_path).map(Self::new);
};
let source = source_or_container.parse()?;
let container_path = parse_container_path(container_path)?;
let Some(options) = split.next() else {
return Ok(Self {
container_path,
options: Some(ShortOptions::new(source)),
});
};
let mut read_only = None;
let mut selinux = None;
for option in options.split(',') {
match option {
"rw" => match read_only {
None => read_only = Some(false),
Some(true) => return Err(ParseShortVolumeError::ReadWriteAndReadOnly),
Some(false) => return Err(ParseShortVolumeError::DuplicateOption("rw")),
},
"ro" => match read_only {
None => read_only = Some(true),
Some(false) => return Err(ParseShortVolumeError::ReadWriteAndReadOnly),
Some(true) => return Err(ParseShortVolumeError::DuplicateOption("ro")),
},
"z" => match selinux {
None => selinux = Some(SELinux::Shared),
Some(SELinux::Private) => {
return Err(ParseShortVolumeError::SELinuxSharedAndPrivate);
}
Some(SELinux::Shared) => {
return Err(ParseShortVolumeError::DuplicateOption("z"));
}
},
"Z" => match selinux {
None => selinux = Some(SELinux::Private),
Some(SELinux::Shared) => {
return Err(ParseShortVolumeError::SELinuxSharedAndPrivate);
}
Some(SELinux::Private) => {
return Err(ParseShortVolumeError::DuplicateOption("Z"));
}
},
unknown => return Err(ParseShortVolumeError::UnknownOption(unknown.to_owned())),
}
}
Ok(Self {
container_path,
options: Some(ShortOptions {
source,
read_only: read_only.unwrap_or_default(),
selinux,
}),
})
}
}
fn parse_container_path(container_path: &str) -> Result<AbsolutePath, ParseShortVolumeError> {
#[allow(clippy::map_err_ignore)]
container_path
.parse()
.map_err(|_| ParseShortVolumeError::AbsoluteContainerPath(container_path.to_owned()))
}
#[derive(Error, Debug, Clone, PartialEq, Eq)]
pub enum ParseShortVolumeError {
#[error("error parsing volume source")]
Source(#[from] ParseSourceError),
#[error("volume container path `{0}` is not absolute")]
AbsoluteContainerPath(String),
#[error("cannot set both `rw` and `ro` in volume options")]
ReadWriteAndReadOnly,
#[error("cannot set both `z` and `Z` in volume options")]
SELinuxSharedAndPrivate,
#[error("volume option `{0}` set multiple times")]
DuplicateOption(&'static str),
#[error("unknown volume option `{0}`")]
UnknownOption(String),
}
impl Display for ShortVolume {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
let Self {
container_path,
options,
} = self;
let container_path = container_path.as_path().display();
if let Some(ShortOptions {
ref source,
read_only,
selinux,
}) = *options
{
write!(f, "{source}:{container_path}")?;
if read_only || selinux.is_some() {
f.write_char(':')?;
if read_only {
f.write_str("ro")?;
if selinux.is_some() {
f.write_char(',')?;
}
}
if let Some(selinux) = selinux {
selinux.fmt(f)?;
}
}
Ok(())
} else {
container_path.fmt(f)
}
}
}
#[derive(
Serialize, DeserializeTryFromString, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash,
)]
#[serde(transparent)]
pub struct AbsolutePath(PathBuf);
impl AbsolutePath {
pub fn new<T>(path: T) -> Result<Self, AbsolutePathError>
where
T: AsRef<Path> + Into<PathBuf>,
{
if path.as_ref().is_absolute() {
Ok(Self(path.into()))
} else {
Err(AbsolutePathError)
}
}
pub fn pop(&mut self) -> bool {
self.0.pop()
}
}
#[derive(Error, Debug, Clone, Copy, PartialEq, Eq)]
#[error("path is not absolute")]
pub struct AbsolutePathError;
macro_rules! path_impls {
($Ty:ident => $Error:ty) => {
impl $Ty {
#[must_use]
pub fn as_path(&self) -> &Path {
self.0.as_path()
}
pub fn push<P: AsRef<Path>>(&mut self, path: P) {
self.0.push(path);
}
#[must_use]
pub const fn as_inner(&self) -> &PathBuf {
&self.0
}
#[must_use]
pub fn into_inner(self) -> PathBuf {
self.0
}
}
impl_try_from! {
$Ty::new -> $Error,
PathBuf, Box<Path>, &Path, Cow<'_, Path>, String, &str,
}
impl FromStr for $Ty {
type Err = $Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.try_into()
}
}
impl AsRef<PathBuf> for $Ty {
fn as_ref(&self) -> &PathBuf {
self.as_inner()
}
}
impl AsRef<Path> for $Ty {
fn as_ref(&self) -> &Path {
self.as_path()
}
}
impl Borrow<Path> for $Ty {
fn borrow(&self) -> &Path {
self.as_path()
}
}
impl PartialEq<Path> for $Ty {
fn eq(&self, other: &Path) -> bool {
self.0.eq(other)
}
}
impl From<$Ty> for PathBuf {
fn from(value: $Ty) -> Self {
value.into_inner()
}
}
impl From<$Ty> for Box<Path> {
fn from(value: $Ty) -> Self {
value.into_inner().into()
}
}
};
}
path_impls!(AbsolutePath => AbsolutePathError);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ShortOptions {
pub source: Source,
pub read_only: bool,
pub selinux: Option<SELinux>,
}
impl ShortOptions {
#[must_use]
pub const fn new(source: Source) -> Self {
Self {
source,
read_only: false,
selinux: None,
}
}
}
impl From<Source> for ShortOptions {
fn from(source: Source) -> Self {
Self::new(source)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Source {
HostPath(HostPath),
Volume(Identifier),
}
impl Source {
pub fn parse<T>(source: T) -> Result<Self, ParseSourceError>
where
T: AsRef<str> + TryInto<HostPath> + TryInto<Identifier>,
<T as TryInto<HostPath>>::Error: Into<ParseSourceError>,
<T as TryInto<Identifier>>::Error: Into<ParseSourceError>,
{
if source.as_ref().starts_with('.') || Path::new(source.as_ref()).is_absolute() {
source.try_into().map(Self::HostPath).map_err(Into::into)
} else {
source.try_into().map(Self::Volume).map_err(Into::into)
}
}
}
#[derive(Error, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParseSourceError {
#[error("error parsing host path")]
HostPath(#[from] HostPathError),
#[error("error parsing volume identifier")]
Identifier(#[from] InvalidIdentifierError),
}
impl FromStr for Source {
type Err = ParseSourceError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl TryFrom<&str> for Source {
type Error = ParseSourceError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
impl TryFrom<String> for Source {
type Error = ParseSourceError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
impl From<HostPath> for Source {
fn from(value: HostPath) -> Self {
Self::HostPath(value)
}
}
impl From<AbsolutePath> for Source {
fn from(value: AbsolutePath) -> Self {
HostPath::from(value).into()
}
}
impl TryFrom<PathBuf> for Source {
type Error = HostPathError;
fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
HostPath::try_from(value).map(Into::into)
}
}
impl TryFrom<Box<Path>> for Source {
type Error = HostPathError;
fn try_from(value: Box<Path>) -> Result<Self, Self::Error> {
HostPath::try_from(value).map(Into::into)
}
}
impl TryFrom<&Path> for Source {
type Error = HostPathError;
fn try_from(value: &Path) -> Result<Self, Self::Error> {
HostPath::try_from(value).map(Into::into)
}
}
impl TryFrom<Cow<'_, Path>> for Source {
type Error = HostPathError;
fn try_from(value: Cow<'_, Path>) -> Result<Self, Self::Error> {
HostPath::try_from(value).map(Into::into)
}
}
impl From<Identifier> for Source {
fn from(value: Identifier) -> Self {
Self::Volume(value)
}
}
impl Display for Source {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
match self {
Self::HostPath(source) => source.as_path().display().fmt(f),
Self::Volume(source) => source.fmt(f),
}
}
}
#[derive(
Serialize, DeserializeTryFromString, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash,
)]
#[serde(transparent)]
pub struct HostPath(PathBuf);
impl HostPath {
pub fn new<T>(path: T) -> Result<Self, HostPathError>
where
T: AsRef<Path> + Into<PathBuf>,
{
if path.as_ref().is_absolute()
|| path.as_ref().components().next().is_some_and(|component| {
matches!(component, Component::CurDir | Component::ParentDir)
})
{
Ok(Self(path.into()))
} else {
Err(HostPathError)
}
}
pub fn pop(&mut self) -> bool {
!self
.0
.parent()
.is_some_and(|parent| parent.as_os_str().is_empty())
&& self.0.pop()
}
}
#[derive(Error, Debug, Clone, Copy, PartialEq, Eq)]
#[error("volume host paths must start with `.` or `..`, or be absolute")]
pub struct HostPathError;
path_impls!(HostPath => HostPathError);
impl From<AbsolutePath> for HostPath {
fn from(value: AbsolutePath) -> Self {
Self(value.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SELinux {
Shared,
Private,
}
impl SELinux {
#[must_use]
pub const fn as_char(self) -> char {
match self {
Self::Shared => 'z',
Self::Private => 'Z',
}
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Shared => "z",
Self::Private => "Z",
}
}
}
impl From<SELinux> for char {
fn from(value: SELinux) -> Self {
value.as_char()
}
}
impl AsRef<str> for SELinux {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl Display for SELinux {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
f.write_char(self.as_char())
}
}
impl Serialize for SELinux {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_char(self.as_char())
}
}
impl<'de> Deserialize<'de> for SELinux {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
match char::deserialize(deserializer)? {
'z' => Ok(Self::Shared),
'Z' => Ok(Self::Private),
char => Err(de::Error::invalid_value(
Unexpected::Char(char),
&"'z' or 'Z'",
)),
}
}
}
#[cfg(test)]
mod tests {
use proptest::{
arbitrary::{any, Arbitrary},
option, prop_assert_eq, prop_compose, prop_oneof, proptest,
strategy::{BoxedStrategy, Just, Strategy},
};
use crate::service::tests::path_no_colon;
use super::*;
impl Arbitrary for AbsolutePath {
type Parameters = ();
type Strategy = BoxedStrategy<Self>;
fn arbitrary_with((): Self::Parameters) -> Self::Strategy {
path_no_colon()
.prop_map(|path| {
if path.is_absolute() {
Self(path)
} else {
Self(Path::new("/").join(path))
}
})
.boxed()
}
}
mod short_volume {
use super::*;
proptest! {
#[test]
fn parse_no_panic(string: String) {
let _ = string.parse::<ShortVolume>();
}
#[test]
fn round_trip(volume in short_volume()) {
prop_assert_eq!(&volume, &volume.to_string().parse()?);
}
}
}
prop_compose! {
fn short_volume()(
container_path: AbsolutePath,
options in option::of(short_options()),
) -> ShortVolume {
ShortVolume {
container_path,
options,
}
}
}
prop_compose! {
fn short_options()(
source in source(),
read_only: bool,
selinux in option::of(selinux()),
) -> ShortOptions {
ShortOptions {
source,
read_only,
selinux
}
}
}
fn source() -> impl Strategy<Value = Source> {
prop_oneof![
host_path().prop_map_into(),
any::<Identifier>().prop_map_into(),
]
}
fn host_path() -> impl Strategy<Value = HostPath> {
path_no_colon().prop_flat_map(|path| {
prop_oneof![Just("/"), Just("."), Just("..")]
.prop_map(move |prefix| HostPath(Path::new(prefix).join(&path)))
})
}
fn selinux() -> impl Strategy<Value = SELinux> {
prop_oneof![Just(SELinux::Shared), Just(SELinux::Private)]
}
}