use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::fs::OpenOptions;
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt, SeekFrom};
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
pub const DEFAULT_CHUNK_SIZE: u64 = 1024 * 1024;
pub const MAX_FILE_SIZE: u64 = 1024 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DiskType {
Private,
Public,
Shared,
}
impl DiskType {
pub fn as_dir_name(&self) -> &'static str {
match self {
DiskType::Private => "private",
DiskType::Public => "public",
DiskType::Shared => "shared",
}
}
}
impl std::fmt::Display for DiskType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DiskType::Private => write!(f, "private"),
DiskType::Public => write!(f, "public"),
DiskType::Shared => write!(f, "shared"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileInfo {
pub path: String,
pub name: String,
pub is_directory: bool,
pub size_bytes: u64,
pub modified_at: i64,
pub content_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChunkInfo {
pub offset: u64,
pub size: u64,
pub chunk_hash: String,
pub total_size: u64,
pub total_chunks: u64,
pub chunk_index: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChunkReadResult {
pub data: Vec<u8>,
pub info: ChunkInfo,
pub is_last: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChunkWriteResult {
pub info: ChunkInfo,
pub cumulative_hash: String,
pub is_complete: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResumeVerificationResult {
pub can_resume: bool,
pub transfer_state: Option<TransferState>,
pub verified_chunks: u64,
pub total_chunks: u64,
pub verified_bytes: u64,
pub failure_reason: Option<String>,
pub file_modified: bool,
pub verified_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChunkVerificationResult {
pub chunk_index: u64,
pub is_valid: bool,
pub expected_hash: Option<String>,
pub actual_hash: String,
pub offset: u64,
pub size: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ResumeCapability {
Full,
Partial,
None,
}
pub const STALE_TRANSFER_THRESHOLD_SECS: i64 = 24 * 60 * 60;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransferState {
pub(crate) transfer_id: String,
pub(crate) entity_id: String,
pub(crate) disk_type: DiskType,
pub(crate) path: String,
pub(crate) total_size: u64,
pub(crate) bytes_written: u64,
#[serde(default)]
pub(crate) hasher_state: Vec<u8>,
pub(crate) chunk_size: u64,
pub(crate) started_at: i64,
pub(crate) last_updated: i64,
pub(crate) cumulative_hash: String,
}
impl TransferState {
pub fn transfer_id(&self) -> &str {
&self.transfer_id
}
pub fn entity_id(&self) -> &str {
&self.entity_id
}
pub fn disk_type(&self) -> DiskType {
self.disk_type
}
pub fn path(&self) -> &str {
&self.path
}
pub fn total_size(&self) -> u64 {
self.total_size
}
pub fn bytes_written(&self) -> u64 {
self.bytes_written
}
pub fn chunk_size(&self) -> u64 {
self.chunk_size
}
pub fn started_at(&self) -> i64 {
self.started_at
}
pub fn last_updated(&self) -> i64 {
self.last_updated
}
pub fn cumulative_hash(&self) -> &str {
&self.cumulative_hash
}
pub fn progress_percent(&self) -> f64 {
if self.total_size == 0 {
100.0
} else {
(self.bytes_written as f64 / self.total_size as f64) * 100.0
}
}
pub fn is_complete(&self) -> bool {
self.bytes_written >= self.total_size
}
pub fn chunks_completed(&self) -> u64 {
self.bytes_written.checked_div(self.chunk_size).unwrap_or(0)
}
pub fn total_chunks(&self) -> u64 {
if self.chunk_size == 0 || self.total_size == 0 {
0
} else {
self.total_size.div_ceil(self.chunk_size)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiskStats {
pub entity_id: String,
pub disk_type: DiskType,
pub used_bytes: u64,
pub file_count: u32,
pub dir_count: u32,
pub last_modified: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DiskFileMetadata {
pub entity_id: String,
pub disk_type: DiskType,
pub path: String,
pub name: String,
pub is_directory: bool,
pub size_bytes: u64,
pub modified_at: i64,
pub content_hash: String,
pub file_path: PathBuf, }
#[derive(Debug)]
pub struct EntityDiskService {
root: PathBuf,
index: RwLock<HashMap<String, DiskFileMetadata>>,
chunked_writes: RwLock<HashMap<String, TransferState>>,
}
impl EntityDiskService {
pub async fn new<P: AsRef<Path>>(root: P) -> Result<Self> {
let root = root.as_ref().to_path_buf();
tokio::fs::create_dir_all(&root)
.await
.with_context(|| format!("Failed to create disk root: {}", root.display()))?;
let service = Self {
root,
index: RwLock::new(HashMap::new()),
chunked_writes: RwLock::new(HashMap::new()),
};
service.load_index().await?;
service.load_transfer_states().await?;
info!(
"EntityDiskService initialized at {}",
service.root.display()
);
Ok(service)
}
fn validate_entity_id(entity_id: &str) -> Result<()> {
if entity_id.is_empty() {
bail!("Entity ID cannot be empty");
}
if entity_id.contains('/') || entity_id.contains('\\') {
bail!("Entity ID cannot contain path separators: {}", entity_id);
}
if entity_id.contains("..") {
bail!(
"Entity ID cannot contain path traversal sequences: {}",
entity_id
);
}
if entity_id.contains('\0') {
bail!("Entity ID cannot contain null bytes");
}
if entity_id.chars().all(|c| c == '.') {
bail!("Entity ID cannot be only dots: {}", entity_id);
}
Ok(())
}
fn sanitize_path(path: &str) -> Result<String> {
let path = path.trim_start_matches('/');
let mut clean_components: Vec<&str> = Vec::new();
for component in path.split('/') {
if component.is_empty() {
continue;
}
if component == "." || component == ".." {
bail!("Path traversal not allowed: {}", path);
}
if component.contains("..") {
bail!(
"Path component contains invalid sequence '..': {}",
component
);
}
if component.contains('\0') {
bail!("Path cannot contain null bytes");
}
clean_components.push(component);
}
Ok(clean_components.join("/"))
}
fn get_entity_disk_path(&self, entity_id: &str, disk_type: DiskType) -> Result<PathBuf> {
Self::validate_entity_id(entity_id)?;
Ok(self
.root
.join("entities")
.join(entity_id)
.join(disk_type.as_dir_name()))
}
fn get_file_path(&self, entity_id: &str, disk_type: DiskType, path: &str) -> Result<PathBuf> {
let disk_path = self.get_entity_disk_path(entity_id, disk_type)?;
let clean_path = Self::sanitize_path(path)?;
let full_path = disk_path.join(&clean_path);
if full_path.exists() {
let canonical_disk = disk_path
.canonicalize()
.unwrap_or_else(|_| disk_path.clone());
let canonical_full = full_path
.canonicalize()
.unwrap_or_else(|_| full_path.clone());
if !canonical_full.starts_with(&canonical_disk) {
bail!(
"Path escapes disk boundary via symlink: {} resolves outside {}",
full_path.display(),
disk_path.display()
);
}
}
Ok(full_path)
}
fn index_key(entity_id: &str, disk_type: DiskType, path: &str) -> String {
format!("{}:{}:{}", entity_id, disk_type, path)
}
pub async fn write_file(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
data: &[u8],
) -> Result<FileInfo> {
if path.is_empty() || path == "/" {
bail!("Invalid file path: cannot write to root");
}
let file_path = self.get_file_path(entity_id, disk_type, path)?;
if let Some(parent) = file_path.parent() {
tokio::fs::create_dir_all(parent).await.with_context(|| {
format!(
"Failed to create parent directories for {}",
file_path.display()
)
})?;
}
tokio::fs::write(&file_path, data)
.await
.with_context(|| format!("Failed to write file: {}", file_path.display()))?;
let content_hash = blake3::hash(data).to_string();
let name = Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string());
let now = chrono::Utc::now().timestamp();
let metadata = DiskFileMetadata {
entity_id: entity_id.to_string(),
disk_type,
path: path.to_string(),
name: name.clone(),
is_directory: false,
size_bytes: data.len() as u64,
modified_at: now,
content_hash: content_hash.clone(),
file_path: file_path.clone(),
};
{
let key = Self::index_key(entity_id, disk_type, path);
let mut index = self.index.write().await;
index.insert(key, metadata);
}
self.save_index().await?;
debug!(
"Wrote file {}:{}{} ({} bytes)",
entity_id,
disk_type,
path,
data.len()
);
Ok(FileInfo {
path: path.to_string(),
name,
is_directory: false,
size_bytes: data.len() as u64,
modified_at: now,
content_hash,
})
}
pub async fn read_file(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
) -> Result<Vec<u8>> {
let key = Self::index_key(entity_id, disk_type, path);
let metadata = {
let index = self.index.read().await;
index.get(&key).cloned()
};
let file_path = match metadata {
Some(meta) => {
if meta.is_directory {
bail!("Cannot read directory as file: {}", path);
}
meta.file_path
}
None => {
let fp = self.get_file_path(entity_id, disk_type, path)?;
if !fp.exists() {
bail!("File not found: {}:{}{}", entity_id, disk_type, path);
}
fp
}
};
let data = tokio::fs::read(&file_path)
.await
.with_context(|| format!("Failed to read file: {}", file_path.display()))?;
debug!(
"Read file {}:{}{} ({} bytes)",
entity_id,
disk_type,
path,
data.len()
);
Ok(data)
}
pub async fn read_chunk(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
offset: u64,
chunk_size: Option<u64>,
) -> Result<ChunkReadResult> {
let chunk_size = chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE);
let file_path = self.get_file_path(entity_id, disk_type, path)?;
if !file_path.exists() {
bail!("File not found: {}:{}{}", entity_id, disk_type, path);
}
let metadata = tokio::fs::metadata(&file_path).await?;
if metadata.is_dir() {
bail!("Cannot read chunk from directory: {}", path);
}
let total_size = metadata.len();
if offset >= total_size {
bail!("Offset {} exceeds file size {}", offset, total_size);
}
let remaining = total_size - offset;
let actual_chunk_size = std::cmp::min(chunk_size, remaining);
let total_chunks = total_size.div_ceil(chunk_size);
let chunk_index = offset / chunk_size;
let is_last = offset + actual_chunk_size >= total_size;
let mut file = tokio::fs::File::open(&file_path).await?;
file.seek(SeekFrom::Start(offset)).await?;
let mut buffer = vec![0u8; actual_chunk_size as usize];
file.read_exact(&mut buffer).await?;
let chunk_hash = blake3::hash(&buffer).to_string();
debug!(
"Read chunk {}/{} of {}:{}{} ({} bytes at offset {})",
chunk_index + 1,
total_chunks,
entity_id,
disk_type,
path,
actual_chunk_size,
offset
);
Ok(ChunkReadResult {
data: buffer,
info: ChunkInfo {
offset,
size: actual_chunk_size,
chunk_hash,
total_size,
total_chunks,
chunk_index,
},
is_last,
})
}
pub async fn start_chunked_write(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
total_size: u64,
chunk_size: Option<u64>,
) -> Result<ChunkInfo> {
if path.is_empty() || path == "/" {
bail!("Invalid file path: cannot write to root");
}
if total_size > MAX_FILE_SIZE {
bail!(
"File size {} exceeds maximum {} (1GB)",
total_size,
MAX_FILE_SIZE
);
}
let chunk_size = chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE);
let file_path = self.get_file_path(entity_id, disk_type, path)?;
if let Some(parent) = file_path.parent() {
tokio::fs::create_dir_all(parent).await.with_context(|| {
format!(
"Failed to create parent directories for {}",
file_path.display()
)
})?;
}
let file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&file_path)
.await
.with_context(|| format!("Failed to create file: {}", file_path.display()))?;
if total_size > 0 {
file.set_len(total_size).await.ok(); }
drop(file);
let total_chunks = if total_size == 0 {
0
} else {
total_size.div_ceil(chunk_size)
};
let now = chrono::Utc::now().timestamp();
let transfer_id = Self::index_key(entity_id, disk_type, path);
let state = TransferState {
transfer_id: transfer_id.clone(),
entity_id: entity_id.to_string(),
disk_type,
path: path.to_string(),
total_size,
bytes_written: 0,
hasher_state: Vec::new(), chunk_size,
started_at: now,
last_updated: now,
cumulative_hash: String::new(),
};
{
let mut writes = self.chunked_writes.write().await;
writes.insert(transfer_id, state);
}
self.save_transfer_states().await?;
debug!(
"Started chunked write for {}:{}{} ({} bytes, {} chunks)",
entity_id, disk_type, path, total_size, total_chunks
);
Ok(ChunkInfo {
offset: 0,
size: 0,
chunk_hash: String::new(),
total_size,
total_chunks,
chunk_index: 0,
})
}
pub async fn write_chunk(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
offset: u64,
data: &[u8],
) -> Result<ChunkWriteResult> {
let key = Self::index_key(entity_id, disk_type, path);
let (chunk_size, total_size, bytes_written) = {
let writes = self.chunked_writes.read().await;
let state = writes.get(&key).ok_or_else(|| {
anyhow::anyhow!(
"No active chunked write for {}:{}{}",
entity_id,
disk_type,
path
)
})?;
if offset != state.bytes_written {
bail!(
"Expected offset {}, got {}. Chunks must be written sequentially.",
state.bytes_written,
offset
);
}
if offset + data.len() as u64 > state.total_size {
bail!(
"Write would exceed declared file size ({} + {} > {})",
offset,
data.len(),
state.total_size
);
}
(state.chunk_size, state.total_size, state.bytes_written)
};
let file_path = self.get_file_path(entity_id, disk_type, path)?;
let mut file = OpenOptions::new()
.write(true)
.open(&file_path)
.await
.with_context(|| format!("Failed to open file for writing: {}", file_path.display()))?;
file.seek(SeekFrom::Start(offset)).await?;
file.write_all(data).await?;
file.flush().await?;
let chunk_hash = blake3::hash(data).to_string();
let new_bytes_written = bytes_written + data.len() as u64;
let is_complete = new_bytes_written >= total_size;
let total_chunks = total_size.div_ceil(chunk_size);
let chunk_index = offset / chunk_size;
let cumulative_hash = if is_complete {
let all_data = tokio::fs::read(&file_path).await?;
blake3::hash(&all_data).to_string()
} else {
chunk_hash.clone()
};
{
let mut writes = self.chunked_writes.write().await;
if let Some(state) = writes.get_mut(&key) {
state.bytes_written = new_bytes_written;
state.last_updated = chrono::Utc::now().timestamp();
state.cumulative_hash = cumulative_hash.clone();
}
}
self.save_transfer_states().await?;
debug!(
"Wrote chunk {}/{} to {}:{}{} ({} bytes at offset {})",
chunk_index + 1,
total_chunks,
entity_id,
disk_type,
path,
data.len(),
offset
);
Ok(ChunkWriteResult {
info: ChunkInfo {
offset,
size: data.len() as u64,
chunk_hash,
total_size,
total_chunks,
chunk_index,
},
cumulative_hash,
is_complete,
})
}
pub async fn finish_chunked_write(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
) -> Result<FileInfo> {
let key = Self::index_key(entity_id, disk_type, path);
let state = {
let mut writes = self.chunked_writes.write().await;
writes.remove(&key).ok_or_else(|| {
anyhow::anyhow!(
"No active chunked write for {}:{}{}",
entity_id,
disk_type,
path
)
})?
};
if state.bytes_written != state.total_size {
bail!(
"Incomplete write: {} of {} bytes written",
state.bytes_written,
state.total_size
);
}
let file_path = self.get_file_path(entity_id, disk_type, path)?;
let data = tokio::fs::read(&file_path).await?;
let content_hash = blake3::hash(&data).to_string();
let name = Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string());
let now = chrono::Utc::now().timestamp();
let metadata = DiskFileMetadata {
entity_id: entity_id.to_string(),
disk_type,
path: path.to_string(),
name: name.clone(),
is_directory: false,
size_bytes: state.total_size,
modified_at: now,
content_hash: content_hash.clone(),
file_path: file_path.clone(),
};
{
let mut index = self.index.write().await;
index.insert(key, metadata);
}
self.save_index().await?;
self.save_transfer_states().await?;
debug!(
"Finished chunked write for {}:{}{} ({} bytes)",
entity_id, disk_type, path, state.total_size
);
Ok(FileInfo {
path: path.to_string(),
name,
is_directory: false,
size_bytes: state.total_size,
modified_at: now,
content_hash,
})
}
pub async fn abort_chunked_write(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
) -> Result<()> {
let key = Self::index_key(entity_id, disk_type, path);
{
let mut writes = self.chunked_writes.write().await;
writes.remove(&key);
}
if let Ok(file_path) = self.get_file_path(entity_id, disk_type, path)
&& file_path.exists()
{
match tokio::fs::remove_file(&file_path).await {
Ok(()) => {}
Err(e) => {
warn!(
"Failed to delete partial file {}: {}",
file_path.display(),
e
);
}
}
}
self.save_transfer_states().await?;
debug!(
"Aborted chunked write for {}:{}{}",
entity_id, disk_type, path
);
Ok(())
}
pub fn calculate_chunk_count(file_size: u64, chunk_size: Option<u64>) -> u64 {
let chunk_size = chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE);
if file_size == 0 {
0
} else {
file_size.div_ceil(chunk_size)
}
}
pub async fn has_active_chunked_write(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
) -> bool {
let key = Self::index_key(entity_id, disk_type, path);
let writes = self.chunked_writes.read().await;
writes.contains_key(&key)
}
pub async fn get_chunked_write_progress(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
) -> Option<(u64, u64)> {
let key = Self::index_key(entity_id, disk_type, path);
let writes = self.chunked_writes.read().await;
writes.get(&key).map(|s| (s.bytes_written, s.total_size))
}
pub async fn verify_resume(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
verify_hashes: bool,
) -> Result<ResumeVerificationResult> {
let key = Self::index_key(entity_id, disk_type, path);
let state = {
let writes = self.chunked_writes.read().await;
writes.get(&key).cloned()
};
let Some(state) = state else {
return Ok(ResumeVerificationResult {
can_resume: false,
transfer_state: None,
verified_chunks: 0,
total_chunks: 0,
verified_bytes: 0,
failure_reason: Some("No active transfer found".to_string()),
file_modified: false,
verified_hash: None,
});
};
let file_path = self.get_file_path(entity_id, disk_type, path)?;
if !file_path.exists() {
let total_chunks = state.total_chunks();
return Ok(ResumeVerificationResult {
can_resume: false,
transfer_state: Some(state),
verified_chunks: 0,
total_chunks,
verified_bytes: 0,
failure_reason: Some("Partial file not found".to_string()),
file_modified: false,
verified_hash: None,
});
}
let metadata = tokio::fs::metadata(&file_path).await?;
let file_size = metadata.len();
let file_modified = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.map(|mtime| mtime > state.last_updated)
.unwrap_or(false);
let size_matches = file_size == state.total_size || file_size == state.bytes_written;
if !size_matches {
return Ok(ResumeVerificationResult {
can_resume: false,
transfer_state: Some(state.clone()),
verified_chunks: 0,
total_chunks: state.total_chunks(),
verified_bytes: 0,
failure_reason: Some(format!(
"File size mismatch: expected {} or {} bytes, found {}",
state.total_size, state.bytes_written, file_size
)),
file_modified,
verified_hash: None,
});
}
if !verify_hashes {
let total_chunks = state.total_chunks();
let verified_chunks = state.chunks_completed();
return Ok(ResumeVerificationResult {
can_resume: !file_modified,
transfer_state: Some(state.clone()),
verified_chunks,
total_chunks,
verified_bytes: state.bytes_written,
failure_reason: if file_modified {
Some("File was modified externally since last chunk write".to_string())
} else {
None
},
file_modified,
verified_hash: None,
});
}
let verification = self
.verify_written_chunks(entity_id, disk_type, path)
.await?;
let verified_chunks = verification.iter().filter(|v| v.is_valid).count() as u64;
let verified_bytes = verification
.iter()
.filter(|v| v.is_valid)
.map(|v| v.size)
.sum();
let all_valid = verification.iter().all(|v| v.is_valid);
let verified_hash = if all_valid && state.bytes_written > 0 {
let mut file = tokio::fs::File::open(&file_path).await?;
let mut data = vec![0u8; state.bytes_written as usize];
file.read_exact(&mut data).await?;
Some(blake3::hash(&data).to_string())
} else {
None
};
let failure_reason = if !all_valid {
Some("Some chunks failed hash verification".to_string())
} else if file_modified {
Some("File was modified externally since last chunk write".to_string())
} else {
None
};
Ok(ResumeVerificationResult {
can_resume: all_valid && !file_modified,
transfer_state: Some(state.clone()),
verified_chunks,
total_chunks: state.total_chunks(),
verified_bytes,
failure_reason,
file_modified,
verified_hash,
})
}
pub async fn verify_written_chunks(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
) -> Result<Vec<ChunkVerificationResult>> {
let key = Self::index_key(entity_id, disk_type, path);
let state = {
let writes = self.chunked_writes.read().await;
writes.get(&key).cloned().ok_or_else(|| {
anyhow::anyhow!("No active transfer for {}:{}{}", entity_id, disk_type, path)
})?
};
let file_path = self.get_file_path(entity_id, disk_type, path)?;
if !file_path.exists() {
bail!("Partial file not found: {}", file_path.display());
}
let mut file = tokio::fs::File::open(&file_path).await?;
let mut results = Vec::new();
let chunks_written = state.chunks_completed();
for chunk_index in 0..chunks_written {
let offset = chunk_index * state.chunk_size;
let remaining = state.bytes_written.saturating_sub(offset);
let chunk_size = remaining.min(state.chunk_size);
let mut buffer = vec![0u8; chunk_size as usize];
file.seek(SeekFrom::Start(offset)).await?;
file.read_exact(&mut buffer).await?;
let actual_hash = blake3::hash(&buffer).to_string();
results.push(ChunkVerificationResult {
chunk_index,
is_valid: true, expected_hash: None,
actual_hash,
offset,
size: chunk_size,
});
}
Ok(results)
}
pub async fn resume_chunked_write(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
verify_hashes: bool,
) -> Result<ResumeVerificationResult> {
let verification = self
.verify_resume(entity_id, disk_type, path, verify_hashes)
.await?;
if !verification.can_resume {
return Ok(verification);
}
let key = Self::index_key(entity_id, disk_type, path);
{
let mut writes = self.chunked_writes.write().await;
if let Some(state) = writes.get_mut(&key) {
state.last_updated = chrono::Utc::now().timestamp();
}
}
self.save_transfer_states().await?;
debug!(
"Resumed chunked write for {}:{}{} at offset {}",
entity_id, disk_type, path, verification.verified_bytes
);
Ok(verification)
}
pub async fn get_resume_capability(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
) -> ResumeCapability {
let verification = match self.verify_resume(entity_id, disk_type, path, false).await {
Ok(v) => v,
Err(_) => return ResumeCapability::None,
};
if !verification.can_resume {
return ResumeCapability::None;
}
if verification.file_modified {
return ResumeCapability::Partial;
}
ResumeCapability::Full
}
pub async fn list_files(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
) -> Result<Vec<FileInfo>> {
let dir_path = if path.is_empty() || path == "/" {
self.get_entity_disk_path(entity_id, disk_type)?
} else {
self.get_file_path(entity_id, disk_type, path)?
};
if !dir_path.exists() {
tokio::fs::create_dir_all(&dir_path)
.await
.with_context(|| format!("Failed to create directory: {}", dir_path.display()))?;
}
if !dir_path.is_dir() {
bail!("Path is not a directory: {}", path);
}
let mut entries = Vec::new();
let mut read_dir = tokio::fs::read_dir(&dir_path)
.await
.with_context(|| format!("Failed to read directory: {}", dir_path.display()))?;
while let Some(entry) = read_dir.next_entry().await? {
let entry_path = entry.path();
let metadata = entry.metadata().await?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') || name == "disk_index.json" {
continue;
}
let entry_relative_path = if path.is_empty() || path == "/" {
format!("/{}", name)
} else {
format!("{}/{}", path.trim_end_matches('/'), name)
};
let (content_hash, size_bytes) = if metadata.is_file() {
let data = tokio::fs::read(&entry_path).await?;
(blake3::hash(&data).to_string(), data.len() as u64)
} else {
(String::new(), 0)
};
let modified_at = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
entries.push(FileInfo {
path: entry_relative_path,
name,
is_directory: metadata.is_dir(),
size_bytes,
modified_at,
content_hash,
});
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
debug!(
"Listed {}:{}{} - {} entries",
entity_id,
disk_type,
path,
entries.len()
);
Ok(entries)
}
pub async fn delete_file(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
) -> Result<()> {
if path.is_empty() || path == "/" {
bail!("Cannot delete root directory");
}
let file_path = self.get_file_path(entity_id, disk_type, path)?;
if !file_path.exists() {
bail!("File not found: {}:{}{}", entity_id, disk_type, path);
}
if file_path.is_dir() {
tokio::fs::remove_dir_all(&file_path)
.await
.with_context(|| format!("Failed to delete directory: {}", file_path.display()))?;
} else {
tokio::fs::remove_file(&file_path)
.await
.with_context(|| format!("Failed to delete file: {}", file_path.display()))?;
}
{
let key = Self::index_key(entity_id, disk_type, path);
let mut index = self.index.write().await;
index.remove(&key);
}
self.save_index().await?;
debug!("Deleted {}:{}{}", entity_id, disk_type, path);
Ok(())
}
pub async fn get_stats(&self, entity_id: &str, disk_type: DiskType) -> Result<DiskStats> {
let disk_path = self.get_entity_disk_path(entity_id, disk_type)?;
let mut used_bytes: u64 = 0;
let mut file_count: u32 = 0;
let mut dir_count: u32 = 0;
let mut last_modified: i64 = 0;
if disk_path.exists() {
self.calculate_stats_recursive(
&disk_path,
&mut used_bytes,
&mut file_count,
&mut dir_count,
&mut last_modified,
)
.await?;
}
Ok(DiskStats {
entity_id: entity_id.to_string(),
disk_type,
used_bytes,
file_count,
dir_count,
last_modified,
})
}
async fn calculate_stats_recursive(
&self,
path: &Path,
used_bytes: &mut u64,
file_count: &mut u32,
dir_count: &mut u32,
last_modified: &mut i64,
) -> Result<()> {
let mut read_dir = tokio::fs::read_dir(path).await?;
while let Some(entry) = read_dir.next_entry().await? {
let entry_path = entry.path();
let metadata = entry.metadata().await?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') || name == "disk_index.json" {
continue;
}
let modified = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
if modified > *last_modified {
*last_modified = modified;
}
if metadata.is_dir() {
*dir_count += 1;
Box::pin(self.calculate_stats_recursive(
&entry_path,
used_bytes,
file_count,
dir_count,
last_modified,
))
.await?;
} else {
*file_count += 1;
*used_bytes += metadata.len();
}
}
Ok(())
}
pub async fn create_directory(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
) -> Result<FileInfo> {
if path.is_empty() || path == "/" {
bail!("Cannot create root directory");
}
let dir_path = self.get_file_path(entity_id, disk_type, path)?;
tokio::fs::create_dir_all(&dir_path)
.await
.with_context(|| format!("Failed to create directory: {}", dir_path.display()))?;
let name = Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string());
let now = chrono::Utc::now().timestamp();
let metadata = DiskFileMetadata {
entity_id: entity_id.to_string(),
disk_type,
path: path.to_string(),
name: name.clone(),
is_directory: true,
size_bytes: 0,
modified_at: now,
content_hash: String::new(),
file_path: dir_path,
};
{
let key = Self::index_key(entity_id, disk_type, path);
let mut index = self.index.write().await;
index.insert(key, metadata);
}
self.save_index().await?;
debug!("Created directory {}:{}{}", entity_id, disk_type, path);
Ok(FileInfo {
path: path.to_string(),
name,
is_directory: true,
size_bytes: 0,
modified_at: now,
content_hash: String::new(),
})
}
pub async fn file_exists(&self, entity_id: &str, disk_type: DiskType, path: &str) -> bool {
match self.get_file_path(entity_id, disk_type, path) {
Ok(file_path) => file_path.exists(),
Err(_) => false, }
}
pub async fn get_file_info(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
) -> Result<FileInfo> {
let file_path = self.get_file_path(entity_id, disk_type, path)?;
if !file_path.exists() {
bail!("File not found: {}:{}{}", entity_id, disk_type, path);
}
let metadata = tokio::fs::metadata(&file_path).await?;
let is_directory = metadata.is_dir();
let name = Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string());
let modified_at = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let (size_bytes, content_hash) = if is_directory {
(0, String::new())
} else {
let data = tokio::fs::read(&file_path).await?;
(data.len() as u64, blake3::hash(&data).to_string())
};
Ok(FileInfo {
path: path.to_string(),
name,
is_directory,
size_bytes,
modified_at,
content_hash,
})
}
async fn load_index(&self) -> Result<()> {
let index_path = self.root.join("disk_index.json");
if !index_path.exists() {
debug!("No existing disk index found, starting fresh");
return Ok(());
}
let data = tokio::fs::read_to_string(&index_path)
.await
.with_context(|| format!("Failed to read disk index at {}", index_path.display()))?;
let stored_index: HashMap<String, DiskFileMetadata> = serde_json::from_str(&data)
.with_context(|| {
format!(
"Failed to parse disk index at {} - file may be corrupt",
index_path.display()
)
})?;
let count = stored_index.len();
let mut index = self.index.write().await;
*index = stored_index;
info!("Loaded {} entries from disk index", count);
Ok(())
}
async fn save_index(&self) -> Result<()> {
let index_path = self.root.join("disk_index.json");
let temp_path = self.root.join(".disk_index.tmp");
let data = {
let index = self.index.read().await;
serde_json::to_string_pretty(&*index).context("Failed to serialize disk index")?
};
tokio::fs::write(&temp_path, &data)
.await
.context("Failed to write temp index file")?;
tokio::fs::rename(&temp_path, &index_path)
.await
.context("Failed to move index file")?;
Ok(())
}
async fn load_transfer_states(&self) -> Result<()> {
let states_path = self.root.join("transfer_states.json");
if !states_path.exists() {
debug!("No existing transfer states found");
return Ok(());
}
let data = tokio::fs::read_to_string(&states_path)
.await
.with_context(|| {
format!(
"Failed to read transfer states at {}",
states_path.display()
)
})?;
let stored_states: HashMap<String, TransferState> = serde_json::from_str(&data)
.with_context(|| {
format!(
"Failed to parse transfer states at {} - file may be corrupt",
states_path.display()
)
})?;
let count = stored_states.len();
let mut states = self.chunked_writes.write().await;
*states = stored_states;
info!("Loaded {} active transfer states", count);
Ok(())
}
async fn save_transfer_states(&self) -> Result<()> {
let states_path = self.root.join("transfer_states.json");
let temp_path = self.root.join(".transfer_states.tmp");
let data = {
let states = self.chunked_writes.read().await;
serde_json::to_string_pretty(&*states).context("Failed to serialize transfer states")?
};
tokio::fs::write(&temp_path, &data)
.await
.context("Failed to write temp transfer states file")?;
tokio::fs::rename(&temp_path, &states_path)
.await
.context("Failed to move transfer states file")?;
Ok(())
}
pub async fn list_active_transfers(&self) -> Vec<TransferState> {
let states = self.chunked_writes.read().await;
states.values().cloned().collect()
}
pub async fn get_transfer_state(&self, transfer_id: &str) -> Option<TransferState> {
let states = self.chunked_writes.read().await;
states.get(transfer_id).cloned()
}
pub async fn get_transfer_state_by_path(
&self,
entity_id: &str,
disk_type: DiskType,
path: &str,
) -> Option<TransferState> {
let transfer_id = Self::index_key(entity_id, disk_type, path);
self.get_transfer_state(&transfer_id).await
}
pub async fn cleanup_stale_transfers(&self) -> Result<usize> {
let now = chrono::Utc::now().timestamp();
let threshold = now - STALE_TRANSFER_THRESHOLD_SECS;
let stale_transfers: Vec<TransferState> = {
let states = self.chunked_writes.read().await;
states
.values()
.filter(|s| s.last_updated < threshold)
.cloned()
.collect()
};
let count = stale_transfers.len();
for transfer in &stale_transfers {
if let Ok(file_path) =
self.get_file_path(&transfer.entity_id, transfer.disk_type, &transfer.path)
&& file_path.exists()
{
match tokio::fs::remove_file(&file_path).await {
Ok(()) => {}
Err(e) => {
warn!(
"Failed to delete partial file {} during stale cleanup: {}",
file_path.display(),
e
);
}
}
}
{
let mut states = self.chunked_writes.write().await;
states.remove(&transfer.transfer_id);
}
debug!(
"Cleaned up stale transfer {} (last updated: {})",
transfer.transfer_id, transfer.last_updated
);
}
if count > 0 {
self.save_transfer_states().await?;
info!("Cleaned up {} stale transfers", count);
}
Ok(count)
}
pub async fn move_file(
&self,
entity_id: &str,
disk_type: DiskType,
source_path: &str,
dest_path: &str,
) -> Result<FileInfo> {
if source_path.is_empty() || source_path == "/" {
bail!("Cannot move root directory");
}
if dest_path.is_empty() || dest_path == "/" {
bail!("Cannot move to root directory");
}
let source_fs_path = self.get_file_path(entity_id, disk_type, source_path)?;
let dest_fs_path = self.get_file_path(entity_id, disk_type, dest_path)?;
if !source_fs_path.exists() {
bail!(
"Source not found: {}:{}{}",
entity_id,
disk_type,
source_path
);
}
if let Some(parent) = dest_fs_path.parent() {
tokio::fs::create_dir_all(parent).await.with_context(|| {
format!(
"Failed to create parent directories for {}",
dest_fs_path.display()
)
})?;
}
tokio::fs::rename(&source_fs_path, &dest_fs_path)
.await
.with_context(|| {
format!(
"Failed to move {} to {}",
source_fs_path.display(),
dest_fs_path.display()
)
})?;
let is_directory = dest_fs_path.is_dir();
let name = std::path::Path::new(dest_path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| dest_path.to_string());
let now = chrono::Utc::now().timestamp();
let (size_bytes, content_hash) = if is_directory {
(0, String::new())
} else {
let data = tokio::fs::read(&dest_fs_path).await?;
(data.len() as u64, blake3::hash(&data).to_string())
};
{
let old_key = Self::index_key(entity_id, disk_type, source_path);
let mut index = self.index.write().await;
index.remove(&old_key);
}
let metadata = DiskFileMetadata {
entity_id: entity_id.to_string(),
disk_type,
path: dest_path.to_string(),
name: name.clone(),
is_directory,
size_bytes,
modified_at: now,
content_hash: content_hash.clone(),
file_path: dest_fs_path,
};
{
let new_key = Self::index_key(entity_id, disk_type, dest_path);
let mut index = self.index.write().await;
index.insert(new_key, metadata);
}
self.save_index().await?;
debug!(
"Moved {}:{}{} to {}",
entity_id, disk_type, source_path, dest_path
);
Ok(FileInfo {
path: dest_path.to_string(),
name,
is_directory,
size_bytes,
modified_at: now,
content_hash,
})
}
pub async fn copy_file(
&self,
entity_id: &str,
disk_type: DiskType,
source_path: &str,
dest_path: &str,
) -> Result<FileInfo> {
if source_path.is_empty() || source_path == "/" {
bail!("Cannot copy root directory");
}
if dest_path.is_empty() || dest_path == "/" {
bail!("Cannot copy to root directory");
}
let source_fs_path = self.get_file_path(entity_id, disk_type, source_path)?;
let dest_fs_path = self.get_file_path(entity_id, disk_type, dest_path)?;
if !source_fs_path.exists() {
bail!(
"Source not found: {}:{}{}",
entity_id,
disk_type,
source_path
);
}
if let Some(parent) = dest_fs_path.parent() {
tokio::fs::create_dir_all(parent).await.with_context(|| {
format!(
"Failed to create parent directories for {}",
dest_fs_path.display()
)
})?;
}
let is_directory = source_fs_path.is_dir();
if is_directory {
Self::copy_dir_recursive(&source_fs_path, &dest_fs_path).await?;
} else {
tokio::fs::copy(&source_fs_path, &dest_fs_path)
.await
.with_context(|| {
format!(
"Failed to copy {} to {}",
source_fs_path.display(),
dest_fs_path.display()
)
})?;
}
let name = std::path::Path::new(dest_path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| dest_path.to_string());
let now = chrono::Utc::now().timestamp();
let (size_bytes, content_hash) = if is_directory {
(0, String::new())
} else {
let data = tokio::fs::read(&dest_fs_path).await?;
(data.len() as u64, blake3::hash(&data).to_string())
};
let metadata = DiskFileMetadata {
entity_id: entity_id.to_string(),
disk_type,
path: dest_path.to_string(),
name: name.clone(),
is_directory,
size_bytes,
modified_at: now,
content_hash: content_hash.clone(),
file_path: dest_fs_path,
};
{
let new_key = Self::index_key(entity_id, disk_type, dest_path);
let mut index = self.index.write().await;
index.insert(new_key, metadata);
}
self.save_index().await?;
debug!(
"Copied {}:{}{} to {}",
entity_id, disk_type, source_path, dest_path
);
Ok(FileInfo {
path: dest_path.to_string(),
name,
is_directory,
size_bytes,
modified_at: now,
content_hash,
})
}
async fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
tokio::fs::create_dir_all(dst).await?;
let mut entries = tokio::fs::read_dir(src).await?;
while let Some(entry) = entries.next_entry().await? {
let entry_path = entry.path();
let dest_path = dst.join(entry.file_name());
if entry_path.is_dir() {
Box::pin(Self::copy_dir_recursive(&entry_path, &dest_path)).await?;
} else {
tokio::fs::copy(&entry_path, &dest_path).await?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn test_write_and_read_file() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
let data = b"Hello, World!";
let info = service
.write_file(entity_id, DiskType::Private, "/docs/test.txt", data)
.await
.unwrap();
assert_eq!(info.name, "test.txt");
assert_eq!(info.size_bytes, 13);
assert!(!info.is_directory);
let read_data = service
.read_file(entity_id, DiskType::Private, "/docs/test.txt")
.await
.unwrap();
assert_eq!(read_data, data);
}
#[tokio::test]
async fn test_list_files() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
service
.write_file(entity_id, DiskType::Public, "/file1.txt", b"one")
.await
.unwrap();
service
.write_file(entity_id, DiskType::Public, "/file2.txt", b"two")
.await
.unwrap();
let files = service
.list_files(entity_id, DiskType::Public, "/")
.await
.unwrap();
assert_eq!(files.len(), 2);
}
#[tokio::test]
async fn test_delete_file() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
service
.write_file(entity_id, DiskType::Shared, "/to_delete.txt", b"delete me")
.await
.unwrap();
assert!(
service
.file_exists(entity_id, DiskType::Shared, "/to_delete.txt")
.await
);
service
.delete_file(entity_id, DiskType::Shared, "/to_delete.txt")
.await
.unwrap();
assert!(
!service
.file_exists(entity_id, DiskType::Shared, "/to_delete.txt")
.await
);
}
#[tokio::test]
async fn test_get_stats() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
service
.write_file(entity_id, DiskType::Private, "/file1.txt", b"hello")
.await
.unwrap();
service
.write_file(entity_id, DiskType::Private, "/dir/file2.txt", b"world")
.await
.unwrap();
let stats = service
.get_stats(entity_id, DiskType::Private)
.await
.unwrap();
assert_eq!(stats.file_count, 2);
assert_eq!(stats.used_bytes, 10); assert_eq!(stats.dir_count, 1); }
#[tokio::test]
async fn test_create_directory() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
let info = service
.create_directory(entity_id, DiskType::Public, "/my-folder")
.await
.unwrap();
assert_eq!(info.name, "my-folder");
assert!(info.is_directory);
assert!(
service
.file_exists(entity_id, DiskType::Public, "/my-folder")
.await
);
}
#[tokio::test]
async fn test_disk_types() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
service
.write_file(entity_id, DiskType::Private, "/private.txt", b"private")
.await
.unwrap();
service
.write_file(entity_id, DiskType::Public, "/public.txt", b"public")
.await
.unwrap();
service
.write_file(entity_id, DiskType::Shared, "/shared.txt", b"shared")
.await
.unwrap();
assert!(
service
.file_exists(entity_id, DiskType::Private, "/private.txt")
.await
);
assert!(
!service
.file_exists(entity_id, DiskType::Public, "/private.txt")
.await
);
assert!(
!service
.file_exists(entity_id, DiskType::Shared, "/private.txt")
.await
);
}
#[tokio::test]
async fn test_read_chunk() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
let data: Vec<u8> = (0..3000).map(|i| (i % 256) as u8).collect();
service
.write_file(entity_id, DiskType::Private, "/large.bin", &data)
.await
.unwrap();
let chunk0 = service
.read_chunk(entity_id, DiskType::Private, "/large.bin", 0, Some(1000))
.await
.unwrap();
assert_eq!(chunk0.data.len(), 1000);
assert_eq!(chunk0.info.offset, 0);
assert_eq!(chunk0.info.size, 1000);
assert_eq!(chunk0.info.total_size, 3000);
assert_eq!(chunk0.info.chunk_index, 0);
assert!(!chunk0.is_last);
let chunk1 = service
.read_chunk(entity_id, DiskType::Private, "/large.bin", 1000, Some(1000))
.await
.unwrap();
assert_eq!(chunk1.data.len(), 1000);
assert_eq!(chunk1.info.offset, 1000);
assert_eq!(chunk1.info.chunk_index, 1);
assert!(!chunk1.is_last);
let chunk2 = service
.read_chunk(entity_id, DiskType::Private, "/large.bin", 2000, Some(1000))
.await
.unwrap();
assert_eq!(chunk2.data.len(), 1000);
assert_eq!(chunk2.info.offset, 2000);
assert_eq!(chunk2.info.chunk_index, 2);
assert!(chunk2.is_last);
assert_eq!(&chunk0.data[..], &data[0..1000]);
assert_eq!(&chunk1.data[..], &data[1000..2000]);
assert_eq!(&chunk2.data[..], &data[2000..3000]);
}
#[tokio::test]
async fn test_chunked_write_workflow() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
let path = "/chunked_file.bin";
let total_size: u64 = 2500;
let init_info = service
.start_chunked_write(entity_id, DiskType::Private, path, total_size, None)
.await
.unwrap();
assert_eq!(init_info.total_size, total_size);
assert!(
service
.has_active_chunked_write(entity_id, DiskType::Private, path)
.await
);
let chunk0: Vec<u8> = (0..1000).map(|i| (i % 256) as u8).collect();
let result0 = service
.write_chunk(entity_id, DiskType::Private, path, 0, &chunk0)
.await
.unwrap();
assert_eq!(result0.info.offset, 0);
assert_eq!(result0.info.size, 1000);
assert!(!result0.is_complete);
let progress = service
.get_chunked_write_progress(entity_id, DiskType::Private, path)
.await;
assert!(progress.is_some());
let (written, total) = progress.unwrap();
assert_eq!(written, 1000);
assert_eq!(total, total_size);
let chunk1: Vec<u8> = (0..1000).map(|i| ((i + 50) % 256) as u8).collect();
let result1 = service
.write_chunk(entity_id, DiskType::Private, path, 1000, &chunk1)
.await
.unwrap();
assert_eq!(result1.info.offset, 1000);
assert!(!result1.is_complete);
let chunk2: Vec<u8> = (0..500).map(|i| ((i + 100) % 256) as u8).collect();
let result2 = service
.write_chunk(entity_id, DiskType::Private, path, 2000, &chunk2)
.await
.unwrap();
assert_eq!(result2.info.offset, 2000);
assert!(result2.is_complete);
let file_info = service
.finish_chunked_write(entity_id, DiskType::Private, path)
.await
.unwrap();
assert_eq!(file_info.name, "chunked_file.bin");
assert_eq!(file_info.size_bytes, total_size);
assert!(
!service
.has_active_chunked_write(entity_id, DiskType::Private, path)
.await
);
let read_data = service
.read_file(entity_id, DiskType::Private, path)
.await
.unwrap();
assert_eq!(read_data.len(), total_size as usize);
assert_eq!(&read_data[0..1000], &chunk0[..]);
assert_eq!(&read_data[1000..2000], &chunk1[..]);
assert_eq!(&read_data[2000..2500], &chunk2[..]);
}
#[tokio::test]
async fn test_abort_chunked_write() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
let path = "/aborted.bin";
service
.start_chunked_write(entity_id, DiskType::Private, path, 5000, None)
.await
.unwrap();
let chunk: Vec<u8> = vec![0u8; 1000];
service
.write_chunk(entity_id, DiskType::Private, path, 0, &chunk)
.await
.unwrap();
assert!(
service
.has_active_chunked_write(entity_id, DiskType::Private, path)
.await
);
service
.abort_chunked_write(entity_id, DiskType::Private, path)
.await
.unwrap();
assert!(
!service
.has_active_chunked_write(entity_id, DiskType::Private, path)
.await
);
assert!(
!service
.file_exists(entity_id, DiskType::Private, path)
.await
);
}
#[tokio::test]
async fn test_chunked_write_offset_mismatch() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
let path = "/mismatch.bin";
service
.start_chunked_write(entity_id, DiskType::Private, path, 3000, None)
.await
.unwrap();
let chunk: Vec<u8> = vec![0u8; 1000];
service
.write_chunk(entity_id, DiskType::Private, path, 0, &chunk)
.await
.unwrap();
let result = service
.write_chunk(entity_id, DiskType::Private, path, 500, &chunk)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Expected offset"));
service
.abort_chunked_write(entity_id, DiskType::Private, path)
.await
.unwrap();
}
#[tokio::test]
async fn test_chunked_write_size_exceeded() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
let path = "/exceeded.bin";
service
.start_chunked_write(entity_id, DiskType::Private, path, 500, None)
.await
.unwrap();
let chunk: Vec<u8> = vec![0u8; 1000];
let result = service
.write_chunk(entity_id, DiskType::Private, path, 0, &chunk)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string()
.contains("Write would exceed declared file size")
);
service
.abort_chunked_write(entity_id, DiskType::Private, path)
.await
.unwrap();
}
#[tokio::test]
async fn test_calculate_chunk_count() {
assert_eq!(
EntityDiskService::calculate_chunk_count(3000, Some(1000)),
3
);
assert_eq!(
EntityDiskService::calculate_chunk_count(3001, Some(1000)),
4
);
assert_eq!(EntityDiskService::calculate_chunk_count(500, Some(1000)), 1);
assert_eq!(EntityDiskService::calculate_chunk_count(0, Some(1000)), 0);
assert_eq!(
EntityDiskService::calculate_chunk_count(2 * 1024 * 1024, None),
2
);
}
#[tokio::test]
async fn test_list_active_transfers() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
let transfers = service.list_active_transfers().await;
assert!(transfers.is_empty());
service
.start_chunked_write(entity_id, DiskType::Private, "/file1.bin", 1000, None)
.await
.unwrap();
let transfers = service.list_active_transfers().await;
assert_eq!(transfers.len(), 1);
assert_eq!(transfers[0].entity_id, entity_id);
assert_eq!(transfers[0].path, "/file1.bin");
service
.start_chunked_write(entity_id, DiskType::Public, "/file2.bin", 2000, None)
.await
.unwrap();
let transfers = service.list_active_transfers().await;
assert_eq!(transfers.len(), 2);
service
.abort_chunked_write(entity_id, DiskType::Private, "/file1.bin")
.await
.unwrap();
let transfers = service.list_active_transfers().await;
assert_eq!(transfers.len(), 1);
assert_eq!(transfers[0].path, "/file2.bin");
service
.abort_chunked_write(entity_id, DiskType::Public, "/file2.bin")
.await
.unwrap();
}
#[tokio::test]
async fn test_get_transfer_state() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
let path = "/transfer_state.bin";
let state = service
.get_transfer_state_by_path(entity_id, DiskType::Private, path)
.await;
assert!(state.is_none());
service
.start_chunked_write(entity_id, DiskType::Private, path, 2500, Some(1000))
.await
.unwrap();
let state = service
.get_transfer_state_by_path(entity_id, DiskType::Private, path)
.await;
assert!(state.is_some());
let state = state.unwrap();
assert_eq!(state.total_size, 2500);
assert_eq!(state.bytes_written, 0);
assert_eq!(state.chunk_size, 1000);
assert!(!state.transfer_id.is_empty());
assert!(state.started_at > 0);
let transfer_id = state.transfer_id.clone();
let state2 = service.get_transfer_state(&transfer_id).await;
assert!(state2.is_some());
assert_eq!(state2.unwrap().path, path);
let chunk: Vec<u8> = (0..1000).map(|i| (i % 256) as u8).collect();
service
.write_chunk(entity_id, DiskType::Private, path, 0, &chunk)
.await
.unwrap();
let state = service
.get_transfer_state_by_path(entity_id, DiskType::Private, path)
.await
.unwrap();
assert_eq!(state.bytes_written, 1000);
assert!(!state.cumulative_hash.is_empty());
service
.abort_chunked_write(entity_id, DiskType::Private, path)
.await
.unwrap();
}
#[tokio::test]
async fn test_transfer_state_persistence() {
let temp = tempdir().unwrap();
let root_path = temp.path().to_owned();
{
let service = EntityDiskService::new(&root_path).await.unwrap();
let entity_id = "test-entity-one-two";
let path = "/persistent.bin";
service
.start_chunked_write(entity_id, DiskType::Private, path, 3000, Some(1000))
.await
.unwrap();
let chunk: Vec<u8> = vec![42u8; 1000];
service
.write_chunk(entity_id, DiskType::Private, path, 0, &chunk)
.await
.unwrap();
let states_path = root_path.join("transfer_states.json");
assert!(states_path.exists());
}
{
let service = EntityDiskService::new(&root_path).await.unwrap();
let transfers = service.list_active_transfers().await;
assert_eq!(transfers.len(), 1);
let state = transfers.first().unwrap();
assert_eq!(state.entity_id, "test-entity-one-two");
assert_eq!(state.path, "/persistent.bin");
assert_eq!(state.total_size, 3000);
assert_eq!(state.bytes_written, 1000);
assert_eq!(state.chunk_size, 1000);
service
.abort_chunked_write(&state.entity_id, state.disk_type, &state.path)
.await
.unwrap();
}
}
#[tokio::test]
async fn test_cleanup_stale_transfers() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
let path = "/stale.bin";
service
.start_chunked_write(entity_id, DiskType::Private, path, 1000, None)
.await
.unwrap();
let cleaned = service.cleanup_stale_transfers().await.unwrap();
assert_eq!(cleaned, 0);
let transfers = service.list_active_transfers().await;
assert_eq!(transfers.len(), 1);
{
let mut states = service.chunked_writes.write().await;
let key = EntityDiskService::index_key(entity_id, DiskType::Private, path);
if let Some(state) = states.get_mut(&key) {
state.last_updated =
chrono::Utc::now().timestamp() - STALE_TRANSFER_THRESHOLD_SECS - 1;
}
}
let cleaned = service.cleanup_stale_transfers().await.unwrap();
assert_eq!(cleaned, 1);
let transfers = service.list_active_transfers().await;
assert!(transfers.is_empty());
}
#[tokio::test]
async fn test_verify_resume_no_transfer() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
let path = "/nonexistent.bin";
let result = service
.verify_resume(entity_id, DiskType::Private, path, false)
.await
.unwrap();
assert!(!result.can_resume);
assert!(result.transfer_state.is_none());
assert_eq!(result.verified_chunks, 0);
assert_eq!(result.total_chunks, 0);
assert!(
result
.failure_reason
.unwrap()
.contains("No active transfer")
);
}
#[tokio::test]
async fn test_verify_resume_missing_file() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
let path = "/missing_file.bin";
service
.start_chunked_write(entity_id, DiskType::Private, path, 3000, Some(1000))
.await
.unwrap();
let chunk = vec![42u8; 1000];
service
.write_chunk(entity_id, DiskType::Private, path, 0, &chunk)
.await
.unwrap();
let file_path = service
.get_file_path(entity_id, DiskType::Private, path)
.unwrap();
tokio::fs::remove_file(&file_path).await.unwrap();
let result = service
.verify_resume(entity_id, DiskType::Private, path, false)
.await
.unwrap();
assert!(!result.can_resume);
assert!(result.transfer_state.is_some());
assert!(
result
.failure_reason
.unwrap()
.contains("Partial file not found")
);
service
.abort_chunked_write(entity_id, DiskType::Private, path)
.await
.unwrap();
}
#[tokio::test]
async fn test_resume_chunked_write() {
let temp = tempdir().unwrap();
let root_path = temp.path().to_owned();
{
let service = EntityDiskService::new(&root_path).await.unwrap();
let entity_id = "test-entity-one-two";
let path = "/resume_test.bin";
service
.start_chunked_write(entity_id, DiskType::Private, path, 3000, Some(1000))
.await
.unwrap();
let chunk1 = vec![1u8; 1000];
service
.write_chunk(entity_id, DiskType::Private, path, 0, &chunk1)
.await
.unwrap();
}
{
let service = EntityDiskService::new(&root_path).await.unwrap();
let entity_id = "test-entity-one-two";
let path = "/resume_test.bin";
let result = service
.resume_chunked_write(entity_id, DiskType::Private, path, false)
.await
.unwrap();
assert!(result.can_resume);
assert_eq!(result.verified_bytes, 1000);
assert_eq!(result.verified_chunks, 1);
assert!(result.transfer_state.is_some());
let state = result.transfer_state.unwrap();
assert_eq!(state.bytes_written, 1000);
let chunk2 = vec![2u8; 1000];
service
.write_chunk(entity_id, DiskType::Private, path, 1000, &chunk2)
.await
.unwrap();
let chunk3 = vec![3u8; 1000];
service
.write_chunk(entity_id, DiskType::Private, path, 2000, &chunk3)
.await
.unwrap();
let file_info = service
.finish_chunked_write(entity_id, DiskType::Private, path)
.await
.unwrap();
assert_eq!(file_info.size_bytes, 3000);
assert!(!file_info.content_hash.is_empty());
}
}
#[tokio::test]
async fn test_resume_capability() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
let path = "/capability.bin";
let capability = service
.get_resume_capability(entity_id, DiskType::Private, path)
.await;
assert_eq!(capability, ResumeCapability::None);
service
.start_chunked_write(entity_id, DiskType::Private, path, 2000, Some(1000))
.await
.unwrap();
let chunk = vec![42u8; 1000];
service
.write_chunk(entity_id, DiskType::Private, path, 0, &chunk)
.await
.unwrap();
let capability = service
.get_resume_capability(entity_id, DiskType::Private, path)
.await;
assert_eq!(capability, ResumeCapability::Full);
service
.abort_chunked_write(entity_id, DiskType::Private, path)
.await
.unwrap();
}
#[tokio::test]
async fn test_verify_written_chunks() {
let temp = tempdir().unwrap();
let service = EntityDiskService::new(temp.path()).await.unwrap();
let entity_id = "test-entity-one-two";
let path = "/verify_chunks.bin";
service
.start_chunked_write(entity_id, DiskType::Private, path, 3000, Some(1000))
.await
.unwrap();
let chunk1: Vec<u8> = (0..1000).map(|i| (i % 256) as u8).collect();
service
.write_chunk(entity_id, DiskType::Private, path, 0, &chunk1)
.await
.unwrap();
let chunk2: Vec<u8> = (100..1100).map(|i| (i % 256) as u8).collect();
service
.write_chunk(entity_id, DiskType::Private, path, 1000, &chunk2)
.await
.unwrap();
let results = service
.verify_written_chunks(entity_id, DiskType::Private, path)
.await
.unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0].chunk_index, 0);
assert!(results[0].is_valid);
assert_eq!(results[0].offset, 0);
assert_eq!(results[0].size, 1000);
let expected_hash1 = blake3::hash(&chunk1).to_string();
assert_eq!(results[0].actual_hash, expected_hash1);
assert_eq!(results[1].chunk_index, 1);
assert!(results[1].is_valid);
assert_eq!(results[1].offset, 1000);
assert_eq!(results[1].size, 1000);
let expected_hash2 = blake3::hash(&chunk2).to_string();
assert_eq!(results[1].actual_hash, expected_hash2);
service
.abort_chunked_write(entity_id, DiskType::Private, path)
.await
.unwrap();
}
}