diff --git a/Cargo.lock b/Cargo.lock index 7ba6411f01..2a3ae4c40b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2884,6 +2884,7 @@ dependencies = [ "http-body-util", "libdd-capabilities", "libdd-common", + "tempfile", "tokio", ] diff --git a/libdd-capabilities-impl/Cargo.toml b/libdd-capabilities-impl/Cargo.toml index a2310f51d8..b83fd6cc1f 100644 --- a/libdd-capabilities-impl/Cargo.toml +++ b/libdd-capabilities-impl/Cargo.toml @@ -21,11 +21,15 @@ bytes = "1" http = "1" libdd-capabilities = { path = "../libdd-capabilities", version = "2.1.0" } libdd-common = { path = "../libdd-common", version = "5.1.0", default-features = false } -tokio = { version = "1", features = ["time"] } +tokio = { version = "1", features = ["fs", "time"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] http-body-util = "0.1" +[dev-dependencies] +tempfile = "3" +tokio = { version = "1", features = ["fs", "macros", "rt", "time"] } + [features] default = ["https"] https = ["libdd-common/https"] diff --git a/libdd-capabilities-impl/src/file.rs b/libdd-capabilities-impl/src/file.rs new file mode 100644 index 0000000000..e88d14f500 --- /dev/null +++ b/libdd-capabilities-impl/src/file.rs @@ -0,0 +1,153 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Native file capability backed by `tokio::fs`. + +use core::future::Future; +use std::io; + +use bytes::Bytes; +use libdd_capabilities::file::{FileCapability, FileError, FileMetadata}; +use libdd_capabilities::maybe_send::MaybeSend; + +#[derive(Clone, Debug)] +pub struct NativeFileCapability; + +impl FileCapability for NativeFileCapability { + fn new() -> Self { + Self + } + + fn read(&self, path: &str) -> impl Future> + MaybeSend { + let path = path.to_owned(); + async move { + match tokio::fs::read(&path).await { + Ok(bytes) => Ok(Bytes::from(bytes)), + Err(e) => Err(map_io_error(e, &path)), + } + } + } + + fn write( + &self, + path: &str, + contents: Bytes, + ) -> impl Future> + MaybeSend { + let path = path.to_owned(); + async move { + tokio::fs::write(&path, &contents) + .await + .map_err(|e| map_io_error(e, &path)) + } + } + + fn metadata( + &self, + path: &str, + ) -> impl Future> + MaybeSend { + let path = path.to_owned(); + async move { + let m = tokio::fs::metadata(&path) + .await + .map_err(|e| map_io_error(e, &path))?; + Ok(FileMetadata { + size: m.len(), + inode: platform_inode(&m), + is_file: m.is_file(), + is_dir: m.is_dir(), + }) + } + } + + fn exists(&self, path: &str) -> impl Future> + MaybeSend { + let path = path.to_owned(); + async move { + match tokio::fs::metadata(&path).await { + Ok(_) => Ok(true), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(map_io_error(e, &path)), + } + } + } +} + +fn map_io_error(e: io::Error, path: &str) -> FileError { + match e.kind() { + io::ErrorKind::NotFound => FileError::NotFound(path.to_owned()), + io::ErrorKind::PermissionDenied => FileError::PermissionDenied(path.to_owned()), + _ => FileError::Io(anyhow::Error::new(e).context(format!("path: {path}"))), + } +} + +#[cfg(unix)] +fn platform_inode(m: &std::fs::Metadata) -> Option { + use std::os::unix::fs::MetadataExt; + Some(m.ino()) +} + +#[cfg(not(unix))] +fn platform_inode(_m: &std::fs::Metadata) -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::TempDir; + + fn tmp() -> TempDir { + TempDir::new().expect("tempdir") + } + + #[tokio::test] + async fn read_write_roundtrip() { + let dir = tmp(); + let path = dir.path().join("file.bin"); + let path_str = path.to_str().unwrap(); + let cap = NativeFileCapability; + cap.write(path_str, Bytes::from_static(b"hello")) + .await + .expect("write ok"); + let got = cap.read(path_str).await.expect("read ok"); + assert_eq!(&got[..], b"hello"); + } + + #[tokio::test] + async fn read_missing_yields_not_found() { + let dir = tmp(); + let path = dir.path().join("missing.bin"); + let cap = NativeFileCapability; + let err = cap.read(path.to_str().unwrap()).await.unwrap_err(); + assert!(matches!(err, FileError::NotFound(_)), "got: {err:?}"); + } + + #[tokio::test] + async fn metadata_reports_size_and_kind() { + let dir = tmp(); + let path = dir.path().join("f.bin"); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(b"1234567890").unwrap(); + drop(f); + let cap = NativeFileCapability; + let m = cap.metadata(path.to_str().unwrap()).await.expect("meta ok"); + assert_eq!(m.size, 10); + assert!(m.is_file); + assert!(!m.is_dir); + #[cfg(unix)] + assert!(m.inode.is_some()); + #[cfg(not(unix))] + assert!(m.inode.is_none()); + } + + #[tokio::test] + async fn exists_true_and_false() { + let dir = tmp(); + let present = dir.path().join("here"); + std::fs::write(&present, b"").unwrap(); + let absent = dir.path().join("gone"); + let cap = NativeFileCapability; + assert!(cap.exists(present.to_str().unwrap()).await.unwrap()); + assert!(!cap.exists(absent.to_str().unwrap()).await.unwrap()); + } +} diff --git a/libdd-capabilities-impl/src/lib.rs b/libdd-capabilities-impl/src/lib.rs index 68c1854ff6..026aa72b02 100644 --- a/libdd-capabilities-impl/src/lib.rs +++ b/libdd-capabilities-impl/src/lib.rs @@ -8,6 +8,7 @@ //! etc.). Leaf crates (FFI, benchmarks) pin this type as the generic parameter. pub mod env; +pub mod file; mod http; pub mod sleep; @@ -15,10 +16,12 @@ use core::future::Future; use std::time::Duration; pub use env::NativeEnvCapability; +pub use file::NativeFileCapability; pub use http::NativeHttpClient; use libdd_capabilities::{http::HttpError, MaybeSend}; pub use libdd_capabilities::{ - EnvCapability, EnvError, HttpClientCapability, LogWriterCapability, SleepCapability, + EnvCapability, EnvError, FileCapability, FileError, FileMetadata, HttpClientCapability, + LogWriterCapability, SleepCapability, }; pub use sleep::NativeSleepCapability; @@ -36,6 +39,7 @@ pub struct NativeCapabilities { http: NativeHttpClient, sleep: NativeSleepCapability, env: NativeEnvCapability, + file: NativeFileCapability, } impl Default for NativeCapabilities { @@ -50,6 +54,7 @@ impl NativeCapabilities { http: NativeHttpClient::new_client(), sleep: NativeSleepCapability, env: NativeEnvCapability, + file: NativeFileCapability, } } } @@ -98,3 +103,35 @@ impl EnvCapability for NativeCapabilities { self.env.get(name) } } + +impl FileCapability for NativeCapabilities { + fn new() -> Self { + Self::new() + } + + fn read( + &self, + path: &str, + ) -> impl Future> + MaybeSend { + self.file.read(path) + } + + fn write( + &self, + path: &str, + contents: bytes::Bytes, + ) -> impl Future> + MaybeSend { + self.file.write(path, contents) + } + + fn metadata( + &self, + path: &str, + ) -> impl Future> + MaybeSend { + self.file.metadata(path) + } + + fn exists(&self, path: &str) -> impl Future> + MaybeSend { + self.file.exists(path) + } +} diff --git a/libdd-capabilities/src/file.rs b/libdd-capabilities/src/file.rs new file mode 100644 index 0000000000..88dc4bd009 --- /dev/null +++ b/libdd-capabilities/src/file.rs @@ -0,0 +1,53 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! File-system capability trait and error types. +//! +//! Async so a wasm impl can await `fs.promises`. Paths are `&str` because +//! wasm callers hand them across the JS boundary as strings. + +use crate::maybe_send::MaybeSend; +use core::future::Future; + +#[derive(Debug, thiserror::Error)] +pub enum FileError { + #[error("File not found: {0}")] + NotFound(String), + #[error("Permission denied: {0}")] + PermissionDenied(String), + #[error("IO error: {0}")] + Io(anyhow::Error), +} + +/// Snapshot of a file-system entry's metadata. +/// +/// `inode` is `None` when the underlying platform does not expose one (Windows +/// via `std`). Node.js exposes an inode on every platform, so the wasm impl +/// always populates it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FileMetadata { + pub size: u64, + pub inode: Option, + pub is_file: bool, + pub is_dir: bool, +} + +pub trait FileCapability: Clone + std::fmt::Debug { + fn new() -> Self; + + fn read(&self, path: &str) + -> impl Future> + MaybeSend; + + fn write( + &self, + path: &str, + contents: bytes::Bytes, + ) -> impl Future> + MaybeSend; + + fn metadata( + &self, + path: &str, + ) -> impl Future> + MaybeSend; + + fn exists(&self, path: &str) -> impl Future> + MaybeSend; +} diff --git a/libdd-capabilities/src/lib.rs b/libdd-capabilities/src/lib.rs index d4e5104b8e..58fda5f112 100644 --- a/libdd-capabilities/src/lib.rs +++ b/libdd-capabilities/src/lib.rs @@ -4,6 +4,7 @@ //! Portable capability traits for cross-platform libdatadog. pub mod env; +pub mod file; pub mod http; pub mod log_output; pub mod maybe_send; @@ -11,6 +12,7 @@ pub mod sleep; pub mod spawn; pub use self::env::{EnvCapability, EnvError}; +pub use self::file::{FileCapability, FileError, FileMetadata}; pub use self::http::{HttpClientCapability, HttpError}; pub use self::log_output::LogWriterCapability; pub use self::sleep::SleepCapability;