use crate::backend::FileDownload;
use crate::error::CliError;
use loonfs_api::{ContentRef, RevisionNo};
use serde::{Deserialize, Serialize};
use std::io::{Read, Seek, Write};
use std::path::{Path, PathBuf};
const PARTIAL_SUFFIX: &str = ".loonfs-partial";
const META_SUFFIX: &str = ".loonfs-partial.meta";
const FOLD_CHUNK_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct PartialMeta {
content_id: String,
size_bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
whole_file_sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
revision_no: Option<u64>,
}
impl PartialMeta {
pub(super) fn describe(content_ref: &ContentRef, revision_no: Option<RevisionNo>) -> Self {
Self {
content_id: content_ref.content_id.to_string(),
size_bytes: content_ref.size_bytes,
whole_file_sha256: content_ref.whole_file_sha256.clone(),
revision_no: revision_no.map(|revision_no| revision_no.0),
}
}
}
pub(super) fn resumable_bytes(destination: &Path, meta: &PartialMeta) -> u64 {
let (Some(partial_path), Some(meta_path)) = (
sibling(destination, PARTIAL_SUFFIX),
sibling(destination, META_SUFFIX),
) else {
return 0;
};
let Ok(recorded) = std::fs::read(&meta_path) else {
return 0;
};
if serde_json::from_slice::<PartialMeta>(&recorded)
.ok()
.as_ref()
!= Some(meta)
{
return 0;
}
let Ok(metadata) = std::fs::metadata(&partial_path) else {
return 0;
};
if metadata.len() > meta.size_bytes {
return 0;
}
metadata.len()
}
pub(super) struct PartialDownload {
file: tempfile::NamedTempFile,
_meta: tempfile::TempPath,
path: PathBuf,
resumed_from: u64,
}
impl PartialDownload {
pub(super) fn open(
destination: &Path,
meta: Option<&PartialMeta>,
resume_from: u64,
) -> std::io::Result<Self> {
let (Some(path), Some(meta_path)) = (
sibling(destination, PARTIAL_SUFFIX),
sibling(destination, META_SUFFIX),
) else {
return Err(std::io::Error::other("destination has no file name"));
};
match meta {
Some(meta) => {
let encoded = serde_json::to_vec(meta).map_err(std::io::Error::other)?;
std::fs::write(&meta_path, encoded)?;
}
None => drop(std::fs::remove_file(&meta_path)),
}
let meta = tempfile::TempPath::try_from_path(&meta_path)?;
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
file.set_len(resume_from)?;
file.seek(std::io::SeekFrom::Start(resume_from))?;
Ok(Self {
file: tempfile::NamedTempFile::from_parts(
file,
tempfile::TempPath::try_from_path(&path)?,
),
_meta: meta,
path,
resumed_from: resume_from,
})
}
pub(super) fn fold_into(&self, download: &mut FileDownload) -> std::io::Result<()> {
if self.resumed_from == 0 {
return Ok(());
}
let mut reader = std::fs::File::open(&self.path)?;
let mut buffer = vec![0u8; FOLD_CHUNK_BYTES];
let mut remaining = self.resumed_from;
while remaining > 0 {
let wanted = buffer.len().min(remaining as usize);
reader.read_exact(&mut buffer[..wanted])?;
download.fold_resumed_prefix(&buffer[..wanted]);
remaining -= wanted as u64;
}
Ok(())
}
pub(super) fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
self.file.write_all(bytes)
}
pub(super) fn install(mut self, destination: &Path, force: bool) -> std::io::Result<()> {
self.file.flush()?;
let persisted = if force {
self.file.persist(destination)
} else {
self.file.persist_noclobber(destination)
};
persisted.map(|_| ()).map_err(|error| error.error)
}
}
fn sibling(destination: &Path, suffix: &str) -> Option<PathBuf> {
let file_name = destination.file_name()?;
let mut name = std::ffi::OsString::from(".");
name.push(file_name);
name.push(suffix);
Some(parent_of(destination).join(name))
}
pub(super) fn parent_of(destination: &Path) -> &Path {
destination
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
}
#[allow(dead_code)]
fn unreadable_note(error: serde_json::Error) -> CliError {
CliError::invalid_input(format!("unreadable partial-download note: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
use loonfs_api::{ContentId, ContentRef};
fn meta_for(bytes: &[u8]) -> PartialMeta {
PartialMeta::describe(&ContentRef::blob_v1(ContentId::generate(), bytes), None)
}
#[test]
fn a_matching_note_resumes_at_what_is_on_disk() {
let dir = tempfile::tempdir().expect("tempdir");
let destination = dir.path().join("file.bin");
let meta = meta_for(b"0123456789");
assert_eq!(
resumable_bytes(&destination, &meta),
0,
"nothing on disk resumes nothing"
);
let mut partial =
PartialDownload::open(&destination, Some(&meta), 0).expect("open partial");
partial.write_all(b"0123").expect("write");
drop(partial);
assert_eq!(
resumable_bytes(&destination, &meta),
0,
"a partial whose download reached a verdict is gone"
);
}
#[test]
fn a_note_that_does_not_match_starts_over() {
let dir = tempfile::tempdir().expect("tempdir");
let destination = dir.path().join("file.bin");
let meta = meta_for(b"0123456789");
let partial_path = sibling(&destination, PARTIAL_SUFFIX).expect("partial path");
let meta_path = sibling(&destination, META_SUFFIX).expect("meta path");
std::fs::write(&partial_path, b"0123").expect("write partial");
std::fs::write(
&meta_path,
serde_json::to_vec(&meta).expect("encode the note"),
)
.expect("write note");
assert_eq!(resumable_bytes(&destination, &meta), 4);
assert_eq!(resumable_bytes(&destination, &meta_for(b"9876543210")), 0);
std::fs::write(&partial_path, vec![0u8; 11]).expect("overlong partial");
assert_eq!(resumable_bytes(&destination, &meta), 0);
std::fs::write(&partial_path, b"0123").expect("write partial");
std::fs::write(&meta_path, b"{\"content_id\":").expect("write torn note");
assert_eq!(resumable_bytes(&destination, &meta), 0);
std::fs::remove_file(&meta_path).expect("remove note");
assert_eq!(resumable_bytes(&destination, &meta), 0);
}
}