Skip to main content

aleph_cid/
lib.rs

1//! kubo-compatible IPFS CID computation for Aleph Cloud.
2//!
3//! This crate is the single source of truth for client-side content
4//! addressing in the Aleph Rust workspace:
5//!
6//! - [`cid`]: the string-validated [`cid::Cid`] type (CIDv0/CIDv1), with
7//!   serde support behind the `serde` feature.
8//! - [`verify`]: streaming CID hashers ([`verify::Hasher`]) for IPFS
9//!   CIDv0/CIDv1 (UnixFS dag-pb, 256 KiB chunks, raw leaves), plus
10//!   [`verify::compute_cid`] for one-shot CIDv0 computation.
11//! - [`folder_hash`]: UnixFS directory DAG construction matching
12//!   `ipfs add -r` (plain directories and HAMT shards), with a block sink for
13//!   streaming the DAG into a CAR file.
14//! - [`car`]: CARv1 framing: header/block writers and a strict root reader.
15//!
16//! It deliberately contains no networking, signing, or async code, and no
17//! Aleph message types, so that it can be reused as-is from FFI bindings
18//! (e.g. a Python wheel). Golden CIDs in `tests/folder_hash.rs` are
19//! regenerated against real kubo via `tests/regen-folder-hash-goldens.sh`.
20
21pub mod car;
22pub mod cid;
23pub mod folder_hash;
24mod proto;
25pub mod verify;
26
27use std::path::{Path, PathBuf};
28
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30pub enum CidVersion {
31    V0,
32    #[default]
33    V1,
34}
35
36#[non_exhaustive]
37#[derive(Debug, Clone)]
38pub struct UploadFolderOptions {
39    pub cid_version: CidVersion,
40    pub pin: bool,
41    pub follow_symlinks: bool,
42}
43
44impl Default for UploadFolderOptions {
45    fn default() -> Self {
46        Self {
47            cid_version: CidVersion::V1,
48            pin: true,
49            follow_symlinks: true,
50        }
51    }
52}
53
54#[non_exhaustive]
55#[derive(Debug)]
56pub struct FolderEntry {
57    /// Relative path from the upload root, forward-slash separated.
58    pub relative_path: String,
59    pub absolute_path: PathBuf,
60}
61
62#[non_exhaustive]
63#[derive(Debug, thiserror::Error)]
64pub enum CollectError {
65    #[error("empty folder: {0}")]
66    Empty(PathBuf),
67    #[error("non-UTF-8 path: {0}")]
68    NonUtf8(PathBuf),
69    #[error("walk failed at {path}: {source}")]
70    Walk {
71        path: PathBuf,
72        #[source]
73        source: walkdir::Error,
74    },
75}
76
77/// Walks `root` and returns one entry per regular file, with the relative
78/// path normalized to forward-slash separators.
79///
80/// Symlinks are followed when `follow_symlinks` is true (matches kubo's
81/// `ipfs add -r` default). Walk errors abort the collection.
82pub fn collect_folder_files(
83    root: &Path,
84    follow_symlinks: bool,
85) -> Result<Vec<FolderEntry>, CollectError> {
86    let mut out = Vec::new();
87    let walker = walkdir::WalkDir::new(root)
88        .follow_links(follow_symlinks)
89        .min_depth(1);
90
91    for entry in walker {
92        let entry = entry.map_err(|e| {
93            let path = e
94                .path()
95                .map(Path::to_path_buf)
96                .unwrap_or_else(|| root.to_path_buf());
97            CollectError::Walk { path, source: e }
98        })?;
99        if !entry.file_type().is_file() {
100            continue;
101        }
102        let abs = entry.path().to_path_buf();
103        let rel = entry
104            .path()
105            .strip_prefix(root)
106            .expect("walkdir entries are descendants of root");
107        let rel_str = rel
108            .to_str()
109            .ok_or_else(|| CollectError::NonUtf8(abs.clone()))?
110            .replace(std::path::MAIN_SEPARATOR, "/");
111        out.push(FolderEntry {
112            relative_path: rel_str,
113            absolute_path: abs,
114        });
115    }
116
117    if out.is_empty() {
118        return Err(CollectError::Empty(root.to_path_buf()));
119    }
120    Ok(out)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use std::fs;
127    use tempfile::TempDir;
128
129    #[test]
130    fn default_options_are_v1_pinned_follow_symlinks() {
131        let opts = UploadFolderOptions::default();
132        assert_eq!(opts.cid_version, CidVersion::V1);
133        assert!(opts.pin);
134        assert!(opts.follow_symlinks);
135    }
136
137    fn make_tree(tmp: &TempDir, files: &[(&str, &str)]) {
138        for (rel, content) in files {
139            let abs = tmp.path().join(rel);
140            if let Some(parent) = abs.parent() {
141                fs::create_dir_all(parent).unwrap();
142            }
143            fs::write(&abs, content).unwrap();
144        }
145    }
146
147    #[test]
148    fn collect_files_flat_directory() {
149        let tmp = TempDir::new().unwrap();
150        make_tree(&tmp, &[("a.txt", "a"), ("b.txt", "b")]);
151        let mut entries = collect_folder_files(tmp.path(), true).unwrap();
152        entries.sort_by(|x, y| x.relative_path.cmp(&y.relative_path));
153        assert_eq!(entries.len(), 2);
154        assert_eq!(entries[0].relative_path, "a.txt");
155        assert_eq!(entries[1].relative_path, "b.txt");
156    }
157
158    #[test]
159    fn collect_files_nested_directory() {
160        let tmp = TempDir::new().unwrap();
161        make_tree(
162            &tmp,
163            &[
164                ("a.txt", "a"),
165                ("sub/b.txt", "b"),
166                ("sub/deeper/c.txt", "c"),
167            ],
168        );
169        let mut paths: Vec<String> = collect_folder_files(tmp.path(), true)
170            .unwrap()
171            .into_iter()
172            .map(|e| e.relative_path)
173            .collect();
174        paths.sort();
175        assert_eq!(paths, vec!["a.txt", "sub/b.txt", "sub/deeper/c.txt"]);
176    }
177
178    #[test]
179    fn collect_files_empty_directory_errors() {
180        let tmp = TempDir::new().unwrap();
181        let err = collect_folder_files(tmp.path(), true).unwrap_err();
182        assert!(matches!(err, CollectError::Empty(_)));
183    }
184
185    #[test]
186    fn collect_files_uses_forward_slashes() {
187        let tmp = TempDir::new().unwrap();
188        make_tree(&tmp, &[("sub/x.txt", "x")]);
189        let entries = collect_folder_files(tmp.path(), true).unwrap();
190        assert_eq!(entries[0].relative_path, "sub/x.txt");
191        assert!(!entries[0].relative_path.contains('\\'));
192    }
193}