Skip to content

Commit 19a2575

Browse files
authored
Merge pull request #85 from hyperlight-dev/feat/readonly-mounts
feat: support read-only filesystem mounts
2 parents 05d232a + 399c361 commit 19a2575

1 file changed

Lines changed: 161 additions & 17 deletions

File tree

host/src/lib.rs

Lines changed: 161 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ const SOCKET_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
160160
pub struct Preopen {
161161
pub host_dir: std::path::PathBuf,
162162
pub guest_path: String,
163+
pub read_only: bool,
163164
}
164165

165166
impl Preopen {
@@ -192,9 +193,16 @@ impl Preopen {
192193
Ok(Self {
193194
host_dir,
194195
guest_path,
196+
read_only: false,
195197
})
196198
}
197199

200+
/// Mark this preopen as read-only.
201+
pub fn read_only(mut self) -> Self {
202+
self.read_only = true;
203+
self
204+
}
205+
198206
/// Parse a `HOST[:GUEST]` CLI argument. When `GUEST` is omitted the
199207
/// default guest mount point is `/host`.
200208
pub fn parse_cli(s: &str) -> Result<Self> {
@@ -1677,30 +1685,34 @@ fn register_net_tools(
16771685
/// matching the guest-supplied path against each preopen's guest path.
16781686
#[derive(Clone)]
16791687
struct FsRouter {
1680-
entries: Vec<(String, FsSandbox)>,
1688+
entries: Vec<(String, FsSandbox, bool)>,
16811689
}
16821690

16831691
impl FsRouter {
16841692
fn new(preopens: &[Preopen]) -> Result<Self> {
16851693
let mut entries = Vec::with_capacity(preopens.len());
16861694
for p in preopens {
1687-
entries.push((p.guest_path.clone(), FsSandbox::new(&p.host_dir)?));
1695+
entries.push((
1696+
p.guest_path.clone(),
1697+
FsSandbox::new(&p.host_dir)?,
1698+
p.read_only,
1699+
));
16881700
}
16891701
// Sort by descending prefix length so longer matches win (e.g.
16901702
// /data/public should match before /data).
16911703
entries.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
16921704
Ok(Self { entries })
16931705
}
16941706

1695-
/// Pick the preopen matching `path` and return its sandbox plus
1696-
/// the path-relative-to-that-sandbox.
1697-
fn route<'a>(&'a self, path: &'a str) -> Result<(&'a FsSandbox, &'a str)> {
1698-
for (prefix, fs) in &self.entries {
1707+
/// Pick the preopen matching `path` and return its sandbox,
1708+
/// the path-relative-to-that-sandbox, and whether it is read-only.
1709+
fn route<'a>(&'a self, path: &'a str) -> Result<(&'a FsSandbox, &'a str, bool)> {
1710+
for (prefix, fs, ro) in &self.entries {
16991711
if path == prefix {
1700-
return Ok((fs, ""));
1712+
return Ok((fs, "", *ro));
17011713
}
17021714
if let Some(tail) = path.strip_prefix(prefix).and_then(|t| t.strip_prefix('/')) {
1703-
return Ok((fs, tail));
1715+
return Ok((fs, tail, *ro));
17041716
}
17051717
}
17061718
Err(anyhow!(
@@ -1709,6 +1721,14 @@ impl FsRouter {
17091721
))
17101722
}
17111723

1724+
fn require_writable<'a>(&'a self, path: &'a str) -> Result<(&'a FsSandbox, &'a str)> {
1725+
let (fs, rel, ro) = self.route(path)?;
1726+
if ro {
1727+
return Err(anyhow!("read-only mount: write to {} denied", path));
1728+
}
1729+
Ok((fs, rel))
1730+
}
1731+
17121732
fn register(self, registry: &mut ToolRegistry) {
17131733
use serde_json::json;
17141734

@@ -1717,7 +1737,7 @@ impl FsRouter {
17171737
let path = args["path"]
17181738
.as_str()
17191739
.ok_or_else(|| anyhow!("fs_read: missing 'path'"))?;
1720-
let (fs, rel) = r.route(path)?;
1740+
let (fs, rel, _ro) = r.route(path)?;
17211741
let target = fs.resolve(rel)?;
17221742
let size = std::fs::metadata(&target)
17231743
.map_err(|e| anyhow!("fs_read {:?}: {}", path, e))?
@@ -1751,7 +1771,7 @@ impl FsRouter {
17511771
));
17521772
}
17531773
let append = args["append"].as_bool().unwrap_or(false);
1754-
let (fs, rel) = r.route(path)?;
1774+
let (fs, rel) = r.require_writable(path)?;
17551775
let target = fs.resolve(rel)?;
17561776
use std::io::Write;
17571777
let mut f = std::fs::OpenOptions::new()
@@ -1769,7 +1789,7 @@ impl FsRouter {
17691789
let r = self.clone();
17701790
registry.register("fs_list", move |args| {
17711791
let path = args["path"].as_str().unwrap_or("");
1772-
let (fs, rel) = r.route(path)?;
1792+
let (fs, rel, _ro) = r.route(path)?;
17731793
let target = fs.resolve(rel)?;
17741794
let mut entries = Vec::new();
17751795
for entry in
@@ -1799,7 +1819,7 @@ impl FsRouter {
17991819
let path = args["path"]
18001820
.as_str()
18011821
.ok_or_else(|| anyhow!("fs_stat: missing 'path'"))?;
1802-
let (fs, rel) = r.route(path)?;
1822+
let (fs, rel, _ro) = r.route(path)?;
18031823
let target = fs.resolve(rel)?;
18041824
let md =
18051825
std::fs::metadata(&target).map_err(|e| anyhow!("fs_stat {:?}: {}", path, e))?;
@@ -1833,7 +1853,7 @@ impl FsRouter {
18331853
.ok_or_else(|| anyhow!("fs_read_bytes: missing 'path'"))?;
18341854
let offset = args["offset"].as_u64().unwrap_or(0);
18351855
let want = args["len"].as_u64().unwrap_or(65536).min(MAX_FS_READ);
1836-
let (fs, rel) = r.route(path)?;
1856+
let (fs, rel, _ro) = r.route(path)?;
18371857
let target = fs.resolve(rel)?;
18381858
let mut f = std::fs::File::open(&target)
18391859
.map_err(|e| anyhow!("fs_read_bytes {:?}: {}", path, e))?;
@@ -1888,7 +1908,7 @@ impl FsRouter {
18881908
}
18891909
}
18901910
let append = args["append"].as_bool().unwrap_or(false);
1891-
let (fs, rel) = r.route(path)?;
1911+
let (fs, rel) = r.require_writable(path)?;
18921912
let target = fs.resolve(rel)?;
18931913
let mut f = std::fs::OpenOptions::new()
18941914
.write(true)
@@ -1923,7 +1943,7 @@ impl FsRouter {
19231943
MAX_TRUNCATE_LEN
19241944
));
19251945
}
1926-
let (fs, rel) = r.route(path)?;
1946+
let (fs, rel) = r.require_writable(path)?;
19271947
let target = fs.resolve(rel)?;
19281948
let f = std::fs::OpenOptions::new()
19291949
.write(true)
@@ -1940,7 +1960,7 @@ impl FsRouter {
19401960
.as_str()
19411961
.ok_or_else(|| anyhow!("fs_mkdir: missing 'path'"))?;
19421962
let parents = args["parents"].as_bool().unwrap_or(false);
1943-
let (fs, rel) = r.route(path)?;
1963+
let (fs, rel) = r.require_writable(path)?;
19441964
let target = fs.resolve(rel)?;
19451965
if parents {
19461966
std::fs::create_dir_all(&target)
@@ -1956,7 +1976,7 @@ impl FsRouter {
19561976
let path = args["path"]
19571977
.as_str()
19581978
.ok_or_else(|| anyhow!("fs_unlink: missing 'path'"))?;
1959-
let (fs, rel) = r.route(path)?;
1979+
let (fs, rel) = r.require_writable(path)?;
19601980
let target = fs.resolve(rel)?;
19611981
if target == *fs.root() {
19621982
return Err(anyhow!("fs_unlink: cannot remove mount root"));
@@ -3038,6 +3058,130 @@ mod tests {
30383058
assert!(s.contains("\"text\":\"hi\""), "{s}");
30393059
}
30403060

3061+
// -- Read-only mount tests ------------------------------------------------
3062+
3063+
#[test]
3064+
fn readonly_mount_allows_reads() {
3065+
let root = tmpdir("ro-read");
3066+
fs::write(root.join("file.txt"), b"hello").unwrap();
3067+
let preopens = vec![Preopen::new(&root, "/data").unwrap().read_only()];
3068+
let mut reg = ToolRegistry::new();
3069+
FsRouter::new(&preopens).unwrap().register(&mut reg);
3070+
3071+
let req = br#"{"name":"fs_read","args":{"path":"/data/file.txt"}}"#;
3072+
let resp = reg.dispatch(req);
3073+
let s = std::str::from_utf8(&resp).unwrap();
3074+
assert!(s.contains("\"text\":\"hello\""), "{s}");
3075+
}
3076+
3077+
#[test]
3078+
fn readonly_mount_blocks_fs_write() {
3079+
let root = tmpdir("ro-write");
3080+
let preopens = vec![Preopen::new(&root, "/data").unwrap().read_only()];
3081+
let mut reg = ToolRegistry::new();
3082+
FsRouter::new(&preopens).unwrap().register(&mut reg);
3083+
3084+
let req = br#"{"name":"fs_write","args":{"path":"/data/new.txt","text":"nope"}}"#;
3085+
let resp = reg.dispatch(req);
3086+
let s = std::str::from_utf8(&resp).unwrap();
3087+
assert!(s.contains("\"error\""), "{s}");
3088+
assert!(s.contains("read-only mount"), "{s}");
3089+
}
3090+
3091+
#[test]
3092+
fn readonly_mount_blocks_fs_mkdir() {
3093+
let root = tmpdir("ro-mkdir");
3094+
let preopens = vec![Preopen::new(&root, "/data").unwrap().read_only()];
3095+
let mut reg = ToolRegistry::new();
3096+
FsRouter::new(&preopens).unwrap().register(&mut reg);
3097+
3098+
let req = br#"{"name":"fs_mkdir","args":{"path":"/data/subdir"}}"#;
3099+
let resp = reg.dispatch(req);
3100+
let s = std::str::from_utf8(&resp).unwrap();
3101+
assert!(s.contains("\"error\""), "{s}");
3102+
assert!(s.contains("read-only mount"), "{s}");
3103+
}
3104+
3105+
#[test]
3106+
fn readonly_mount_blocks_fs_unlink() {
3107+
let root = tmpdir("ro-unlink");
3108+
fs::write(root.join("victim.txt"), b"data").unwrap();
3109+
let preopens = vec![Preopen::new(&root, "/data").unwrap().read_only()];
3110+
let mut reg = ToolRegistry::new();
3111+
FsRouter::new(&preopens).unwrap().register(&mut reg);
3112+
3113+
let req = br#"{"name":"fs_unlink","args":{"path":"/data/victim.txt"}}"#;
3114+
let resp = reg.dispatch(req);
3115+
let s = std::str::from_utf8(&resp).unwrap();
3116+
assert!(s.contains("\"error\""), "{s}");
3117+
assert!(s.contains("read-only mount"), "{s}");
3118+
assert!(
3119+
root.join("victim.txt").exists(),
3120+
"file should not be deleted"
3121+
);
3122+
}
3123+
3124+
#[test]
3125+
fn readonly_mount_blocks_fs_truncate() {
3126+
let root = tmpdir("ro-trunc");
3127+
fs::write(root.join("file.txt"), b"hello world").unwrap();
3128+
let preopens = vec![Preopen::new(&root, "/data").unwrap().read_only()];
3129+
let mut reg = ToolRegistry::new();
3130+
FsRouter::new(&preopens).unwrap().register(&mut reg);
3131+
3132+
let req = br#"{"name":"fs_truncate","args":{"path":"/data/file.txt","length":0}}"#;
3133+
let resp = reg.dispatch(req);
3134+
let s = std::str::from_utf8(&resp).unwrap();
3135+
assert!(s.contains("\"error\""), "{s}");
3136+
assert!(s.contains("read-only mount"), "{s}");
3137+
}
3138+
3139+
#[test]
3140+
fn readonly_mount_blocks_fs_write_bytes() {
3141+
let root = tmpdir("ro-wbytes");
3142+
let preopens = vec![Preopen::new(&root, "/data").unwrap().read_only()];
3143+
let mut reg = ToolRegistry::new();
3144+
FsRouter::new(&preopens).unwrap().register(&mut reg);
3145+
3146+
let req = br#"{"name":"fs_write_bytes","args":{"path":"/data/bin.dat","data":"AAAA"}}"#;
3147+
let resp = reg.dispatch(req);
3148+
let s = std::str::from_utf8(&resp).unwrap();
3149+
assert!(s.contains("\"error\""), "{s}");
3150+
assert!(s.contains("read-only mount"), "{s}");
3151+
}
3152+
3153+
#[test]
3154+
fn mixed_rw_and_ro_mounts() {
3155+
let rw_root = tmpdir("mixed-rw");
3156+
let ro_root = tmpdir("mixed-ro");
3157+
fs::write(ro_root.join("existing.txt"), b"read me").unwrap();
3158+
let preopens = vec![
3159+
Preopen::new(&rw_root, "/rw").unwrap(),
3160+
Preopen::new(&ro_root, "/ro").unwrap().read_only(),
3161+
];
3162+
let mut reg = ToolRegistry::new();
3163+
FsRouter::new(&preopens).unwrap().register(&mut reg);
3164+
3165+
// Write to rw mount succeeds
3166+
let req = br#"{"name":"fs_write","args":{"path":"/rw/ok.txt","text":"yes"}}"#;
3167+
let resp = reg.dispatch(req);
3168+
let s = std::str::from_utf8(&resp).unwrap();
3169+
assert!(s.contains("\"bytes_written\""), "{s}");
3170+
3171+
// Read from ro mount succeeds
3172+
let req = br#"{"name":"fs_read","args":{"path":"/ro/existing.txt"}}"#;
3173+
let resp = reg.dispatch(req);
3174+
let s = std::str::from_utf8(&resp).unwrap();
3175+
assert!(s.contains("\"text\":\"read me\""), "{s}");
3176+
3177+
// Write to ro mount fails
3178+
let req = br#"{"name":"fs_write","args":{"path":"/ro/nope.txt","text":"no"}}"#;
3179+
let resp = reg.dispatch(req);
3180+
let s = std::str::from_utf8(&resp).unwrap();
3181+
assert!(s.contains("\"error\""), "{s}");
3182+
assert!(s.contains("read-only mount"), "{s}");
3183+
}
3184+
30413185
// -- NetworkPolicy tests --------------------------------------------------
30423186

30433187
#[test]

0 commit comments

Comments
 (0)