Skip to content

Commit 20a404a

Browse files
authored
fix(ln): allow symlinks in ReadWrite RealFs mounts (#1251)
Closes #1158
1 parent deb0d42 commit 20a404a

3 files changed

Lines changed: 65 additions & 12 deletions

File tree

crates/bashkit/src/fs/posix.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,10 @@ impl<B: FsBackend + 'static> FileSystem for PosixFs<B> {
242242
}
243243

244244
async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
245-
let target = Self::normalize(target);
245+
// Don't normalize target: symlink targets are stored as-is on disk.
246+
// Normalizing a relative target to absolute would break containment checks.
246247
let link = Self::normalize(link);
247-
self.backend.symlink(&target, &link).await
248+
self.backend.symlink(target, &link).await
248249
}
249250

250251
async fn read_link(&self, path: &Path) -> Result<PathBuf> {

crates/bashkit/src/fs/realfs.rs

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -414,16 +414,46 @@ impl FsBackend for RealFs {
414414
Ok(())
415415
}
416416

417-
/// THREAT[TM-ESC-003]: Symlink creation is blocked in RealFs to prevent
418-
/// sandbox escape. Even though bashkit itself doesn't follow symlinks
419-
/// (TM-ESC-002), any external process sharing the directory tree would
420-
/// follow them, enabling reads/writes to arbitrary host paths.
421-
async fn symlink(&self, _target: &Path, _link: &Path) -> Result<()> {
422-
Err(IoError::new(
423-
ErrorKind::PermissionDenied,
424-
"symlink creation is not allowed in RealFs (sandbox security)",
425-
)
426-
.into())
417+
/// THREAT[TM-ESC-003]: Symlink creation in RealFs is allowed only in
418+
/// ReadWrite mode. The OS resolves symlink targets on the host filesystem,
419+
/// so we must validate that the effective target stays within the mount
420+
/// root on disk. Absolute targets are rejected. Relative targets are
421+
/// normalized against the link's host-side parent directory.
422+
async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
423+
self.check_writable()?;
424+
let real_link = self.resolve(link)?;
425+
426+
// Absolute targets always escape the mount root on disk
427+
if target.is_absolute() {
428+
return Err(IoError::new(
429+
ErrorKind::PermissionDenied,
430+
"symlink with absolute target not allowed in RealFs (sandbox security)",
431+
)
432+
.into());
433+
}
434+
435+
// Relative targets: resolve against the link's host-side parent
436+
// to verify the effective path stays within root
437+
let link_parent = real_link.parent().unwrap_or(&self.root);
438+
let effective = normalize_host_path(&link_parent.join(target));
439+
if !effective.starts_with(&self.root) {
440+
return Err(IoError::new(
441+
ErrorKind::PermissionDenied,
442+
"symlink target escapes realfs root (sandbox security)",
443+
)
444+
.into());
445+
}
446+
447+
#[cfg(unix)]
448+
{
449+
tokio::fs::symlink(target, &real_link).await?;
450+
}
451+
#[cfg(not(unix))]
452+
{
453+
let _ = target;
454+
tokio::fs::write(&real_link, "").await?;
455+
}
456+
Ok(())
427457
}
428458

429459
async fn read_link(&self, path: &Path) -> Result<PathBuf> {

crates/bashkit/tests/realfs_tests.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,28 @@ async fn realfs_symlink_relative_escape_blocked() {
343343
);
344344
}
345345

346+
#[tokio::test]
347+
async fn realfs_symlink_within_mount_allowed() {
348+
let dir = setup_host_dir();
349+
std::fs::write(dir.path().join("original.txt"), "content").unwrap();
350+
351+
let mut bash = Bash::builder()
352+
.mount_real_readwrite_at(dir.path(), "/mnt/workspace")
353+
.build();
354+
355+
// Relative symlink within mount should succeed (exit code 0)
356+
let r = bash
357+
.exec("ln -s original.txt /mnt/workspace/link.txt 2>&1; echo $?")
358+
.await
359+
.unwrap();
360+
assert!(
361+
r.stdout.trim().ends_with('0'),
362+
"Symlink within mount should succeed, got stdout: {} stderr: {}",
363+
r.stdout,
364+
r.stderr
365+
);
366+
}
367+
346368
// --- Runtime mount/unmount (exercises Bash::mount / Bash::unmount) ---
347369

348370
#[tokio::test]

0 commit comments

Comments
 (0)