/* This file is part of DarkFi (https://dark.fi) * * Copyright (C) 2020-2026 Dyne.org foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ //! Chunk-based file storage implementation. //! This is a building block for a DHT or something similar. //! //! The API supports file/directory insertion and retrieval. There is //! intentionally no `remove` support. File removal should be handled //! externally, and then it is only required to run `garbage_collect()` to //! clean things up. //! //! The hash of a file is the BLAKE3 hash of hashed chunks in the correct //! order. //! The hash of a directory is the BLAKE3 hash of hashed chunks in the correct //! order and the ordered list of (file path, file sizes). //! All hashes (file, directory, chunk) are 32 bytes long, and are encoded in //! base58 whenever necessary. //! //! The filesystem hierarchy stores a `files` directory storing metadata //! about a full file and a `directories` directory storing metadata about all //! files in a directory (all subdirectories included). //! The filename of a file in `files` or `directories` is the hash of the //! file/directory as defined above. //! Inside a file in `files` is the ordered list of the chunks making up the //! full file. //! Inside a file in `directories` is the ordered list of the chunks making up //! each full file, and the (relative) file path and hash of all files in the //! directory. //! //! To get the chunks you split the full file into `MAX_CHUNK_SIZE` sized //! slices, the last chunk is the only one that can be smaller than that. //! //! It might look like the following: //! ``` //! /files/B9fFKaEYphw2oH5PDbeL1TTAcSzL6ax84p8SjBKzuYzX //! /files/8nA3ndjFFee3n5wMPLZampLpGaMJi3od4MSyaXPDoF91 //! /files/... //! /directories/FXDduPcEohVzsSxtNVSFU64qtYxEVEHBMkF4k5cBvt3B //! /directories/AHjU1LizfGqsGnF8VSa9kphSQ5pqS4YjmPqme5RZajsj //! /directories/... //! ``` //! //! Inside a file metadata (file in `files`) is the ordered list of chunk //! hashes, for example: //! ``` //! 2bQPxSR8Frz7S7JW3DRAzEtkrHfLXB1CN65V7az77pUp //! CvjvN6MfWQYK54DgKNR7MPgFSZqsCgpWKF2p8ot66CCP //! ``` //! //! Inside a directory metadata (file in `directories`) is, in addition to //! chunk hashes, the path and size of each file in the directory. For example: //! ``` //! 8Kb55jeqJsq7WTBN93gvBzh2zmXAXVPh111VqD3Hi42V //! GLiBqpLPTbpJhSMYfzi3s7WivrTViov7ShX7uso6fG5s //! picture.jpg 312948 //! ``` //! Chunks of a directory can include multiple files, if multiple files fit //! into `MAX_CHUNK_SIZE`. The chunks are computed as if all the files were //! concatenated into a single big file, to minimize the number of chunks. //! //! The full file is not copied, and individual chunks are not stored by //! geode. Additionally it does not keep track of the full files path. use std::{ collections::HashSet, path::{Path, PathBuf}, }; use futures::{AsyncRead, AsyncSeek}; use smol::{ fs::{self, File}, io::{ AsyncBufReadExt, AsyncReadExt, AsyncSeekExt, AsyncWriteExt, BufReader, Cursor, ErrorKind, SeekFrom, }, stream::StreamExt, }; use tracing::{debug, info, warn}; use crate::{Error, Result}; mod chunked_storage; pub use chunked_storage::{Chunk, ChunkedStorage}; mod file_sequence; pub use file_sequence::FileSequence; mod util; pub use util::{hash_to_string, read_until_filled}; /// Defined maximum size of a stored chunk (256 KiB) pub const MAX_CHUNK_SIZE: usize = 262_144; /// Path prefix where file metadata is stored const FILES_PATH: &str = "files"; /// Path prefix where directory metadata is stored const DIRS_PATH: &str = "directories"; /// Chunk-based file storage interface. pub struct Geode { /// Path to the filesystem directory where file metadata is stored pub files_path: PathBuf, /// Path to the filesystem directory where directory metadata is stored pub dirs_path: PathBuf, } impl Geode { /// Instantiate a new [`Geode`] object. /// `base_path` defines the root directory where Geode will store its /// file metadata and chunks. pub async fn new(base_path: &PathBuf) -> Result { let mut files_path: PathBuf = base_path.into(); files_path.push(FILES_PATH); let mut dirs_path: PathBuf = base_path.into(); dirs_path.push(DIRS_PATH); // Create necessary directory structure if needed fs::create_dir_all(&files_path).await?; fs::create_dir_all(&dirs_path).await?; Ok(Self { files_path, dirs_path }) } /// Attempt to read chunk hashes and files metadata from a given metadata path. /// This works for both file metadata and directory metadata. /// Returns (chunk hashes, [(file path, file size)]). async fn read_metadata(path: &PathBuf) -> Result<(Vec, Vec<(PathBuf, u64)>)> { debug!(target: "geode::read_dir_metadata", "Reading chunks from {path:?} (dir)"); let mut chunk_hashes = vec![]; let mut files = vec![]; let fd = File::open(path).await?; let mut lines = BufReader::new(fd).lines(); while let Some(line) = lines.next().await { let line = line?; let line = line.trim(); if line.is_empty() { continue; // Skip empty lines } let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() == 2 { // File let file_path = PathBuf::from(parts[0]); if file_path.clone().is_absolute() { return Err(Error::Custom(format!( "Path of file {} is absolute, which is not allowed", parts[0] ))) } // Check for `..` in the path components for component in file_path.clone().components() { if component == std::path::Component::ParentDir { return Err(Error::Custom(format!("Path of file {} contains reference to parent dir, which is not allowed", parts[0]))) } } let file_size = parts[1].parse::()?; files.push((file_path, file_size)); } else if parts.len() == 1 { // Chunk let chunk_hash_str = parts[0].trim(); if chunk_hash_str.is_empty() { break; // Stop reading chunk hashes on empty line } let mut hash_buf = [0u8; 32]; bs58::decode(chunk_hash_str).onto(&mut hash_buf)?; let chunk_hash = blake3::Hash::from_bytes(hash_buf); chunk_hashes.push(chunk_hash); } else { // Invalid format return Err(Error::Custom("Invalid directory metadata format".to_string())); } } Ok((chunk_hashes, files)) } /// Perform garbage collection over the filesystem hierarchy. /// Returns a set representing deleted files. pub async fn garbage_collect(&self) -> Result> { info!(target: "geode::garbage_collect", "[Geode] Performing garbage collection"); // We track corrupt files here. let mut deleted_files = HashSet::new(); // Perform health check over metadata. For now we just ensure they // have the correct format. let file_paths = fs::read_dir(&self.files_path).await?; let dir_paths = fs::read_dir(&self.dirs_path).await?; let mut paths = file_paths.chain(dir_paths); while let Some(file) = paths.next().await { let Ok(entry) = file else { continue }; let path = entry.path(); // Skip if we're not a plain file if !path.is_file() { continue } // Make sure that the filename is a BLAKE3 hash let file_name = match path.file_name().and_then(|n| n.to_str()) { Some(v) => v, None => continue, }; let mut hash_buf = [0u8; 32]; let hash = match bs58::decode(file_name).onto(&mut hash_buf) { Ok(_) => blake3::Hash::from_bytes(hash_buf), Err(_) => continue, }; // The filename is a BLAKE3 hash. It should contain a newline-separated // list of chunks which represent the full file. If that is not the case // we will consider it a corrupted file and delete it. if Self::read_metadata(&path).await.is_err() { if let Err(e) = fs::remove_file(path).await { warn!( target: "geode::garbage_collect", "[Geode] Garbage collect failed to remove corrupted metadata: {e}" ); } deleted_files.insert(hash); continue } } info!(target: "geode::garbage_collect", "[Geode] Garbage collection finished"); Ok(deleted_files) } /// Chunk a stream. /// Returns a hasher (containing the chunk hashes), and the list of chunk hashes. pub async fn chunk_stream( &self, mut stream: impl AsyncRead + Unpin, ) -> Result<(blake3::Hasher, Vec)> { let mut hasher = blake3::Hasher::new(); let mut chunk_hashes = vec![]; loop { let mut buf = vec![0u8; MAX_CHUNK_SIZE]; let bytes_read = stream.read(&mut buf).await?; if bytes_read == 0 { break } let chunk_hash = blake3::hash(&buf[..bytes_read]); hasher.update(chunk_hash.as_bytes()); chunk_hashes.push(chunk_hash); } Ok((hasher, chunk_hashes)) } /// Sorts files by their PathBuf. pub fn sort_files(&self, files: &mut [(PathBuf, u64)]) { files.sort_by(|(a, _), (b, _)| a.to_string_lossy().cmp(&b.to_string_lossy())); } /// Add chunk hashes to `hasher`. pub fn hash_chunks_metadata(&self, hasher: &mut blake3::Hasher, chunk_hashes: &[blake3::Hash]) { for chunk in chunk_hashes { hasher.update(chunk.as_bytes()); } } /// Add files metadata to `hasher`. /// You must sort the files using `sort_files`. pub fn hash_files_metadata( &self, hasher: &mut blake3::Hasher, relative_files: &[(PathBuf, u64)], ) { for file in relative_files { hasher.update(file.0.to_string_lossy().to_string().as_bytes()); hasher.update(&file.1.to_le_bytes()); } } /// Create and insert file or directory metadata into Geode. /// Always overwrites any existing file. /// Verifies that the metadata is valid. /// The `relative_files` slice is empty for files. pub async fn insert_metadata( &self, hash: &blake3::Hash, chunk_hashes: &[blake3::Hash], relative_files: &[(PathBuf, u64)], ) -> Result<()> { info!(target: "geode::insert_metadata", "[Geode] Inserting metadata"); // Verify the metadata if !self.verify_metadata(hash, chunk_hashes, relative_files) { return Err(Error::GeodeNeedsGc) } // Write the metadata file let mut file_path = match relative_files.is_empty() { true => self.files_path.clone(), false => self.dirs_path.clone(), }; file_path.push(hash_to_string(hash).as_str()); let mut file_fd = File::create(&file_path).await?; for ch in chunk_hashes { file_fd.write(format!("{}\n", hash_to_string(ch).as_str()).as_bytes()).await?; } for file in relative_files { file_fd.write(format!("{} {}\n", file.0.to_string_lossy(), file.1).as_bytes()).await?; } file_fd.flush().await?; Ok(()) } /// Write a single chunk given a stream. /// The file must be inserted into Geode before calling this method. /// Always overwrites any existing chunk. Returns the chunk hash and /// the number of bytes written to the file system. pub async fn write_chunk( &self, chunked: &mut ChunkedStorage, stream: impl AsRef<[u8]>, ) -> Result<(blake3::Hash, usize)> { info!(target: "geode::write_chunk", "[Geode] Writing single chunk"); let mut cursor = Cursor::new(&stream); let mut chunk = vec![0u8; MAX_CHUNK_SIZE]; // Read the stream to get the chunk content let chunk_slice = read_until_filled(&mut cursor, &mut chunk).await?; // Get the chunk hash from the content let chunk_hash = blake3::hash(chunk_slice); // Get the chunk index in the file/directory from the chunk hash let chunk_index = match chunked.iter().position(|c| c.hash == chunk_hash) { Some(index) => index, None => { return Err(Error::GeodeNeedsGc); } }; // Compute byte position from the chunk index and the chunk size let position = (chunk_index as u64) * (MAX_CHUNK_SIZE as u64); // Seek to the correct position let fileseq = &mut chunked.get_fileseq_mut(); fileseq.seek(SeekFrom::Start(position)).await?; // This will write the chunk, and truncate files if `chunked` is a directory. let bytes_written = fileseq.write(chunk_slice).await?; // If it's the last chunk of a file (and it's *not* a directory), // truncate the file to the correct length. // This is because contrary to directories, we do not know the exact // file size from its metadata, we only know the number of chunks. // Therefore we only know the exact size once we know the size of the // last chunk. // We also update the `FileSequence` to the exact size. if !chunked.is_dir() && chunk_index == chunked.len() - 1 { let exact_file_size = chunked.len() * MAX_CHUNK_SIZE - (MAX_CHUNK_SIZE - chunk_slice.len()); if let Some(file) = &chunked.get_fileseq_mut().get_current_file() { let _ = file.set_len(exact_file_size as u64); } chunked.get_fileseq_mut().set_file_size(0, exact_file_size as u64); } Ok((chunk_hash, bytes_written)) } /// Fetch file/directory metadata from Geode. Returns [`ChunkedStorage`]. Returns an error if /// the read failed in any way (could also be the file does not exist). pub async fn get(&self, hash: &blake3::Hash, path: &Path) -> Result { let hash_str = hash_to_string(hash); info!(target: "geode::get", "[Geode] Getting chunks for {hash_str}..."); // Try to read the file or dir metadata. If it's corrupt, return an error signalling // that garbage collection needs to run. let metadata_paths = [self.files_path.join(&hash_str), self.dirs_path.join(&hash_str)]; for metadata_path in metadata_paths { match Self::read_metadata(&metadata_path).await { Ok((chunk_hashes, files)) => { return self.create_chunked_storage(hash, path, &chunk_hashes, &files).await } Err(e) => { if !matches!(e, Error::Io(ErrorKind::NotFound)) { return Err(Error::GeodeNeedsGc) } } }; } Err(Error::GeodeFileNotFound) } /// Create a ChunkedStorage from metadata. /// `hash` is the hash of the file or directory. async fn create_chunked_storage( &self, hash: &blake3::Hash, path: &Path, chunk_hashes: &[blake3::Hash], relative_files: &[(PathBuf, u64)], // Only used by directories ) -> Result { // Make sure the file or directory is valid if !self.verify_metadata(hash, chunk_hashes, relative_files) { return Err(Error::GeodeNeedsGc); } let chunked = if relative_files.is_empty() { // File let file_size = (chunk_hashes.len() * MAX_CHUNK_SIZE) as u64; // Upper bound, not actual file size ChunkedStorage::new(chunk_hashes, &[(path.to_path_buf(), file_size)], false) } else { // Directory let files: Vec<_> = relative_files .iter() .map(|(file_path, size)| (path.join(file_path), *size)) .collect(); ChunkedStorage::new(chunk_hashes, &files, true) }; Ok(chunked) } /// Fetch a single chunk from Geode. Returns a Vec containing the chunk content /// if it is found. /// The returned chunk is NOT verified. pub async fn get_chunk( &self, chunked: &mut ChunkedStorage, chunk_hash: &blake3::Hash, ) -> Result> { info!(target: "geode::get_chunk", "[Geode] Getting chunk {}", hash_to_string(chunk_hash)); // Get the chunk index in the file from the chunk hash let chunk_index = match chunked.iter().position(|c| c.hash == *chunk_hash) { Some(index) => index, None => return Err(Error::GeodeChunkNotFound), }; // Read the file to get the chunk content let chunk = self.read_chunk(&mut chunked.get_fileseq_mut(), &chunk_index).await?; Ok(chunk) } /// Read the file at `file_path` to get its chunk with index `chunk_index`. /// Returns the chunk content in a Vec. pub async fn read_chunk( &self, mut stream: impl AsyncRead + Unpin + AsyncSeek, chunk_index: &usize, ) -> Result> { let position = (*chunk_index as u64) * (MAX_CHUNK_SIZE as u64); let mut buf = vec![0u8; MAX_CHUNK_SIZE]; stream.seek(SeekFrom::Start(position)).await?; let bytes_read = stream.read(&mut buf).await?; Ok(buf[..bytes_read].to_vec()) } /// Verifies that the file hash matches the chunk hashes. pub fn verify_metadata( &self, hash: &blake3::Hash, chunk_hashes: &[blake3::Hash], files: &[(PathBuf, u64)], ) -> bool { info!(target: "geode::verify_metadata", "[Geode] Verifying metadata for {}", hash_to_string(hash)); let mut hasher = blake3::Hasher::new(); self.hash_chunks_metadata(&mut hasher, chunk_hashes); self.hash_files_metadata(&mut hasher, files); *hash == hasher.finalize() } /// Verifies that the chunk hash matches the content. pub fn verify_chunk(&self, chunk_hash: &blake3::Hash, chunk_slice: &[u8]) -> bool { info!(target: "geode::verify_chunk", "[Geode] Verifying chunk {}", hash_to_string(chunk_hash)); blake3::hash(chunk_slice) == *chunk_hash } }