context: add ContextDriver::is_dir

list_files returns context-relative paths (rooted inside the chroot
for an unshare context, on the remote for ssh): whether an entry is
a directory can only be decided through the context, never with a
host-side stat. Give every driver a proper is_dir rather than
approximating it with exists, so callers can keep directories and
files apart — the deb package-directory search lists directories
only.
This commit is contained in:
2026-09-22 14:29:12 +02:00
parent cc5bbd2297
commit 681fa3d687
7 changed files with 80 additions and 0 deletions
+15
View File
@@ -70,6 +70,12 @@ pub trait ContextDriver {
fn read_file(&self, path: &Path) -> io::Result<String>;
fn write_file(&self, path: &Path, content: &str) -> io::Result<()>;
fn exists(&self, path: &Path) -> io::Result<bool>;
/// Check if a path is a directory inside the context
///
/// Distinct from [`ContextDriver::exists`] because paths returned by
/// [`ContextDriver::list_files`] are context-relative and can only be
/// classified through the context, never with a host-side stat.
fn is_dir(&self, path: &Path) -> io::Result<bool>;
/// Clean up any resources held by the driver (e.g. unmount overlay filesystems).
/// Called before the chroot directory is removed.
@@ -313,6 +319,15 @@ impl Context {
self.driver().as_ref().unwrap().exists(path)
}
/// Check if a path is a directory inside context
///
/// Paths returned by [`Context::list_files`] are context-relative
/// (e.g. rooted inside the chroot for an unshare context): whether they
/// are directories can only be decided through the context.
pub fn is_dir(&self, path: &Path) -> io::Result<bool> {
self.driver().as_ref().unwrap().is_dir(path)
}
/// Clean up any resources held by the driver (e.g. unmount overlay filesystems).
/// Called before the chroot directory is removed.
pub fn cleanup(&self) -> io::Result<()> {
+4
View File
@@ -155,6 +155,10 @@ impl ContextDriver for LocalDriver {
fn exists(&self, path: &Path) -> io::Result<bool> {
Ok(path.exists())
}
fn is_dir(&self, path: &Path) -> io::Result<bool> {
Ok(path.is_dir())
}
}
fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
+34
View File
@@ -385,6 +385,40 @@ mod tests {
assert!(!dest.join("src/.svn").exists());
}
/// The unshare driver maps context-relative paths onto the chroot root
/// on the host: `is_dir` must answer through that mapping (a host-side
/// stat of the unmapped path sees nothing), which is what lets the deb
/// package-directory search classify staged entries.
#[test]
fn test_unshare_is_dir_maps_through_the_chroot_root() {
let chroot = tempfile::tempdir().unwrap();
fs::create_dir_all(chroot.path().join("tmp/work/tree/debian")).unwrap();
fs::write(chroot.path().join("tmp/work/orig.tar.xz"), "tar").unwrap();
let base = Context::new(ContextConfig::Local).unwrap();
let ctx = Context::with_parent(
ContextConfig::Unshare {
path: chroot.path().to_string_lossy().to_string(),
parent: None,
},
Arc::new(base),
);
assert!(ctx.is_dir(std::path::Path::new("/tmp/work/tree")).unwrap());
assert!(
ctx.exists(std::path::Path::new("/tmp/work/tree/debian"))
.unwrap()
);
assert!(
!ctx.is_dir(std::path::Path::new("/tmp/work/orig.tar.xz"))
.unwrap()
);
assert!(
!ctx.exists(std::path::Path::new("/tmp/work/missing"))
.unwrap()
);
}
/// The overlay-mount path exposes the tree verbatim, so pruning happens
/// after the fact: nested VCS metadata must be removed recursively.
#[test]
+10
View File
@@ -296,6 +296,16 @@ impl ContextDriver for SchrootDriver {
)?;
Ok(status.success())
}
fn is_dir(&self, path: &Path) -> io::Result<bool> {
let status = self.run(
"test",
&["-d".to_string(), path.to_string_lossy().to_string()],
&[],
None,
)?;
Ok(status.success())
}
}
#[cfg(test)]
+8
View File
@@ -306,6 +306,14 @@ impl ContextDriver for SshDriver {
Err(_) => Ok(false),
}
}
fn is_dir(&self, path: &Path) -> io::Result<bool> {
let sess = connect_ssh(&self.host, self.user.as_deref(), self.port)?;
let sftp = sess.sftp().map_err(io::Error::other)?;
// Same error tolerance as `exists`: an unreachable path is not a
// directory, and the caller decides what absence means.
Ok(sftp.stat(path).map(|stat| stat.is_dir()).unwrap_or(false))
}
}
impl SshDriver {
+5
View File
@@ -356,6 +356,11 @@ impl ContextDriver for UnshareDriver {
let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/'));
self.parent().exists(&host_path)
}
fn is_dir(&self, path: &Path) -> io::Result<bool> {
let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/'));
Ok(host_path.is_dir())
}
}
impl UnshareDriver {
+4
View File
@@ -445,6 +445,10 @@ mod imp {
self.inner.exists(path)
}
fn is_dir(&self, path: &Path) -> io::Result<bool> {
self.inner.is_dir(path)
}
fn cleanup(&self) -> io::Result<()> {
self.inner.cleanup()
}