From 7c1f4879a3e612629a268d60cf9e7f470268ae90 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:32:51 +0200 Subject: [PATCH 01/26] feat(aggregator): add IPFS gateway configuration --- mithril-aggregator/src/configuration.rs | 33 +++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/mithril-aggregator/src/configuration.rs b/mithril-aggregator/src/configuration.rs index 4c9d7af2df4..1411248e61b 100644 --- a/mithril-aggregator/src/configuration.rs +++ b/mithril-aggregator/src/configuration.rs @@ -130,6 +130,18 @@ pub trait ConfigurationSource { panic!("snapshot_use_cdn_domain is not implemented."); } + /// URL of a Kubo IPFS RPC API, setting this will enable IPFS upload for immutable snapshots + fn ipfs_rpc_url(&self) -> Option { + panic!("ipfs_rpc_url is not implemented."); + } + + /// Parsed URL of a Kubo IPFS RPC API (see [ipfs_rpc_url][ConfigurationSource::ipfs_rpc_url]) + fn get_ipfs_rpc_url(&self) -> StdResult> { + self.ipfs_rpc_url() + .map(|url| SanitizedUrlWithTrailingSlash::parse(&url)) + .transpose() + } + /// Server listening IP fn server_ip(&self) -> String { panic!("server_ip is not implemented."); @@ -534,6 +546,9 @@ pub struct ServeCommandConfiguration { /// Use CDN domain to construct snapshot urls if snapshot_uploader_type is Gcp pub snapshot_use_cdn_domain: bool, + /// URL of a Kubo IPFS RPC API, setting this will enable IPFS upload for immutable snapshots + pub ipfs_rpc_url: Option, + /// Server listening IP pub server_ip: String, @@ -793,6 +808,7 @@ impl ServeCommandConfiguration { snapshot_uploader_type: SnapshotUploaderType::Local, snapshot_bucket_name: None, snapshot_use_cdn_domain: false, + ipfs_rpc_url: None, server_ip: "0.0.0.0".to_string(), server_port: 8000, public_server_url: None, @@ -911,6 +927,10 @@ impl ConfigurationSource for ServeCommandConfiguration { self.snapshot_use_cdn_domain } + fn ipfs_rpc_url(&self) -> Option { + self.ipfs_rpc_url.clone() + } + fn server_ip(&self) -> String { self.server_ip.clone() } @@ -1436,6 +1456,19 @@ mod test { assert!(!config.allow_http_serve_directory()); } + #[test] + fn get_ipfs_rpc_url_return_sanitized_public_url_if_it_is_set() { + let config = ServeCommandConfiguration { + ipfs_rpc_url: Some("https://example.com:8080/".to_string()), + ..ServeCommandConfiguration::new_sample(temp_dir!()) + }; + + assert_eq!( + config.get_ipfs_rpc_url().unwrap().unwrap().as_str(), + "https://example.com:8080/" + ); + } + #[test] fn get_server_url_return_local_url_with_server_base_path_if_public_url_is_not_set() { let config = ServeCommandConfiguration { From 375375721b233ed537700c3c2efe3d6994655803 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:45:09 +0200 Subject: [PATCH 02/26] feat(aggregator): introduce Kubo RPC client with query and response handling - Use the same, but simplified, request system than the `aggregator_http_client`. - Handle errors automatically - Support query level request configurations, and timeout --- .../src/tools/kubo_rpc_client/api.rs | 336 ++++++++++++++++++ .../src/tools/kubo_rpc_client/mod.rs | 24 ++ mithril-aggregator/src/tools/mod.rs | 1 + 3 files changed, 361 insertions(+) create mode 100644 mithril-aggregator/src/tools/kubo_rpc_client/api.rs create mode 100644 mithril-aggregator/src/tools/kubo_rpc_client/mod.rs diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/api.rs b/mithril-aggregator/src/tools/kubo_rpc_client/api.rs new file mode 100644 index 00000000000..856c7ab3cef --- /dev/null +++ b/mithril-aggregator/src/tools/kubo_rpc_client/api.rs @@ -0,0 +1,336 @@ +use std::time::Duration; + +use anyhow::Context; +use reqwest::{RequestBuilder, Response, StatusCode, Url}; +use slog::{Logger, trace}; + +use mithril_common::logging::LoggerExtensions; +use mithril_common::{StdError, StdResult}; + +use crate::tools::url_sanitizer::SanitizedUrlWithTrailingSlash; + +/// Trait for defining RPC queries to the Kubo IPFS node. +#[async_trait::async_trait] +pub trait KuboRpcQuery: Sync { + /// The type of the successful response from this query. + type Response; + + /// Returns the API route path for this query. + fn route(&self) -> String; + + /// Configures the RPC request before sending. + /// + /// Override this method to add query parameters, headers, or body to the request. + /// + /// Note: Configuration made by the [KuboRpcClient] won't be overridden since they are applied after this method. + async fn configure_request( + &self, + request_builder: RequestBuilder, + ) -> StdResult { + Ok(request_builder) + } + + /// Timeout for the RPC request. + fn timeout() -> Duration { + Duration::from_secs(1) + } + + /// Handles a successful RPC response and converts it to the expected response type. + async fn handle_success(&self, response: Response) -> StdResult; + + /// Handles an error RPC response + /// + /// Default to returning an error with the response text, but can be overridden to handle errors + /// differently. + async fn handle_error(&self, response: Response) -> StdResult { + let status_code = response.status(); + let response_text = response.text().await.unwrap_or_else(|e| e.to_string()); + + Err(format_response_error(status_code, &response_text)) + } +} + +/// HTTP client for sending RPC requests to a Kubo IPFS node. +/// +/// This requester handles the HTTP communication layer, including request construction, +/// timeout management, and error handling. +pub struct KuboRpcClient { + rpc_base_url: Url, + client: reqwest::Client, + logger: Logger, +} + +impl KuboRpcClient { + /// Creates a new Kubo RPC client. + pub fn new(rpc_base_url: SanitizedUrlWithTrailingSlash, logger: Logger) -> StdResult { + Ok(Self { + rpc_base_url: rpc_base_url.into(), + client: reqwest::Client::builder() + .build() + .with_context(|| "RPC client creation failed")?, + logger: logger.new_with_component_name::(), + }) + } + + /// Sends an RPC query to the Kubo node and returns the parsed response. + /// + /// Returns an error if the request fails, times out, or the response indicates failure. + pub async fn send(&self, query: Q) -> StdResult { + let route = query.route(); + let endpoint = join_endpoint(&self.rpc_base_url, &route)?; + trace!(self.logger, "Kubo RPC POST"; "endpoint" => %endpoint); + + let request_builder = query + .configure_request(self.client.post(endpoint)) + .await + .with_context(|| { + format!("Failed to configure request for Kubo RPC endpoint: '{route}'") + })? + .timeout(Q::timeout()); + + let response = request_builder + .send() + .await + .with_context(|| format!("Failed to send request to Kubo RPC endpoint: '{route}'"))?; + + if response.status().is_success() { + query.handle_success(response).await + } else { + query.handle_error(response).await + } + } +} + +fn join_endpoint(base_url: &Url, endpoint: &str) -> StdResult { + let normalized_endpoint = if let Some(stripped_endpoint) = endpoint.strip_prefix("/") { + stripped_endpoint + } else { + endpoint + }; + + base_url + .join(normalized_endpoint) + .with_context(|| format!("Could not join `{base_url}` to URL `{endpoint}`")) +} + +pub(super) fn format_response_error(status: StatusCode, response_text: &str) -> StdError { + anyhow::anyhow!("Request to Kubo RPC failed: {status}: '{response_text}'") +} + +#[cfg(test)] +mod tests { + use httpmock::Method::POST; + + use crate::tools::kubo_rpc_client::test_tools::setup_server_and_client; + + use super::*; + + struct QueryWithoutResponse; + + #[async_trait::async_trait] + impl KuboRpcQuery for QueryWithoutResponse { + type Response = (); + + fn route(&self) -> String { + "foo".to_string() + } + + async fn handle_success(&self, _response: Response) -> StdResult { + Ok(()) + } + } + + struct QueryWithResponseAndParam { + param: String, + } + + #[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)] + struct QueryResponse { + foo: String, + bar: u32, + } + + #[async_trait::async_trait] + impl KuboRpcQuery for QueryWithResponseAndParam { + type Response = QueryResponse; + + fn route(&self) -> String { + "/foo".to_string() + } + + async fn configure_request( + &self, + request_builder: RequestBuilder, + ) -> StdResult { + Ok(request_builder.query(&[("param", &self.param)])) + } + + async fn handle_success(&self, response: Response) -> StdResult { + let json = response.json().await?; + Ok(json) + } + } + + #[test] + fn join_an_endpoint_with_a_leading_slash_should_keep_existing_components() { + let base_url = Url::parse("http://localhost:5001/api/v0/").unwrap(); + + assert_eq!( + "http://localhost:5001/api/v0/foo", + join_endpoint(&base_url, "/foo").unwrap().as_str() + ); + } + + #[tokio::test] + async fn minimal_request_with_only_route() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST).path("/foo"); + then.status(200); + }); + + client.send(QueryWithoutResponse).await.unwrap(); + } + + #[tokio::test] + async fn minimal_request_with_route_and_param() { + let expected_response = QueryResponse { + foo: "pika".to_string(), + bar: 123, + }; + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST).path("/foo").query_param("param", "bar"); + then.status(200).json_body_obj(&expected_response); + }); + + let response = client + .send(QueryWithResponseAndParam { + param: "bar".to_string(), + }) + .await + .unwrap(); + + assert_eq!(expected_response, response); + } + + #[tokio::test] + async fn query_times_out_when_response_exceeds_configured_timeout() { + struct TimeoutQuery; + + #[async_trait::async_trait] + impl KuboRpcQuery for TimeoutQuery { + type Response = (); + + fn route(&self) -> String { + "will_timeout".to_string() + } + + fn timeout() -> Duration { + Duration::from_millis(10) + } + + async fn handle_success(&self, _response: Response) -> StdResult { + Ok(()) + } + } + + let (server, client) = setup_server_and_client(); + let _server_mock = server.mock(|when, then| { + when.any_request(); + then.delay(Duration::from_millis(100)); + }); + + let error = client.send(TimeoutQuery).await.unwrap_err(); + + assert!( + format!("{error:?}").contains("operation timed out"), + "Expected error message to contain 'operation timed out'\ngot '{error:?}'", + ) + } + + mod errors { + use super::*; + + macro_rules! assert_error_text_contains { + ($error: expr, $expect_contains: expr) => { + let error = &$error; + assert!( + error.contains($expect_contains), + "Expected error message to contain '{}'\ngot '{error:?}'", + $expect_contains, + ); + }; + } + + #[tokio::test] + async fn handle_json_errors() { + let json = serde_json::json!({"title": "error", "message":"an error"}); + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.any_request(); + then.status(400).json_body(json.clone()); + }); + + let err = client.send(QueryWithoutResponse).await.unwrap_err(); + assert_error_text_contains!(err.to_string(), &json.to_string()); + } + + #[tokio::test] + async fn handle_malformed_json() { + let malformed_json = r###"{"title": "error" "message":"an error"}"###; + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.any_request(); + then.status(400) + .body(malformed_json) + .header("Content-Type", "application/json"); + }); + + let err = client.send(QueryWithoutResponse).await.unwrap_err(); + assert_error_text_contains!(err.to_string(), &malformed_json.to_string()); + } + + #[tokio::test] + async fn handle_text_error() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.any_request(); + then.status(400).body("an error message"); + }); + + let err = client.send(QueryWithoutResponse).await.unwrap_err(); + assert_error_text_contains!(err.to_string(), ": 'an error message'"); + } + + #[tokio::test] + async fn handle_4xx_error() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.any_request(); + then.status(400).body("an error message"); + }); + + let err = client.send(QueryWithoutResponse).await.unwrap_err(); + assert_error_text_contains!( + err.to_string(), + "Request to Kubo RPC failed: 400 Bad Request:" + ); + } + + #[tokio::test] + async fn handle_5xx_error() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.any_request(); + then.status(500).body("an error message"); + }); + + let err = client.send(QueryWithoutResponse).await.unwrap_err(); + assert_error_text_contains!( + err.to_string(), + "Request to Kubo RPC failed: 500 Internal Server Error:" + ); + } + } +} diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/mod.rs b/mithril-aggregator/src/tools/kubo_rpc_client/mod.rs new file mode 100644 index 00000000000..a099507a81c --- /dev/null +++ b/mithril-aggregator/src/tools/kubo_rpc_client/mod.rs @@ -0,0 +1,24 @@ +mod api; + +pub use api::{KuboRpcClient, KuboRpcQuery}; + +#[cfg(test)] +mod test_tools { + use httpmock::MockServer; + + use crate::test::TestLogger; + use crate::tools::url_sanitizer::SanitizedUrlWithTrailingSlash; + + use super::KuboRpcClient; + + pub(super) fn setup_server_and_client() -> (MockServer, KuboRpcClient) { + let server = MockServer::start(); + let client = KuboRpcClient::new( + SanitizedUrlWithTrailingSlash::parse(&server.base_url()).unwrap(), + TestLogger::stdout(), + ) + .unwrap(); + + (server, client) + } +} diff --git a/mithril-aggregator/src/tools/mod.rs b/mithril-aggregator/src/tools/mod.rs index 93b3777bdde..6387f9b2c70 100644 --- a/mithril-aggregator/src/tools/mod.rs +++ b/mithril-aggregator/src/tools/mod.rs @@ -1,6 +1,7 @@ mod certificates_hash_migrator; mod era; mod genesis; +pub mod kubo_rpc_client; mod protocol_configuration; pub mod signer_importer; mod single_signature_authenticator; From 3c3b120c846217b2a2653b8494aed7c474360317 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:04:35 +0200 Subject: [PATCH 03/26] feat(aggregator): add IPFS query implementations for Kubo RPC client - Add `IpfsAddQuery` for adding files to IPFS. - Add `IpfsFilesQPQuery` for copying files to the IPFS MFS. - Add `IpfsFilesMkdirQuery` for creating directories in the IPFS MFS. - Add `IpfsFilesStatQuery` for retrieving file status in the IPFS MFS. --- .../src/tools/kubo_rpc_client/mod.rs | 1 + .../tools/kubo_rpc_client/query/ipfs_add.rs | 114 ++++++++++++++ .../kubo_rpc_client/query/ipfs_files_cp.rs | 50 ++++++ .../kubo_rpc_client/query/ipfs_files_mkdir.rs | 92 +++++++++++ .../kubo_rpc_client/query/ipfs_files_stat.rs | 145 ++++++++++++++++++ .../src/tools/kubo_rpc_client/query/mod.rs | 9 ++ 6 files changed, 411 insertions(+) create mode 100644 mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs create mode 100644 mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_cp.rs create mode 100644 mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_mkdir.rs create mode 100644 mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_stat.rs create mode 100644 mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/mod.rs b/mithril-aggregator/src/tools/kubo_rpc_client/mod.rs index a099507a81c..4dda014f169 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/mod.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/mod.rs @@ -1,4 +1,5 @@ mod api; +pub mod query; pub use api::{KuboRpcClient, KuboRpcQuery}; diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs new file mode 100644 index 00000000000..a009a3f2ab7 --- /dev/null +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs @@ -0,0 +1,114 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Context; +use reqwest::{RequestBuilder, Response}; +use serde::Deserialize; + +use mithril_common::StdResult; + +use crate::tools::kubo_rpc_client::KuboRpcQuery; + +/// Query to add a file to IPFS via the Kubo RPC API. +/// +/// see: https://docs.ipfs.tech/reference/kubo/rpc/#api-v0-add +// TODO: Enforce most add parameters to make CID deterministic. +pub struct IpfsAddQuery { + file_path: PathBuf, +} + +/// Response from the IPFS add operation. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct IpfsAddResponse { + /// Name of the added file + pub name: String, + /// Hash of the added file (CID) + pub hash: String, +} + +impl IpfsAddQuery { + /// Create a query that will add the given file to IPFS. + pub fn new>(file_path: P) -> Self { + Self { + file_path: file_path.as_ref().to_path_buf(), + } + } +} + +#[async_trait::async_trait] +impl KuboRpcQuery for IpfsAddQuery { + type Response = IpfsAddResponse; + + fn route(&self) -> String { + "api/v0/add".to_string() + } + + async fn configure_request( + &self, + request_builder: RequestBuilder, + ) -> StdResult { + let form = reqwest::multipart::Form::new().file("file", &self.file_path).await?; + Ok(request_builder.multipart(form)) + } + + async fn handle_success(&self, response: Response) -> StdResult { + response + .json() + .await + .with_context(|| "Failed to deserialize IPFS add response") + } +} + +#[cfg(test)] +mod tests { + use httpmock::Method::POST; + + use mithril_common::temp_dir_create; + + use crate::tools::kubo_rpc_client::test_tools::setup_server_and_client; + + use super::*; + + #[tokio::test] + async fn return_add_data_if_request_succeeds() { + let test_dir = temp_dir_create!(); + let file = test_dir.join("test.txt"); + std::fs::File::create(&file).unwrap(); + + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST).path("/api/v0/add"); + then.status(200).json_body(serde_json::json!({"Name":"test.txt","Hash":"QmYi7wrRFKVCcTB56A6Pep2j31Q5mHfmmu21RzHXu25RVR","Size":"23"})); + }); + + let response = client.send(IpfsAddQuery::new(file)).await.unwrap(); + assert_eq!( + IpfsAddResponse { + name: "test.txt".to_string(), + hash: "QmYi7wrRFKVCcTB56A6Pep2j31Q5mHfmmu21RzHXu25RVR".to_string(), + }, + response + ); + } + + #[tokio::test] + async fn return_error_if_request_fails() { + let test_dir = temp_dir_create!(); + let file = test_dir.join("test.txt"); + std::fs::File::create(&file).unwrap(); + + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST).path("/api/v0/add"); + then.status(500).json_body( + serde_json::json!({"Message":"paths must start with a leading slash","Code":0,"Type":"error"}), + ); + }); + + let err = client.send(IpfsAddQuery::new(file)).await.unwrap_err(); + assert!( + err.to_string().contains("paths must start with a leading slash"), + "unexpected error: {err}" + ); + } +} diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_cp.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_cp.rs new file mode 100644 index 00000000000..0bac520b28a --- /dev/null +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_cp.rs @@ -0,0 +1,50 @@ +use std::path::{Path, PathBuf}; + +use reqwest::{RequestBuilder, Response}; + +use mithril_common::StdResult; + +use crate::tools::kubo_rpc_client::KuboRpcQuery; + +/// Query to reference IPFS files in an MFS (Mutable File System) in IPFS via the Kubo RPC API. +/// +/// see: https://docs.ipfs.tech/reference/kubo/rpc/#api-v0-files-cp +pub struct IpfsFilesCpQuery { + source_cid: String, + dest_directory: PathBuf, +} + +impl IpfsFilesCpQuery { + /// Create a query that will reference the given IPFS CID in the given MFS directory. + pub fn reference_file_in_mfs_dir>( + source_cid: String, + dest_directory: P, + ) -> Self { + Self { + source_cid, + dest_directory: dest_directory.as_ref().to_path_buf(), + } + } +} + +#[async_trait::async_trait] +impl KuboRpcQuery for IpfsFilesCpQuery { + type Response = (); + + fn route(&self) -> String { + "api/v0/files/cp".to_string() + } + + async fn configure_request( + &self, + request_builder: RequestBuilder, + ) -> StdResult { + Ok(request_builder + .query(&[("arg", &self.source_cid)]) + .query(&[("arg", &self.dest_directory)])) + } + + async fn handle_success(&self, _response: Response) -> StdResult { + Ok(()) + } +} diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_mkdir.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_mkdir.rs new file mode 100644 index 00000000000..cab0c3415c3 --- /dev/null +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_mkdir.rs @@ -0,0 +1,92 @@ +use std::path::{Path, PathBuf}; + +use reqwest::{RequestBuilder, Response}; + +use mithril_common::StdResult; + +use crate::tools::kubo_rpc_client::KuboRpcQuery; + +/// Query to make an MFS (Mutable File System) directory in IPFS via the Kubo RPC API. +/// +/// Note: `parents` flag is set to true by default; this has two effects: +/// - parent directories will be created if they do not exist. +/// - the command will succeed even if the directory already exists. +/// +/// see: https://docs.ipfs.tech/reference/kubo/rpc/#api-v0-files-mkdir +pub struct IpfsFilesMkdirQuery { + ipfs_absolute_path: PathBuf, +} + +impl IpfsFilesMkdirQuery { + /// Create a query that will create the given IPFS absolute path as an MFS directory. + pub fn create_mfs_directory>(ipfs_absolute_path: P) -> Self { + Self { + ipfs_absolute_path: ipfs_absolute_path.as_ref().to_path_buf(), + } + } +} + +#[async_trait::async_trait] +impl KuboRpcQuery for IpfsFilesMkdirQuery { + type Response = (); + + fn route(&self) -> String { + "api/v0/files/mkdir".to_string() + } + + async fn configure_request( + &self, + request_builder: RequestBuilder, + ) -> StdResult { + Ok(request_builder + .query(&[("arg", &self.ipfs_absolute_path)]) + .query(&[("parents", true)])) + } + + async fn handle_success(&self, _response: Response) -> StdResult { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use httpmock::Method::POST; + + use crate::tools::kubo_rpc_client::test_tools::setup_server_and_client; + + use super::*; + + #[tokio::test] + async fn succeeds_when_server_returns_200() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST) + .path("/api/v0/files/mkdir") + .query_param("arg", "/test") + .query_param("parents", "true"); + then.status(200); + }); + + client + .send(IpfsFilesMkdirQuery::create_mfs_directory("/test")) + .await + .unwrap(); + } + + #[tokio::test] + async fn return_error_if_request_fails_with_other_message() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST).path("/api/v0/files/mkdir").query_param("arg", "/test"); + then.status(500).json_body( + serde_json::json!({"Message":"paths must start with a leading slash","Code":0,"Type":"error"}), + ); + }); + + let err = client + .send(IpfsFilesMkdirQuery::create_mfs_directory("/test")) + .await + .unwrap_err(); + assert!(err.to_string().contains("paths must start with a leading slash")); + } +} diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_stat.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_stat.rs new file mode 100644 index 00000000000..347644f61e9 --- /dev/null +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_stat.rs @@ -0,0 +1,145 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Context; +use reqwest::{RequestBuilder, Response}; +use serde::Deserialize; + +use mithril_common::StdResult; + +use crate::tools::kubo_rpc_client::KuboRpcQuery; +use crate::tools::kubo_rpc_client::api::format_response_error; + +/// Query to display file status in an MFS (Mutable File System) in IPFS via the Kubo RPC API. +/// +/// see: https://docs.ipfs.tech/reference/kubo/rpc/#api-v0-files-stat +pub struct IpfsFilesStatQuery { + path_in_ipfs: PathBuf, +} + +/// Response from the IPFS files stat operation. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct IpfsStatResponse { + /// Hash (CID) of the file + pub hash: String, + /// Size of the file in bytes + pub size: u64, + /// Cumulative size including blocks + pub cumulative_size: u64, + /// Type of the IPFS object + pub r#type: MfsStatType, +} + +/// Type of MFS (Mutable File System) entry in IPFS. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MfsStatType { + /// A file entry + File, + /// A directory entry + Directory, +} + +impl IpfsFilesStatQuery { + /// Create a query that will get the status of a file in the MFS. + pub fn new>(path_in_ipfs: P) -> Self { + Self { + path_in_ipfs: path_in_ipfs.as_ref().to_path_buf(), + } + } +} + +#[async_trait::async_trait] +impl KuboRpcQuery for IpfsFilesStatQuery { + type Response = Option; + + fn route(&self) -> String { + "api/v0/files/stat".to_string() + } + + async fn configure_request( + &self, + request_builder: RequestBuilder, + ) -> StdResult { + Ok(request_builder.query(&[("arg", &self.path_in_ipfs)])) + } + + async fn handle_success(&self, response: Response) -> StdResult { + response + .json() + .await + .map(Some) + .with_context(|| "Failed to deserialize IPFS stat response") + } + + async fn handle_error(&self, response: Response) -> StdResult { + let status = response.status(); + let body = response + .text() + .await + .with_context(|| "Failed to read IPFS files stat error response")?; + + if body.contains("file does not exist") { + Ok(None) + } else { + Err(format_response_error(status, &body)) + } + } +} + +#[cfg(test)] +mod tests { + use httpmock::Method::POST; + + use crate::tools::kubo_rpc_client::test_tools::setup_server_and_client; + + use super::*; + + #[tokio::test] + async fn return_stat_data_if_request_succeeds() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST).path("/api/v0/files/stat"); + then.status(200).json_body(serde_json::json!({"Hash": "QmHash", "Size": 1, "CumulativeSize": 2, "Type": "file"})); + }); + + let response = client.send(IpfsFilesStatQuery::new("/test")).await.unwrap(); + assert_eq!( + Some(IpfsStatResponse { + hash: "QmHash".to_string(), + size: 1, + cumulative_size: 2, + r#type: MfsStatType::File + }), + response + ); + } + + #[tokio::test] + async fn return_none_if_request_fails_with_not_exist_message() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST).path("/api/v0/files/stat"); + then.status(500).json_body( + serde_json::json!({"Message":"file does not exist","Code":0,"Type":"error"}), + ); + }); + + let response = client.send(IpfsFilesStatQuery::new("/test")).await.unwrap(); + assert_eq!(None, response); + } + + #[tokio::test] + async fn return_error_if_request_fails_with_other_message() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST).path("/api/v0/files/stat"); + then.status(500).json_body( + serde_json::json!({"Message":"paths must start with a leading slash","Code":0,"Type":"error"}), + ); + }); + + let err = client.send(IpfsFilesStatQuery::new("/test")).await.unwrap_err(); + assert!(err.to_string().contains("paths must start with a leading slash")); + } +} diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs new file mode 100644 index 00000000000..d7258068f30 --- /dev/null +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs @@ -0,0 +1,9 @@ +mod ipfs_add; +mod ipfs_files_cp; +mod ipfs_files_mkdir; +mod ipfs_files_stat; + +pub use ipfs_add::*; +pub use ipfs_files_cp::*; +pub use ipfs_files_mkdir::*; +pub use ipfs_files_stat::*; From 2320f832bdbdc6b874e62f5e7479b576a47601ee Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:40:32 +0200 Subject: [PATCH 04/26] feat(aggregator): add IPFS file uploader This is the first, naive, implementation that lacks features such as proper CID management --- .../cardano_database_artifacts/immutable.rs | 28 ++- .../src/file_uploaders/ipfs_uploader.rs | 207 ++++++++++++++++++ mithril-aggregator/src/file_uploaders/mod.rs | 2 + 3 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 mithril-aggregator/src/file_uploaders/ipfs_uploader.rs diff --git a/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs b/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs index 70203f73e74..5e452d14e04 100644 --- a/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs +++ b/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs @@ -7,13 +7,15 @@ use slog::{Logger, error}; use mithril_common::{ StdResult, - entities::{CompressionAlgorithm, ImmutableFileNumber, ImmutablesLocation, MultiFilesUri}, + entities::{ + CompressionAlgorithm, ImmutableFileNumber, ImmutablesLocation, MultiFilesUri, TemplateUri, + }, logging::LoggerExtensions, }; use crate::{ DumbUploader, FileUploader, - file_uploaders::{CloudUploader, LocalUploader}, + file_uploaders::{CloudUploader, IpfsUploader, LocalUploader}, services::Snapshotter, }; @@ -99,6 +101,28 @@ impl ImmutableFilesUploader for LocalUploader { } } +#[async_trait] +impl ImmutableFilesUploader for IpfsUploader { + async fn batch_upload( + &self, + filepaths: &[PathBuf], + compression_algorithm: Option, + ) -> StdResult { + for filepath in filepaths { + self.upload(filepath).await?; + } + + let directory_cid = self.get_current_directory_cid().await?; + + Ok(ImmutablesLocation::CloudStorage { + uri: MultiFilesUri::Template(TemplateUri(format!( + "{directory_cid}/{{immutable_file_number}}.tar.zst" + ))), + compression_algorithm, + }) + } +} + #[async_trait] impl ImmutableFilesUploader for CloudUploader { async fn batch_upload( diff --git a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs new file mode 100644 index 00000000000..c898960a6a5 --- /dev/null +++ b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs @@ -0,0 +1,207 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::Context; +use slog::{Logger, trace}; + +use mithril_common::StdResult; +use mithril_common::entities::FileUri; +use mithril_common::logging::LoggerExtensions; + +use crate::FileUploader; +use crate::tools::kubo_rpc_client::KuboRpcClient; +use crate::tools::kubo_rpc_client::query::{ + IpfsAddQuery, IpfsFilesCpQuery, IpfsFilesMkdirQuery, IpfsFilesStatQuery, +}; + +/// IPFS Content Identifier (CID) +pub type Cid = String; + +/// File uploader that stores files to IPFS +pub struct IpfsUploader { + rpc_client: Arc, + ipfs_dir_path: PathBuf, + logger: Logger, +} + +impl IpfsUploader { + /// Create a new IPFS uploader + pub fn new( + rpc_client: Arc, + ipfs_dir_path: PathBuf, + logger: &Logger, + ) -> Self { + Self { + rpc_client, + ipfs_dir_path, + logger: logger.new_with_component_name::(), + } + } + + /// Get the current directory CID, reflecting the latest state of the directory + pub async fn get_current_directory_cid(&self) -> StdResult { + self.rpc_client.get_dir_cid(&self.ipfs_dir_path).await + } +} + +#[async_trait::async_trait] +impl FileUploader for IpfsUploader { + async fn upload_without_retry(&self, filepath: &Path) -> StdResult { + trace!(self.logger, "Uploading file to IPFS"; "file_path" => %filepath.display()); + self.rpc_client + .create_dir(&self.ipfs_dir_path) + .await + .with_context(|| { + format!( + "Failed to create directory '{}' in IPFS", + self.ipfs_dir_path.display() + ) + })?; + + match self.rpc_client.file_exists(filepath).await.with_context(|| { + format!( + "Failed to check if file '{}' exists in IPFS", + filepath.display() + ) + })? { + Some(cid) => { + trace!(self.logger, "File already exists in IPFS"; "cid" => %cid); + Ok(FileUri(cid)) + } + None => { + let cid = self.rpc_client.upload_file(filepath).await.with_context(|| { + format!("Failed to upload file '{}' to IPFS", filepath.display()) + })?; + trace!( + self.logger, "File upload to IPFS finished"; + "file_path" => %filepath.display(), "cid" => %cid + ); + + Ok(FileUri(cid)) + } + } + } +} + +/// Backend trait for IPFS operations +#[cfg_attr(test, mockall::automock)] +#[async_trait::async_trait] +pub trait IpfsBackendUploader: Sync + Send { + /// Create a directory in IPFS + async fn create_dir(&self, dir_path: &Path) -> StdResult<()>; + + /// Get the CID of a directory + async fn get_dir_cid(&self, dir_path: &Path) -> StdResult; + + /// Upload a file to IPFS and return its CID + async fn upload_file(&self, file_path: &Path) -> StdResult; + + /// Reference a file in a directory by its CID + async fn reference_file_in_dir(&self, file_cid: &Cid, dir_path: &Path) -> StdResult<()>; + + /// Check if a file exists and return its CID if it does + async fn file_exists(&self, file_path: &Path) -> StdResult>; +} + +#[async_trait::async_trait] +impl IpfsBackendUploader for KuboRpcClient { + async fn create_dir(&self, dir_path: &Path) -> StdResult<()> { + self.send(IpfsFilesMkdirQuery::create_mfs_directory(dir_path)).await + } + + async fn get_dir_cid(&self, dir_path: &Path) -> StdResult { + let stat = self.send(IpfsFilesStatQuery::new(dir_path)).await?.with_context(|| { + format!( + "Directory {} does not exist in IPFS node", + dir_path.display() + ) + })?; + Ok(stat.hash) + } + + async fn upload_file(&self, file_path: &Path) -> StdResult { + let res = self.send(IpfsAddQuery::new(file_path)).await?; + Ok(res.hash) + } + + async fn reference_file_in_dir(&self, file_cid: &Cid, dir_path: &Path) -> StdResult<()> { + self.send(IpfsFilesCpQuery::reference_file_in_mfs_dir( + file_cid.to_string(), + dir_path, + )) + .await + } + + async fn file_exists(&self, file_path: &Path) -> StdResult> { + let stat = self.send(IpfsFilesStatQuery::new(file_path)).await?; + Ok(stat.map(|stat| stat.hash)) + } +} + +#[cfg(test)] +mod tests { + use mockall::predicate::eq; + + use mithril_common::test::mock_extensions::MockBuilder; + + use crate::test::TestLogger; + + use super::*; + + #[tokio::test] + async fn create_dir_when_uploading() { + let uploader = IpfsUploader::new( + MockBuilder::configure(|mock: &mut MockIpfsBackendUploader| { + mock.expect_create_dir() + .with(eq(PathBuf::from("/test/dir"))) + .returning(|_| Ok(())); + mock.expect_file_exists().returning(|_| Ok(None)); + mock.expect_upload_file().returning(|_| Ok(String::new())); + }), + PathBuf::from("/test/dir"), + &TestLogger::stdout(), + ); + + uploader.upload_without_retry(Path::new("whatever")).await.unwrap(); + } + + #[tokio::test] + async fn existing_file_is_not_uploaded_and_its_cid_is_returned() { + let uploader = IpfsUploader::new( + MockBuilder::configure(|mock: &mut MockIpfsBackendUploader| { + mock.expect_create_dir().returning(|_| Ok(())); + mock.expect_file_exists() + .with(eq(Path::new("/a/file"))) + .returning(|_| Ok(Some("existing".to_string()))); + mock.expect_upload_file().never(); + }), + PathBuf::from("/test/dir"), + &TestLogger::stdout(), + ); + + let result = uploader.upload_without_retry(Path::new("/a/file")).await.unwrap(); + + assert_eq!(FileUri("existing".to_string()), result); + } + + #[tokio::test] + async fn non_existing_file_is_uploaded_and_its_cid_is_returned() { + let uploader = IpfsUploader::new( + MockBuilder::configure(|mock: &mut MockIpfsBackendUploader| { + mock.expect_create_dir() + .with(eq(PathBuf::from("/test/dir"))) + .returning(|_| Ok(())); + mock.expect_file_exists() + .with(eq(Path::new("/a/file"))) + .returning(|_| Ok(None)); + mock.expect_upload_file() + .with(eq(Path::new("/a/file"))) + .returning(|_| Ok(String::new())); + }), + PathBuf::from("/test/dir"), + &TestLogger::stdout(), + ); + + uploader.upload_without_retry(Path::new("/a/file")).await.unwrap(); + } +} diff --git a/mithril-aggregator/src/file_uploaders/mod.rs b/mithril-aggregator/src/file_uploaders/mod.rs index 5851aa953cb..702a128bed4 100644 --- a/mithril-aggregator/src/file_uploaders/mod.rs +++ b/mithril-aggregator/src/file_uploaders/mod.rs @@ -1,9 +1,11 @@ mod cloud_uploader; mod dumb_uploader; mod interface; +mod ipfs_uploader; mod local_uploader; pub use cloud_uploader::{CloudRemotePath, CloudUploader, GCloudBackendUploader}; pub use dumb_uploader::*; pub use interface::{FileUploadRetryPolicy, FileUploader}; +pub use ipfs_uploader::IpfsUploader; pub use local_uploader::LocalUploader; From e15e7c7add9415c11b33c9666051151de059ceb8 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:55:08 +0200 Subject: [PATCH 05/26] feat(aggregator): integrate IPFS uploader into dependency injection and file upload handling --- .../builder/protocol/artifacts.rs | 67 +++++++++++++------ 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs b/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs index c579f2d2320..b02f74c9e1f 100644 --- a/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs +++ b/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs @@ -17,7 +17,8 @@ use crate::configuration::AncillaryFilesSignerConfig; use crate::dependency_injection::builder::SNAPSHOT_ARTIFACTS_DIR; use crate::dependency_injection::{DependenciesBuilder, DependenciesBuilderError, Result}; use crate::file_uploaders::{ - CloudRemotePath, CloudUploader, FileUploadRetryPolicy, GCloudBackendUploader, LocalUploader, + CloudRemotePath, CloudUploader, FileUploadRetryPolicy, GCloudBackendUploader, IpfsUploader, + LocalUploader, }; use crate::get_dependency; use crate::http_server::{CARDANO_DATABASE_DOWNLOAD_PATH, SNAPSHOT_DOWNLOAD_PATH}; @@ -29,6 +30,8 @@ use crate::services::{ SignedEntityServiceArtifactsDependencies, Snapshotter, }; use crate::tools::DEFAULT_GCP_CREDENTIALS_JSON_ENV_VAR; +use crate::tools::kubo_rpc_client::KuboRpcClient; +use crate::tools::url_sanitizer::SanitizedUrlWithTrailingSlash; use crate::{DumbUploader, ExecutionEnvironment, FileUploader, SnapshotUploaderType}; impl DependenciesBuilder { @@ -253,6 +256,18 @@ impl DependenciesBuilder { )) } + async fn build_ipfs_uploader( + &self, + rpc_url: SanitizedUrlWithTrailingSlash, + ) -> Result { + let rpc_api_client = KuboRpcClient::new(rpc_url, self.root_logger())?; + Ok(IpfsUploader::new( + Arc::new(rpc_api_client), + PathBuf::from("/mithril"), + &self.root_logger(), + )) + } + async fn build_cardano_database_ancillary_uploaders( &self, ) -> Result>> { @@ -301,28 +316,36 @@ impl DependenciesBuilder { ) -> Result>> { let logger = self.root_logger(); if self.configuration.environment() == ExecutionEnvironment::Production { - match self.configuration.snapshot_uploader_type() { - SnapshotUploaderType::Gcp => { - let allow_overwrite = false; - let remote_folder_path = - CloudRemotePath::new("cardano-database").join("immutable"); - - Ok(vec![Arc::new( - self.build_gcp_uploader(remote_folder_path, allow_overwrite).await?, - )]) - } - SnapshotUploaderType::Local => { - let server_url_prefix = self.configuration.get_server_url()?; - let immutable_url_prefix = server_url_prefix - .sanitize_join(&format!("{CARDANO_DATABASE_DOWNLOAD_PATH}/immutable/"))?; - - Ok(vec![Arc::new(LocalUploader::new_without_copy( - immutable_url_prefix, - FileUploadRetryPolicy::default(), - logger, - ))]) - } + let mut uploaders: Vec> = + match self.configuration.snapshot_uploader_type() { + SnapshotUploaderType::Gcp => { + let allow_overwrite = false; + let remote_folder_path = + CloudRemotePath::new("cardano-database").join("immutable"); + + vec![Arc::new( + self.build_gcp_uploader(remote_folder_path, allow_overwrite).await?, + )] + } + SnapshotUploaderType::Local => { + let server_url_prefix = self.configuration.get_server_url()?; + let immutable_url_prefix = server_url_prefix.sanitize_join(&format!( + "{CARDANO_DATABASE_DOWNLOAD_PATH}/immutable/" + ))?; + + vec![Arc::new(LocalUploader::new_without_copy( + immutable_url_prefix, + FileUploadRetryPolicy::default(), + logger, + ))] + } + }; + + if let Some(url) = self.configuration.get_ipfs_rpc_url()? { + uploaders.push(Arc::new(self.build_ipfs_uploader(url).await?)); } + + Ok(uploaders) } else { Ok(vec![Arc::new(DumbUploader::new( FileUploadRetryPolicy::never(), From d07b266bae23945b7ad3d733457168e151f9e0ac Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:58:24 +0200 Subject: [PATCH 06/26] refactor(aggregator): simplify IPFS file upload by allowing direct MFS support This avoids doing a `file cp` request to reference the file into an MFS folder. --- .../src/file_uploaders/ipfs_uploader.rs | 39 ++++++--------- .../tools/kubo_rpc_client/query/ipfs_add.rs | 21 +++++++- .../kubo_rpc_client/query/ipfs_files_cp.rs | 50 ------------------- .../src/tools/kubo_rpc_client/query/mod.rs | 2 - 4 files changed, 36 insertions(+), 76 deletions(-) delete mode 100644 mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_cp.rs diff --git a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs index c898960a6a5..40b7a7bac09 100644 --- a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs +++ b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs @@ -10,9 +10,7 @@ use mithril_common::logging::LoggerExtensions; use crate::FileUploader; use crate::tools::kubo_rpc_client::KuboRpcClient; -use crate::tools::kubo_rpc_client::query::{ - IpfsAddQuery, IpfsFilesCpQuery, IpfsFilesMkdirQuery, IpfsFilesStatQuery, -}; +use crate::tools::kubo_rpc_client::query::{IpfsAddQuery, IpfsFilesMkdirQuery, IpfsFilesStatQuery}; /// IPFS Content Identifier (CID) pub type Cid = String; @@ -69,9 +67,13 @@ impl FileUploader for IpfsUploader { Ok(FileUri(cid)) } None => { - let cid = self.rpc_client.upload_file(filepath).await.with_context(|| { - format!("Failed to upload file '{}' to IPFS", filepath.display()) - })?; + let cid = self + .rpc_client + .upload_file(filepath, &self.ipfs_dir_path) + .await + .with_context(|| { + format!("Failed to upload file '{}' to IPFS", filepath.display()) + })?; trace!( self.logger, "File upload to IPFS finished"; "file_path" => %filepath.display(), "cid" => %cid @@ -94,10 +96,7 @@ pub trait IpfsBackendUploader: Sync + Send { async fn get_dir_cid(&self, dir_path: &Path) -> StdResult; /// Upload a file to IPFS and return its CID - async fn upload_file(&self, file_path: &Path) -> StdResult; - - /// Reference a file in a directory by its CID - async fn reference_file_in_dir(&self, file_cid: &Cid, dir_path: &Path) -> StdResult<()>; + async fn upload_file(&self, file_path: &Path, mfs_path: &Path) -> StdResult; /// Check if a file exists and return its CID if it does async fn file_exists(&self, file_path: &Path) -> StdResult>; @@ -119,19 +118,13 @@ impl IpfsBackendUploader for KuboRpcClient { Ok(stat.hash) } - async fn upload_file(&self, file_path: &Path) -> StdResult { - let res = self.send(IpfsAddQuery::new(file_path)).await?; + async fn upload_file(&self, file_path: &Path, mfs_path: &Path) -> StdResult { + let res = self + .send(IpfsAddQuery::new_with_mfs_reference(file_path, mfs_path)) + .await?; Ok(res.hash) } - async fn reference_file_in_dir(&self, file_cid: &Cid, dir_path: &Path) -> StdResult<()> { - self.send(IpfsFilesCpQuery::reference_file_in_mfs_dir( - file_cid.to_string(), - dir_path, - )) - .await - } - async fn file_exists(&self, file_path: &Path) -> StdResult> { let stat = self.send(IpfsFilesStatQuery::new(file_path)).await?; Ok(stat.map(|stat| stat.hash)) @@ -156,7 +149,7 @@ mod tests { .with(eq(PathBuf::from("/test/dir"))) .returning(|_| Ok(())); mock.expect_file_exists().returning(|_| Ok(None)); - mock.expect_upload_file().returning(|_| Ok(String::new())); + mock.expect_upload_file().returning(|_, _| Ok(String::new())); }), PathBuf::from("/test/dir"), &TestLogger::stdout(), @@ -195,8 +188,8 @@ mod tests { .with(eq(Path::new("/a/file"))) .returning(|_| Ok(None)); mock.expect_upload_file() - .with(eq(Path::new("/a/file"))) - .returning(|_| Ok(String::new())); + .with(eq(Path::new("/a/file")), eq(Path::new("/test/dir"))) + .returning(|_, _| Ok("test_cid".to_string())); }), PathBuf::from("/test/dir"), &TestLogger::stdout(), diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs index a009a3f2ab7..2490d016291 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs @@ -14,6 +14,7 @@ use crate::tools::kubo_rpc_client::KuboRpcQuery; // TODO: Enforce most add parameters to make CID deterministic. pub struct IpfsAddQuery { file_path: PathBuf, + to_files: Option, } /// Response from the IPFS add operation. @@ -31,6 +32,18 @@ impl IpfsAddQuery { pub fn new>(file_path: P) -> Self { Self { file_path: file_path.as_ref().to_path_buf(), + to_files: None, + } + } + + /// Create a query that will add the given file to IPFS and reference it in the MFS. + pub fn new_with_mfs_reference, P2: AsRef>( + file_path: P1, + mfs_path: P2, + ) -> Self { + Self { + file_path: file_path.as_ref().to_path_buf(), + to_files: Some(mfs_path.as_ref().to_path_buf()), } } } @@ -48,7 +61,13 @@ impl KuboRpcQuery for IpfsAddQuery { request_builder: RequestBuilder, ) -> StdResult { let form = reqwest::multipart::Form::new().file("file", &self.file_path).await?; - Ok(request_builder.multipart(form)) + let mut request_builder = request_builder.multipart(form); + + if let Some(mfs_path) = &self.to_files { + request_builder = request_builder.query(&[("to-files", mfs_path)]); + } + + Ok(request_builder) } async fn handle_success(&self, response: Response) -> StdResult { diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_cp.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_cp.rs deleted file mode 100644 index 0bac520b28a..00000000000 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_cp.rs +++ /dev/null @@ -1,50 +0,0 @@ -use std::path::{Path, PathBuf}; - -use reqwest::{RequestBuilder, Response}; - -use mithril_common::StdResult; - -use crate::tools::kubo_rpc_client::KuboRpcQuery; - -/// Query to reference IPFS files in an MFS (Mutable File System) in IPFS via the Kubo RPC API. -/// -/// see: https://docs.ipfs.tech/reference/kubo/rpc/#api-v0-files-cp -pub struct IpfsFilesCpQuery { - source_cid: String, - dest_directory: PathBuf, -} - -impl IpfsFilesCpQuery { - /// Create a query that will reference the given IPFS CID in the given MFS directory. - pub fn reference_file_in_mfs_dir>( - source_cid: String, - dest_directory: P, - ) -> Self { - Self { - source_cid, - dest_directory: dest_directory.as_ref().to_path_buf(), - } - } -} - -#[async_trait::async_trait] -impl KuboRpcQuery for IpfsFilesCpQuery { - type Response = (); - - fn route(&self) -> String { - "api/v0/files/cp".to_string() - } - - async fn configure_request( - &self, - request_builder: RequestBuilder, - ) -> StdResult { - Ok(request_builder - .query(&[("arg", &self.source_cid)]) - .query(&[("arg", &self.dest_directory)])) - } - - async fn handle_success(&self, _response: Response) -> StdResult { - Ok(()) - } -} diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs index d7258068f30..95dbb3a4b50 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs @@ -1,9 +1,7 @@ mod ipfs_add; -mod ipfs_files_cp; mod ipfs_files_mkdir; mod ipfs_files_stat; pub use ipfs_add::*; -pub use ipfs_files_cp::*; pub use ipfs_files_mkdir::*; pub use ipfs_files_stat::*; From 6eb97695d07656861b6b7659f611759bffaa91b0 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:54:24 +0200 Subject: [PATCH 07/26] fix(aggregator): missing error backtrace in archive uploads `to_string()` was naively used, reminder: with `anyhow` errors backtraces are only shown if the error is debug formated. --- .../artifact_builder/cardano_database_artifacts/ancillary.rs | 5 ++--- .../artifact_builder/cardano_database_artifacts/digest.rs | 5 ++--- .../artifact_builder/cardano_database_artifacts/immutable.rs | 5 ++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/ancillary.rs b/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/ancillary.rs index 4c07c17ef97..eb38e91afdc 100644 --- a/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/ancillary.rs +++ b/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/ancillary.rs @@ -177,9 +177,8 @@ impl AncillaryArtifactBuilder { } Err(e) => { error!( - self.logger, - "Failed to upload ancillary archive"; - "error" => e.to_string() + self.logger, "Failed to upload ancillary archive"; + "error" => ?e ); } } diff --git a/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/digest.rs b/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/digest.rs index 885d84f7b3b..b8380b484a0 100644 --- a/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/digest.rs +++ b/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/digest.rs @@ -251,9 +251,8 @@ impl DigestArtifactBuilder { } Err(e) => { error!( - self.logger, - "Failed to upload digest file"; - "error" => e.to_string() + self.logger, "Failed to upload digest file"; + "error" => ?e ); } } diff --git a/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs b/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs index 5e452d14e04..4a6f66d015c 100644 --- a/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs +++ b/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs @@ -265,9 +265,8 @@ impl ImmutableArtifactBuilder { } Err(e) => { error!( - self.logger, - "Failed to upload immutable archive"; - "error" => e.to_string() + self.logger, "Failed to upload immutable archive"; + "error" => ?e ); } } From fccb5aa7c903d0cc43a4369dab421593e93581b5 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:00:18 +0200 Subject: [PATCH 08/26] refactor(aggregator): replace `PathBuf` with `IpfsMfsDirPath` for IPFS MFS dir paths Introduce `IpfsMfsDirPath` to enforce absolute paths with trailing slashes for IPFS directories. This avoids successful uploads but ... that fails to reference the file in MFS dir (as if the dir path does not finish with a trailing slash, kubo silently fails the referencing). --- .../builder/protocol/artifacts.rs | 4 +- .../src/file_uploaders/ipfs_uploader.rs | 47 ++++---- .../src/tools/kubo_rpc_client/mod.rs | 2 + .../src/tools/kubo_rpc_client/path.rs | 102 ++++++++++++++++++ .../tools/kubo_rpc_client/query/ipfs_add.rs | 11 +- .../kubo_rpc_client/query/ipfs_files_mkdir.rs | 22 ++-- 6 files changed, 148 insertions(+), 40 deletions(-) create mode 100644 mithril-aggregator/src/tools/kubo_rpc_client/path.rs diff --git a/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs b/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs index b02f74c9e1f..5c915151f6c 100644 --- a/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs +++ b/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs @@ -30,7 +30,7 @@ use crate::services::{ SignedEntityServiceArtifactsDependencies, Snapshotter, }; use crate::tools::DEFAULT_GCP_CREDENTIALS_JSON_ENV_VAR; -use crate::tools::kubo_rpc_client::KuboRpcClient; +use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcClient}; use crate::tools::url_sanitizer::SanitizedUrlWithTrailingSlash; use crate::{DumbUploader, ExecutionEnvironment, FileUploader, SnapshotUploaderType}; @@ -263,7 +263,7 @@ impl DependenciesBuilder { let rpc_api_client = KuboRpcClient::new(rpc_url, self.root_logger())?; Ok(IpfsUploader::new( Arc::new(rpc_api_client), - PathBuf::from("/mithril"), + IpfsMfsDirPath::from("/mithril"), &self.root_logger(), )) } diff --git a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs index 40b7a7bac09..0781130b801 100644 --- a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs +++ b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; use anyhow::Context; @@ -9,8 +9,8 @@ use mithril_common::entities::FileUri; use mithril_common::logging::LoggerExtensions; use crate::FileUploader; -use crate::tools::kubo_rpc_client::KuboRpcClient; use crate::tools::kubo_rpc_client::query::{IpfsAddQuery, IpfsFilesMkdirQuery, IpfsFilesStatQuery}; +use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcClient}; /// IPFS Content Identifier (CID) pub type Cid = String; @@ -18,7 +18,7 @@ pub type Cid = String; /// File uploader that stores files to IPFS pub struct IpfsUploader { rpc_client: Arc, - ipfs_dir_path: PathBuf, + ipfs_dir_path: IpfsMfsDirPath, logger: Logger, } @@ -26,7 +26,7 @@ impl IpfsUploader { /// Create a new IPFS uploader pub fn new( rpc_client: Arc, - ipfs_dir_path: PathBuf, + ipfs_dir_path: IpfsMfsDirPath, logger: &Logger, ) -> Self { Self { @@ -52,7 +52,7 @@ impl FileUploader for IpfsUploader { .with_context(|| { format!( "Failed to create directory '{}' in IPFS", - self.ipfs_dir_path.display() + self.ipfs_dir_path ) })?; @@ -90,13 +90,13 @@ impl FileUploader for IpfsUploader { #[async_trait::async_trait] pub trait IpfsBackendUploader: Sync + Send { /// Create a directory in IPFS - async fn create_dir(&self, dir_path: &Path) -> StdResult<()>; + async fn create_dir(&self, dir_path: &IpfsMfsDirPath) -> StdResult<()>; /// Get the CID of a directory - async fn get_dir_cid(&self, dir_path: &Path) -> StdResult; + async fn get_dir_cid(&self, dir_path: &IpfsMfsDirPath) -> StdResult; /// Upload a file to IPFS and return its CID - async fn upload_file(&self, file_path: &Path, mfs_path: &Path) -> StdResult; + async fn upload_file(&self, file_path: &Path, mfs_path: &IpfsMfsDirPath) -> StdResult; /// Check if a file exists and return its CID if it does async fn file_exists(&self, file_path: &Path) -> StdResult>; @@ -104,21 +104,19 @@ pub trait IpfsBackendUploader: Sync + Send { #[async_trait::async_trait] impl IpfsBackendUploader for KuboRpcClient { - async fn create_dir(&self, dir_path: &Path) -> StdResult<()> { + async fn create_dir(&self, dir_path: &IpfsMfsDirPath) -> StdResult<()> { self.send(IpfsFilesMkdirQuery::create_mfs_directory(dir_path)).await } - async fn get_dir_cid(&self, dir_path: &Path) -> StdResult { - let stat = self.send(IpfsFilesStatQuery::new(dir_path)).await?.with_context(|| { - format!( - "Directory {} does not exist in IPFS node", - dir_path.display() - ) - })?; + async fn get_dir_cid(&self, dir_path: &IpfsMfsDirPath) -> StdResult { + let stat = self + .send(IpfsFilesStatQuery::new(dir_path.as_ref())) + .await? + .with_context(|| format!("Directory {dir_path} does not exist in IPFS node",))?; Ok(stat.hash) } - async fn upload_file(&self, file_path: &Path, mfs_path: &Path) -> StdResult { + async fn upload_file(&self, file_path: &Path, mfs_path: &IpfsMfsDirPath) -> StdResult { let res = self .send(IpfsAddQuery::new_with_mfs_reference(file_path, mfs_path)) .await?; @@ -146,12 +144,12 @@ mod tests { let uploader = IpfsUploader::new( MockBuilder::configure(|mock: &mut MockIpfsBackendUploader| { mock.expect_create_dir() - .with(eq(PathBuf::from("/test/dir"))) + .with(eq(IpfsMfsDirPath::from("/test/dir"))) .returning(|_| Ok(())); mock.expect_file_exists().returning(|_| Ok(None)); mock.expect_upload_file().returning(|_, _| Ok(String::new())); }), - PathBuf::from("/test/dir"), + IpfsMfsDirPath::from("/test/dir"), &TestLogger::stdout(), ); @@ -168,7 +166,7 @@ mod tests { .returning(|_| Ok(Some("existing".to_string()))); mock.expect_upload_file().never(); }), - PathBuf::from("/test/dir"), + IpfsMfsDirPath::from("/test/dir"), &TestLogger::stdout(), ); @@ -182,16 +180,19 @@ mod tests { let uploader = IpfsUploader::new( MockBuilder::configure(|mock: &mut MockIpfsBackendUploader| { mock.expect_create_dir() - .with(eq(PathBuf::from("/test/dir"))) + .with(eq(IpfsMfsDirPath::from("/test/dir"))) .returning(|_| Ok(())); mock.expect_file_exists() .with(eq(Path::new("/a/file"))) .returning(|_| Ok(None)); mock.expect_upload_file() - .with(eq(Path::new("/a/file")), eq(Path::new("/test/dir"))) + .with( + eq(Path::new("/a/file")), + eq(IpfsMfsDirPath::from("/test/dir")), + ) .returning(|_, _| Ok("test_cid".to_string())); }), - PathBuf::from("/test/dir"), + IpfsMfsDirPath::from("/test/dir"), &TestLogger::stdout(), ); diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/mod.rs b/mithril-aggregator/src/tools/kubo_rpc_client/mod.rs index 4dda014f169..ce7ff234def 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/mod.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/mod.rs @@ -1,7 +1,9 @@ mod api; +mod path; pub mod query; pub use api::{KuboRpcClient, KuboRpcQuery}; +pub use path::IpfsMfsDirPath; #[cfg(test)] mod test_tools { diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/path.rs b/mithril-aggregator/src/tools/kubo_rpc_client/path.rs new file mode 100644 index 00000000000..1000d4f61c2 --- /dev/null +++ b/mithril-aggregator/src/tools/kubo_rpc_client/path.rs @@ -0,0 +1,102 @@ +use std::fmt::Display; + +/// A path to a Mutable File System directory in IPFS. +/// +/// It enforces that the path is absolute and has a trailing slash. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(transparent)] +pub struct IpfsMfsDirPath(String); + +impl IpfsMfsDirPath { + /// Creates a new instance of `IpfsMfsDirPath` from the given path-like input. + /// + /// This function takes an input that can be represented as a string and ensures + /// the following: + /// - The resulting path always starts with a '/' if it does not already. + /// - The resulting path always ends with a '/' if it does not already. + pub fn from>(path: P) -> Self { + let mut path = path.as_ref().to_string(); + + if !path.starts_with('/') { + path.insert(0, '/'); + } + + if !path.ends_with('/') { + path.push('/'); + } + + Self(path) + } +} + +impl Display for IpfsMfsDirPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl<'de> serde::Deserialize<'de> for IpfsMfsDirPath { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let path = String::deserialize(deserializer)?; + Ok(Self::from(&path)) + } +} + +impl AsRef for IpfsMfsDirPath { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enforces_absolute_and_trailing_slash() { + assert_eq!("/", IpfsMfsDirPath::from("").as_ref()); + assert_eq!("/", IpfsMfsDirPath::from("/").as_ref()); + assert_eq!("/dir/", IpfsMfsDirPath::from("/dir").as_ref()); + assert_eq!("/dir/", IpfsMfsDirPath::from("/dir/").as_ref()); + assert_eq!("/dir/subdir/", IpfsMfsDirPath::from("/dir/subdir").as_ref()); + assert_eq!( + "/dir/subdir/", + IpfsMfsDirPath::from("/dir/subdir/").as_ref() + ); + } + + #[test] + fn deserializing_enforces_absolute_and_trailing_slash() { + assert_eq!( + IpfsMfsDirPath::from("/"), + serde_json::from_str(r#""""#).unwrap() + ); + assert_eq!( + IpfsMfsDirPath::from("/"), + serde_json::from_str(r#""/""#).unwrap(), + ); + assert_eq!( + IpfsMfsDirPath::from("/dir/"), + serde_json::from_str(r#""/dir""#).unwrap(), + ); + assert_eq!( + IpfsMfsDirPath::from("/dir/"), + serde_json::from_str(r#""/dir/""#).unwrap(), + ); + assert_eq!( + IpfsMfsDirPath::from("/dir with spaces/"), + serde_json::from_str(r#""/dir with spaces/""#).unwrap() + ); + assert_eq!( + IpfsMfsDirPath::from("/dir/subdir/"), + serde_json::from_str(r#""/dir/subdir""#).unwrap() + ); + assert_eq!( + IpfsMfsDirPath::from("/dir/subdir/"), + serde_json::from_str(r#""/dir/subdir/""#).unwrap() + ); + } +} diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs index 2490d016291..cebcc59e878 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use mithril_common::StdResult; -use crate::tools::kubo_rpc_client::KuboRpcQuery; +use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcQuery}; /// Query to add a file to IPFS via the Kubo RPC API. /// @@ -14,7 +14,7 @@ use crate::tools::kubo_rpc_client::KuboRpcQuery; // TODO: Enforce most add parameters to make CID deterministic. pub struct IpfsAddQuery { file_path: PathBuf, - to_files: Option, + to_files: Option, } /// Response from the IPFS add operation. @@ -29,6 +29,7 @@ pub struct IpfsAddResponse { impl IpfsAddQuery { /// Create a query that will add the given file to IPFS. + #[cfg(test)] pub fn new>(file_path: P) -> Self { Self { file_path: file_path.as_ref().to_path_buf(), @@ -37,13 +38,13 @@ impl IpfsAddQuery { } /// Create a query that will add the given file to IPFS and reference it in the MFS. - pub fn new_with_mfs_reference, P2: AsRef>( + pub fn new_with_mfs_reference>( file_path: P1, - mfs_path: P2, + mfs_path: &IpfsMfsDirPath, ) -> Self { Self { file_path: file_path.as_ref().to_path_buf(), - to_files: Some(mfs_path.as_ref().to_path_buf()), + to_files: Some(mfs_path.clone()), } } } diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_mkdir.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_mkdir.rs index cab0c3415c3..901d683ffcb 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_mkdir.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_mkdir.rs @@ -1,10 +1,8 @@ -use std::path::{Path, PathBuf}; - use reqwest::{RequestBuilder, Response}; use mithril_common::StdResult; -use crate::tools::kubo_rpc_client::KuboRpcQuery; +use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcQuery}; /// Query to make an MFS (Mutable File System) directory in IPFS via the Kubo RPC API. /// @@ -14,14 +12,14 @@ use crate::tools::kubo_rpc_client::KuboRpcQuery; /// /// see: https://docs.ipfs.tech/reference/kubo/rpc/#api-v0-files-mkdir pub struct IpfsFilesMkdirQuery { - ipfs_absolute_path: PathBuf, + ipfs_absolute_path: IpfsMfsDirPath, } impl IpfsFilesMkdirQuery { /// Create a query that will create the given IPFS absolute path as an MFS directory. - pub fn create_mfs_directory>(ipfs_absolute_path: P) -> Self { + pub fn create_mfs_directory(ipfs_absolute_path: &IpfsMfsDirPath) -> Self { Self { - ipfs_absolute_path: ipfs_absolute_path.as_ref().to_path_buf(), + ipfs_absolute_path: ipfs_absolute_path.clone(), } } } @@ -62,13 +60,15 @@ mod tests { server.mock(|when, then| { when.method(POST) .path("/api/v0/files/mkdir") - .query_param("arg", "/test") + .query_param("arg", "/test/") .query_param("parents", "true"); then.status(200); }); client - .send(IpfsFilesMkdirQuery::create_mfs_directory("/test")) + .send(IpfsFilesMkdirQuery::create_mfs_directory( + &IpfsMfsDirPath::from("/test"), + )) .await .unwrap(); } @@ -77,14 +77,16 @@ mod tests { async fn return_error_if_request_fails_with_other_message() { let (server, client) = setup_server_and_client(); server.mock(|when, then| { - when.method(POST).path("/api/v0/files/mkdir").query_param("arg", "/test"); + when.method(POST).path("/api/v0/files/mkdir"); then.status(500).json_body( serde_json::json!({"Message":"paths must start with a leading slash","Code":0,"Type":"error"}), ); }); let err = client - .send(IpfsFilesMkdirQuery::create_mfs_directory("/test")) + .send(IpfsFilesMkdirQuery::create_mfs_directory( + &IpfsMfsDirPath::from("/test"), + )) .await .unwrap_err(); assert!(err.to_string().contains("paths must start with a leading slash")); From afb170eaf983a911f2dc71bfc6d70ca3292e1e96 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:00:26 +0200 Subject: [PATCH 09/26] refactor(aggregator): harden `IpfsBackendUploader::file_exists` to support MFS directories - Update `file_exists` to include MFS directory path for better file existence checks. - Add helper function for constructing MFS file paths. - Include additional debug traits and test cases. --- .../src/file_uploaders/ipfs_uploader.rs | 65 ++++++++++++++----- .../tools/kubo_rpc_client/query/ipfs_add.rs | 1 + .../kubo_rpc_client/query/ipfs_files_mkdir.rs | 1 + .../kubo_rpc_client/query/ipfs_files_stat.rs | 60 ++++++++++++++--- 4 files changed, 101 insertions(+), 26 deletions(-) diff --git a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs index 0781130b801..ad23339ecc4 100644 --- a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs +++ b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs @@ -56,12 +56,16 @@ impl FileUploader for IpfsUploader { ) })?; - match self.rpc_client.file_exists(filepath).await.with_context(|| { - format!( - "Failed to check if file '{}' exists in IPFS", - filepath.display() - ) - })? { + match self + .rpc_client + .file_exists(&self.ipfs_dir_path, filepath) + .await + .with_context(|| { + format!( + "Failed to check if file '{}' exists in IPFS", + filepath.display() + ) + })? { Some(cid) => { trace!(self.logger, "File already exists in IPFS"; "cid" => %cid); Ok(FileUri(cid)) @@ -98,8 +102,12 @@ pub trait IpfsBackendUploader: Sync + Send { /// Upload a file to IPFS and return its CID async fn upload_file(&self, file_path: &Path, mfs_path: &IpfsMfsDirPath) -> StdResult; - /// Check if a file exists and return its CID if it does - async fn file_exists(&self, file_path: &Path) -> StdResult>; + /// Check if a file exists in a given MFS directory and return its CID if it does + async fn file_exists( + &self, + mfs_dir_path: &IpfsMfsDirPath, + file_path: &Path, + ) -> StdResult>; } #[async_trait::async_trait] @@ -123,8 +131,17 @@ impl IpfsBackendUploader for KuboRpcClient { Ok(res.hash) } - async fn file_exists(&self, file_path: &Path) -> StdResult> { - let stat = self.send(IpfsFilesStatQuery::new(file_path)).await?; + async fn file_exists( + &self, + mfs_dir_path: &IpfsMfsDirPath, + file_path: &Path, + ) -> StdResult> { + let stat = self + .send(IpfsFilesStatQuery::for_file_in_dir( + mfs_dir_path, + file_path, + )?) + .await?; Ok(stat.map(|stat| stat.hash)) } } @@ -146,7 +163,7 @@ mod tests { mock.expect_create_dir() .with(eq(IpfsMfsDirPath::from("/test/dir"))) .returning(|_| Ok(())); - mock.expect_file_exists().returning(|_| Ok(None)); + mock.expect_file_exists().returning(|_, _| Ok(None)); mock.expect_upload_file().returning(|_, _| Ok(String::new())); }), IpfsMfsDirPath::from("/test/dir"), @@ -162,15 +179,21 @@ mod tests { MockBuilder::configure(|mock: &mut MockIpfsBackendUploader| { mock.expect_create_dir().returning(|_| Ok(())); mock.expect_file_exists() - .with(eq(Path::new("/a/file"))) - .returning(|_| Ok(Some("existing".to_string()))); + .with( + eq(IpfsMfsDirPath::from("/test/dir")), + eq(Path::new("/a/dummy-file.txt")), + ) + .returning(|_, _| Ok(Some("existing".to_string()))); mock.expect_upload_file().never(); }), IpfsMfsDirPath::from("/test/dir"), &TestLogger::stdout(), ); - let result = uploader.upload_without_retry(Path::new("/a/file")).await.unwrap(); + let result = uploader + .upload_without_retry(Path::new("/a/dummy-file.txt")) + .await + .unwrap(); assert_eq!(FileUri("existing".to_string()), result); } @@ -183,11 +206,14 @@ mod tests { .with(eq(IpfsMfsDirPath::from("/test/dir"))) .returning(|_| Ok(())); mock.expect_file_exists() - .with(eq(Path::new("/a/file"))) - .returning(|_| Ok(None)); + .with( + eq(IpfsMfsDirPath::from("/test/dir")), + eq(Path::new("/a/dummy-file.txt")), + ) + .returning(|_, _| Ok(None)); mock.expect_upload_file() .with( - eq(Path::new("/a/file")), + eq(Path::new("/a/dummy-file.txt")), eq(IpfsMfsDirPath::from("/test/dir")), ) .returning(|_, _| Ok("test_cid".to_string())); @@ -196,6 +222,9 @@ mod tests { &TestLogger::stdout(), ); - uploader.upload_without_retry(Path::new("/a/file")).await.unwrap(); + uploader + .upload_without_retry(Path::new("/a/dummy-file.txt")) + .await + .unwrap(); } } diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs index cebcc59e878..e78a0b1be06 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs @@ -12,6 +12,7 @@ use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcQuery}; /// /// see: https://docs.ipfs.tech/reference/kubo/rpc/#api-v0-add // TODO: Enforce most add parameters to make CID deterministic. +#[derive(Debug)] pub struct IpfsAddQuery { file_path: PathBuf, to_files: Option, diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_mkdir.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_mkdir.rs index 901d683ffcb..e745bfea9a0 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_mkdir.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_mkdir.rs @@ -11,6 +11,7 @@ use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcQuery}; /// - the command will succeed even if the directory already exists. /// /// see: https://docs.ipfs.tech/reference/kubo/rpc/#api-v0-files-mkdir +#[derive(Debug)] pub struct IpfsFilesMkdirQuery { ipfs_absolute_path: IpfsMfsDirPath, } diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_stat.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_stat.rs index 347644f61e9..947697d3790 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_stat.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_stat.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::Path; use anyhow::Context; use reqwest::{RequestBuilder, Response}; @@ -6,14 +6,15 @@ use serde::Deserialize; use mithril_common::StdResult; -use crate::tools::kubo_rpc_client::KuboRpcQuery; use crate::tools::kubo_rpc_client::api::format_response_error; +use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcQuery}; /// Query to display file status in an MFS (Mutable File System) in IPFS via the Kubo RPC API. /// /// see: https://docs.ipfs.tech/reference/kubo/rpc/#api-v0-files-stat +#[derive(Debug)] pub struct IpfsFilesStatQuery { - path_in_ipfs: PathBuf, + path_in_ipfs: String, } /// Response from the IPFS files stat operation. @@ -42,11 +43,32 @@ pub enum MfsStatType { impl IpfsFilesStatQuery { /// Create a query that will get the status of a file in the MFS. - pub fn new>(path_in_ipfs: P) -> Self { + pub fn new>(path_in_ipfs: P) -> Self { Self { - path_in_ipfs: path_in_ipfs.as_ref().to_path_buf(), + path_in_ipfs: path_in_ipfs.as_ref().to_string(), } } + + /// Creates a query for the file in `mfs_dir` whose name is taken from `file_path`. + /// + /// Only the final component of `file_path` is used. Returns an error if the path + /// has no file name. + pub fn for_file_in_dir>( + mfs_dir: &IpfsMfsDirPath, + file_path: P, + ) -> StdResult { + let filename = file_path + .as_ref() + .file_name() + .with_context(|| { + format!( + "Failed to get filename from path: {}", + file_path.as_ref().display() + ) + })? + .to_string_lossy(); + Ok(Self::new(format!("{mfs_dir}{filename}"))) + } } #[async_trait::async_trait] @@ -95,11 +117,31 @@ mod tests { use super::*; + #[test] + fn for_file_in_dir_builds_mfs_path_from_directory_and_file_name() { + let query = IpfsFilesStatQuery::for_file_in_dir( + &IpfsMfsDirPath::from("/test/dir"), + "/local/archive/dummy-file.txt", + ) + .unwrap(); + + assert_eq!("/test/dir/dummy-file.txt", query.path_in_ipfs); + } + + #[test] + fn for_file_in_dir_returns_error_when_path_has_no_file_name() { + let error = + IpfsFilesStatQuery::for_file_in_dir(&IpfsMfsDirPath::from("/test/dir"), Path::new("")) + .unwrap_err(); + + assert!(error.to_string().contains("Failed to get filename from path")); + } + #[tokio::test] async fn return_stat_data_if_request_succeeds() { let (server, client) = setup_server_and_client(); server.mock(|when, then| { - when.method(POST).path("/api/v0/files/stat"); + when.method(POST).path("/api/v0/files/stat").query_param("arg", "/test"); then.status(200).json_body(serde_json::json!({"Hash": "QmHash", "Size": 1, "CumulativeSize": 2, "Type": "file"})); }); @@ -119,7 +161,9 @@ mod tests { async fn return_none_if_request_fails_with_not_exist_message() { let (server, client) = setup_server_and_client(); server.mock(|when, then| { - when.method(POST).path("/api/v0/files/stat"); + when.method(POST) + .path("/api/v0/files/stat") + .query_param("arg", "/test"); then.status(500).json_body( serde_json::json!({"Message":"file does not exist","Code":0,"Type":"error"}), ); @@ -133,7 +177,7 @@ mod tests { async fn return_error_if_request_fails_with_other_message() { let (server, client) = setup_server_and_client(); server.mock(|when, then| { - when.method(POST).path("/api/v0/files/stat"); + when.method(POST).path("/api/v0/files/stat").query_param("arg", "/test"); then.status(500).json_body( serde_json::json!({"Message":"paths must start with a leading slash","Code":0,"Type":"error"}), ); From ef77bcebdd4cd4c998d3cb7704ab1895aef2d56a Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:56:08 +0200 Subject: [PATCH 10/26] feat(aggregator): preliminary `no_copy` support to `IpfsAddQuery` with query param and multipart header handling --- .../tools/kubo_rpc_client/query/ipfs_add.rs | 66 ++++++++++++++++++- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs index e78a0b1be06..4a15e55cd5a 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs @@ -16,6 +16,7 @@ use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcQuery}; pub struct IpfsAddQuery { file_path: PathBuf, to_files: Option, + enable_no_copy: bool, } /// Response from the IPFS add operation. @@ -35,6 +36,7 @@ impl IpfsAddQuery { Self { file_path: file_path.as_ref().to_path_buf(), to_files: None, + enable_no_copy: false, } } @@ -46,8 +48,20 @@ impl IpfsAddQuery { Self { file_path: file_path.as_ref().to_path_buf(), to_files: Some(mfs_path.clone()), + enable_no_copy: false, } } + + /// Tells IPFS to not copy the file to its internal storage, but instead to reference it directly. + /// + /// **Test only** until we properly implement this feature (we must symlink the file into the + /// IPFS root directory and set the `abspath` multipart header to the symlinked path, else the + /// request will be rejected). + #[cfg(test)] + pub fn no_copy(mut self) -> Self { + self.enable_no_copy = true; + self + } } #[async_trait::async_trait] @@ -60,15 +74,40 @@ impl KuboRpcQuery for IpfsAddQuery { async fn configure_request( &self, - request_builder: RequestBuilder, + mut request_builder: RequestBuilder, ) -> StdResult { - let form = reqwest::multipart::Form::new().file("file", &self.file_path).await?; - let mut request_builder = request_builder.multipart(form); + let mut part = reqwest::multipart::Part::file(&self.file_path).await?; if let Some(mfs_path) = &self.to_files { request_builder = request_builder.query(&[("to-files", mfs_path)]); } + if self.enable_no_copy { + request_builder = request_builder.query(&[("nocopy", true)]); + + // Add the "abspath" header to the file part + // It sets the absolute path of the file being added to IPFS and MUST point to a path + // inside the IPFS root directory. + // + // It may be different from the file path provided, e.g.: file_path could point to the + // real location of the file to be added while `abspath` points to the symlinked path of + // the same file but inside the IPFS root directory. + let mut part_headers = reqwest::header::HeaderMap::new(); + let abspath = std::path::absolute(&self.file_path) + .with_context(|| "Failed to get absolute path of file")?; + part_headers.insert( + "abspath", + abspath + .to_str() + .with_context(|| "Failed to convert absolute file path to string")? + .try_into()?, + ); + part = part.headers(part_headers); + } + + let request_builder = + request_builder.multipart(reqwest::multipart::Form::new().part("file", part)); + Ok(request_builder) } @@ -132,4 +171,25 @@ mod tests { "unexpected error: {err}" ); } + + #[tokio::test] + async fn no_copy_adds_query_parameter_and_abspath_multipart_header() { + let test_dir = temp_dir_create!(); + let file = test_dir.join("test.txt"); + std::fs::File::create(&file).unwrap(); + + let abs_filepath = file.canonicalize().unwrap().to_string_lossy().to_string(); + + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST) + .path("/api/v0/add") + .query_param("nocopy", "true") + .body_includes(format!("abspath: {abs_filepath}")); + then.status(200) + .json_body(serde_json::json!({"Name":"test.txt","Hash":"whatever"})); + }); + + client.send(IpfsAddQuery::new(file).no_copy()).await.unwrap(); + } } From 56acee8143429bacfcca7d6dedaeca860369426a Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:05:45 +0200 Subject: [PATCH 11/26] feat(aggregator): enforce CID determinism in `IpfsAddQuery` by specifying add parameters - Add query parameters such as `cid-version`, `hash`, `raw-leaves`, and others to ensure deterministic CID generation. - Update tests to validate enforced parameters for CID consistency. --- .../tools/kubo_rpc_client/query/ipfs_add.rs | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs index 4a15e55cd5a..72b7f32b636 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs @@ -11,7 +11,6 @@ use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcQuery}; /// Query to add a file to IPFS via the Kubo RPC API. /// /// see: https://docs.ipfs.tech/reference/kubo/rpc/#api-v0-add -// TODO: Enforce most add parameters to make CID deterministic. #[derive(Debug)] pub struct IpfsAddQuery { file_path: PathBuf, @@ -105,8 +104,20 @@ impl KuboRpcQuery for IpfsAddQuery { part = part.headers(part_headers); } - let request_builder = - request_builder.multipart(reqwest::multipart::Form::new().part("file", part)); + let request_builder = request_builder + .multipart(reqwest::multipart::Form::new().part("file", part)) + .query(&[("pin", true)]) + // Parameters below ensure deterministic CID. + .query(&[ + ("inline", false), + ("preserve-mode", false), + ("preserve-mtime", false), + ("raw-leaves", true), + ("trickle", false), + ("wrap-with-directory", false), + ]) + .query(&[("cid-version", 1)]) + .query(&[("hash", "sha2-256"), ("chunker", "size-262144")]); Ok(request_builder) } @@ -137,7 +148,7 @@ mod tests { let (server, client) = setup_server_and_client(); server.mock(|when, then| { - when.method(POST).path("/api/v0/add"); + when.method(POST).path("/api/v0/add").query_param("pin", "true"); then.status(200).json_body(serde_json::json!({"Name":"test.txt","Hash":"QmYi7wrRFKVCcTB56A6Pep2j31Q5mHfmmu21RzHXu25RVR","Size":"23"})); }); @@ -172,6 +183,30 @@ mod tests { ); } + #[tokio::test] + async fn make_produced_cid_more_deterministic_by_enforcing_parameters_that_may_affect_cid() { + let test_dir = temp_dir_create!(); + let file = test_dir.join("test.txt"); + std::fs::File::create(&file).unwrap(); + + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST).path("/api/v0/add") + .query_param("cid-version", "1") + .query_param("hash", "sha2-256") + .query_param("raw-leaves", "true") + .query_param("chunker", "size-262144") + .query_param("inline", "false") + .query_param("trickle", "false") + .query_param("preserve-mode", "false") + .query_param("preserve-mtime", "false") + .query_param("wrap-with-directory", "false"); + then.status(200).json_body(serde_json::json!({"Name":"test.txt","Hash":"QmYi7wrRFKVCcTB56A6Pep2j31Q5mHfmmu21RzHXu25RVR","Size":"23"})); + }); + + client.send(IpfsAddQuery::new(file)).await.unwrap(); + } + #[tokio::test] async fn no_copy_adds_query_parameter_and_abspath_multipart_header() { let test_dir = temp_dir_create!(); From cf5d7fee5fba0b72ba06616380021a50887d12db Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:22:59 +0200 Subject: [PATCH 12/26] feat: introduce support for IPFS locations in Cardano database artifacts - Copied from `CloudStorage` as it share the same characteristics - Exclude IPFS locations temporarily in client logic until downloader implementation is complete. --- .../cardano_database_artifacts/immutable.rs | 2 +- .../src/commands/cardano_db/show.rs | 8 +++++ .../download_unpack/internal_downloader.rs | 3 +- .../src/entities/cardano_database.rs | 24 ++++++++++++- .../src/messages/cardano_database.rs | 36 ++++++++++++++++++- 5 files changed, 69 insertions(+), 4 deletions(-) diff --git a/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs b/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs index 4a6f66d015c..91b0d8938af 100644 --- a/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs +++ b/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs @@ -114,7 +114,7 @@ impl ImmutableFilesUploader for IpfsUploader { let directory_cid = self.get_current_directory_cid().await?; - Ok(ImmutablesLocation::CloudStorage { + Ok(ImmutablesLocation::Ipfs { uri: MultiFilesUri::Template(TemplateUri(format!( "{directory_cid}/{{immutable_file_number}}.tar.zst" ))), diff --git a/mithril-client-cli/src/commands/cardano_db/show.rs b/mithril-client-cli/src/commands/cardano_db/show.rs index 8d0391c0d23..c6d7e12b18f 100644 --- a/mithril-client-cli/src/commands/cardano_db/show.rs +++ b/mithril-client-cli/src/commands/cardano_db/show.rs @@ -141,6 +141,14 @@ fn immutables_location_iter( template_uri.0 )), }, + ImmutablesLocation::Ipfs { + uri, + compression_algorithm: _, + } => match uri { + MultiFilesUri::Template(template_uri) => { + Some(format!("IPFS, template_uri: \"{}\"", template_uri.0)) + } + }, ImmutablesLocation::Unknown => None, }) } diff --git a/mithril-client/src/cardano_database_client/download_unpack/internal_downloader.rs b/mithril-client/src/cardano_database_client/download_unpack/internal_downloader.rs index 0d8aea5f218..497555a7b7e 100644 --- a/mithril-client/src/cardano_database_client/download_unpack/internal_downloader.rs +++ b/mithril-client/src/cardano_database_client/download_unpack/internal_downloader.rs @@ -173,7 +173,8 @@ impl InternalArtifactDownloader { } } // Note: unknown locations should have been filtered out by `sanitized_locations` - ImmutablesLocation::Unknown => unreachable!(), + // IPFS locations are excluded as we do not have a downloader for them yet + ImmutablesLocation::Ipfs { .. } | ImmutablesLocation::Unknown => unreachable!(), }; locations_to_try.push(location_to_try); diff --git a/mithril-common/src/entities/cardano_database.rs b/mithril-common/src/entities/cardano_database.rs index 0a2ba932747..df42c36b490 100644 --- a/mithril-common/src/entities/cardano_database.rs +++ b/mithril-common/src/entities/cardano_database.rs @@ -124,6 +124,15 @@ pub enum ImmutablesLocation { #[serde(skip_serializing_if = "Option::is_none")] compression_algorithm: Option, }, + /// IPFS location. + Ipfs { + /// URI of the IPFS location. + uri: MultiFilesUri, + + /// Compression algorithm of the Cardano database artifacts. + #[serde(skip_serializing_if = "Option::is_none")] + compression_algorithm: Option, + }, /// Catchall for unknown location variants. #[serde(other)] Unknown, @@ -322,7 +331,8 @@ mod tests { } #[test] - fn should_not_display_compression_algorithm_in_json_immutable_location_when_none() { + fn should_not_display_compression_algorithm_in_json_immutable_cloud_storage_location_when_none() + { let json = serde_json::json!(ImmutablesLocation::CloudStorage { uri: MultiFilesUri::Template(TemplateUri("https://example.com".to_string())), compression_algorithm: None, @@ -332,4 +342,16 @@ mod tests { r#"{"type":"cloud_storage","uri":{"Template":"https://example.com"}}"# ); } + + #[test] + fn should_not_display_compression_algorithm_in_json_immutable_ipfs_location_when_none() { + let json = serde_json::json!(ImmutablesLocation::Ipfs { + uri: MultiFilesUri::Template(TemplateUri("https://example.com".to_string())), + compression_algorithm: None, + }); + assert_eq!( + json.to_string(), + r#"{"type":"ipfs","uri":{"Template":"https://example.com"}}"# + ); + } } diff --git a/mithril-common/src/messages/cardano_database.rs b/mithril-common/src/messages/cardano_database.rs index 1874f23254f..44bba566f0a 100644 --- a/mithril-common/src/messages/cardano_database.rs +++ b/mithril-common/src/messages/cardano_database.rs @@ -52,7 +52,13 @@ impl ImmutablesMessagePart { let sanitized_locations: Vec<_> = self .locations .iter() - .filter(|l| !matches!(l, ImmutablesLocation::Unknown)) + .filter(|l| { + !matches!( + l, + // Temporarily exclude IPFS locations until the download is implemented. + ImmutablesLocation::Unknown | ImmutablesLocation::Ipfs { .. } + ) + }) .cloned() .collect(); @@ -197,6 +203,12 @@ mod tests { "uri": { "Template": "https://host-2/immutables-{immutable_file_number}" } + }, + { + "type": "ipfs", + "uri": { + "Template": "QmSiDJUe8MsPRwbAJuT5PtRmxXLJVsUK1j2ZSN3SKkAN6Z/{immutable_file_number}" + } } ] }, @@ -251,6 +263,12 @@ mod tests { )), compression_algorithm: None, }, + ImmutablesLocation::Ipfs { + uri: MultiFilesUri::Template(TemplateUri( + "QmSiDJUe8MsPRwbAJuT5PtRmxXLJVsUK1j2ZSN3SKkAN6Z/{immutable_file_number}".to_string(), + )), + compression_algorithm: None, + }, ], }, ancillary: AncillaryMessagePart { @@ -391,6 +409,22 @@ mod tests { .sanitized_locations() .expect_err("Should fail since all locations are unknown."); } + + // Temporarily exclude IPFS locations until the download is implemented. + #[test] + fn fails_if_all_locations_are_ipfs() { + ImmutablesMessagePart { + locations: vec![ImmutablesLocation::Ipfs { + uri: MultiFilesUri::Template(TemplateUri( + "Whatever_CID/{immutable_file_number}.tar.zst".to_string(), + )), + compression_algorithm: None, + }], + average_size_uncompressed: 512, + } + .sanitized_locations() + .expect_err("Should fail since all locations are IPFS."); + } } mod sanitize_ancillary_locations { From 4003efeb975269c306594ed65a6cc4e585dd0ab6 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:48:52 +0200 Subject: [PATCH 13/26] refactor(aggregator): rework IPFS server RPC configuration, adding mfs folder param and future proofing Usage of a deserializable configuration struct will allow easy addition of tightly grouped parameters, instead of adding multiple, sparse, keys. --- mithril-aggregator/src/configuration.rs | 108 +++++++++++++----- .../builder/protocol/artifacts.rs | 14 +-- 2 files changed, 88 insertions(+), 34 deletions(-) diff --git a/mithril-aggregator/src/configuration.rs b/mithril-aggregator/src/configuration.rs index 1411248e61b..7a9c683ed3b 100644 --- a/mithril-aggregator/src/configuration.rs +++ b/mithril-aggregator/src/configuration.rs @@ -130,16 +130,9 @@ pub trait ConfigurationSource { panic!("snapshot_use_cdn_domain is not implemented."); } - /// URL of a Kubo IPFS RPC API, setting this will enable IPFS upload for immutable snapshots - fn ipfs_rpc_url(&self) -> Option { - panic!("ipfs_rpc_url is not implemented."); - } - - /// Parsed URL of a Kubo IPFS RPC API (see [ipfs_rpc_url][ConfigurationSource::ipfs_rpc_url]) - fn get_ipfs_rpc_url(&self) -> StdResult> { - self.ipfs_rpc_url() - .map(|url| SanitizedUrlWithTrailingSlash::parse(&url)) - .transpose() + /// Configuration parameters to connect to a Kubo IPFS RPC API, setting this will enable IPFS upload for immutable snapshots + fn ipfs_rpc_server_config(&self) -> Option { + panic!("ipfs_rpc_server_config is not implemented."); } /// Server listening IP @@ -547,7 +540,18 @@ pub struct ServeCommandConfiguration { pub snapshot_use_cdn_domain: bool, /// URL of a Kubo IPFS RPC API, setting this will enable IPFS upload for immutable snapshots - pub ipfs_rpc_url: Option, + /// + /// `mfs_folder_name` (optional) allows overriding the name of the folder in the IPFS MFS (Mutable File System) + /// where the snapshots will be stored. Defaults to "mithril" if not specified. + #[example = "\ + `{ \"url\": \"http://localhost:5001/\" }`\ + or `{ \"url\": \"http://localhost:5001/\", \"mfs_folder_name\": \"custom-folder\" }`\ + "] + #[serde( + default, + deserialize_with = "serde_deserialization::string_or_struct_optional" + )] + pub ipfs_rpc_server_config: Option, /// Server listening IP pub server_ip: String, @@ -731,6 +735,44 @@ pub enum SnapshotUploaderType { Local, } +/// Configuration for connecting to a Kubo IPFS RPC server. +/// +/// This struct holds the connection details and settings for uploading +/// immutable snapshots to IPFS via the Kubo RPC API. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct IpfsRpcServerConfig { + /// URL of the Kubo IPFS RPC API endpoint. + /// + /// Example: `http://localhost:5001/` + pub url: String, + + /// Name of the folder in the MFS (Mutable File System) where snapshots will be stored. + /// + /// Defaults to "mithril" if not specified. + #[serde(default = "default_mfs_folder_name")] + pub mfs_folder_name: String, +} + +impl IpfsRpcServerConfig { + /// Parsed URL of a Kubo IPFS RPC API + pub fn sanitized_url(&self) -> StdResult { + SanitizedUrlWithTrailingSlash::parse(&self.url) + .with_context(|| "Invalid IPFS RPC server URL") + } +} + +fn default_mfs_folder_name() -> String { + "mithril".to_string() +} + +impl FromStr for IpfsRpcServerConfig { + type Err = serde_json::Error; + + fn from_str(s: &str) -> Result { + serde_json::from_str(s) + } +} + /// Configuration to connect to the Blockfrost API. /// /// Currently only used to fetch the ticker and name for registered pools. @@ -808,7 +850,7 @@ impl ServeCommandConfiguration { snapshot_uploader_type: SnapshotUploaderType::Local, snapshot_bucket_name: None, snapshot_use_cdn_domain: false, - ipfs_rpc_url: None, + ipfs_rpc_server_config: None, server_ip: "0.0.0.0".to_string(), server_port: 8000, public_server_url: None, @@ -927,8 +969,8 @@ impl ConfigurationSource for ServeCommandConfiguration { self.snapshot_use_cdn_domain } - fn ipfs_rpc_url(&self) -> Option { - self.ipfs_rpc_url.clone() + fn ipfs_rpc_server_config(&self) -> Option { + self.ipfs_rpc_server_config.clone() } fn server_ip(&self) -> String { @@ -1456,19 +1498,6 @@ mod test { assert!(!config.allow_http_serve_directory()); } - #[test] - fn get_ipfs_rpc_url_return_sanitized_public_url_if_it_is_set() { - let config = ServeCommandConfiguration { - ipfs_rpc_url: Some("https://example.com:8080/".to_string()), - ..ServeCommandConfiguration::new_sample(temp_dir!()) - }; - - assert_eq!( - config.get_ipfs_rpc_url().unwrap().unwrap().as_str(), - "https://example.com:8080/" - ); - } - #[test] fn get_server_url_return_local_url_with_server_base_path_if_public_url_is_not_set() { let config = ServeCommandConfiguration { @@ -1552,6 +1581,31 @@ mod test { assert!(!config.is_follower_aggregator()); } + #[test] + fn deserializing_ipfs_rpc_server_parameters() { + let deserialized_without_mfs_dir: IpfsRpcServerConfig = + serde_json::from_str(r#"{ "url": "http://localhost:5001/" }"#).unwrap(); + assert_eq!( + deserialized_without_mfs_dir, + IpfsRpcServerConfig { + url: "http://localhost:5001/".to_string(), + mfs_folder_name: default_mfs_folder_name(), + } + ); + + let deserialized_with_mfs_dir: IpfsRpcServerConfig = serde_json::from_str( + r#"{ "url": "http://localhost:5001/", "mfs_folder_name": "altered" }"#, + ) + .unwrap(); + assert_eq!( + deserialized_with_mfs_dir, + IpfsRpcServerConfig { + url: "http://localhost:5001/".to_string(), + mfs_folder_name: "altered".to_string(), + } + ); + } + #[test] fn deserializing_blockfrost_parameters() { let deserialized_without_base_url: BlockfrostParameters = diff --git a/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs b/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs index 5c915151f6c..3fde2f67530 100644 --- a/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs +++ b/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs @@ -13,7 +13,7 @@ use crate::artifact_builder::{ DigestSnapshotter, ImmutableArtifactBuilder, ImmutableFilesUploader, MithrilStakeDistributionArtifactBuilder, }; -use crate::configuration::AncillaryFilesSignerConfig; +use crate::configuration::{AncillaryFilesSignerConfig, IpfsRpcServerConfig}; use crate::dependency_injection::builder::SNAPSHOT_ARTIFACTS_DIR; use crate::dependency_injection::{DependenciesBuilder, DependenciesBuilderError, Result}; use crate::file_uploaders::{ @@ -31,7 +31,6 @@ use crate::services::{ }; use crate::tools::DEFAULT_GCP_CREDENTIALS_JSON_ENV_VAR; use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcClient}; -use crate::tools::url_sanitizer::SanitizedUrlWithTrailingSlash; use crate::{DumbUploader, ExecutionEnvironment, FileUploader, SnapshotUploaderType}; impl DependenciesBuilder { @@ -258,12 +257,13 @@ impl DependenciesBuilder { async fn build_ipfs_uploader( &self, - rpc_url: SanitizedUrlWithTrailingSlash, + ipfs_rpc_config: IpfsRpcServerConfig, ) -> Result { - let rpc_api_client = KuboRpcClient::new(rpc_url, self.root_logger())?; + let rpc_api_client = + KuboRpcClient::new(ipfs_rpc_config.sanitized_url()?, self.root_logger())?; Ok(IpfsUploader::new( Arc::new(rpc_api_client), - IpfsMfsDirPath::from("/mithril"), + IpfsMfsDirPath::from(ipfs_rpc_config.mfs_folder_name), &self.root_logger(), )) } @@ -341,8 +341,8 @@ impl DependenciesBuilder { } }; - if let Some(url) = self.configuration.get_ipfs_rpc_url()? { - uploaders.push(Arc::new(self.build_ipfs_uploader(url).await?)); + if let Some(ipfs_rpc_config) = self.configuration.ipfs_rpc_server_config() { + uploaders.push(Arc::new(self.build_ipfs_uploader(ipfs_rpc_config).await?)); } Ok(uploaders) From 8eddeb30a14a7b9a3192875f5f30df90418827af Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:14:38 +0200 Subject: [PATCH 14/26] refactor(aggregator): replace `from` method with `From` trait implementations for `IpfsMfsDirPath` Simplify and standardize path handling by using `From` and `From<&str>` trait implementations, for cleaner code and better integration with Rust conventions. --- .../src/tools/kubo_rpc_client/path.rs | 55 +++++++++++-------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/path.rs b/mithril-aggregator/src/tools/kubo_rpc_client/path.rs index 1000d4f61c2..3e9320539dc 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/path.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/path.rs @@ -7,28 +7,6 @@ use std::fmt::Display; #[serde(transparent)] pub struct IpfsMfsDirPath(String); -impl IpfsMfsDirPath { - /// Creates a new instance of `IpfsMfsDirPath` from the given path-like input. - /// - /// This function takes an input that can be represented as a string and ensures - /// the following: - /// - The resulting path always starts with a '/' if it does not already. - /// - The resulting path always ends with a '/' if it does not already. - pub fn from>(path: P) -> Self { - let mut path = path.as_ref().to_string(); - - if !path.starts_with('/') { - path.insert(0, '/'); - } - - if !path.ends_with('/') { - path.push('/'); - } - - Self(path) - } -} - impl Display for IpfsMfsDirPath { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) @@ -41,7 +19,7 @@ impl<'de> serde::Deserialize<'de> for IpfsMfsDirPath { D: serde::Deserializer<'de>, { let path = String::deserialize(deserializer)?; - Ok(Self::from(&path)) + Ok(Self::from(path)) } } @@ -51,6 +29,37 @@ impl AsRef for IpfsMfsDirPath { } } +impl From for IpfsMfsDirPath { + /// Converts a [String] into an `IpfsMfsDirPath`. + /// + /// It ensures the following: + /// - The resulting path always starts with a '/' if it does not already. + /// - The resulting path always ends with a '/' if it does not already. + fn from(path: String) -> Self { + let mut path = path; + if !path.starts_with('/') { + path.insert(0, '/'); + } + + if !path.ends_with('/') { + path.push('/'); + } + + Self(path) + } +} + +impl From<&str> for IpfsMfsDirPath { + /// Converts a [str] into an `IpfsMfsDirPath`. + /// + /// It ensures the following: + /// - The resulting path always starts with a '/' if it does not already. + /// - The resulting path always ends with a '/' if it does not already. + fn from(value: &str) -> Self { + value.to_string().into() + } +} + #[cfg(test)] mod tests { use super::*; From f4e69084b60cc5076109876d55420dde5f53e511 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:06:53 +0200 Subject: [PATCH 15/26] feat(aggregator): dynamic timeout in `IpfsAddQuery` based on file size Base timeout to 10 seconds, increasing 1 second per MiB up to a max of 3 minutes. --- .../src/tools/kubo_rpc_client/api.rs | 6 +- .../tools/kubo_rpc_client/query/ipfs_add.rs | 95 +++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/api.rs b/mithril-aggregator/src/tools/kubo_rpc_client/api.rs index 856c7ab3cef..0d143bdbfdf 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/api.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/api.rs @@ -31,7 +31,7 @@ pub trait KuboRpcQuery: Sync { } /// Timeout for the RPC request. - fn timeout() -> Duration { + fn timeout(&self) -> Duration { Duration::from_secs(1) } @@ -86,7 +86,7 @@ impl KuboRpcClient { .with_context(|| { format!("Failed to configure request for Kubo RPC endpoint: '{route}'") })? - .timeout(Q::timeout()); + .timeout(query.timeout()); let response = request_builder .send() @@ -226,7 +226,7 @@ mod tests { "will_timeout".to_string() } - fn timeout() -> Duration { + fn timeout(&self) -> Duration { Duration::from_millis(10) } diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs index 72b7f32b636..60ad7b78312 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs @@ -1,4 +1,5 @@ use std::path::{Path, PathBuf}; +use std::time::Duration; use anyhow::Context; use reqwest::{RequestBuilder, Response}; @@ -29,6 +30,9 @@ pub struct IpfsAddResponse { } impl IpfsAddQuery { + const BASE_TIMEOUT_SECS: u64 = 10; + const MAX_TIMEOUT_SECS: u64 = 3 * 60; + /// Create a query that will add the given file to IPFS. #[cfg(test)] pub fn new>(file_path: P) -> Self { @@ -61,6 +65,29 @@ impl IpfsAddQuery { self.enable_no_copy = true; self } + + /// Calculates the request timeout from the file size. + /// + /// The timeout starts at 10 seconds and increases by one second for every started MiB, up to + /// a maximum of three minutes. + /// If the file size cannot be determined, the maximum timeout is used. + /// + /// Note: expected file size in production for immutables archive is 1-100 MiB (mostly 2-20 Mib). + fn timeout_duration(file_size_in_bytes: Option) -> Duration { + const BYTES_PER_MIB: u64 = 1024 * 1024; + + match file_size_in_bytes { + None => Duration::from_secs(Self::MAX_TIMEOUT_SECS), + Some(size_in_bytes) => { + let file_size_mib = size_in_bytes.div_ceil(BYTES_PER_MIB); + let timeout_secs = Self::BASE_TIMEOUT_SECS + .saturating_add(file_size_mib) + .min(Self::MAX_TIMEOUT_SECS); + + Duration::from_secs(timeout_secs) + } + } + } } #[async_trait::async_trait] @@ -122,6 +149,11 @@ impl KuboRpcQuery for IpfsAddQuery { Ok(request_builder) } + fn timeout(&self) -> Duration { + let file_size_in_bytes = self.file_path.metadata().map(|m| m.len()).ok(); + Self::timeout_duration(file_size_in_bytes) + } + async fn handle_success(&self, response: Response) -> StdResult { response .json() @@ -227,4 +259,67 @@ mod tests { client.send(IpfsAddQuery::new(file).no_copy()).await.unwrap(); } + + mod timeout { + use super::*; + + const MIB: u64 = 1024 * 1024; + + #[test] + fn timeout_duration_returns_maximum_when_file_size_is_unknown() { + assert_eq!( + Duration::from_secs(180), + IpfsAddQuery::timeout_duration(None) + ); + } + + #[test] + fn timeout_duration_adds_one_second_for_each_started_mib() { + let cases = [ + (0, IpfsAddQuery::BASE_TIMEOUT_SECS), + (1, 11), + (MIB, 11), + (MIB + 1, 12), + (50 * MIB, 60), + (100 * MIB, 110), + ]; + + for (file_size, expected_timeout_secs) in cases { + assert_eq!( + Duration::from_secs(expected_timeout_secs), + IpfsAddQuery::timeout_duration(Some(file_size)), + "unexpected timeout for file size {file_size}" + ); + } + } + + #[test] + fn timeout_duration_is_capped_at_three_minutes() { + let cases = [ + (169 * MIB, 179), + (169 * MIB + 1, IpfsAddQuery::MAX_TIMEOUT_SECS), + (170 * MIB, IpfsAddQuery::MAX_TIMEOUT_SECS), + (171 * MIB, IpfsAddQuery::MAX_TIMEOUT_SECS), + (u64::MAX, IpfsAddQuery::MAX_TIMEOUT_SECS), + ]; + + for (file_size, expected_timeout_secs) in cases { + assert_eq!( + Duration::from_secs(expected_timeout_secs), + IpfsAddQuery::timeout_duration(Some(file_size)), + "unexpected timeout for file size {file_size}" + ); + } + } + + #[test] + fn timeout_is_based_on_the_uploaded_file_size() { + let test_dir = temp_dir_create!(); + let file_path = test_dir.join("43-mib-file.bin"); + std::fs::File::create(&file_path).unwrap().set_len(43 * MIB).unwrap(); + + let query = IpfsAddQuery::new(file_path); + assert_eq!(Duration::from_secs(53), query.timeout()); + } + } } From bd1cc12684483a9548911fd8f949fe98aa2a6109 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:32:42 +0200 Subject: [PATCH 16/26] feat(aggregator): ensure IPFS directory creation is done only once Use a `OnceCell` to guarantee directory creation occurs only once during multiple file uploads. --- .../src/file_uploaders/ipfs_uploader.rs | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs index ad23339ecc4..8a956715ac8 100644 --- a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs +++ b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::Context; use slog::{Logger, trace}; +use tokio::sync::OnceCell; use mithril_common::StdResult; use mithril_common::entities::FileUri; @@ -19,6 +20,7 @@ pub type Cid = String; pub struct IpfsUploader { rpc_client: Arc, ipfs_dir_path: IpfsMfsDirPath, + directory_created: OnceCell<()>, logger: Logger, } @@ -32,10 +34,28 @@ impl IpfsUploader { Self { rpc_client, ipfs_dir_path, + directory_created: OnceCell::new(), logger: logger.new_with_component_name::(), } } + async fn ensure_directory_exists(&self) -> StdResult<()> { + self.directory_created + .get_or_try_init(|| async { + self.rpc_client + .create_dir(&self.ipfs_dir_path) + .await + .with_context(|| { + format!( + "Failed to create directory '{}' in IPFS", + self.ipfs_dir_path + ) + }) + }) + .await?; + Ok(()) + } + /// Get the current directory CID, reflecting the latest state of the directory pub async fn get_current_directory_cid(&self) -> StdResult { self.rpc_client.get_dir_cid(&self.ipfs_dir_path).await @@ -46,15 +66,7 @@ impl IpfsUploader { impl FileUploader for IpfsUploader { async fn upload_without_retry(&self, filepath: &Path) -> StdResult { trace!(self.logger, "Uploading file to IPFS"; "file_path" => %filepath.display()); - self.rpc_client - .create_dir(&self.ipfs_dir_path) - .await - .with_context(|| { - format!( - "Failed to create directory '{}' in IPFS", - self.ipfs_dir_path - ) - })?; + self.ensure_directory_exists().await?; match self .rpc_client @@ -157,12 +169,13 @@ mod tests { use super::*; #[tokio::test] - async fn create_dir_when_uploading() { + async fn create_dir_only_once_when_uploading_multiple_time() { let uploader = IpfsUploader::new( MockBuilder::configure(|mock: &mut MockIpfsBackendUploader| { mock.expect_create_dir() .with(eq(IpfsMfsDirPath::from("/test/dir"))) - .returning(|_| Ok(())); + .returning(|_| Ok(())) + .once(); mock.expect_file_exists().returning(|_, _| Ok(None)); mock.expect_upload_file().returning(|_, _| Ok(String::new())); }), @@ -171,6 +184,8 @@ mod tests { ); uploader.upload_without_retry(Path::new("whatever")).await.unwrap(); + uploader.upload_without_retry(Path::new("whatever")).await.unwrap(); + uploader.upload_without_retry(Path::new("whatever")).await.unwrap(); } #[tokio::test] From f5413b325574aebb1d02bb22a07bb7802217b2f9 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:03:28 +0200 Subject: [PATCH 17/26] feat(aggregator): add `IpfsFilesLsQuery` for listing MFS directories via Kubo RPC API With a 10s timeout, we will have to monitor if this is enough in testing environments. --- .../kubo_rpc_client/query/ipfs_files_ls.rs | 156 ++++++++++++++++++ .../src/tools/kubo_rpc_client/query/mod.rs | 2 + 2 files changed, 158 insertions(+) create mode 100644 mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_ls.rs diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_ls.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_ls.rs new file mode 100644 index 00000000000..da4b1aac9f3 --- /dev/null +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_ls.rs @@ -0,0 +1,156 @@ +use anyhow::Context; +use reqwest::{RequestBuilder, Response}; +use serde::Deserialize; +use std::collections::HashMap; +use std::time::Duration; + +use mithril_common::StdResult; + +use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcQuery}; + +/// Query to list directories in an MFS (Mutable File System) in IPFS via the Kubo RPC API. +/// +/// Returns a map of file names to their hashes / CIDs. +/// +/// see: https://docs.ipfs.tech/reference/kubo/rpc/#api-v0-files-ls +#[derive(Debug)] +pub struct IpfsFilesLsQuery { + dir: IpfsMfsDirPath, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct IpfsLsResponse { + entries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct IpfsLsResponseItem { + /// Name of the added file + name: String, + /// Hash of the added file (CID) + hash: String, +} + +impl IpfsFilesLsQuery { + /// Create a query that will get the list a directory in the MFS. + pub fn new(mfs_dir: &IpfsMfsDirPath) -> Self { + Self { + dir: mfs_dir.clone(), + } + } +} + +#[async_trait::async_trait] +impl KuboRpcQuery for IpfsFilesLsQuery { + type Response = HashMap; + + fn route(&self) -> String { + "api/v0/files/ls".to_string() + } + + async fn configure_request( + &self, + request_builder: RequestBuilder, + ) -> StdResult { + Ok(request_builder + .query(&[("arg", &self.dir)]) + // enable long listing (else hashes are empty) and disable sorting (handled rust-side) + .query(&[("long", "true"), ("U", "true")])) + } + + fn timeout(&self) -> Duration { + Duration::from_secs(10) + } + + async fn handle_success(&self, response: Response) -> StdResult { + let response: IpfsLsResponse = response + .json() + .await + .with_context(|| "Failed to deserialize IPFS ls response")?; + + Ok(response + .entries + .into_iter() + .map(|item| (item.name, item.hash)) + .collect()) + } +} + +#[cfg(test)] +mod tests { + use httpmock::Method::POST; + + use crate::tools::kubo_rpc_client::test_tools::setup_server_and_client; + + use super::*; + + #[tokio::test] + async fn return_items_list_if_request_succeeds() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST) + .path("/api/v0/files/ls") + .query_param("arg", "/test/") + .query_param("long", "true") + .query_param("U", "true"); + then.status(200).json_body(serde_json::json!({ + "Entries":[ + {"Name":"00000.tar.zst","Type":0,"Size":28486,"Hash":"QmePDH8sb7dux6VEvACJYS3m76D4Cc8eyfhejs7wcDFwWi"}, + {"Name":"00001.tar.zst","Type":0,"Size":28557,"Hash":"QmXtTUpZervXkza1KmmmnfkxqJmLxZAsRXUfVKQHPBBkFA"}, + {"Name":"00002.tar.zst","Type":0,"Size":29518,"Hash":"QmYd4yX3ms9jeLcDd9r3DZaMYuKb7TNX5dL1VD1wyNgKac"}, + {"Name":"00003.tar.zst","Type":0,"Size":28951,"Hash":"Qmbh4AHrNT8GMrJYLAyku88zhoZAyJcsRRyXNFbDJsbCp9"}, + {"Name":"sub-dir","Type":1,"Size":0,"Hash":"QmX5UvqhAYnEqAGx41SCovCg4x6NTF5XEVBMLarqk8J4x7"} + ] + })); + }); + + let response = client + .send(IpfsFilesLsQuery::new(&IpfsMfsDirPath::from("/test"))) + .await + .unwrap(); + assert_eq!( + HashMap::::from([ + ( + "00000.tar.zst".to_string(), + "QmePDH8sb7dux6VEvACJYS3m76D4Cc8eyfhejs7wcDFwWi".to_string(), + ), + ( + "00001.tar.zst".to_string(), + "QmXtTUpZervXkza1KmmmnfkxqJmLxZAsRXUfVKQHPBBkFA".to_string(), + ), + ( + "00002.tar.zst".to_string(), + "QmYd4yX3ms9jeLcDd9r3DZaMYuKb7TNX5dL1VD1wyNgKac".to_string(), + ), + ( + "00003.tar.zst".to_string(), + "Qmbh4AHrNT8GMrJYLAyku88zhoZAyJcsRRyXNFbDJsbCp9".to_string(), + ), + ( + "sub-dir".to_string(), + "QmX5UvqhAYnEqAGx41SCovCg4x6NTF5XEVBMLarqk8J4x7".to_string(), + ), + ]), + response + ); + } + + #[tokio::test] + async fn return_error_if_request_fails_with_other_message() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST).path("/api/v0/files/ls").query_param("arg", "/test/"); + then.status(500).json_body( + serde_json::json!({"Message":"paths must start with a leading slash","Code":0,"Type":"error"}), + ); + }); + + let err = client + .send(IpfsFilesLsQuery::new(&IpfsMfsDirPath::from("/test"))) + .await + .unwrap_err(); + assert!(err.to_string().contains("paths must start with a leading slash")); + } +} diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs index 95dbb3a4b50..42e3dfc97a8 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs @@ -1,7 +1,9 @@ mod ipfs_add; +mod ipfs_files_ls; mod ipfs_files_mkdir; mod ipfs_files_stat; pub use ipfs_add::*; +pub use ipfs_files_ls::*; pub use ipfs_files_mkdir::*; pub use ipfs_files_stat::*; From 1a4d1a0fa6aff4e10efe66be2de39a3f81d48324 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:24:57 +0200 Subject: [PATCH 18/26] refactor(aggregator): add `IpfsMfsDirPath.join_file_name_from` to centralize mfs path compute from a dir and a `&Path` Supersede removing `for_file_in_dir` from `IpfsFilesStatQuery` (use `query::new(mfs_dir.join_file_name_from(..))` instead). --- .../src/file_uploaders/ipfs_uploader.rs | 7 ++- .../src/tools/kubo_rpc_client/path.rs | 43 ++++++++++++++++++ .../kubo_rpc_client/query/ipfs_files_stat.rs | 45 +------------------ 3 files changed, 47 insertions(+), 48 deletions(-) diff --git a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs index 8a956715ac8..0971f22fd53 100644 --- a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs +++ b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs @@ -149,10 +149,9 @@ impl IpfsBackendUploader for KuboRpcClient { file_path: &Path, ) -> StdResult> { let stat = self - .send(IpfsFilesStatQuery::for_file_in_dir( - mfs_dir_path, - file_path, - )?) + .send(IpfsFilesStatQuery::new( + mfs_dir_path.join_file_name_from(file_path)?, + )) .await?; Ok(stat.map(|stat| stat.hash)) } diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/path.rs b/mithril-aggregator/src/tools/kubo_rpc_client/path.rs index 3e9320539dc..639ca6ad166 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/path.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/path.rs @@ -1,4 +1,9 @@ use std::fmt::Display; +use std::path::Path; + +use anyhow::Context; + +use mithril_common::StdResult; /// A path to a Mutable File System directory in IPFS. /// @@ -7,6 +12,26 @@ use std::fmt::Display; #[serde(transparent)] pub struct IpfsMfsDirPath(String); +impl IpfsMfsDirPath { + /// Return a path for a file in this directory whose name is taken from `file_path`. + /// + /// Only the final component of `file_path` is used. Returns an error if the path + /// has no file name. + pub fn join_file_name_from>(&self, file_path: P) -> StdResult { + let filename = file_path + .as_ref() + .file_name() + .with_context(|| { + format!( + "Failed to get filename from path: {}", + file_path.as_ref().display() + ) + })? + .to_string_lossy(); + Ok(format!("{}{filename}", self.0)) + } +} + impl Display for IpfsMfsDirPath { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) @@ -108,4 +133,22 @@ mod tests { serde_json::from_str(r#""/dir/subdir/""#).unwrap() ); } + + #[test] + fn join_file_name_from_builds_mfs_path_from_directory_and_file_name() { + let path = IpfsMfsDirPath::from("/test/dir") + .join_file_name_from("/local/archive/dummy-file.txt") + .unwrap(); + + assert_eq!("/test/dir/dummy-file.txt", path); + } + + #[test] + fn join_file_name_from_returns_error_when_path_has_no_file_name() { + let error = IpfsMfsDirPath::from("/test/dir") + .join_file_name_from(Path::new("")) + .unwrap_err(); + + assert!(error.to_string().contains("Failed to get filename from path")); + } } diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_stat.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_stat.rs index 947697d3790..b372442840e 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_stat.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_stat.rs @@ -1,13 +1,11 @@ -use std::path::Path; - use anyhow::Context; use reqwest::{RequestBuilder, Response}; use serde::Deserialize; use mithril_common::StdResult; +use crate::tools::kubo_rpc_client::KuboRpcQuery; use crate::tools::kubo_rpc_client::api::format_response_error; -use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcQuery}; /// Query to display file status in an MFS (Mutable File System) in IPFS via the Kubo RPC API. /// @@ -48,27 +46,6 @@ impl IpfsFilesStatQuery { path_in_ipfs: path_in_ipfs.as_ref().to_string(), } } - - /// Creates a query for the file in `mfs_dir` whose name is taken from `file_path`. - /// - /// Only the final component of `file_path` is used. Returns an error if the path - /// has no file name. - pub fn for_file_in_dir>( - mfs_dir: &IpfsMfsDirPath, - file_path: P, - ) -> StdResult { - let filename = file_path - .as_ref() - .file_name() - .with_context(|| { - format!( - "Failed to get filename from path: {}", - file_path.as_ref().display() - ) - })? - .to_string_lossy(); - Ok(Self::new(format!("{mfs_dir}{filename}"))) - } } #[async_trait::async_trait] @@ -117,26 +94,6 @@ mod tests { use super::*; - #[test] - fn for_file_in_dir_builds_mfs_path_from_directory_and_file_name() { - let query = IpfsFilesStatQuery::for_file_in_dir( - &IpfsMfsDirPath::from("/test/dir"), - "/local/archive/dummy-file.txt", - ) - .unwrap(); - - assert_eq!("/test/dir/dummy-file.txt", query.path_in_ipfs); - } - - #[test] - fn for_file_in_dir_returns_error_when_path_has_no_file_name() { - let error = - IpfsFilesStatQuery::for_file_in_dir(&IpfsMfsDirPath::from("/test/dir"), Path::new("")) - .unwrap_err(); - - assert!(error.to_string().contains("Failed to get filename from path")); - } - #[tokio::test] async fn return_stat_data_if_request_succeeds() { let (server, client) = setup_server_and_client(); From d7100c0a438664ba10eab42866cf32d1fbeb2a4c Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:17:35 +0200 Subject: [PATCH 19/26] feat(aggregator): add caching for IPFS batch uploads with MFS directory listing support Same mechanisms as the Cloud Uploader: cache everything once at batch start, check for each path if it's in the cache, else check existence. --- .../cardano_database_artifacts/immutable.rs | 1 + .../src/file_uploaders/ipfs_uploader.rs | 262 ++++++++++++++---- 2 files changed, 212 insertions(+), 51 deletions(-) diff --git a/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs b/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs index 91b0d8938af..f07300efc36 100644 --- a/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs +++ b/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs @@ -108,6 +108,7 @@ impl ImmutableFilesUploader for IpfsUploader { filepaths: &[PathBuf], compression_algorithm: Option, ) -> StdResult { + self.refresh_existing_files_path_cache().await?; for filepath in filepaths { self.upload(filepath).await?; } diff --git a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs index 0971f22fd53..d7e10f359d7 100644 --- a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs +++ b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs @@ -1,7 +1,8 @@ -use std::path::Path; -use std::sync::Arc; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; -use anyhow::Context; +use anyhow::{Context, anyhow}; use slog::{Logger, trace}; use tokio::sync::OnceCell; @@ -10,17 +11,26 @@ use mithril_common::entities::FileUri; use mithril_common::logging::LoggerExtensions; use crate::FileUploader; -use crate::tools::kubo_rpc_client::query::{IpfsAddQuery, IpfsFilesMkdirQuery, IpfsFilesStatQuery}; +use crate::tools::kubo_rpc_client::query::{ + IpfsAddQuery, IpfsFilesLsQuery, IpfsFilesMkdirQuery, IpfsFilesStatQuery, +}; use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcClient}; /// IPFS Content Identifier (CID) pub type Cid = String; /// File uploader that stores files to IPFS +/// +/// #### Cache policy +/// The integrated cache is designed to work with batch uploads only, as it relies on the assumption +/// that all files are uploaded at once. +/// Consequently, items are not cached individually, and the cache is designed to be reset before +/// each batch using [IpfsUploader::refresh_existing_files_path_cache]. pub struct IpfsUploader { rpc_client: Arc, ipfs_dir_path: IpfsMfsDirPath, directory_created: OnceCell<()>, + existing_files_cache: Mutex>, logger: Logger, } @@ -35,6 +45,7 @@ impl IpfsUploader { rpc_client, ipfs_dir_path, directory_created: OnceCell::new(), + existing_files_cache: Mutex::new(HashMap::new()), logger: logger.new_with_component_name::(), } } @@ -60,6 +71,37 @@ impl IpfsUploader { pub async fn get_current_directory_cid(&self) -> StdResult { self.rpc_client.get_dir_cid(&self.ipfs_dir_path).await } + + /// Refresh the cache of files paths from the cloud backend + pub async fn refresh_existing_files_path_cache(&self) -> StdResult<()> { + self.ensure_directory_exists().await?; + + let files = self + .rpc_client + .list_directory_files(&self.ipfs_dir_path) + .await + .with_context(|| "listing files in IPFS MFS directory")?; + + let mut cache = self + .existing_files_cache + .lock() + .map_err(|_| anyhow!("Failed to acquire lock on existing_files_path_cache"))?; + *cache = files; + Ok(()) + } + + fn find_file_in_cache(&self, file_path: &Path) -> StdResult> { + let filename = file_path + .file_name() + .with_context(|| format!("Failed to get filename from path: {}", file_path.display()))? + .to_string_lossy(); + let cache = self + .existing_files_cache + .lock() + .map_err(|_| anyhow!("Failed to acquire lock on existing_files_path_cache"))?; + + Ok(cache.get(filename.as_ref()).cloned()) + } } #[async_trait::async_trait] @@ -68,6 +110,13 @@ impl FileUploader for IpfsUploader { trace!(self.logger, "Uploading file to IPFS"; "file_path" => %filepath.display()); self.ensure_directory_exists().await?; + if let Some(cid) = self + .find_file_in_cache(filepath) + .with_context(|| format!("Failed to find file '{}' in cache", filepath.display()))? + { + return Ok(FileUri(cid)); + } + match self .rpc_client .file_exists(&self.ipfs_dir_path, filepath) @@ -108,6 +157,12 @@ pub trait IpfsBackendUploader: Sync + Send { /// Create a directory in IPFS async fn create_dir(&self, dir_path: &IpfsMfsDirPath) -> StdResult<()>; + /// List all paths in an MFS directory + async fn list_directory_files( + &self, + dir_path: &IpfsMfsDirPath, + ) -> StdResult>; + /// Get the CID of a directory async fn get_dir_cid(&self, dir_path: &IpfsMfsDirPath) -> StdResult; @@ -128,6 +183,13 @@ impl IpfsBackendUploader for KuboRpcClient { self.send(IpfsFilesMkdirQuery::create_mfs_directory(dir_path)).await } + async fn list_directory_files( + &self, + dir_path: &IpfsMfsDirPath, + ) -> StdResult> { + self.send(IpfsFilesLsQuery::new(dir_path)).await + } + async fn get_dir_cid(&self, dir_path: &IpfsMfsDirPath) -> StdResult { let stat = self .send(IpfsFilesStatQuery::new(dir_path.as_ref())) @@ -167,20 +229,42 @@ mod tests { use super::*; + impl IpfsUploader { + fn new_for_test>( + mfs_dir: P, + mock_config: impl FnOnce(&mut MockIpfsBackendUploader), + ) -> Self { + Self::new( + MockBuilder::configure(mock_config), + mfs_dir.into(), + &TestLogger::stdout(), + ) + } + + fn with_initial_cache, V: Into>( + mut self, + initial_cache: HashMap, + ) -> Self { + self.existing_files_cache = + Mutex::new(initial_cache.into_iter().map(|(k, v)| (k.into(), v.into())).collect()); + self + } + + fn cache_content(&self) -> HashMap { + self.existing_files_cache.lock().unwrap().clone() + } + } + #[tokio::test] async fn create_dir_only_once_when_uploading_multiple_time() { - let uploader = IpfsUploader::new( - MockBuilder::configure(|mock: &mut MockIpfsBackendUploader| { - mock.expect_create_dir() - .with(eq(IpfsMfsDirPath::from("/test/dir"))) - .returning(|_| Ok(())) - .once(); - mock.expect_file_exists().returning(|_, _| Ok(None)); - mock.expect_upload_file().returning(|_, _| Ok(String::new())); - }), - IpfsMfsDirPath::from("/test/dir"), - &TestLogger::stdout(), - ); + let uploader = IpfsUploader::new_for_test(IpfsMfsDirPath::from("/test/dir"), |mock| { + mock.expect_create_dir() + .with(eq(IpfsMfsDirPath::from("/test/dir"))) + .returning(|_| Ok(())) + .once(); + mock.expect_file_exists().returning(|_, _| Ok(None)); + mock.expect_upload_file().returning(|_, _| Ok(String::new())); + }); uploader.upload_without_retry(Path::new("whatever")).await.unwrap(); uploader.upload_without_retry(Path::new("whatever")).await.unwrap(); @@ -189,20 +273,16 @@ mod tests { #[tokio::test] async fn existing_file_is_not_uploaded_and_its_cid_is_returned() { - let uploader = IpfsUploader::new( - MockBuilder::configure(|mock: &mut MockIpfsBackendUploader| { - mock.expect_create_dir().returning(|_| Ok(())); - mock.expect_file_exists() - .with( - eq(IpfsMfsDirPath::from("/test/dir")), - eq(Path::new("/a/dummy-file.txt")), - ) - .returning(|_, _| Ok(Some("existing".to_string()))); - mock.expect_upload_file().never(); - }), - IpfsMfsDirPath::from("/test/dir"), - &TestLogger::stdout(), - ); + let uploader = IpfsUploader::new_for_test(IpfsMfsDirPath::from("/test/dir"), |mock| { + mock.expect_create_dir().returning(|_| Ok(())); + mock.expect_file_exists() + .with( + eq(IpfsMfsDirPath::from("/test/dir")), + eq(Path::new("/a/dummy-file.txt")), + ) + .returning(|_, _| Ok(Some("existing".to_string()))); + mock.expect_upload_file().never(); + }); let result = uploader .upload_without_retry(Path::new("/a/dummy-file.txt")) @@ -214,31 +294,111 @@ mod tests { #[tokio::test] async fn non_existing_file_is_uploaded_and_its_cid_is_returned() { - let uploader = IpfsUploader::new( - MockBuilder::configure(|mock: &mut MockIpfsBackendUploader| { - mock.expect_create_dir() - .with(eq(IpfsMfsDirPath::from("/test/dir"))) - .returning(|_| Ok(())); - mock.expect_file_exists() - .with( - eq(IpfsMfsDirPath::from("/test/dir")), - eq(Path::new("/a/dummy-file.txt")), - ) - .returning(|_, _| Ok(None)); - mock.expect_upload_file() - .with( - eq(Path::new("/a/dummy-file.txt")), - eq(IpfsMfsDirPath::from("/test/dir")), - ) - .returning(|_, _| Ok("test_cid".to_string())); - }), - IpfsMfsDirPath::from("/test/dir"), - &TestLogger::stdout(), - ); + let uploader = IpfsUploader::new_for_test(IpfsMfsDirPath::from("/test/dir"), |mock| { + mock.expect_create_dir() + .with(eq(IpfsMfsDirPath::from("/test/dir"))) + .returning(|_| Ok(())); + mock.expect_file_exists() + .with( + eq(IpfsMfsDirPath::from("/test/dir")), + eq(Path::new("/a/dummy-file.txt")), + ) + .returning(|_, _| Ok(None)); + mock.expect_upload_file() + .with( + eq(Path::new("/a/dummy-file.txt")), + eq(IpfsMfsDirPath::from("/test/dir")), + ) + .returning(|_, _| Ok("test_cid".to_string())); + }); uploader .upload_without_retry(Path::new("/a/dummy-file.txt")) .await .unwrap(); } + + mod file_caching { + use super::*; + + #[track_caller] + fn assert_cache_eq, V: Into>( + uploader: &IpfsUploader, + expected: HashMap, + ) { + assert_eq!( + expected + .into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect::>(), + uploader.cache_content() + ); + } + + #[tokio::test] + async fn refresh_list_only_once() { + let uploader = + IpfsUploader::new_for_test("/test/dir", |mock: &mut MockIpfsBackendUploader| { + mock.expect_create_dir().returning(|_| Ok(())); + mock.expect_list_dir() + .with(eq(IpfsMfsDirPath::from("/test/dir"))) + .return_once(move |_| Ok(HashMap::from([("key".into(), "value".into())]))) + .once(); + }); + + uploader.refresh_existing_files_path_cache().await.unwrap(); + + assert_cache_eq(&uploader, HashMap::from([("key", "value")])); + } + + #[tokio::test] + async fn failed_refresh_does_not_overwrite_cache() { + let uploader = + IpfsUploader::new_for_test("/test/dir", |mock: &mut MockIpfsBackendUploader| { + mock.expect_create_dir().returning(|_| Ok(())); + mock.expect_list_dir() + .return_once(move |_| Err(anyhow!("error"))) + .once(); + }) + .with_initial_cache(HashMap::from([("initial", "content")])); + + uploader.refresh_existing_files_path_cache().await.unwrap_err(); + + assert_cache_eq(&uploader, HashMap::from([("initial", "content")])); + } + + #[tokio::test] + async fn cached_files_are_not_stat_nor_uploaded() { + let uploader = + IpfsUploader::new_for_test("/test/dir", |mock: &mut MockIpfsBackendUploader| { + mock.expect_create_dir().returning(|_| Ok(())); + mock.expect_file_exists().never(); + mock.expect_upload_file().never(); + }) + .with_initial_cache(HashMap::from([("dummy-file.txt", "FileCid")])); + + let uri = uploader.upload(Path::new("/my/dummy-file.txt")).await.unwrap(); + + assert_eq!(FileUri("FileCid".to_string()), uri); + } + + #[tokio::test] + async fn check_file_exist_if_not_in_cache() { + let uploader = + IpfsUploader::new_for_test("/test/dir", |mock: &mut MockIpfsBackendUploader| { + mock.expect_create_dir().returning(|_| Ok(())); + mock.expect_file_exists() + .with( + eq(IpfsMfsDirPath::from("/test/dir")), + eq(Path::new("/my/dummy-file.txt")), + ) + .returning(|_, _| Ok(Some("FileCid".to_string()))); + mock.expect_upload_file().never(); + }); + + let uri = uploader.upload(Path::new("/my/dummy-file.txt")).await.unwrap(); + + assert_eq!(FileUri("FileCid".to_string()), uri); + } + } } From 12b008f7decaff3a7a5ed403d2633fcd3e2205d3 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:05:45 +0200 Subject: [PATCH 20/26] refactor(aggregator): simplify IPFS uploader by using a per batch cache Removing the needs for synchronisation (mutex, onceCell). --- .../cardano_database_artifacts/immutable.rs | 7 +- .../src/file_uploaders/ipfs_uploader.rs | 413 ++++++++++-------- 2 files changed, 235 insertions(+), 185 deletions(-) diff --git a/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs b/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs index f07300efc36..c894dc89ce3 100644 --- a/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs +++ b/mithril-aggregator/src/artifact_builder/cardano_database_artifacts/immutable.rs @@ -108,12 +108,7 @@ impl ImmutableFilesUploader for IpfsUploader { filepaths: &[PathBuf], compression_algorithm: Option, ) -> StdResult { - self.refresh_existing_files_path_cache().await?; - for filepath in filepaths { - self.upload(filepath).await?; - } - - let directory_cid = self.get_current_directory_cid().await?; + let directory_cid = self.batch_upload_to_dir(filepaths).await?; Ok(ImmutablesLocation::Ipfs { uri: MultiFilesUri::Template(TemplateUri(format!( diff --git a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs index d7e10f359d7..0f26a58f1cd 100644 --- a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs +++ b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs @@ -1,10 +1,9 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; -use anyhow::{Context, anyhow}; +use anyhow::Context; use slog::{Logger, trace}; -use tokio::sync::OnceCell; use mithril_common::StdResult; use mithril_common::entities::FileUri; @@ -20,17 +19,9 @@ use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcClient}; pub type Cid = String; /// File uploader that stores files to IPFS -/// -/// #### Cache policy -/// The integrated cache is designed to work with batch uploads only, as it relies on the assumption -/// that all files are uploaded at once. -/// Consequently, items are not cached individually, and the cache is designed to be reset before -/// each batch using [IpfsUploader::refresh_existing_files_path_cache]. pub struct IpfsUploader { rpc_client: Arc, ipfs_dir_path: IpfsMfsDirPath, - directory_created: OnceCell<()>, - existing_files_cache: Mutex>, logger: Logger, } @@ -44,27 +35,45 @@ impl IpfsUploader { Self { rpc_client, ipfs_dir_path, - directory_created: OnceCell::new(), - existing_files_cache: Mutex::new(HashMap::new()), logger: logger.new_with_component_name::(), } } - async fn ensure_directory_exists(&self) -> StdResult<()> { - self.directory_created - .get_or_try_init(|| async { - self.rpc_client - .create_dir(&self.ipfs_dir_path) - .await + /// Upload a batch of files at once to the target IPFS MFS directory, returning the updated CID + /// of the directory + /// + /// Compared to uploading the files one by one: + /// - it checks if the target directory exists in IPFS only once + /// - it batches the existence checks of the files by listing them using one `files ls` query + pub async fn batch_upload_to_dir(&self, files: &[PathBuf]) -> StdResult { + self.ensure_directory_exists().await?; + + let existing_entries = self + .rpc_client + .list_directory_files(&self.ipfs_dir_path) + .await + .with_context(|| "listing files in IPFS MFS directory")?; + + for file_path in files { + let filename = + file_path + .file_name() + .and_then(|name| name.to_str()) .with_context(|| { format!( - "Failed to create directory '{}' in IPFS", - self.ipfs_dir_path + "File path '{}' has no valid UTF-8 filename", + file_path.display() ) - }) - }) - .await?; - Ok(()) + })?; + + if existing_entries.contains_key(filename) { + continue; + } + + self.upload_missing_file_once(file_path).await?; + } + + self.get_current_directory_cid().await } /// Get the current directory CID, reflecting the latest state of the directory @@ -72,52 +81,22 @@ impl IpfsUploader { self.rpc_client.get_dir_cid(&self.ipfs_dir_path).await } - /// Refresh the cache of files paths from the cloud backend - pub async fn refresh_existing_files_path_cache(&self) -> StdResult<()> { - self.ensure_directory_exists().await?; - - let files = self - .rpc_client - .list_directory_files(&self.ipfs_dir_path) + async fn ensure_directory_exists(&self) -> StdResult<()> { + self.rpc_client + .create_dir(&self.ipfs_dir_path) .await - .with_context(|| "listing files in IPFS MFS directory")?; - - let mut cache = self - .existing_files_cache - .lock() - .map_err(|_| anyhow!("Failed to acquire lock on existing_files_path_cache"))?; - *cache = files; - Ok(()) - } - - fn find_file_in_cache(&self, file_path: &Path) -> StdResult> { - let filename = file_path - .file_name() - .with_context(|| format!("Failed to get filename from path: {}", file_path.display()))? - .to_string_lossy(); - let cache = self - .existing_files_cache - .lock() - .map_err(|_| anyhow!("Failed to acquire lock on existing_files_path_cache"))?; - - Ok(cache.get(filename.as_ref()).cloned()) + .with_context(|| { + format!( + "Failed to create directory '{}' in IPFS", + self.ipfs_dir_path + ) + }) } -} -#[async_trait::async_trait] -impl FileUploader for IpfsUploader { - async fn upload_without_retry(&self, filepath: &Path) -> StdResult { - trace!(self.logger, "Uploading file to IPFS"; "file_path" => %filepath.display()); + async fn upload_file_once(&self, filepath: &Path) -> StdResult { self.ensure_directory_exists().await?; if let Some(cid) = self - .find_file_in_cache(filepath) - .with_context(|| format!("Failed to find file '{}' in cache", filepath.display()))? - { - return Ok(FileUri(cid)); - } - - match self .rpc_client .file_exists(&self.ipfs_dir_path, filepath) .await @@ -126,27 +105,37 @@ impl FileUploader for IpfsUploader { "Failed to check if file '{}' exists in IPFS", filepath.display() ) - })? { - Some(cid) => { - trace!(self.logger, "File already exists in IPFS"; "cid" => %cid); - Ok(FileUri(cid)) - } - None => { - let cid = self - .rpc_client - .upload_file(filepath, &self.ipfs_dir_path) - .await - .with_context(|| { - format!("Failed to upload file '{}' to IPFS", filepath.display()) - })?; - trace!( - self.logger, "File upload to IPFS finished"; - "file_path" => %filepath.display(), "cid" => %cid - ); - - Ok(FileUri(cid)) - } + })? + { + trace!(self.logger, "File already exists in IPFS"; "cid" => %cid); + return Ok(FileUri(cid)); } + + self.upload_missing_file_once(filepath).await + } + + // Note: this method assumes that the target directory exists and the file does not exist in IPFS + async fn upload_missing_file_once(&self, filepath: &Path) -> StdResult { + trace!(self.logger, "Uploading file to IPFS"; "file_path" => %filepath.display()); + + let cid = self + .rpc_client + .upload_file(filepath, &self.ipfs_dir_path) + .await + .with_context(|| format!("Failed to upload file '{}' to IPFS", filepath.display()))?; + trace!( + self.logger, "File upload to IPFS finished"; + "file_path" => %filepath.display(), "cid" => %cid + ); + + Ok(FileUri(cid)) + } +} + +#[async_trait::async_trait] +impl FileUploader for IpfsUploader { + async fn upload_without_retry(&self, filepath: &Path) -> StdResult { + self.upload_file_once(filepath).await } } @@ -240,35 +229,6 @@ mod tests { &TestLogger::stdout(), ) } - - fn with_initial_cache, V: Into>( - mut self, - initial_cache: HashMap, - ) -> Self { - self.existing_files_cache = - Mutex::new(initial_cache.into_iter().map(|(k, v)| (k.into(), v.into())).collect()); - self - } - - fn cache_content(&self) -> HashMap { - self.existing_files_cache.lock().unwrap().clone() - } - } - - #[tokio::test] - async fn create_dir_only_once_when_uploading_multiple_time() { - let uploader = IpfsUploader::new_for_test(IpfsMfsDirPath::from("/test/dir"), |mock| { - mock.expect_create_dir() - .with(eq(IpfsMfsDirPath::from("/test/dir"))) - .returning(|_| Ok(())) - .once(); - mock.expect_file_exists().returning(|_, _| Ok(None)); - mock.expect_upload_file().returning(|_, _| Ok(String::new())); - }); - - uploader.upload_without_retry(Path::new("whatever")).await.unwrap(); - uploader.upload_without_retry(Path::new("whatever")).await.unwrap(); - uploader.upload_without_retry(Path::new("whatever")).await.unwrap(); } #[tokio::test] @@ -318,87 +278,182 @@ mod tests { .unwrap(); } - mod file_caching { + mod batch_upload { + use anyhow::anyhow; + use super::*; - #[track_caller] - fn assert_cache_eq, V: Into>( - uploader: &IpfsUploader, - expected: HashMap, - ) { - assert_eq!( - expected - .into_iter() - .map(|(k, v)| (k.into(), v.into())) - .collect::>(), - uploader.cache_content() - ); + const MFS_DIR: &str = "/test/dir"; + + #[tokio::test] + async fn uploads_only_missing_files_and_returns_directory_cid() { + let existing_files = HashMap::from([( + "already-uploaded.txt".to_string(), + "existing-cid".to_string(), + )]); + + let uploader = IpfsUploader::new_for_test(MFS_DIR, move |mock| { + mock.expect_create_dir() + .with(eq(IpfsMfsDirPath::from(MFS_DIR))) + .return_once(|_| Ok(())) + .once(); + mock.expect_list_directory_files() + .with(eq(IpfsMfsDirPath::from(MFS_DIR))) + .return_once(move |_| Ok(existing_files)) + .once(); + mock.expect_file_exists().never(); + mock.expect_upload_file() + .with( + eq(PathBuf::from("/local/new-file-1.txt")), + eq(IpfsMfsDirPath::from(MFS_DIR)), + ) + .return_once(|_, _| Ok("file-1-cid".to_string())) + .once(); + mock.expect_upload_file() + .with( + eq(PathBuf::from("/other/new-file-2.txt")), + eq(IpfsMfsDirPath::from(MFS_DIR)), + ) + .return_once(|_, _| Ok("file-2-cid".to_string())) + .once(); + mock.expect_get_dir_cid() + .with(eq(IpfsMfsDirPath::from(MFS_DIR))) + .return_once(|_| Ok("directory-cid".to_string())) + .once(); + }); + + let directory_cid = uploader + .batch_upload_to_dir(&[ + PathBuf::from("/local/already-uploaded.txt"), + PathBuf::from("/local/new-file-1.txt"), + PathBuf::from("/other/new-file-2.txt"), + ]) + .await + .unwrap(); + + assert_eq!("directory-cid", directory_cid); } #[tokio::test] - async fn refresh_list_only_once() { - let uploader = - IpfsUploader::new_for_test("/test/dir", |mock: &mut MockIpfsBackendUploader| { - mock.expect_create_dir().returning(|_| Ok(())); - mock.expect_list_dir() - .with(eq(IpfsMfsDirPath::from("/test/dir"))) - .return_once(move |_| Ok(HashMap::from([("key".into(), "value".into())]))) - .once(); - }); - - uploader.refresh_existing_files_path_cache().await.unwrap(); - - assert_cache_eq(&uploader, HashMap::from([("key", "value")])); + async fn empty_batch_returns_current_directory_cid_without_uploading_files() { + let uploader = IpfsUploader::new_for_test(MFS_DIR, |mock| { + mock.expect_create_dir() + .with(eq(IpfsMfsDirPath::from(MFS_DIR))) + .return_once(|_| Ok(())) + .once(); + mock.expect_list_directory_files() + .with(eq(IpfsMfsDirPath::from(MFS_DIR))) + .return_once(|_| Ok(HashMap::new())) + .once(); + mock.expect_file_exists().never(); + mock.expect_upload_file().never(); + mock.expect_get_dir_cid() + .with(eq(IpfsMfsDirPath::from(MFS_DIR))) + .return_once(|_| Ok("directory-cid".to_string())) + .once(); + }); + + let directory_cid = uploader.batch_upload_to_dir(&[]).await.unwrap(); + + assert_eq!("directory-cid", directory_cid); } #[tokio::test] - async fn failed_refresh_does_not_overwrite_cache() { - let uploader = - IpfsUploader::new_for_test("/test/dir", |mock: &mut MockIpfsBackendUploader| { - mock.expect_create_dir().returning(|_| Ok(())); - mock.expect_list_dir() - .return_once(move |_| Err(anyhow!("error"))) - .once(); - }) - .with_initial_cache(HashMap::from([("initial", "content")])); - - uploader.refresh_existing_files_path_cache().await.unwrap_err(); - - assert_cache_eq(&uploader, HashMap::from([("initial", "content")])); + async fn returns_error_when_a_file_path_has_no_filename() { + let uploader = IpfsUploader::new_for_test(MFS_DIR, |mock| { + mock.expect_create_dir().return_once(|_| Ok(())).once(); + mock.expect_list_directory_files() + .return_once(|_| Ok(HashMap::new())) + .once(); + mock.expect_file_exists().never(); + mock.expect_upload_file().never(); + mock.expect_get_dir_cid().never(); + }); + + uploader + .batch_upload_to_dir(&[PathBuf::new()]) + .await + .expect_err("a path without a filename should fail"); } #[tokio::test] - async fn cached_files_are_not_stat_nor_uploaded() { - let uploader = - IpfsUploader::new_for_test("/test/dir", |mock: &mut MockIpfsBackendUploader| { - mock.expect_create_dir().returning(|_| Ok(())); - mock.expect_file_exists().never(); - mock.expect_upload_file().never(); - }) - .with_initial_cache(HashMap::from([("dummy-file.txt", "FileCid")])); - - let uri = uploader.upload(Path::new("/my/dummy-file.txt")).await.unwrap(); - - assert_eq!(FileUri("FileCid".to_string()), uri); + async fn returns_error_when_directory_creation_fails() { + let uploader = IpfsUploader::new_for_test(MFS_DIR, |mock| { + mock.expect_create_dir() + .with(eq(IpfsMfsDirPath::from(MFS_DIR))) + .return_once(|_| Err(anyhow!("create directory failure"))) + .once(); + mock.expect_list_directory_files().never(); + mock.expect_upload_file().never(); + mock.expect_get_dir_cid().never(); + }); + + uploader + .batch_upload_to_dir(&[PathBuf::from("new-file.txt")]) + .await + .expect_err("directory creation failure should be returned"); } #[tokio::test] - async fn check_file_exist_if_not_in_cache() { - let uploader = - IpfsUploader::new_for_test("/test/dir", |mock: &mut MockIpfsBackendUploader| { - mock.expect_create_dir().returning(|_| Ok(())); - mock.expect_file_exists() - .with( - eq(IpfsMfsDirPath::from("/test/dir")), - eq(Path::new("/my/dummy-file.txt")), - ) - .returning(|_, _| Ok(Some("FileCid".to_string()))); - mock.expect_upload_file().never(); - }); + async fn returns_error_when_listing_directory_files_fails() { + let uploader = IpfsUploader::new_for_test(MFS_DIR, |mock| { + mock.expect_create_dir().return_once(|_| Ok(())).once(); + mock.expect_list_directory_files() + .with(eq(IpfsMfsDirPath::from(MFS_DIR))) + .return_once(|_| Err(anyhow!("list directory failure"))) + .once(); + mock.expect_upload_file().never(); + mock.expect_get_dir_cid().never(); + }); + + uploader + .batch_upload_to_dir(&[PathBuf::from("new-file.txt")]) + .await + .expect_err("directory listing failure should be returned"); + } - let uri = uploader.upload(Path::new("/my/dummy-file.txt")).await.unwrap(); + #[tokio::test] + async fn returns_error_when_uploading_a_missing_file_fails() { + let uploader = IpfsUploader::new_for_test(MFS_DIR, |mock| { + mock.expect_create_dir().return_once(|_| Ok(())).once(); + mock.expect_list_directory_files() + .return_once(|_| Ok(HashMap::new())) + .once(); + mock.expect_file_exists().never(); + mock.expect_upload_file() + .with( + eq(PathBuf::from("/local/new-file.txt")), + eq(IpfsMfsDirPath::from(MFS_DIR)), + ) + .return_once(|_, _| Err(anyhow!("upload failure"))) + .once(); + mock.expect_get_dir_cid().never(); + }); + + uploader + .batch_upload_to_dir(&[PathBuf::from("/local/new-file.txt")]) + .await + .expect_err("file upload failure should be returned"); + } - assert_eq!(FileUri("FileCid".to_string()), uri); + #[tokio::test] + async fn returns_error_when_retrieving_directory_cid_fails() { + let uploader = IpfsUploader::new_for_test(MFS_DIR, |mock| { + mock.expect_create_dir().return_once(|_| Ok(())).once(); + mock.expect_list_directory_files() + .return_once(|_| Ok(HashMap::new())) + .once(); + mock.expect_upload_file().never(); + mock.expect_get_dir_cid() + .with(eq(IpfsMfsDirPath::from(MFS_DIR))) + .return_once(|_| Err(anyhow!("get directory CID failure"))) + .once(); + }); + + uploader + .batch_upload_to_dir(&[]) + .await + .expect_err("directory CID retrieval failure should be returned"); } } } From c8c961ef6661e957bd701ffe62bd4a7ddc8c90e7 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:13:09 +0200 Subject: [PATCH 21/26] refactor(aggregator): extract retry logic into a reusable utility function for file uploads Allowing to avoid rewriting the retry loop logic in a context where the automatic retry capability of `upload` can't be used. --- .../src/file_uploaders/interface.rs | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/mithril-aggregator/src/file_uploaders/interface.rs b/mithril-aggregator/src/file_uploaders/interface.rs index 5aa68a4f516..416ad9c82d9 100644 --- a/mithril-aggregator/src/file_uploaders/interface.rs +++ b/mithril-aggregator/src/file_uploaders/interface.rs @@ -47,20 +47,35 @@ pub trait FileUploader: Sync + Send { /// Upload a file with retries according to the retry policy. async fn upload(&self, filepath: &Path) -> StdResult { let retry_policy = self.retry_policy(); + retry( + async || self.upload_without_retry(filepath).await, + retry_policy, + format!(" Uploaded file path: {}", filepath.display()), + ) + .await + } +} - let mut nb_attempts = 0; - loop { - nb_attempts += 1; - match self.upload_without_retry(filepath).await { - Ok(result) => return Ok(result), - Err(e) if nb_attempts >= retry_policy.attempts => { - return Err(e.context(format!( - "Upload failed after {nb_attempts} attempts. Uploaded file path: {}", - filepath.display() - ))); - } - _ => tokio::time::sleep(retry_policy.delay_between_attempts).await, +pub(super) async fn retry( + f: F, + retry_policy: FileUploadRetryPolicy, + additional_err_context: String, +) -> StdResult +where + F: Fn() -> Fut, + Fut: Future>, +{ + let mut nb_attempts = 0; + loop { + nb_attempts += 1; + match f().await { + Ok(result) => return Ok(result), + Err(e) if nb_attempts >= retry_policy.attempts => { + return Err(e.context(format!( + "Upload failed after {nb_attempts} attempts.{additional_err_context}" + ))); } + _ => tokio::time::sleep(retry_policy.delay_between_attempts).await, } } } From cc1d539f2d83fbedc62e43fe78327d36f4031312 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:13:41 +0200 Subject: [PATCH 22/26] feat(aggregator): add retry policy support for IPFS file uploads --- .../builder/protocol/artifacts.rs | 1 + .../src/file_uploaders/ipfs_uploader.rs | 63 ++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs b/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs index 3fde2f67530..2b1c3768489 100644 --- a/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs +++ b/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs @@ -264,6 +264,7 @@ impl DependenciesBuilder { Ok(IpfsUploader::new( Arc::new(rpc_api_client), IpfsMfsDirPath::from(ipfs_rpc_config.mfs_folder_name), + FileUploadRetryPolicy::default(), &self.root_logger(), )) } diff --git a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs index 0f26a58f1cd..00408441f9c 100644 --- a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs +++ b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs @@ -10,6 +10,8 @@ use mithril_common::entities::FileUri; use mithril_common::logging::LoggerExtensions; use crate::FileUploader; +use crate::file_uploaders::FileUploadRetryPolicy; +use crate::file_uploaders::interface::retry; use crate::tools::kubo_rpc_client::query::{ IpfsAddQuery, IpfsFilesLsQuery, IpfsFilesMkdirQuery, IpfsFilesStatQuery, }; @@ -22,6 +24,7 @@ pub type Cid = String; pub struct IpfsUploader { rpc_client: Arc, ipfs_dir_path: IpfsMfsDirPath, + retry_policy: FileUploadRetryPolicy, logger: Logger, } @@ -30,11 +33,13 @@ impl IpfsUploader { pub fn new( rpc_client: Arc, ipfs_dir_path: IpfsMfsDirPath, + retry_policy: FileUploadRetryPolicy, logger: &Logger, ) -> Self { Self { rpc_client, ipfs_dir_path, + retry_policy, logger: logger.new_with_component_name::(), } } @@ -70,7 +75,13 @@ impl IpfsUploader { continue; } - self.upload_missing_file_once(file_path).await?; + // We are bypassing retry-capable [FileUploader::upload] to avoid already batched checks, so we need to retry manually + retry( + || async { self.upload_missing_file_once(file_path).await }, + self.retry_policy(), + format!(" Uploaded file path: {}", file_path.display()), + ) + .await?; } self.get_current_directory_cid().await @@ -137,6 +148,10 @@ impl FileUploader for IpfsUploader { async fn upload_without_retry(&self, filepath: &Path) -> StdResult { self.upload_file_once(filepath).await } + + fn retry_policy(&self) -> FileUploadRetryPolicy { + self.retry_policy.clone() + } } /// Backend trait for IPFS operations @@ -226,6 +241,7 @@ mod tests { Self::new( MockBuilder::configure(mock_config), mfs_dir.into(), + FileUploadRetryPolicy::never(), &TestLogger::stdout(), ) } @@ -279,6 +295,8 @@ mod tests { } mod batch_upload { + use std::time::Duration; + use anyhow::anyhow; use super::*; @@ -334,6 +352,49 @@ mod tests { assert_eq!("directory-cid", directory_cid); } + #[tokio::test] + async fn support_retry() { + let mut uploader = IpfsUploader::new_for_test(MFS_DIR, move |mock| { + mock.expect_create_dir() + .with(eq(IpfsMfsDirPath::from(MFS_DIR))) + .return_once(|_| Ok(())) + .once(); + mock.expect_list_directory_files() + .with(eq(IpfsMfsDirPath::from(MFS_DIR))) + .return_once(move |_| Ok(HashMap::new())) + .once(); + mock.expect_upload_file() + .with( + eq(PathBuf::from("/local/new-file.txt")), + eq(IpfsMfsDirPath::from(MFS_DIR)), + ) + .return_once(|_, _| Err(anyhow!("first upload failed"))) + .once(); + mock.expect_upload_file() + .with( + eq(PathBuf::from("/local/new-file.txt")), + eq(IpfsMfsDirPath::from(MFS_DIR)), + ) + .return_once(|_, _| Ok("file-cid".to_string())) + .once(); + mock.expect_get_dir_cid() + .with(eq(IpfsMfsDirPath::from(MFS_DIR))) + .return_once(|_| Ok("directory-cid".to_string())) + .once(); + }); + uploader.retry_policy = FileUploadRetryPolicy { + attempts: 2, + delay_between_attempts: Duration::from_millis(5), + }; + + let directory_cid = uploader + .batch_upload_to_dir(&[PathBuf::from("/local/new-file.txt")]) + .await + .unwrap(); + + assert_eq!("directory-cid", directory_cid); + } + #[tokio::test] async fn empty_batch_returns_current_directory_cid_without_uploading_files() { let uploader = IpfsUploader::new_for_test(MFS_DIR, |mock| { From a94c053b717b2b6ae24856783463dcbed51e08a2 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:44:52 +0200 Subject: [PATCH 23/26] refactor(aggregator): rename `Cid` to `IpfsCid` --- .../src/file_uploaders/ipfs_uploader.rs | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs index 00408441f9c..97aaf51d569 100644 --- a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs +++ b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs @@ -18,7 +18,7 @@ use crate::tools::kubo_rpc_client::query::{ use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcClient}; /// IPFS Content Identifier (CID) -pub type Cid = String; +pub type IpfsCid = String; /// File uploader that stores files to IPFS pub struct IpfsUploader { @@ -50,7 +50,7 @@ impl IpfsUploader { /// Compared to uploading the files one by one: /// - it checks if the target directory exists in IPFS only once /// - it batches the existence checks of the files by listing them using one `files ls` query - pub async fn batch_upload_to_dir(&self, files: &[PathBuf]) -> StdResult { + pub async fn batch_upload_to_dir(&self, files: &[PathBuf]) -> StdResult { self.ensure_directory_exists().await?; let existing_entries = self @@ -88,7 +88,7 @@ impl IpfsUploader { } /// Get the current directory CID, reflecting the latest state of the directory - pub async fn get_current_directory_cid(&self) -> StdResult { + pub async fn get_current_directory_cid(&self) -> StdResult { self.rpc_client.get_dir_cid(&self.ipfs_dir_path).await } @@ -165,20 +165,20 @@ pub trait IpfsBackendUploader: Sync + Send { async fn list_directory_files( &self, dir_path: &IpfsMfsDirPath, - ) -> StdResult>; + ) -> StdResult>; /// Get the CID of a directory - async fn get_dir_cid(&self, dir_path: &IpfsMfsDirPath) -> StdResult; + async fn get_dir_cid(&self, dir_path: &IpfsMfsDirPath) -> StdResult; /// Upload a file to IPFS and return its CID - async fn upload_file(&self, file_path: &Path, mfs_path: &IpfsMfsDirPath) -> StdResult; + async fn upload_file(&self, file_path: &Path, mfs_path: &IpfsMfsDirPath) -> StdResult; /// Check if a file exists in a given MFS directory and return its CID if it does async fn file_exists( &self, mfs_dir_path: &IpfsMfsDirPath, file_path: &Path, - ) -> StdResult>; + ) -> StdResult>; } #[async_trait::async_trait] @@ -190,11 +190,11 @@ impl IpfsBackendUploader for KuboRpcClient { async fn list_directory_files( &self, dir_path: &IpfsMfsDirPath, - ) -> StdResult> { + ) -> StdResult> { self.send(IpfsFilesLsQuery::new(dir_path)).await } - async fn get_dir_cid(&self, dir_path: &IpfsMfsDirPath) -> StdResult { + async fn get_dir_cid(&self, dir_path: &IpfsMfsDirPath) -> StdResult { let stat = self .send(IpfsFilesStatQuery::new(dir_path.as_ref())) .await? @@ -202,7 +202,7 @@ impl IpfsBackendUploader for KuboRpcClient { Ok(stat.hash) } - async fn upload_file(&self, file_path: &Path, mfs_path: &IpfsMfsDirPath) -> StdResult { + async fn upload_file(&self, file_path: &Path, mfs_path: &IpfsMfsDirPath) -> StdResult { let res = self .send(IpfsAddQuery::new_with_mfs_reference(file_path, mfs_path)) .await?; @@ -213,7 +213,7 @@ impl IpfsBackendUploader for KuboRpcClient { &self, mfs_dir_path: &IpfsMfsDirPath, file_path: &Path, - ) -> StdResult> { + ) -> StdResult> { let stat = self .send(IpfsFilesStatQuery::new( mfs_dir_path.join_file_name_from(file_path)?, From a20529b149b381c3e894331cc8b6781170644f01 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:37:36 +0200 Subject: [PATCH 24/26] fix(aggregator): correctly handle IPFS ls of empty directories Kubo do not set `[]` but `null` for entries when a directory exists but is empty. --- .../src/file_uploaders/ipfs_uploader.rs | 3 +- .../src/tools/kubo_rpc_client/api.rs | 17 +++++ .../kubo_rpc_client/query/ipfs_files_ls.rs | 65 ++++++++++++++++--- .../kubo_rpc_client/query/ipfs_files_stat.rs | 14 +--- 4 files changed, 77 insertions(+), 22 deletions(-) diff --git a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs index 97aaf51d569..ffdf51434b8 100644 --- a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs +++ b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs @@ -191,7 +191,8 @@ impl IpfsBackendUploader for KuboRpcClient { &self, dir_path: &IpfsMfsDirPath, ) -> StdResult> { - self.send(IpfsFilesLsQuery::new(dir_path)).await + let response = self.send(IpfsFilesLsQuery::new(dir_path)).await?; + Ok(response.unwrap_or_default()) } async fn get_dir_cid(&self, dir_path: &IpfsMfsDirPath) -> StdResult { diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/api.rs b/mithril-aggregator/src/tools/kubo_rpc_client/api.rs index 0d143bdbfdf..13607d81d58 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/api.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/api.rs @@ -117,6 +117,23 @@ pub(super) fn format_response_error(status: StatusCode, response_text: &str) -> anyhow::anyhow!("Request to Kubo RPC failed: {status}: '{response_text}'") } +pub(super) async fn handle_file_not_exist_error( + query_name: &str, + response: Response, +) -> StdResult> { + let status = response.status(); + let body = response + .text() + .await + .with_context(|| format!("Failed to read IPFS {query_name} error response"))?; + + if body.contains("file does not exist") { + Ok(None) + } else { + Err(format_response_error(status, &body)) + } +} + #[cfg(test)] mod tests { use httpmock::Method::POST; diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_ls.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_ls.rs index da4b1aac9f3..151c2ad1cea 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_ls.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_ls.rs @@ -6,6 +6,7 @@ use std::time::Duration; use mithril_common::StdResult; +use crate::tools::kubo_rpc_client::api::handle_file_not_exist_error; use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcQuery}; /// Query to list directories in an MFS (Mutable File System) in IPFS via the Kubo RPC API. @@ -21,7 +22,7 @@ pub struct IpfsFilesLsQuery { #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "PascalCase")] struct IpfsLsResponse { - entries: Vec, + entries: Option>, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -44,7 +45,7 @@ impl IpfsFilesLsQuery { #[async_trait::async_trait] impl KuboRpcQuery for IpfsFilesLsQuery { - type Response = HashMap; + type Response = Option>; fn route(&self) -> String { "api/v0/files/ls".to_string() @@ -70,11 +71,17 @@ impl KuboRpcQuery for IpfsFilesLsQuery { .await .with_context(|| "Failed to deserialize IPFS ls response")?; - Ok(response - .entries - .into_iter() - .map(|item| (item.name, item.hash)) - .collect()) + match response.entries { + Some(entries) => Ok(Some( + entries.into_iter().map(|item| (item.name, item.hash)).collect(), + )), + // If entries are null, the directory exists but is empty + None => Ok(Some(HashMap::::new())), + } + } + + async fn handle_error(&self, response: Response) -> StdResult { + handle_file_not_exist_error("files ls", response).await } } @@ -86,6 +93,26 @@ mod tests { use super::*; + #[tokio::test] + async fn return_empty_list_when_directory_is_empty() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST) + .path("/api/v0/files/ls") + .query_param("arg", "/test/") + .query_param("long", "true") + .query_param("U", "true"); + // Kubo returns null if the directory exists but is empty + then.status(200).json_body(serde_json::json!({ "Entries": null })); + }); + + let response = client + .send(IpfsFilesLsQuery::new(&IpfsMfsDirPath::from("/test"))) + .await + .unwrap(); + assert_eq!(Some(HashMap::::new()), response); + } + #[tokio::test] async fn return_items_list_if_request_succeeds() { let (server, client) = setup_server_and_client(); @@ -110,8 +137,9 @@ mod tests { .send(IpfsFilesLsQuery::new(&IpfsMfsDirPath::from("/test"))) .await .unwrap(); + assert_eq!( - HashMap::::from([ + Some(HashMap::::from([ ( "00000.tar.zst".to_string(), "QmePDH8sb7dux6VEvACJYS3m76D4Cc8eyfhejs7wcDFwWi".to_string(), @@ -132,11 +160,30 @@ mod tests { "sub-dir".to_string(), "QmX5UvqhAYnEqAGx41SCovCg4x6NTF5XEVBMLarqk8J4x7".to_string(), ), - ]), + ])), response ); } + #[tokio::test] + async fn return_none_if_request_fails_with_not_exist_message() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST) + .path("/api/v0/files/ls") + .query_param("arg", "/test/"); + then.status(500).json_body( + serde_json::json!({"Message":"file does not exist","Code":0,"Type":"error"}), + ); + }); + + let response = client + .send(IpfsFilesLsQuery::new(&IpfsMfsDirPath::from("/test"))) + .await + .unwrap(); + assert_eq!(None, response); + } + #[tokio::test] async fn return_error_if_request_fails_with_other_message() { let (server, client) = setup_server_and_client(); diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_stat.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_stat.rs index b372442840e..6c3ebd73e64 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_stat.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_files_stat.rs @@ -5,7 +5,7 @@ use serde::Deserialize; use mithril_common::StdResult; use crate::tools::kubo_rpc_client::KuboRpcQuery; -use crate::tools::kubo_rpc_client::api::format_response_error; +use crate::tools::kubo_rpc_client::api::handle_file_not_exist_error; /// Query to display file status in an MFS (Mutable File System) in IPFS via the Kubo RPC API. /// @@ -72,17 +72,7 @@ impl KuboRpcQuery for IpfsFilesStatQuery { } async fn handle_error(&self, response: Response) -> StdResult { - let status = response.status(); - let body = response - .text() - .await - .with_context(|| "Failed to read IPFS files stat error response")?; - - if body.contains("file does not exist") { - Ok(None) - } else { - Err(format_response_error(status, &body)) - } + handle_file_not_exist_error("files stat", response).await } } From af7c48239e596554f3e2ec692211e7a5ebde5060 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:18:03 +0200 Subject: [PATCH 25/26] chore: update changelog --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b63124459c6..ed91ddac050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,9 @@ As a minor extension, we have adopted a slightly different versioning convention - Support for bytes encoding of the SNARK aggregate signatures in the certificates. - Reduced the encoded size of the SNARK proofs by serializing their bytes as CBOR byte strings. - Support for reading on-chain protocol configurations for both Aggregator and Signer. - -- **UNSTABLE** Reworked the Mithril aggregator's file archiver to produce byte-stable archives across systems. - - Existing archives must be regenerated by the Mithril aggregator to ensure byte stability. + - Reworked the Mithril aggregator's file archiver to produce byte-stable archives across systems. + - Existing archives must be regenerated by the Mithril aggregator to ensure byte stability. + - Support IPFS for uploads of immutable files in the Mithril aggregator. - **REMOVED** support for `Gzip` compression/decompression in the Mithril aggregator and client: - The aggregator no longer produces or supports `Gzip` compression for snapshot-related artifacts: immutable files and ancillaries. From 7c724308290e665efef58266698664e7c2e0f6f9 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:33:41 +0200 Subject: [PATCH 26/26] chore: upgrade crate versions * mithril-aggregator from `0.10.0` to `0.10.1` * mithril-client-cli from `0.13.21` to `0.13.22` * mithril-client from `0.14.19` to `0.14.20` * mithril-common from `0.7.18` to `0.7.19` --- Cargo.lock | 8 ++++---- .../mithril-cardano-node-internal-database/Cargo.toml | 2 +- internal/mithril-aggregator-client/Cargo.toml | 2 +- internal/mithril-aggregator-discovery/Cargo.toml | 2 +- mithril-aggregator/Cargo.toml | 2 +- mithril-client-cli/Cargo.toml | 2 +- mithril-client/Cargo.toml | 4 ++-- mithril-common/Cargo.toml | 2 +- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bad4d87307a..e6026e12a93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4214,7 +4214,7 @@ dependencies = [ [[package]] name = "mithril-aggregator" -version = "0.10.0" +version = "0.10.1" dependencies = [ "anyhow", "async-trait", @@ -4418,7 +4418,7 @@ dependencies = [ [[package]] name = "mithril-client" -version = "0.14.19" +version = "0.14.20" dependencies = [ "anyhow", "async-trait", @@ -4456,7 +4456,7 @@ dependencies = [ [[package]] name = "mithril-client-cli" -version = "0.13.21" +version = "0.13.22" dependencies = [ "anyhow", "async-trait", @@ -4513,7 +4513,7 @@ dependencies = [ [[package]] name = "mithril-common" -version = "0.7.18" +version = "0.7.19" dependencies = [ "anyhow", "async-trait", diff --git a/internal/cardano-node/mithril-cardano-node-internal-database/Cargo.toml b/internal/cardano-node/mithril-cardano-node-internal-database/Cargo.toml index b44b872a882..9a4ef2de1bf 100644 --- a/internal/cardano-node/mithril-cardano-node-internal-database/Cargo.toml +++ b/internal/cardano-node/mithril-cardano-node-internal-database/Cargo.toml @@ -15,7 +15,7 @@ anyhow = { workspace = true } async-trait = { workspace = true } digest = { workspace = true } hex = { workspace = true } -mithril-common = { path = "../../../mithril-common", version = "0.7.18" } +mithril-common = { path = "../../../mithril-common", version = "0.7.19" } serde = { workspace = true } serde_json = { workspace = true } sha2 = "0.10.9" diff --git a/internal/mithril-aggregator-client/Cargo.toml b/internal/mithril-aggregator-client/Cargo.toml index 048776d8eac..dcf5b8884ad 100644 --- a/internal/mithril-aggregator-client/Cargo.toml +++ b/internal/mithril-aggregator-client/Cargo.toml @@ -13,7 +13,7 @@ include = ["**/*.rs", "Cargo.toml", "README.md"] [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } -mithril-common = { path = "../../mithril-common", version = "0.7.18" } +mithril-common = { path = "../../mithril-common", version = "0.7.19" } reqwest = { workspace = true } semver = { workspace = true } serde = { workspace = true } diff --git a/internal/mithril-aggregator-discovery/Cargo.toml b/internal/mithril-aggregator-discovery/Cargo.toml index 43f6eff84c6..1e06d6de833 100644 --- a/internal/mithril-aggregator-discovery/Cargo.toml +++ b/internal/mithril-aggregator-discovery/Cargo.toml @@ -14,7 +14,7 @@ include = ["**/*.rs", "Cargo.toml", "README.md", ".gitignore"] anyhow = { workspace = true } async-trait = { workspace = true } mithril-aggregator-client = { path = "../mithril-aggregator-client", version = "0.2.4" } -mithril-common = { path = "../../mithril-common", version = "0.7.18" } +mithril-common = { path = "../../mithril-common", version = "0.7.19" } rand = { version = "0.10.2" } reqwest = { workspace = true } serde = { workspace = true } diff --git a/mithril-aggregator/Cargo.toml b/mithril-aggregator/Cargo.toml index 06cbc7d8abe..81739dd6e2b 100644 --- a/mithril-aggregator/Cargo.toml +++ b/mithril-aggregator/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mithril-aggregator" -version = "0.10.0" +version = "0.10.1" description = "A Mithril Aggregator server" authors = { workspace = true } edition = { workspace = true } diff --git a/mithril-client-cli/Cargo.toml b/mithril-client-cli/Cargo.toml index 0f511c8704e..96799e17658 100644 --- a/mithril-client-cli/Cargo.toml +++ b/mithril-client-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mithril-client-cli" -version = "0.13.21" +version = "0.13.22" description = "A Mithril Client" authors = { workspace = true } edition = { workspace = true } diff --git a/mithril-client/Cargo.toml b/mithril-client/Cargo.toml index e326fe8ba20..12e98a052fa 100644 --- a/mithril-client/Cargo.toml +++ b/mithril-client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mithril-client" -version = "0.14.19" +version = "0.14.20" description = "Mithril client library" authors = { workspace = true } edition = { workspace = true } @@ -63,7 +63,7 @@ chrono = { workspace = true } flume = { version = "0.12.0", optional = true } futures = "0.3.32" mithril-aggregator-client = { path = "../internal/mithril-aggregator-client", version = "0.2.4" } -mithril-common = { path = "../mithril-common", version = "0.7.18", default-features = false } +mithril-common = { path = "../mithril-common", version = "0.7.19", default-features = false } reqwest = { workspace = true, default-features = false, features = ["charset", "http2", "stream", "system-proxy"] } serde = { workspace = true } serde_json = { workspace = true } diff --git a/mithril-common/Cargo.toml b/mithril-common/Cargo.toml index aa42ed99055..ef0bb7b95c6 100644 --- a/mithril-common/Cargo.toml +++ b/mithril-common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mithril-common" -version = "0.7.18" +version = "0.7.19" description = "Common types, interfaces, and utilities for Mithril nodes." authors = { workspace = true } edition = { workspace = true }