#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MountOrigin {
ManagedVolume(String),
BindMount(String),
Anonymous,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedMount {
pub origin: MountOrigin,
pub guest_path: String,
pub read_only: bool,
}
pub fn parse(spec: &str) -> anyhow::Result<ParsedMount> {
let spec = spec.trim();
if spec.is_empty() {
anyhow::bail!("empty volume spec");
}
let fields = split_fields(spec);
let fields: Vec<&str> = fields.iter().map(|f| f.trim()).collect();
match fields.as_slice() {
[guest] => Ok(ParsedMount {
origin: MountOrigin::Anonymous,
guest_path: absolute_box_path(guest)?,
read_only: false,
}),
[guest, mode] if is_mode(mode) => Ok(ParsedMount {
origin: MountOrigin::Anonymous,
guest_path: absolute_box_path(guest)?,
read_only: mode.eq_ignore_ascii_case("ro"),
}),
[source, guest] => Ok(ParsedMount {
origin: classify(source)?,
guest_path: absolute_box_path(guest)?,
read_only: false,
}),
[source, guest, options] => Ok(ParsedMount {
origin: classify(source)?,
guest_path: absolute_box_path(guest)?,
read_only: parse_read_only(options),
}),
_ => anyhow::bail!(
"invalid volume spec {spec:?}; use VOLUME:BOX_PATH, HOST_PATH:BOX_PATH[:OPTIONS], \
or BOX_PATH[:OPTIONS] for an anonymous volume"
),
}
}
fn split_fields(spec: &str) -> Vec<String> {
let mut fields = Vec::new();
let mut buffer = String::new();
for ch in spec.chars() {
if ch == ':' && !is_drive_letter(&buffer) {
fields.push(std::mem::take(&mut buffer));
} else {
buffer.push(ch);
}
}
fields.push(buffer);
fields
}
fn is_drive_letter(buffer: &str) -> bool {
let mut chars = buffer.chars();
matches!((chars.next(), chars.next()), (Some(c), None) if c.is_ascii_alphabetic())
}
fn classify(source: &str) -> anyhow::Result<MountOrigin> {
if source.is_empty() {
anyhow::bail!("volume source must be non-empty");
}
let host_path = match source.chars().next() {
Some('.') | Some('/') | Some('~') => true,
_ if source.starts_with(r"\\") => true,
_ => is_windows_drive_prefix(source),
};
Ok(if host_path {
MountOrigin::BindMount(source.to_string())
} else {
MountOrigin::ManagedVolume(source.to_string())
})
}
pub fn is_windows_drive_prefix(source: &str) -> bool {
let bytes = source.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'\\' || bytes[2] == b'/')
}
fn is_mode(field: &str) -> bool {
field.eq_ignore_ascii_case("ro") || field.eq_ignore_ascii_case("rw")
}
fn parse_read_only(options: &str) -> bool {
options
.split(',')
.any(|option| option.trim().eq_ignore_ascii_case("ro"))
}
fn absolute_box_path(guest: &str) -> anyhow::Result<String> {
if guest.is_empty() {
anyhow::bail!("volume box path must be non-empty");
}
if !guest.starts_with('/') {
anyhow::bail!("volume box path must be absolute (e.g. /data), got {guest:?}");
}
Ok(guest.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn managed(spec: &str) -> String {
match parse(spec).unwrap().origin {
MountOrigin::ManagedVolume(volume) => volume,
other => panic!("{spec:?} should be a managed volume, got {other:?}"),
}
}
fn host(spec: &str) -> String {
match parse(spec).unwrap().origin {
MountOrigin::BindMount(path) => path,
other => panic!("{spec:?} should be a host path, got {other:?}"),
}
}
#[test]
fn bare_source_is_a_managed_volume() {
assert_eq!(managed("my-data:/data"), "my-data");
assert_eq!(managed("vol_01K2EXAMPLE:/data"), "vol_01K2EXAMPLE");
assert_eq!(managed("data:/data"), "data");
}
#[test]
fn single_letter_source_is_swallowed_as_a_drive_letter() {
let error = parse("a:/data").unwrap_err().to_string();
assert!(error.contains("must be absolute"), "{error}");
}
#[test]
fn leading_dot_slash_or_tilde_is_a_host_path() {
assert_eq!(host("/host/data:/data"), "/host/data");
assert_eq!(host("./data:/data"), "./data");
assert_eq!(host("../data:/data"), "../data");
assert_eq!(host("~/data:/data"), "~/data");
}
#[test]
fn windows_drive_paths_survive_the_split() {
assert_eq!(host(r"C:\data:/data"), r"C:\data");
assert_eq!(host(r"D:/host/path:/data"), r"D:/host/path");
assert_eq!(host(r"\\server\share:/data"), r"\\server\share");
}
#[test]
fn windows_drive_path_still_takes_options() {
let mount = parse(r"C:\data:/data:ro").unwrap();
assert_eq!(mount.origin, MountOrigin::BindMount(r"C:\data".to_string()));
assert_eq!(mount.guest_path, "/data");
assert!(mount.read_only);
}
#[test]
fn anonymous_forms() {
assert_eq!(parse("/data").unwrap().origin, MountOrigin::Anonymous);
let read_only = parse("/data:ro").unwrap();
assert_eq!(read_only.origin, MountOrigin::Anonymous);
assert!(read_only.read_only);
assert!(!parse("/data:rw").unwrap().read_only);
}
#[test]
fn read_only_option_is_parsed_for_both_origins() {
assert!(parse("my-data:/data:ro").unwrap().read_only);
assert!(parse("/host:/data:ro").unwrap().read_only);
assert!(!parse("/host:/data:rw,nocopy").unwrap().read_only);
assert!(!parse("/host:/data:rw").unwrap().read_only);
}
#[test]
fn box_path_must_be_absolute() {
for spec in ["my-data:data", "/host:data", "data"] {
let error = parse(spec).unwrap_err().to_string();
assert!(error.contains("must be absolute"), "{spec}: {error}");
}
}
#[test]
fn rejects_empty_and_overlong_specs() {
assert!(parse("").unwrap_err().to_string().contains("empty"));
assert!(parse(" ").unwrap_err().to_string().contains("empty"));
let error = parse("a:/b:ro:extra:more").unwrap_err().to_string();
assert!(error.contains("invalid volume spec"), "{error}");
}
}