From 681fa3d6871b823024a253e3ff0ef86c201ffef2 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Tue, 22 Sep 2026 14:29:12 +0200 Subject: [PATCH] context: add ContextDriver::is_dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/context/api.rs | 15 +++++++++++++++ src/context/local.rs | 4 ++++ src/context/mod.rs | 34 ++++++++++++++++++++++++++++++++++ src/context/schroot.rs | 10 ++++++++++ src/context/ssh.rs | 8 ++++++++ src/context/unshare.rs | 5 +++++ src/test_support.rs | 4 ++++ 7 files changed, 80 insertions(+) diff --git a/src/context/api.rs b/src/context/api.rs index 9d61ea9..b6b4a91 100644 --- a/src/context/api.rs +++ b/src/context/api.rs @@ -70,6 +70,12 @@ pub trait ContextDriver { fn read_file(&self, path: &Path) -> io::Result; fn write_file(&self, path: &Path, content: &str) -> io::Result<()>; fn exists(&self, path: &Path) -> io::Result; + /// 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; /// 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 { + 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<()> { diff --git a/src/context/local.rs b/src/context/local.rs index 4688c48..d38f536 100644 --- a/src/context/local.rs +++ b/src/context/local.rs @@ -155,6 +155,10 @@ impl ContextDriver for LocalDriver { fn exists(&self, path: &Path) -> io::Result { Ok(path.exists()) } + + fn is_dir(&self, path: &Path) -> io::Result { + Ok(path.is_dir()) + } } fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> { diff --git a/src/context/mod.rs b/src/context/mod.rs index 893090d..c686979 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -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] diff --git a/src/context/schroot.rs b/src/context/schroot.rs index e12d6ef..aa264ee 100644 --- a/src/context/schroot.rs +++ b/src/context/schroot.rs @@ -296,6 +296,16 @@ impl ContextDriver for SchrootDriver { )?; Ok(status.success()) } + + fn is_dir(&self, path: &Path) -> io::Result { + let status = self.run( + "test", + &["-d".to_string(), path.to_string_lossy().to_string()], + &[], + None, + )?; + Ok(status.success()) + } } #[cfg(test)] diff --git a/src/context/ssh.rs b/src/context/ssh.rs index 2ea4b9a..8f9c173 100644 --- a/src/context/ssh.rs +++ b/src/context/ssh.rs @@ -306,6 +306,14 @@ impl ContextDriver for SshDriver { Err(_) => Ok(false), } } + + fn is_dir(&self, path: &Path) -> io::Result { + 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 { diff --git a/src/context/unshare.rs b/src/context/unshare.rs index afb8678..43ca512 100644 --- a/src/context/unshare.rs +++ b/src/context/unshare.rs @@ -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 { + let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/')); + Ok(host_path.is_dir()) + } } impl UnshareDriver { diff --git a/src/test_support.rs b/src/test_support.rs index 79956f4..885fe5e 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -445,6 +445,10 @@ mod imp { self.inner.exists(path) } + fn is_dir(&self, path: &Path) -> io::Result { + self.inner.is_dir(path) + } + fn cleanup(&self) -> io::Result<()> { self.inner.cleanup() }