From d0b0063c63b6e4699c1bd02a0d7c2b1cfd883873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Hanuszczak?= Date: Fri, 21 Nov 2025 17:36:26 +0100 Subject: [PATCH 01/13] Prepare boilerplate for `get_file_metadata_kmx`. --- crates/rrg-proto/build.rs | 1 + crates/rrg/Cargo.toml | 6 +++ crates/rrg/src/action.rs | 7 ++++ .../rrg/src/action/get_file_metadata_kmx.rs | 40 +++++++++++++++++++ crates/rrg/src/request.rs | 4 ++ proto/rrg.proto | 2 + proto/rrg/action/get_file_metadata_kmx.proto | 15 +++++++ 7 files changed, 75 insertions(+) create mode 100644 crates/rrg/src/action/get_file_metadata_kmx.rs create mode 100644 proto/rrg/action/get_file_metadata_kmx.proto diff --git a/crates/rrg-proto/build.rs b/crates/rrg-proto/build.rs index 2de8f443..e518233f 100644 --- a/crates/rrg-proto/build.rs +++ b/crates/rrg-proto/build.rs @@ -19,6 +19,7 @@ const PROTOS: &'static [&'static str] = &[ "../../proto/rrg/action/get_file_contents_kmx.proto", "../../proto/rrg/action/get_file_sha256.proto", "../../proto/rrg/action/get_file_metadata.proto", + "../../proto/rrg/action/get_file_metadata_kmx.proto", "../../proto/rrg/action/get_filesystem_timeline.proto", "../../proto/rrg/action/get_filesystem_timeline_tsk.proto", "../../proto/rrg/action/get_system_metadata.proto", diff --git a/crates/rrg/Cargo.toml b/crates/rrg/Cargo.toml index 30805f60..3bde206c 100644 --- a/crates/rrg/Cargo.toml +++ b/crates/rrg/Cargo.toml @@ -12,6 +12,7 @@ default = [ "action-get_file_metadata-md5", "action-get_file_metadata-sha1", "action-get_file_metadata-sha256", + "action-get_file_metadata_kmx", "action-get_file_contents", "action-get_file_sha256", "action-grep_file_contents", @@ -34,6 +35,7 @@ action-get_file_metadata = [] action-get_file_metadata-md5 = ["action-get_file_metadata", "dep:md-5"] action-get_file_metadata-sha1 = ["action-get_file_metadata", "dep:sha1"] action-get_file_metadata-sha256 = ["action-get_file_metadata", "dep:sha2"] +action-get_file_metadata_kmx = ["dep:keramics-formats", "dep:keramics-vfs"] action-get_file_contents = ["dep:sha2"] action-get_file_contents_kmx = ["dep:keramics-core", "dep:keramics-formats", "dep:keramics-types"] action-get_file_sha256 = ["dep:sha2"] @@ -128,6 +130,10 @@ optional = true version = "0.0.0" optional = true +[dependencies.keramics-vfs] +version = "0.0.0" +optional = true + [dependencies.md-5] version = "0.10.6" optional = true diff --git a/crates/rrg/src/action.rs b/crates/rrg/src/action.rs index 8ef0c59f..2094b55c 100644 --- a/crates/rrg/src/action.rs +++ b/crates/rrg/src/action.rs @@ -21,6 +21,9 @@ pub mod get_system_metadata; #[cfg(feature = "action-get_file_metadata")] pub mod get_file_metadata; +#[cfg(feature = "action-get_file_metadata_kmx")] +pub mod get_file_metadata_kmx; + #[cfg(feature = "action-get_file_contents")] pub mod get_file_contents; @@ -109,6 +112,10 @@ where GetFileMetadata => { handle(session, request, self::get_file_metadata::handle) } + #[cfg(feature = "action-get_file_metadata_kmx")] + GetFileMetadataKmx => { + handle(session, request, self::get_file_metadata_kmx::handle) + } #[cfg(feature = "action-get_file_contents")] GetFileContents => { handle(session, request, self::get_file_contents::handle) diff --git a/crates/rrg/src/action/get_file_metadata_kmx.rs b/crates/rrg/src/action/get_file_metadata_kmx.rs new file mode 100644 index 00000000..6491dc80 --- /dev/null +++ b/crates/rrg/src/action/get_file_metadata_kmx.rs @@ -0,0 +1,40 @@ +// Copyright 2025 Google LLC +// +// Use of this source code is governed by an MIT-style license that can be found +// in the LICENSE file or at https://opensource.org/licenses/MIT. + +/// Arguments of the `get_file_metadata_kmx` action. +pub struct Args { + // TODO. +} + +/// Result of the `get_file_metadata_kmx` action. +pub struct Item { + // TODO. +} + +/// Handles invocations of the `get_file_metadata_kmx` action. +pub fn handle(session: &mut S, args: Args) -> crate::session::Result<()> +where + S: crate::session::Session, +{ + todo!() +} + +impl crate::request::Args for Args { + + type Proto = rrg_proto::get_file_metadata_kmx::Args; + + fn from_proto(mut proto: Self::Proto) -> Result { + todo!() + } +} + +impl crate::response::Item for Item { + + type Proto = rrg_proto::get_file_metadata_kmx::Result; + + fn into_proto(self) -> Self::Proto { + todo!() + } +} diff --git a/crates/rrg/src/request.rs b/crates/rrg/src/request.rs index 38d7e767..47780f0b 100644 --- a/crates/rrg/src/request.rs +++ b/crates/rrg/src/request.rs @@ -17,6 +17,8 @@ pub enum Action { GetSystemMetadata, /// Get metadata about the specified file. GetFileMetadata, + /// Get metadata about the specified file using Keramics. + GetFileMetadataKmx, /// Get contents of the specified file. GetFileContents, /// Get contents of the specified file using Keramics. @@ -67,6 +69,7 @@ impl std::fmt::Display for Action { match *self { Action::GetSystemMetadata => write!(fmt, "get_system_metadata"), Action::GetFileMetadata => write!(fmt, "get_file_metadata"), + Action::GetFileMetadataKmx => write!(fmt, "get_file_metadata_kmx"), Action::GetFileContents => write!(fmt, "get_file_contents"), Action::GetFileContentsKmx => write!(fmt, "get_file_contents_kmx"), Action::GetFileSha256 => write!(fmt, "get_file_sha256"), @@ -122,6 +125,7 @@ impl TryFrom for Action { match proto { GET_SYSTEM_METADATA => Ok(Action::GetSystemMetadata), GET_FILE_METADATA => Ok(Action::GetFileMetadata), + GET_FILE_METADATA_KMX => Ok(Action::GetFileMetadataKmx), GET_FILE_CONTENTS => Ok(Action::GetFileContents), GET_FILE_CONTENTS_KMX => Ok(Action::GetFileContentsKmx), GET_FILE_SHA256 => Ok(Action::GetFileSha256), diff --git a/proto/rrg.proto b/proto/rrg.proto index 36704252..483254c0 100644 --- a/proto/rrg.proto +++ b/proto/rrg.proto @@ -60,6 +60,8 @@ enum Action { SCAN_PROCESS_MEMORY_YARA = 22; // Get contents of the specified file using Keramics. GET_FILE_CONTENTS_KMX = 23; + // Get metadata of the specified file using Keramics. + GET_FILE_METADATA_KMX = 24; // TODO: Define more actions that should be supported. diff --git a/proto/rrg/action/get_file_metadata_kmx.proto b/proto/rrg/action/get_file_metadata_kmx.proto new file mode 100644 index 00000000..4ebe2c7c --- /dev/null +++ b/proto/rrg/action/get_file_metadata_kmx.proto @@ -0,0 +1,15 @@ +// Copyright 2025 Google LLC +// +// Use of this source code is governed by an MIT-style license that can be found +// in the LICENSE file or at https://opensource.org/licenses/MIT. +syntax = "proto3"; + +package rrg.action.get_file_metadata_kmx; + +message Args { + // TODO. +} + +message Result { + // TODO. +} From f88e7cd4f2b0bae117f3efce8d8b3595783e17c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Hanuszczak?= Date: Wed, 26 Nov 2025 19:14:55 +0100 Subject: [PATCH 02/13] Create test utilities for loop devices. --- .../rrg/src/action/get_file_metadata_kmx.rs | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/crates/rrg/src/action/get_file_metadata_kmx.rs b/crates/rrg/src/action/get_file_metadata_kmx.rs index 6491dc80..a9b2124c 100644 --- a/crates/rrg/src/action/get_file_metadata_kmx.rs +++ b/crates/rrg/src/action/get_file_metadata_kmx.rs @@ -38,3 +38,119 @@ impl crate::response::Item for Item { todo!() } } + +#[cfg(test)] +mod tests { + + + struct LoopDev { + path: std::path::PathBuf, + is_closed: bool, + } + + impl LoopDev { + + fn new

(file_path: P) -> std::io::Result + where + P: AsRef, + { + use regex::Regex; + + let output = std::process::Command::new("udisksctl") + .arg("loop-setup") + .arg("--file").arg(file_path.as_ref()) + .arg("--no-user-interaction") + .output()?; + if !output.status.success() { + return Err(std::io::Error::new(std::io::ErrorKind::Other, format! { + "failed to run `udisksctl loop-setup` (stdout: {:?}, stderr: {:?})", + String::from_utf8_lossy(&output.stdout).as_ref(), + String::from_utf8_lossy(&output.stderr).as_ref(), + })) + } + let output_stdout = String::from_utf8_lossy(&output.stdout); + + match Regex::new("Mapped file .* as (?P.*)\\.") + .unwrap() + .captures(&output_stdout) + { + Some(captures) => Ok(LoopDev { + path: std::path::PathBuf::from(&captures["devloop"]), + is_closed: false, + }), + None => return Err(std::io::Error::new(std::io::ErrorKind::Other, format! { + "unexpected `udisksctl loop-setup` output: {:?}", + output_stdout, + })), + } + } + + fn close(mut self) -> std::io::Result<()> { + assert!(!self.is_closed); + // We set this bit even before the file is actually closed (which + // may fail and not actually close the device!). This is because in + // case closing fails, we don't want to allow closing again. we need + // this behaviour especially because of the `drop` method that is + // bound to run eventually, attempting to close again any unclosed + // device. + self.is_closed = true; + + let output = std::process::Command::new("udisksctl") + .arg("loop-delete") + .arg("--block-device").arg(&self.path) + .arg("--no-user-interaction") + .output()?; + if !output.status.success() { + return Err(std::io::Error::new(std::io::ErrorKind::Other, format! { + "failed to run `udisksctl loop-delete` (stdout: {:?}, stderr: {:?})", + String::from_utf8_lossy(&output.stdout).as_ref(), + String::from_utf8_lossy(&output.stderr).as_ref(), + })) + } + + Ok(()) + } + } + + impl Drop for LoopDev { + + fn drop(&mut self) { + if !self.is_closed { + // `close` takes an owned value, so we replace `self` with some + // dummy closed device (it being closed is important to avoid + // infinite recursion) and then call explicit close on obtained + // owned value. + let closed = LoopDev { + path: std::path::PathBuf::new(), + is_closed: true, + }; + + std::mem::replace(self, closed).close() + .expect("failed to close the loop device"); + } + } + } + + #[test] + fn loop_dev_new_and_close() { + let file = tempfile::NamedTempFile::new() + .unwrap(); + + let loop_dev = LoopDev::new(&file) + .unwrap(); + + loop_dev.close() + .unwrap(); + } + + #[test] + fn loop_dev_new_and_drop() { + let file = tempfile::NamedTempFile::new() + .unwrap(); + + let loop_dev = LoopDev::new(&file) + .unwrap(); + + drop(loop_dev); + } +} From 46f6d18490401e28db4e5c8b97c801accfc69f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Hanuszczak?= Date: Wed, 26 Nov 2025 19:49:05 +0100 Subject: [PATCH 03/13] Create test utilities for NTFS mounts. --- .../rrg/src/action/get_file_metadata_kmx.rs | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/crates/rrg/src/action/get_file_metadata_kmx.rs b/crates/rrg/src/action/get_file_metadata_kmx.rs index a9b2124c..4d84f7ae 100644 --- a/crates/rrg/src/action/get_file_metadata_kmx.rs +++ b/crates/rrg/src/action/get_file_metadata_kmx.rs @@ -42,6 +42,125 @@ impl crate::response::Item for Item { #[cfg(test)] mod tests { + struct LoopDevNtfsMount<'dev> { + loop_dev: &'dev mut LoopDev, + path: std::path::PathBuf, + is_unmounted: bool, + } + + impl<'dev> LoopDevNtfsMount<'dev> { + + fn new(loop_dev: &'dev mut LoopDev) -> std::io::Result> { + use regex::Regex; + + let output = std::process::Command::new("udisksctl") + .arg("mount") + .arg("--filesystem-type").arg("ntfs") + .arg("--block-device").arg(&loop_dev.path) + .arg("--no-user-interaction") + .output()?; + if !output.status.success() { + return Err(std::io::Error::new(std::io::ErrorKind::Other, format! { + "failed to run `udisksctl mount` (stdout: {:?}, stderr: {:?})", + String::from_utf8_lossy(&output.stdout).as_ref(), + String::from_utf8_lossy(&output.stderr).as_ref(), + })) + } + let output_stdout = String::from_utf8_lossy(&output.stdout); + + match Regex::new("Mounted .* at (?P.*)") + .unwrap() + .captures(&output_stdout) + { + Some(captures) => Ok(LoopDevNtfsMount { + path: std::path::PathBuf::from(&captures["mount"]), + is_unmounted: false, + loop_dev, + }), + None => return Err(std::io::Error::new(std::io::ErrorKind::Other, format! { + "unexpected `udisksctl loop-setup` output: {:?}", + output_stdout, + })), + } + } + + fn unmount(mut self) -> std::io::Result<()> { + assert!(!self.is_unmounted); + // See similar comment in `LoopDev::close` method on why we set it + // even before unmounting succeeded. + self.is_unmounted = true; + + let output = std::process::Command::new("udisksctl") + .arg("unmount") + .arg("--block-device").arg(&self.loop_dev.path) + .arg("--no-user-interaction") + .output()?; + if !output.status.success() { + return Err(std::io::Error::new(std::io::ErrorKind::Other, format! { + "failed to run `udisksctl unmount` (stdout: {:?}, stderr: {:?})", + String::from_utf8_lossy(&output.stdout).as_ref(), + String::from_utf8_lossy(&output.stderr).as_ref(), + })) + } + + Ok(()) + } + } + + #[test] + fn loop_dev_ntfs_mount_new_and_unmount() { + use std::io::Write as _; + + let mut file = tempfile::NamedTempFile::new() + .unwrap(); + // We initialize the file to have 2 MiB. Minimum size of NTFS image is + // 1 MiB, so we use 2 MiB just to be on the safe side. + file.write_all(&vec![0; 2 * 1024 * 1024]) + .unwrap(); + file.flush() + .unwrap(); + std::process::Command::new("mkfs.ntfs") + .arg("--force") + .arg(file.path()) + .output() + .unwrap(); + + let mut loop_dev = LoopDev::new(&file) + .unwrap(); + + let loop_dev_ntfs_mount = LoopDevNtfsMount::new(&mut loop_dev) + .unwrap(); + + loop_dev_ntfs_mount.unmount() + .unwrap(); + } + + #[test] + fn loop_dev_ntfs_mount_new_and_drop() { + use std::io::Write as _; + + let mut file = tempfile::NamedTempFile::new() + .unwrap(); + // We initialize the file to have 2 MiB. Minimum size of NTFS image is + // 1 MiB, so we use 2 MiB just to be on the safe side. + file.write_all(&vec![0; 2 * 1024 * 1024]) + .unwrap(); + file.flush() + .unwrap(); + std::process::Command::new("mkfs.ntfs") + .arg("--force") + .arg(file.path()) + .output() + .unwrap(); + + let mut loop_dev = LoopDev::new(&file) + .unwrap(); + + let loop_dev_ntfs_mount = LoopDevNtfsMount::new(&mut loop_dev) + .unwrap(); + + drop(loop_dev_ntfs_mount); + } struct LoopDev { path: std::path::PathBuf, From 5586668eef89efa6de6e4e2fc82aad61835e5e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Hanuszczak?= Date: Thu, 27 Nov 2025 12:53:34 +0100 Subject: [PATCH 04/13] Create test utility for file-backed NTFS. --- .../rrg/src/action/get_file_metadata_kmx.rs | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/crates/rrg/src/action/get_file_metadata_kmx.rs b/crates/rrg/src/action/get_file_metadata_kmx.rs index 4d84f7ae..58878e0e 100644 --- a/crates/rrg/src/action/get_file_metadata_kmx.rs +++ b/crates/rrg/src/action/get_file_metadata_kmx.rs @@ -42,6 +42,107 @@ impl crate::response::Item for Item { #[cfg(test)] mod tests { + fn ntfs_temp_file(init: F) -> std::io::Result + where + F: FnOnce(&std::path::Path) -> std::io::Result<()>, + { + use std::io::Write as _; + + let mut file = tempfile::NamedTempFile::new()?; + // We initialize the file to have 2 MiB. Minimum size of NTFS image is + // 1 MiB, so we use 2 MiB just to be on the safe side. + file.write_all(&vec![0; 2 * 1024 * 1024])?; + file.flush()?; + + let output = std::process::Command::new("mkfs.ntfs") + .arg("--force") + .arg(file.path()) + .output()?; + if !output.status.success() { + return Err(std::io::Error::new(std::io::ErrorKind::Other, format! { + "failed to run `mkfs.ntfs` (stdout: {:?}, stderr: {:?})", + String::from_utf8_lossy(&output.stdout).as_ref(), + String::from_utf8_lossy(&output.stderr).as_ref(), + })) + } + + let mut loop_dev = LoopDev::new(&file)?; + let loop_dev_ntfs_mount = LoopDevNtfsMount::new(&mut loop_dev)?; + + init(&loop_dev_ntfs_mount.path)?; + + loop_dev_ntfs_mount.unmount()?; + loop_dev.close()?; + + Ok(file) + } + + #[test] + fn ntfs_temp_file_empty() { + let file = ntfs_temp_file(|_| Ok(())) + .unwrap(); + + let data_stream: keramics_core::DataStreamReference = { + std::sync::Arc::new(std::sync::RwLock::new(NamedTempFileWrapper(file))) + }; + + let mut ntfs = keramics_formats::ntfs::NtfsFileSystem::new(); + ntfs.read_data_stream(&data_stream) + .unwrap(); + + assert!(ntfs.get_root_directory().is_ok()); + } + + #[test] + fn ntfs_temp_file_files() { + let file = ntfs_temp_file(|path| { + std::fs::write(path.join("foo"), b"Lorem ipsum.") + .unwrap(); + std::fs::write(path.join("bar"), b"Dolor sit amet.") + .unwrap(); + + Ok(()) + }).unwrap(); + + let data_stream: keramics_core::DataStreamReference = { + std::sync::Arc::new(std::sync::RwLock::new(NamedTempFileWrapper(file))) + }; + + let mut ntfs = keramics_formats::ntfs::NtfsFileSystem::new(); + ntfs.read_data_stream(&data_stream) + .unwrap(); + + let mut entry_root = ntfs.get_root_directory() + .unwrap(); + + let entry_foo = entry_root.get_sub_file_entry_by_name(&keramics_types::Ucs2String::from("foo")) + .unwrap().unwrap(); + assert_eq!(entry_foo.get_size(), b"Lorem ipsum.".len() as u64); + + let entry_bar = entry_root.get_sub_file_entry_by_name(&keramics_types::Ucs2String::from("bar")) + .unwrap().unwrap(); + assert_eq!(entry_bar.get_size(), b"Dolor sit amet.".len() as u64); + } + + // TODO: Keramics defines its own `DataStream` type rather than using + // standard interfaces. Thus, we wrap `NamedTempFile` to provide our own + // implementation of it. + struct NamedTempFileWrapper(tempfile::NamedTempFile); + impl keramics_core::DataStream for NamedTempFileWrapper { + + fn get_size(&mut self) -> Result { + self.0.as_file_mut().get_size() + } + + fn read(&mut self, buf: &mut [u8]) -> Result { + self.0.as_file_mut().read(buf) + } + + fn seek(&mut self, pos: std::io::SeekFrom) -> Result { + self.0.as_file_mut().seek(pos) + } + } + struct LoopDevNtfsMount<'dev> { loop_dev: &'dev mut LoopDev, path: std::path::PathBuf, From 54dc6ad823e9a27ea4bc0607488ee065a4f0917f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Hanuszczak?= Date: Fri, 28 Nov 2025 15:55:16 +0100 Subject: [PATCH 05/13] Create first draft of `get_file_metadata_kmx`. --- crates/rrg/Cargo.toml | 6 +- .../rrg/src/action/get_file_metadata_kmx.rs | 148 +++++++++++++++++- proto/rrg/action/get_file_metadata_kmx.proto | 22 ++- 3 files changed, 167 insertions(+), 9 deletions(-) diff --git a/crates/rrg/Cargo.toml b/crates/rrg/Cargo.toml index 3bde206c..1f995b3d 100644 --- a/crates/rrg/Cargo.toml +++ b/crates/rrg/Cargo.toml @@ -35,7 +35,7 @@ action-get_file_metadata = [] action-get_file_metadata-md5 = ["action-get_file_metadata", "dep:md-5"] action-get_file_metadata-sha1 = ["action-get_file_metadata", "dep:sha1"] action-get_file_metadata-sha256 = ["action-get_file_metadata", "dep:sha2"] -action-get_file_metadata_kmx = ["dep:keramics-formats", "dep:keramics-vfs"] +action-get_file_metadata_kmx = ["dep:keramics-core", "dep:keramics-datetime", "dep:keramics-formats", "dep:keramics-types", "dep:keramics-vfs"] action-get_file_contents = ["dep:sha2"] action-get_file_contents_kmx = ["dep:keramics-core", "dep:keramics-formats", "dep:keramics-types"] action-get_file_sha256 = ["dep:sha2"] @@ -122,6 +122,10 @@ optional = true version = "0.0.0" optional = true +[dependencies.keramics-datetime] +version = "0.0.0" +optional = true + [dependencies.keramics-formats] version = "0.0.0" optional = true diff --git a/crates/rrg/src/action/get_file_metadata_kmx.rs b/crates/rrg/src/action/get_file_metadata_kmx.rs index 58878e0e..6ba5aafc 100644 --- a/crates/rrg/src/action/get_file_metadata_kmx.rs +++ b/crates/rrg/src/action/get_file_metadata_kmx.rs @@ -5,12 +5,14 @@ /// Arguments of the `get_file_metadata_kmx` action. pub struct Args { - // TODO. + volume_path: Option, + path: keramics_formats::ntfs::NtfsPath, } /// Result of the `get_file_metadata_kmx` action. pub struct Item { - // TODO. + path: keramics_formats::ntfs::NtfsPath, + len: u64, } /// Handles invocations of the `get_file_metadata_kmx` action. @@ -18,15 +20,74 @@ pub fn handle(session: &mut S, args: Args) -> crate::session::Result<()> where S: crate::session::Session, { - todo!() + // TODO: Add support for inferring the volume from path. + let Some(volume_path) = args.volume_path else { + return Err(crate::session::Error::action(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "volume path must be provided", + ))); + }; + + log::debug!("opening NTFS volume at '{}'", volume_path.display()); + + let volume = std::fs::File::open(&volume_path) + .map_err(|error| crate::session::Error::action(error))?; + let volume_data_stream: keramics_core::DataStreamReference = { + std::sync::Arc::new(std::sync::RwLock::new(volume)) + }; + + log::debug!("parsing NTFS volume at '{}'", volume_path.display()); + + let mut ntfs = keramics_formats::ntfs::NtfsFileSystem::new(); + ntfs.read_data_stream(&volume_data_stream) + .map_err(|error| crate::session::Error::action(error))?; + + log::debug!("collecting metadata for '{:?}'", args.path); + + let file_entry = match ntfs.get_file_entry_by_path(&args.path) { + Ok(Some(file_entry)) => file_entry, + Ok(None) => { + log::error! { + "no metadata for '{:?}'", + args.path, + }; + return Ok(()) + } + Err(error) => { + log::error! { + "failed to collect metadata for '{:?}': {error}", + args.path, + }; + return Ok(()) + } + }; + + log::debug!("sending metadata for '{:?}'", args.path); + + session.reply(Item { + path: args.path, + len: file_entry.get_size(), + })?; + + Ok(()) } impl crate::request::Args for Args { type Proto = rrg_proto::get_file_metadata_kmx::Args; - fn from_proto(mut proto: Self::Proto) -> Result { - todo!() + fn from_proto(proto: Self::Proto) -> Result { + use crate::request::ParseArgsError; + + // TODO: Do not go through UTF-8 conversion. + let path = str::from_utf8(proto.path().raw_bytes()) + .map_err(|error| ParseArgsError::invalid_field("path", error))?; + let path = keramics_formats::ntfs::NtfsPath::from(path); + + Ok(Args { + volume_path: None, + path, + }) } } @@ -35,13 +96,88 @@ impl crate::response::Item for Item { type Proto = rrg_proto::get_file_metadata_kmx::Result; fn into_proto(self) -> Self::Proto { - todo!() + // TODO: Use lossless conversion (preferably in Keramics directly). + let path = std::path::PathBuf::from_iter( + self.path.components.iter() + .map(|comp| String::from_utf16_lossy(&comp.elements)) + ); + + let mut proto = rrg_proto::get_file_metadata_kmx::Result::new(); + proto.set_path(path.into()); + proto.mut_metadata().set_size(self.len); + + proto } } #[cfg(test)] mod tests { + use super::*; + + #[test] + fn handle_non_existent() { + let ntfs_file = ntfs_temp_file(|_| Ok(())) + .unwrap(); + + let args = Args { + volume_path: Some(ntfs_file.path().to_path_buf()), + path: keramics_formats::ntfs::NtfsPath::from("\\idonotexist"), + }; + + let mut session = crate::session::FakeSession::new(); + assert!(handle(&mut session, args).is_ok()); + + assert_eq!(session.reply_count(), 0); + } + + #[test] + fn handle_regular_file() { + let ntfs_file = ntfs_temp_file(|ntfs_path| { + std::fs::write(ntfs_path.join("foo"), b"Lorem ipsum.")?; + + Ok(()) + }).unwrap(); + + let args = Args { + volume_path: Some(ntfs_file.path().to_path_buf()), + path: keramics_formats::ntfs::NtfsPath::from("\\foo"), + }; + + let mut session = crate::session::FakeSession::new(); + assert!(handle(&mut session, args).is_ok()); + + assert_eq!(session.reply_count(), 1); + + let item = session.reply::(0); + assert_eq!(item.path, keramics_formats::ntfs::NtfsPath::from("\\foo")); + assert_eq!(item.len, b"Lorem ipsum.".len() as u64); + // TODO: Add assertions about the file type. + } + + #[test] + fn handle_dir() { + let ntfs_file = ntfs_temp_file(|ntfs_path| { + std::fs::create_dir(ntfs_path.join("foo"))?; + + Ok(()) + }).unwrap(); + + let args = Args { + volume_path: Some(ntfs_file.path().to_path_buf()), + path: keramics_formats::ntfs::NtfsPath::from("\\foo"), + }; + + let mut session = crate::session::FakeSession::new(); + assert!(handle(&mut session, args).is_ok()); + + assert_eq!(session.reply_count(), 1); + + let item = session.reply::(0); + assert_eq!(item.path, keramics_formats::ntfs::NtfsPath::from("\\foo")); + // TODO: Add assertions about the file type. + } + fn ntfs_temp_file(init: F) -> std::io::Result where F: FnOnce(&std::path::Path) -> std::io::Result<()>, diff --git a/proto/rrg/action/get_file_metadata_kmx.proto b/proto/rrg/action/get_file_metadata_kmx.proto index 4ebe2c7c..e084ab68 100644 --- a/proto/rrg/action/get_file_metadata_kmx.proto +++ b/proto/rrg/action/get_file_metadata_kmx.proto @@ -6,10 +6,28 @@ syntax = "proto3"; package rrg.action.get_file_metadata_kmx; +import "rrg/fs.proto"; + message Args { - // TODO. + // Root path to the file to get the metadata for. + // + // Note that if a path points to a symbolic link, the metadata associated + // with the link itself will be returned, not the metadata of the file that + // the link points to. + rrg.fs.Path path = 1; // TODO: Add support for multiple paths. + + // TODO: Add the remaining fields that original `get_file_metadata` supports. } message Result { - // TODO. + // Path to the file. + // + // This is the original root path of the file as specified in the arguments, + // possibly with some suffix in case of child files. + rrg.fs.Path path = 1; + + // Metadata of the file. + rrg.fs.FileMetadata metadata = 2; + + // TODO: Add the remaining fields that original `get_file_metadata` supports. } From bb09027b97a19f8b8f52ab0ab11f03f7f16ee835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Hanuszczak?= Date: Fri, 28 Nov 2025 16:58:39 +0100 Subject: [PATCH 06/13] Hide `udisksctl`-dependant tests behind a feature. --- crates/rrg/Cargo.toml | 1 + crates/rrg/src/action/get_file_metadata_kmx.rs | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/crates/rrg/Cargo.toml b/crates/rrg/Cargo.toml index 1f995b3d..d5e69ce5 100644 --- a/crates/rrg/Cargo.toml +++ b/crates/rrg/Cargo.toml @@ -65,6 +65,7 @@ test-setfattr = [] test-chattr = [] test-fuse = ["dep:fuse"] test-libguestfs = [] +test-udisksctl = [] test-wtmp = [] [dependencies.ospect] diff --git a/crates/rrg/src/action/get_file_metadata_kmx.rs b/crates/rrg/src/action/get_file_metadata_kmx.rs index 6ba5aafc..56cfc5ed 100644 --- a/crates/rrg/src/action/get_file_metadata_kmx.rs +++ b/crates/rrg/src/action/get_file_metadata_kmx.rs @@ -115,6 +115,7 @@ mod tests { use super::*; + #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] #[test] fn handle_non_existent() { let ntfs_file = ntfs_temp_file(|_| Ok(())) @@ -131,6 +132,7 @@ mod tests { assert_eq!(session.reply_count(), 0); } + #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] #[test] fn handle_regular_file() { let ntfs_file = ntfs_temp_file(|ntfs_path| { @@ -155,6 +157,7 @@ mod tests { // TODO: Add assertions about the file type. } + #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] #[test] fn handle_dir() { let ntfs_file = ntfs_temp_file(|ntfs_path| { @@ -213,6 +216,7 @@ mod tests { Ok(file) } + #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] #[test] fn ntfs_temp_file_empty() { let file = ntfs_temp_file(|_| Ok(())) @@ -229,6 +233,7 @@ mod tests { assert!(ntfs.get_root_directory().is_ok()); } + #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] #[test] fn ntfs_temp_file_files() { let file = ntfs_temp_file(|path| { @@ -344,6 +349,7 @@ mod tests { } } + #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] #[test] fn loop_dev_ntfs_mount_new_and_unmount() { use std::io::Write as _; @@ -372,6 +378,7 @@ mod tests { .unwrap(); } + #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] #[test] fn loop_dev_ntfs_mount_new_and_drop() { use std::io::Write as _; @@ -487,6 +494,7 @@ mod tests { } } + #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] #[test] fn loop_dev_new_and_close() { let file = tempfile::NamedTempFile::new() @@ -499,6 +507,7 @@ mod tests { .unwrap(); } + #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] #[test] fn loop_dev_new_and_drop() { let file = tempfile::NamedTempFile::new() From 1b5daac09c6ce5a15eea512b4b3e93d878bc5e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Hanuszczak?= Date: Fri, 28 Nov 2025 17:18:58 +0100 Subject: [PATCH 07/13] Enable `udisksctl`-dependant tests in CI. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4de6a72b..0ef11c4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,4 +47,4 @@ jobs: run: cargo build --features 'action-get_file_contents_kmx action-get_filesystem_timeline_tsk' # TODO: Add a step that runs tests with all action features disabled. - name: 'Run RRG tests' - run: cargo test --features 'test-chattr test-setfattr test-fuse test-wtmp test-libguestfs action-get_file_contents_kmx action-get_filesystem_timeline_tsk' + run: cargo test --features 'test-chattr test-setfattr test-fuse test-wtmp test-libguestfs test-udisksctl action-get_file_contents_kmx action-get_filesystem_timeline_tsk' From d04ce2d4fc203c7bf669d80f6f2aee6c87df7066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Hanuszczak?= Date: Fri, 28 Nov 2025 19:55:00 +0100 Subject: [PATCH 08/13] Create test utility for `guestmount`. --- .../rrg/src/action/get_file_metadata_kmx.rs | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/crates/rrg/src/action/get_file_metadata_kmx.rs b/crates/rrg/src/action/get_file_metadata_kmx.rs index 56cfc5ed..a7371b01 100644 --- a/crates/rrg/src/action/get_file_metadata_kmx.rs +++ b/crates/rrg/src/action/get_file_metadata_kmx.rs @@ -518,4 +518,134 @@ mod tests { drop(loop_dev); } + + struct GuestMount { + mountpoint: std::path::PathBuf, + is_mounted: bool, + } + + impl GuestMount { + + fn new(image: PI, mountpoint: PM) -> std::io::Result + where + PI: AsRef, + PM: AsRef, + { + let output = std::process::Command::new("guestmount") + .arg("--add").arg(image.as_ref().as_os_str()) + .arg("--mount").arg("/dev/sda:/::ntfs") + .arg(mountpoint.as_ref().as_os_str()) + .output()?; + if !output.status.success() { + return Err(std::io::Error::new(std::io::ErrorKind::Other, format! { + "failed to run `guestmount` (stdout: {:?}, stderr: {:?})", + String::from_utf8_lossy(&output.stdout).as_ref(), + String::from_utf8_lossy(&output.stderr).as_ref(), + })) + } + + Ok(GuestMount { + mountpoint: mountpoint.as_ref().to_path_buf(), + is_mounted: true, + }) + } + + fn unmount(mut self) -> std::io::Result<()> { + assert!(self.is_mounted); + // We set this bit even before the file is actually closed (which + // may fail and not actually close the device!). This is because in + // case closing fails, we don't want to allow closing again. we need + // this behaviour especially because of the `drop` method that is + // bound to run eventually, attempting to close again any unclosed + // device. + self.is_mounted = false; + + let output = std::process::Command::new("guestunmount") + .arg(self.mountpoint.as_os_str()) + .output()?; + if !output.status.success() { + return Err(std::io::Error::new(std::io::ErrorKind::Other, format! { + "failed to run `guestunmount` (stdout: {:?}, stderr: {:?})", + String::from_utf8_lossy(&output.stdout).as_ref(), + String::from_utf8_lossy(&output.stderr).as_ref(), + })) + } + + Ok(()) + } + } + + impl Drop for GuestMount { + + fn drop(&mut self) { + if self.is_mounted { + // `unmount` takes an owned value, so we replace `self` with a + // dummy closed device (it being unmounted is important to avoid + // infinite recursion) and then call explicit close on obtained + // owned value. + let unmounted = GuestMount { + mountpoint: std::path::PathBuf::new(), + is_mounted: false, + }; + + std::mem::replace(self, unmounted).unmount() + .expect("failed to unmount"); + } + } + } + + #[test] + fn guest_mount_new_and_unmount() { + use std::io::Write as _; + + let mut image = tempfile::NamedTempFile::new() + .unwrap(); + // We initialize the file to have 2 MiB. Minimum size of NTFS image is + // 1 MiB, so we use 2 MiB just to be on the safe side. + image.write_all(&vec![0; 2 * 1024 * 1024]) + .unwrap(); + image.flush() + .unwrap(); + std::process::Command::new("mkfs.ntfs") + .arg("--force") + .arg(image.path()) + .output() + .unwrap(); + + let mountpoint = tempfile::tempdir() + .unwrap(); + + let mount = GuestMount::new(&image, &mountpoint) + .unwrap(); + + mount.unmount() + .unwrap(); + } + + #[test] + fn guest_mount_new_and_drop() { + use std::io::Write as _; + + let mut image = tempfile::NamedTempFile::new() + .unwrap(); + // We initialize the file to have 2 MiB. Minimum size of NTFS image is + // 1 MiB, so we use 2 MiB just to be on the safe side. + image.write_all(&vec![0; 2 * 1024 * 1024]) + .unwrap(); + image.flush() + .unwrap(); + std::process::Command::new("mkfs.ntfs") + .arg("--force") + .arg(image.path()) + .output() + .unwrap(); + + let mountpoint = tempfile::tempdir() + .unwrap(); + + let mount = GuestMount::new(&image, &mountpoint) + .unwrap(); + + drop(mount) + } } From 7232f4283704f89969f8a5f309a917185e553b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Hanuszczak?= Date: Fri, 28 Nov 2025 20:05:59 +0100 Subject: [PATCH 09/13] Use `guestmount` for file-backed NTFS. --- crates/rrg/src/action/get_file_metadata_kmx.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/rrg/src/action/get_file_metadata_kmx.rs b/crates/rrg/src/action/get_file_metadata_kmx.rs index a7371b01..46f1d199 100644 --- a/crates/rrg/src/action/get_file_metadata_kmx.rs +++ b/crates/rrg/src/action/get_file_metadata_kmx.rs @@ -205,13 +205,11 @@ mod tests { })) } - let mut loop_dev = LoopDev::new(&file)?; - let loop_dev_ntfs_mount = LoopDevNtfsMount::new(&mut loop_dev)?; + let mountpoint = tempfile::tempdir()?; - init(&loop_dev_ntfs_mount.path)?; - - loop_dev_ntfs_mount.unmount()?; - loop_dev.close()?; + let mount = GuestMount::new(file.path(), mountpoint.path())?; + init(mountpoint.path())?; + mount.unmount()?; Ok(file) } From ca30db852b05f64c9973b002a712705f1068a3ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Hanuszczak?= Date: Mon, 1 Dec 2025 11:58:26 +0100 Subject: [PATCH 10/13] Fix race condition issue with `guestmount`. --- .../rrg/src/action/get_file_metadata_kmx.rs | 57 ++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/rrg/src/action/get_file_metadata_kmx.rs b/crates/rrg/src/action/get_file_metadata_kmx.rs index 46f1d199..79cf8f81 100644 --- a/crates/rrg/src/action/get_file_metadata_kmx.rs +++ b/crates/rrg/src/action/get_file_metadata_kmx.rs @@ -519,6 +519,7 @@ mod tests { struct GuestMount { mountpoint: std::path::PathBuf, + pid: Option, is_mounted: bool, } @@ -529,9 +530,23 @@ mod tests { PI: AsRef, PM: AsRef, { + // `guestmount` spawns a separate process to serve the files. When + // we call `guestunmount` to unmount, even though the call returns, + // the background process still flushes the file in the background. + // To only finish the unmount after everything is properly flushed, + // we wait until the background process is gone [1]. + // + // The only way to get the PID fo the background process seems to be + // through a "PID file" which is written by `guestmount`, so we use + // a temporary file for that. + // + // [1]: https://libguestfs.org/guestmount.1.html#race-conditions-possible-when-shutting-down-the-connection + let pid_file = tempfile::NamedTempFile::new()?; + let output = std::process::Command::new("guestmount") .arg("--add").arg(image.as_ref().as_os_str()) .arg("--mount").arg("/dev/sda:/::ntfs") + .arg("--pid-file").arg(pid_file.path().as_os_str()) .arg(mountpoint.as_ref().as_os_str()) .output()?; if !output.status.success() { @@ -542,10 +557,29 @@ mod tests { })) } - Ok(GuestMount { + // At this point we successfully created the mount but we have not + // parsed the PID file yet which we mail fail to do so. But even if + // we cannot read the PID file, we should still clean the mount when + // returning an error. + // + // + // Thus we create a `GuestMount` instance here (without PID) an in + // case of an error, RAII will take care of running `guestunmount`. + let mut mount = GuestMount { mountpoint: mountpoint.as_ref().to_path_buf(), + pid: None, is_mounted: true, - }) + }; + + let pid = || -> Result> { + let pid_string = String::from_utf8(std::fs::read(pid_file.path())?)?; + Ok(pid_string.trim().parse::()?) + }().map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, format! { + "invalid PID file contents: {error}" + }))?; + mount.pid = Some(pid); + + Ok(mount) } fn unmount(mut self) -> std::io::Result<()> { @@ -569,6 +603,24 @@ mod tests { })) } + // See the constructor and [1] for more information about this PID. + // Note that might not have the PID available and still want to run + // the constructor (e.g. in case `guestmount` succeeded but parsing + // the PID file failed). + // + // We use procfs [2] to determine whether the background process is + // done. We do a bit of busy waiting here but this involves a system + // call, so we should not waste too much time. + // + // [1]: https://libguestfs.org/guestmount.1.html#race-conditions-possible-when-shutting-down-the-connection + // [2]: https://en.wikipedia.org/wiki/Procfs + if let Some(pid) = self.pid { + let pid_path = format!("/proc/{}", pid); + while std::fs::exists(&pid_path)? { + std::thread::yield_now(); + } + } + Ok(()) } } @@ -583,6 +635,7 @@ mod tests { // owned value. let unmounted = GuestMount { mountpoint: std::path::PathBuf::new(), + pid: None, is_mounted: false, }; From 4658b3efd3cb187125528f89b4a4b68a8793669c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Hanuszczak?= Date: Mon, 1 Dec 2025 11:59:42 +0100 Subject: [PATCH 11/13] Remove `udisksctl`-based test utilities. --- .../rrg/src/action/get_file_metadata_kmx.rs | 235 ------------------ 1 file changed, 235 deletions(-) diff --git a/crates/rrg/src/action/get_file_metadata_kmx.rs b/crates/rrg/src/action/get_file_metadata_kmx.rs index 79cf8f81..2f0c61bc 100644 --- a/crates/rrg/src/action/get_file_metadata_kmx.rs +++ b/crates/rrg/src/action/get_file_metadata_kmx.rs @@ -282,241 +282,6 @@ mod tests { } } - struct LoopDevNtfsMount<'dev> { - loop_dev: &'dev mut LoopDev, - path: std::path::PathBuf, - is_unmounted: bool, - } - - impl<'dev> LoopDevNtfsMount<'dev> { - - fn new(loop_dev: &'dev mut LoopDev) -> std::io::Result> { - use regex::Regex; - - let output = std::process::Command::new("udisksctl") - .arg("mount") - .arg("--filesystem-type").arg("ntfs") - .arg("--block-device").arg(&loop_dev.path) - .arg("--no-user-interaction") - .output()?; - if !output.status.success() { - return Err(std::io::Error::new(std::io::ErrorKind::Other, format! { - "failed to run `udisksctl mount` (stdout: {:?}, stderr: {:?})", - String::from_utf8_lossy(&output.stdout).as_ref(), - String::from_utf8_lossy(&output.stderr).as_ref(), - })) - } - let output_stdout = String::from_utf8_lossy(&output.stdout); - - match Regex::new("Mounted .* at (?P.*)") - .unwrap() - .captures(&output_stdout) - { - Some(captures) => Ok(LoopDevNtfsMount { - path: std::path::PathBuf::from(&captures["mount"]), - is_unmounted: false, - loop_dev, - }), - None => return Err(std::io::Error::new(std::io::ErrorKind::Other, format! { - "unexpected `udisksctl loop-setup` output: {:?}", - output_stdout, - })), - } - } - - fn unmount(mut self) -> std::io::Result<()> { - assert!(!self.is_unmounted); - // See similar comment in `LoopDev::close` method on why we set it - // even before unmounting succeeded. - self.is_unmounted = true; - - let output = std::process::Command::new("udisksctl") - .arg("unmount") - .arg("--block-device").arg(&self.loop_dev.path) - .arg("--no-user-interaction") - .output()?; - if !output.status.success() { - return Err(std::io::Error::new(std::io::ErrorKind::Other, format! { - "failed to run `udisksctl unmount` (stdout: {:?}, stderr: {:?})", - String::from_utf8_lossy(&output.stdout).as_ref(), - String::from_utf8_lossy(&output.stderr).as_ref(), - })) - } - - Ok(()) - } - } - - #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] - #[test] - fn loop_dev_ntfs_mount_new_and_unmount() { - use std::io::Write as _; - - let mut file = tempfile::NamedTempFile::new() - .unwrap(); - // We initialize the file to have 2 MiB. Minimum size of NTFS image is - // 1 MiB, so we use 2 MiB just to be on the safe side. - file.write_all(&vec![0; 2 * 1024 * 1024]) - .unwrap(); - file.flush() - .unwrap(); - std::process::Command::new("mkfs.ntfs") - .arg("--force") - .arg(file.path()) - .output() - .unwrap(); - - let mut loop_dev = LoopDev::new(&file) - .unwrap(); - - let loop_dev_ntfs_mount = LoopDevNtfsMount::new(&mut loop_dev) - .unwrap(); - - loop_dev_ntfs_mount.unmount() - .unwrap(); - } - - #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] - #[test] - fn loop_dev_ntfs_mount_new_and_drop() { - use std::io::Write as _; - - let mut file = tempfile::NamedTempFile::new() - .unwrap(); - // We initialize the file to have 2 MiB. Minimum size of NTFS image is - // 1 MiB, so we use 2 MiB just to be on the safe side. - file.write_all(&vec![0; 2 * 1024 * 1024]) - .unwrap(); - file.flush() - .unwrap(); - std::process::Command::new("mkfs.ntfs") - .arg("--force") - .arg(file.path()) - .output() - .unwrap(); - - let mut loop_dev = LoopDev::new(&file) - .unwrap(); - - let loop_dev_ntfs_mount = LoopDevNtfsMount::new(&mut loop_dev) - .unwrap(); - - drop(loop_dev_ntfs_mount); - } - - struct LoopDev { - path: std::path::PathBuf, - is_closed: bool, - } - - impl LoopDev { - - fn new

(file_path: P) -> std::io::Result - where - P: AsRef, - { - use regex::Regex; - - let output = std::process::Command::new("udisksctl") - .arg("loop-setup") - .arg("--file").arg(file_path.as_ref()) - .arg("--no-user-interaction") - .output()?; - if !output.status.success() { - return Err(std::io::Error::new(std::io::ErrorKind::Other, format! { - "failed to run `udisksctl loop-setup` (stdout: {:?}, stderr: {:?})", - String::from_utf8_lossy(&output.stdout).as_ref(), - String::from_utf8_lossy(&output.stderr).as_ref(), - })) - } - let output_stdout = String::from_utf8_lossy(&output.stdout); - - match Regex::new("Mapped file .* as (?P.*)\\.") - .unwrap() - .captures(&output_stdout) - { - Some(captures) => Ok(LoopDev { - path: std::path::PathBuf::from(&captures["devloop"]), - is_closed: false, - }), - None => return Err(std::io::Error::new(std::io::ErrorKind::Other, format! { - "unexpected `udisksctl loop-setup` output: {:?}", - output_stdout, - })), - } - } - - fn close(mut self) -> std::io::Result<()> { - assert!(!self.is_closed); - // We set this bit even before the file is actually closed (which - // may fail and not actually close the device!). This is because in - // case closing fails, we don't want to allow closing again. we need - // this behaviour especially because of the `drop` method that is - // bound to run eventually, attempting to close again any unclosed - // device. - self.is_closed = true; - - let output = std::process::Command::new("udisksctl") - .arg("loop-delete") - .arg("--block-device").arg(&self.path) - .arg("--no-user-interaction") - .output()?; - if !output.status.success() { - return Err(std::io::Error::new(std::io::ErrorKind::Other, format! { - "failed to run `udisksctl loop-delete` (stdout: {:?}, stderr: {:?})", - String::from_utf8_lossy(&output.stdout).as_ref(), - String::from_utf8_lossy(&output.stderr).as_ref(), - })) - } - - Ok(()) - } - } - - impl Drop for LoopDev { - - fn drop(&mut self) { - if !self.is_closed { - // `close` takes an owned value, so we replace `self` with some - // dummy closed device (it being closed is important to avoid - // infinite recursion) and then call explicit close on obtained - // owned value. - let closed = LoopDev { - path: std::path::PathBuf::new(), - is_closed: true, - }; - - std::mem::replace(self, closed).close() - .expect("failed to close the loop device"); - } - } - } - - #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] - #[test] - fn loop_dev_new_and_close() { - let file = tempfile::NamedTempFile::new() - .unwrap(); - - let loop_dev = LoopDev::new(&file) - .unwrap(); - - loop_dev.close() - .unwrap(); - } - - #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] - #[test] - fn loop_dev_new_and_drop() { - let file = tempfile::NamedTempFile::new() - .unwrap(); - - let loop_dev = LoopDev::new(&file) - .unwrap(); - - drop(loop_dev); - } - struct GuestMount { mountpoint: std::path::PathBuf, pid: Option, From 53f8c34045325054d191df130767f6c4274b8ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Hanuszczak?= Date: Mon, 1 Dec 2025 12:12:53 +0100 Subject: [PATCH 12/13] Switch to feature flag for `guestmount`. --- .github/workflows/ci.yml | 2 +- crates/rrg/Cargo.toml | 1 - crates/rrg/src/action/get_file_metadata_kmx.rs | 12 +++++++----- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ef11c4e..4de6a72b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,4 +47,4 @@ jobs: run: cargo build --features 'action-get_file_contents_kmx action-get_filesystem_timeline_tsk' # TODO: Add a step that runs tests with all action features disabled. - name: 'Run RRG tests' - run: cargo test --features 'test-chattr test-setfattr test-fuse test-wtmp test-libguestfs test-udisksctl action-get_file_contents_kmx action-get_filesystem_timeline_tsk' + run: cargo test --features 'test-chattr test-setfattr test-fuse test-wtmp test-libguestfs action-get_file_contents_kmx action-get_filesystem_timeline_tsk' diff --git a/crates/rrg/Cargo.toml b/crates/rrg/Cargo.toml index d5e69ce5..1f995b3d 100644 --- a/crates/rrg/Cargo.toml +++ b/crates/rrg/Cargo.toml @@ -65,7 +65,6 @@ test-setfattr = [] test-chattr = [] test-fuse = ["dep:fuse"] test-libguestfs = [] -test-udisksctl = [] test-wtmp = [] [dependencies.ospect] diff --git a/crates/rrg/src/action/get_file_metadata_kmx.rs b/crates/rrg/src/action/get_file_metadata_kmx.rs index 2f0c61bc..3bc0f49e 100644 --- a/crates/rrg/src/action/get_file_metadata_kmx.rs +++ b/crates/rrg/src/action/get_file_metadata_kmx.rs @@ -115,7 +115,7 @@ mod tests { use super::*; - #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] + #[cfg_attr(not(all(target_os = "linux", feature = "test-libguestfs")), ignore)] #[test] fn handle_non_existent() { let ntfs_file = ntfs_temp_file(|_| Ok(())) @@ -132,7 +132,7 @@ mod tests { assert_eq!(session.reply_count(), 0); } - #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] + #[cfg_attr(not(all(target_os = "linux", feature = "test-libguestfs")), ignore)] #[test] fn handle_regular_file() { let ntfs_file = ntfs_temp_file(|ntfs_path| { @@ -157,7 +157,7 @@ mod tests { // TODO: Add assertions about the file type. } - #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] + #[cfg_attr(not(all(target_os = "linux", feature = "test-libguestfs")), ignore)] #[test] fn handle_dir() { let ntfs_file = ntfs_temp_file(|ntfs_path| { @@ -214,7 +214,7 @@ mod tests { Ok(file) } - #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] + #[cfg_attr(not(all(target_os = "linux", feature = "test-libguestfs")), ignore)] #[test] fn ntfs_temp_file_empty() { let file = ntfs_temp_file(|_| Ok(())) @@ -231,7 +231,7 @@ mod tests { assert!(ntfs.get_root_directory().is_ok()); } - #[cfg_attr(not(all(target_os = "linux", feature = "test-udisksctl")), ignore)] + #[cfg_attr(not(all(target_os = "linux", feature = "test-libguestfs")), ignore)] #[test] fn ntfs_temp_file_files() { let file = ntfs_temp_file(|path| { @@ -410,6 +410,7 @@ mod tests { } } + #[cfg_attr(not(all(target_os = "linux", feature = "test-libguestfs")), ignore)] #[test] fn guest_mount_new_and_unmount() { use std::io::Write as _; @@ -438,6 +439,7 @@ mod tests { .unwrap(); } + #[cfg_attr(not(all(target_os = "linux", feature = "test-libguestfs")), ignore)] #[test] fn guest_mount_new_and_drop() { use std::io::Write as _; From 584d3ff605cb50a3a831072472ab72237bc20627 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Hanuszczak?= Date: Fri, 23 Jan 2026 16:43:58 +0100 Subject: [PATCH 13/13] Make `get_file_metadata_kmx` disabled by default. --- .github/workflows/ci.yml | 4 ++-- crates/rrg/Cargo.toml | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4de6a72b..29d89345 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: rustc --version cargo --version - name: 'Build RRG executable' - run: cargo build --features 'action-get_file_contents_kmx action-get_filesystem_timeline_tsk' + run: cargo build --features 'action-get_file_contents_kmx action-get_file_metadata_kmx action-get_filesystem_timeline_tsk' # TODO: Add a step that runs tests with all action features disabled. - name: 'Run RRG tests' - run: cargo test --features 'test-chattr test-setfattr test-fuse test-wtmp test-libguestfs action-get_file_contents_kmx action-get_filesystem_timeline_tsk' + run: cargo test --features 'test-chattr test-setfattr test-fuse test-wtmp test-libguestfs action-get_file_contents_kmx action-get_file_metadata_kmx action-get_filesystem_timeline_tsk' diff --git a/crates/rrg/Cargo.toml b/crates/rrg/Cargo.toml index 1f995b3d..6d3c6470 100644 --- a/crates/rrg/Cargo.toml +++ b/crates/rrg/Cargo.toml @@ -12,7 +12,6 @@ default = [ "action-get_file_metadata-md5", "action-get_file_metadata-sha1", "action-get_file_metadata-sha256", - "action-get_file_metadata_kmx", "action-get_file_contents", "action-get_file_sha256", "action-grep_file_contents",