Skip to content

Commit 450685c

Browse files
authored
fix(drivers): reject whitespace in mount fields (#2086)
Signed-off-by: Evan Lezar <elezar@nvidia.com>
1 parent 914da33 commit 450685c

3 files changed

Lines changed: 84 additions & 85 deletions

File tree

crates/openshell-core/src/driver_mounts.rs

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,32 +13,36 @@ const RESERVED_MOUNT_TARGETS: &[&str] = &[
1313
];
1414

1515
/// Validate a non-empty driver mount source.
16-
pub fn validate_mount_source(source: &str, field: &str) -> Result<String, String> {
17-
let source = source.trim();
16+
pub fn validate_mount_source(source: &str, field: &str) -> Result<(), String> {
1817
if source.is_empty() {
1918
return Err(format!("{field} must not be empty"));
2019
}
20+
if source != source.trim() {
21+
return Err(format!("{field} must not contain surrounding whitespace"));
22+
}
2123
if source.as_bytes().contains(&0) {
2224
return Err(format!("{field} must not contain NUL bytes"));
2325
}
24-
Ok(source.to_string())
26+
Ok(())
2527
}
2628

2729
/// Validate a bind mount source as an absolute host path.
28-
pub fn validate_absolute_mount_source(source: &str, field: &str) -> Result<String, String> {
29-
let source = validate_mount_source(source, field)?;
30-
if !Path::new(&source).is_absolute() {
30+
pub fn validate_absolute_mount_source(source: &str, field: &str) -> Result<(), String> {
31+
validate_mount_source(source, field)?;
32+
if !Path::new(source).is_absolute() {
3133
return Err(format!("{field} must be an absolute host path"));
3234
}
33-
Ok(source)
35+
Ok(())
3436
}
3537

3638
/// Validate a relative subpath inside a runtime-managed mount source.
37-
pub fn validate_mount_subpath(subpath: &str) -> Result<String, String> {
38-
let subpath = subpath.trim();
39+
pub fn validate_mount_subpath(subpath: &str) -> Result<(), String> {
3940
if subpath.is_empty() {
4041
return Err("mount subpath must not be empty".to_string());
4142
}
43+
if subpath != subpath.trim() {
44+
return Err("mount subpath must not contain surrounding whitespace".to_string());
45+
}
4246
if subpath.as_bytes().contains(&0) {
4347
return Err("mount subpath must not contain NUL bytes".to_string());
4448
}
@@ -50,57 +54,56 @@ pub fn validate_mount_subpath(subpath: &str) -> Result<String, String> {
5054
{
5155
return Err("mount subpath must be relative and must not contain '..'".to_string());
5256
}
53-
Ok(subpath.to_string())
57+
Ok(())
5458
}
5559

5660
/// Validate a container-side mount target for user-supplied driver mounts.
57-
pub fn validate_container_mount_target(target: &str) -> Result<String, String> {
58-
let target = normalize_container_mount_target(target);
61+
pub fn validate_container_mount_target(target: &str) -> Result<(), String> {
5962
if target.is_empty() {
6063
return Err("mount target must not be empty".to_string());
6164
}
65+
if target != target.trim() {
66+
return Err("mount target must not contain surrounding whitespace".to_string());
67+
}
6268
if target.as_bytes().contains(&0) {
6369
return Err("mount target must not contain NUL bytes".to_string());
6470
}
6571
if !target.starts_with('/') {
6672
return Err("mount target must be an absolute container path".to_string());
6773
}
68-
if target == "/" {
74+
let path = Path::new(target);
75+
if path == Path::new("/") {
6976
return Err("mount target must not be the container root".to_string());
7077
}
71-
let path = Path::new(&target);
7278
if path
7379
.components()
7480
.any(|component| matches!(component, std::path::Component::ParentDir))
7581
{
7682
return Err("mount target must not contain '..'".to_string());
7783
}
78-
if target == "/sandbox" {
84+
if path == Path::new("/sandbox") {
7985
return Err("mount target '/sandbox' is reserved for the OpenShell workspace".to_string());
8086
}
8187
for reserved in RESERVED_MOUNT_TARGETS {
82-
if path_is_or_under(&target, reserved) {
88+
if path_is_or_under(path, Path::new(reserved)) {
8389
return Err(format!(
8490
"mount target '{target}' conflicts with reserved OpenShell path '{reserved}'"
8591
));
8692
}
8793
}
88-
Ok(target)
94+
Ok(())
8995
}
9096

91-
fn normalize_container_mount_target(target: &str) -> String {
92-
let target = target.trim();
97+
/// Normalize a validated container-side mount target for semantic comparison.
98+
pub fn normalize_mount_target(target: &str) -> String {
9399
if target == "/" {
94100
return target.to_string();
95101
}
96102
target.trim_end_matches('/').to_string()
97103
}
98104

99-
fn path_is_or_under(path: &str, parent: &str) -> bool {
100-
path == parent
101-
|| path
102-
.strip_prefix(parent)
103-
.is_some_and(|rest| rest.starts_with('/'))
105+
fn path_is_or_under(path: &Path, parent: &Path) -> bool {
106+
path == parent || path.starts_with(parent)
104107
}
105108

106109
#[cfg(test)]
@@ -109,10 +112,8 @@ mod tests {
109112

110113
#[test]
111114
fn container_target_allows_paths_under_workspace() {
112-
assert_eq!(
113-
validate_container_mount_target("/sandbox/work/").unwrap(),
114-
"/sandbox/work"
115-
);
115+
validate_container_mount_target("/sandbox/work/").unwrap();
116+
assert_eq!(normalize_mount_target("/sandbox/work/"), "/sandbox/work");
116117
}
117118

118119
#[test]
@@ -138,16 +139,30 @@ mod tests {
138139

139140
#[test]
140141
fn container_target_does_not_prefix_match_unrelated_paths() {
141-
assert_eq!(
142-
validate_container_mount_target("/etc/openshell-tools").unwrap(),
143-
"/etc/openshell-tools"
144-
);
142+
validate_container_mount_target("/etc/openshell-tools").unwrap();
145143
}
146144

147145
#[test]
148146
fn mount_subpath_must_be_relative_without_parent_dirs() {
149-
assert_eq!(validate_mount_subpath(" project/a ").unwrap(), "project/a");
147+
assert!(validate_mount_subpath("project/a").is_ok());
148+
assert!(validate_mount_subpath(" project/a ").is_err());
150149
assert!(validate_mount_subpath("/project").is_err());
151150
assert!(validate_mount_subpath("../project").is_err());
152151
}
152+
153+
#[test]
154+
fn mount_values_reject_surrounding_whitespace() {
155+
assert_eq!(
156+
validate_mount_source(" volume ", "volume source").unwrap_err(),
157+
"volume source must not contain surrounding whitespace"
158+
);
159+
assert_eq!(
160+
validate_absolute_mount_source(" /host/path", "bind source").unwrap_err(),
161+
"bind source must not contain surrounding whitespace"
162+
);
163+
assert_eq!(
164+
validate_container_mount_target("/sandbox/work ").unwrap_err(),
165+
"mount target must not contain surrounding whitespace"
166+
);
167+
}
153168
}

crates/openshell-driver-docker/src/lib.rs

Lines changed: 16 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -555,20 +555,18 @@ impl DockerComputeDriver {
555555
) -> Result<(), Status> {
556556
for mount in &driver_config.mounts {
557557
if let DockerDriverMountConfig::Volume { source, .. } = mount {
558-
match self.docker.inspect_volume(source.trim()).await {
558+
match self.docker.inspect_volume(source).await {
559559
Ok(volume) => {
560560
if !self.config.enable_bind_mounts && docker_volume_is_bind_backed(&volume)
561561
{
562562
return Err(Status::failed_precondition(format!(
563-
"docker volume '{}' is backed by a host bind mount and requires enable_bind_mounts = true in [openshell.drivers.docker]",
564-
source.trim()
563+
"docker volume '{source}' is backed by a host bind mount and requires enable_bind_mounts = true in [openshell.drivers.docker]"
565564
)));
566565
}
567566
}
568567
Err(err) if is_not_found_error(&err) => {
569568
return Err(Status::failed_precondition(format!(
570-
"docker volume '{}' does not exist",
571-
source.trim()
569+
"docker volume '{source}' does not exist"
572570
)));
573571
}
574572
Err(err) => {
@@ -1775,14 +1773,8 @@ fn docker_mount_from_config(config: &DockerDriverMountConfig) -> Result<Mount, S
17751773
read_only,
17761774
} => Ok(Mount {
17771775
typ: Some(MountTypeEnum::BIND),
1778-
source: Some(
1779-
driver_mounts::validate_absolute_mount_source(source, "bind source")
1780-
.map_err(Status::failed_precondition)?,
1781-
),
1782-
target: Some(
1783-
driver_mounts::validate_container_mount_target(target)
1784-
.map_err(Status::failed_precondition)?,
1785-
),
1776+
source: Some(source.clone()),
1777+
target: Some(target.clone()),
17861778
read_only: Some(*read_only),
17871779
..Default::default()
17881780
}),
@@ -1793,27 +1785,13 @@ fn docker_mount_from_config(config: &DockerDriverMountConfig) -> Result<Mount, S
17931785
subpath,
17941786
} => Ok(Mount {
17951787
typ: Some(MountTypeEnum::VOLUME),
1796-
source: Some(
1797-
driver_mounts::validate_mount_source(source, "volume source")
1798-
.map_err(Status::failed_precondition)?,
1799-
),
1800-
target: Some(
1801-
driver_mounts::validate_container_mount_target(target)
1802-
.map_err(Status::failed_precondition)?,
1803-
),
1788+
source: Some(source.clone()),
1789+
target: Some(target.clone()),
18041790
read_only: Some(*read_only),
1805-
volume_options: subpath
1806-
.as_ref()
1807-
.map(|subpath| {
1808-
Ok::<MountVolumeOptions, Status>(MountVolumeOptions {
1809-
subpath: Some(
1810-
driver_mounts::validate_mount_subpath(subpath)
1811-
.map_err(Status::failed_precondition)?,
1812-
),
1813-
..Default::default()
1814-
})
1815-
})
1816-
.transpose()?,
1791+
volume_options: subpath.as_ref().map(|subpath| MountVolumeOptions {
1792+
subpath: Some(subpath.clone()),
1793+
..Default::default()
1794+
}),
18171795
..Default::default()
18181796
}),
18191797
DockerDriverMountConfig::Tmpfs {
@@ -1823,10 +1801,7 @@ fn docker_mount_from_config(config: &DockerDriverMountConfig) -> Result<Mount, S
18231801
mode,
18241802
} => Ok(Mount {
18251803
typ: Some(MountTypeEnum::TMPFS),
1826-
target: Some(
1827-
driver_mounts::validate_container_mount_target(target)
1828-
.map_err(Status::failed_precondition)?,
1829-
),
1804+
target: Some(target.clone()),
18301805
tmpfs_options: Some(MountTmpfsOptions {
18311806
size_bytes: validate_optional_positive_integral_i64(
18321807
*size_bytes,
@@ -1906,11 +1881,12 @@ fn validate_docker_driver_mounts(
19061881
));
19071882
}
19081883
};
1909-
let target = driver_mounts::validate_container_mount_target(target)
1884+
driver_mounts::validate_container_mount_target(target)
19101885
.map_err(Status::failed_precondition)?;
1911-
if !targets.insert(target.clone()) {
1886+
let normalized_target = driver_mounts::normalize_mount_target(target);
1887+
if !targets.insert(normalized_target.clone()) {
19121888
return Err(Status::failed_precondition(format!(
1913-
"duplicate docker driver_config mount target '{target}'"
1889+
"duplicate docker driver_config mount target '{normalized_target}'"
19141890
)));
19151891
}
19161892
}

crates/openshell-driver-podman/src/container.rs

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ pub fn podman_driver_volume_mount_sources(
493493
.mounts
494494
.into_iter()
495495
.filter_map(|mount| match mount {
496-
PodmanDriverMountConfig::Volume { source, .. } => Some(source.trim().to_string()),
496+
PodmanDriverMountConfig::Volume { source, .. } => Some(source),
497497
_ => None,
498498
})
499499
.collect())
@@ -515,7 +515,7 @@ pub fn podman_driver_image_mount_sources(
515515
.mounts
516516
.into_iter()
517517
.filter_map(|mount| match mount {
518-
PodmanDriverMountConfig::Image { source, .. } => Some(source.trim().to_string()),
518+
PodmanDriverMountConfig::Image { source, .. } => Some(source),
519519
_ => None,
520520
})
521521
.collect())
@@ -541,10 +541,12 @@ fn podman_user_mounts(
541541
target,
542542
read_only,
543543
} => {
544+
driver_mounts::validate_absolute_mount_source(&source, "bind source")?;
545+
driver_mounts::validate_container_mount_target(&target)?;
544546
result.mounts.push(Mount {
545547
kind: "bind".into(),
546-
source: driver_mounts::validate_absolute_mount_source(&source, "bind source")?,
547-
destination: driver_mounts::validate_container_mount_target(&target)?,
548+
source,
549+
destination: target,
548550
options: vec![
549551
if read_only { "ro" } else { "rw" }.to_string(),
550552
"rbind".to_string(),
@@ -558,9 +560,11 @@ fn podman_user_mounts(
558560
subpath,
559561
} => {
560562
reject_subpath(subpath.as_deref(), "podman volume mounts")?;
563+
driver_mounts::validate_mount_source(&source, "volume source")?;
564+
driver_mounts::validate_container_mount_target(&target)?;
561565
result.volumes.push(NamedVolume {
562-
name: driver_mounts::validate_mount_source(&source, "volume source")?,
563-
dest: driver_mounts::validate_container_mount_target(&target)?,
566+
name: source,
567+
dest: target,
564568
options: vec![if read_only { "ro" } else { "rw" }.to_string()],
565569
});
566570
}
@@ -583,10 +587,11 @@ fn podman_user_mounts(
583587
{
584588
options.push(format!("mode={mode:o}"));
585589
}
590+
driver_mounts::validate_container_mount_target(&target)?;
586591
result.mounts.push(Mount {
587592
kind: "tmpfs".into(),
588593
source: "tmpfs".into(),
589-
destination: driver_mounts::validate_container_mount_target(&target)?,
594+
destination: target,
590595
options,
591596
});
592597
}
@@ -597,9 +602,11 @@ fn podman_user_mounts(
597602
subpath,
598603
} => {
599604
reject_subpath(subpath.as_deref(), "podman image mounts")?;
605+
driver_mounts::validate_mount_source(&source, "image source")?;
606+
driver_mounts::validate_container_mount_target(&target)?;
600607
result.image_volumes.push(ImageVolume {
601-
source: driver_mounts::validate_mount_source(&source, "image source")?,
602-
destination: driver_mounts::validate_container_mount_target(&target)?,
608+
source,
609+
destination: target,
603610
rw: !read_only,
604611
});
605612
}
@@ -671,10 +678,11 @@ fn validate_podman_driver_mounts(
671678
target
672679
}
673680
};
674-
let target = driver_mounts::validate_container_mount_target(target)?;
675-
if !targets.insert(target.clone()) {
681+
driver_mounts::validate_container_mount_target(target)?;
682+
let normalized_target = driver_mounts::normalize_mount_target(target);
683+
if !targets.insert(normalized_target.clone()) {
676684
return Err(format!(
677-
"duplicate podman driver_config mount target '{target}'"
685+
"duplicate podman driver_config mount target '{normalized_target}'"
678686
));
679687
}
680688
}

0 commit comments

Comments
 (0)