1use std::ffi::OsString;
2use std::fs::{self, File, OpenOptions};
3use std::io::{Read, Seek, SeekFrom, Write};
4use std::path::{Path, PathBuf};
5
6use fs2::FileExt;
7
8use crate::chunk::{ChunkPlan, normalize_chunk_size};
9use crate::metadata::{PartMetadata, RemoteInfo, slot_size_for};
10use crate::{HashConfig, HashVerifier, Result, TakanawaError, hash_url};
11
12#[derive(Debug)]
14pub struct PartFile {
15 file: File,
16 lock_file: File,
17 lock_path: PathBuf,
18 part_path: PathBuf,
19 slot_size: u64,
20 active_slot: u8,
21 metadata: PartMetadata,
22}
23
24impl PartFile {
25 pub fn open_or_create(
36 target_path: &Path,
37 url: &str,
38 remote: &RemoteInfo,
39 chunk_size: u64,
40 hash: HashConfig,
41 ) -> Result<Self> {
42 if target_path.exists() {
43 return Err(TakanawaError::TargetExists(target_path.to_owned()));
44 }
45
46 let chunk_size = normalize_chunk_size(chunk_size)?;
47 let part_path = part_path_for(target_path);
48 let lock_path = part_lock_path_for(target_path);
49 let lock_file = acquire_lock(&lock_path)?;
50 let slot_size = slot_size_for(remote.content_len, chunk_size)?;
51 let expected_len = remote
52 .content_len
53 .checked_add(slot_size.checked_mul(2).ok_or_else(|| {
54 TakanawaError::InvalidConfig("part file length overflow".to_owned())
55 })?)
56 .ok_or_else(|| TakanawaError::InvalidConfig("part file length overflow".to_owned()))?;
57 let url_hash = hash_url(url);
58
59 if part_path.exists() {
60 let mut file = OpenOptions::new().read(true).write(true).open(&part_path)?;
61 let actual_len = file.metadata()?.len();
62 if actual_len != expected_len {
63 return Err(TakanawaError::PartSizeMismatch {
64 expected: expected_len,
65 actual: actual_len,
66 });
67 }
68
69 let (metadata, active_slot) =
70 read_best_metadata(&mut file, remote.content_len, slot_size)?;
71 metadata.ensure_compatible(url_hash, remote, chunk_size, hash)?;
72 return Ok(Self {
73 file,
74 lock_file,
75 lock_path,
76 part_path,
77 slot_size,
78 active_slot,
79 metadata,
80 });
81 }
82
83 let mut file = OpenOptions::new()
84 .read(true)
85 .write(true)
86 .create_new(true)
87 .open(&part_path)?;
88 file.set_len(expected_len)?;
89
90 let metadata = PartMetadata::new(url_hash, remote, chunk_size, hash)?;
91 let slot = metadata.encode_slot(slot_size)?;
92 file.seek(SeekFrom::Start(remote.content_len))?;
93 file.write_all(&slot)?;
94 file.sync_all()?;
95
96 Ok(Self {
97 file,
98 lock_file,
99 lock_path,
100 part_path,
101 slot_size,
102 active_slot: 0,
103 metadata,
104 })
105 }
106
107 #[must_use]
108 pub const fn metadata(&self) -> &PartMetadata {
110 &self.metadata
111 }
112
113 #[must_use]
114 pub fn incomplete_chunks(&self) -> Vec<u64> {
116 self.metadata.bitmap.incomplete_indices()
117 }
118
119 pub fn write_chunk(&mut self, index: u64, bytes: &[u8]) -> Result<()> {
128 let plan = ChunkPlan::new(self.metadata.content_len, self.metadata.chunk_size)?;
129 let chunk = plan.chunk(index)?;
130 if bytes.len() != usize::try_from(chunk.len).unwrap_or(usize::MAX) {
131 return Err(TakanawaError::HttpProtocol(format!(
132 "chunk {index} length mismatch: expected {}, got {}",
133 chunk.len,
134 bytes.len()
135 )));
136 }
137 if self.metadata.bitmap.is_complete(index)? {
138 return Ok(());
139 }
140
141 self.write_chunk_bytes(index, 0, bytes)?;
142 self.commit_chunk(index)
143 }
144
145 pub fn write_chunk_bytes(&mut self, index: u64, chunk_offset: u64, bytes: &[u8]) -> Result<()> {
155 let plan = ChunkPlan::new(self.metadata.content_len, self.metadata.chunk_size)?;
156 let chunk = plan.chunk(index)?;
157 let len = u64::try_from(bytes.len()).map_err(|_| {
158 TakanawaError::InvalidConfig(format!(
159 "chunk {index} write length does not fit in file offsets"
160 ))
161 })?;
162 let end = chunk_offset.checked_add(len).ok_or_else(|| {
163 TakanawaError::InvalidConfig(format!("chunk {index} write offset overflow"))
164 })?;
165 if end > chunk.len {
166 return Err(TakanawaError::InvalidConfig(format!(
167 "chunk {index} write range {chunk_offset}..{end} exceeds chunk length {}",
168 chunk.len
169 )));
170 }
171 if bytes.is_empty() || self.metadata.bitmap.is_complete(index)? {
172 return Ok(());
173 }
174
175 self.file
176 .seek(SeekFrom::Start(chunk.start + chunk_offset))?;
177 self.file.write_all(bytes)?;
178 Ok(())
179 }
180
181 pub fn commit_chunk(&mut self, index: u64) -> Result<()> {
190 let plan = ChunkPlan::new(self.metadata.content_len, self.metadata.chunk_size)?;
191 let _chunk = plan.chunk(index)?;
192 if self.metadata.bitmap.is_complete(index)? {
193 return Ok(());
194 }
195
196 self.file.sync_data()?;
197
198 self.metadata.bitmap.mark_complete(index)?;
199 self.commit_metadata()
200 }
201
202 pub fn finalize(mut self, target_path: &Path) -> Result<()> {
212 if target_path.exists() {
213 return Err(TakanawaError::TargetExists(target_path.to_owned()));
214 }
215 if !self.metadata.all_complete() {
216 return Err(TakanawaError::InvalidConfig(
217 "cannot finalize an incomplete part file".to_owned(),
218 ));
219 }
220
221 if !self.verify_hash()? {
222 return Err(TakanawaError::HashMismatch);
223 }
224
225 let PartFile {
226 file,
227 lock_file,
228 lock_path,
229 part_path,
230 metadata,
231 ..
232 } = self;
233 file.set_len(metadata.content_len)?;
234 file.sync_all()?;
235 drop(file);
236 fs::rename(&part_path, target_path)?;
237 sync_parent_dir(target_path);
238 drop(lock_file);
239 let _ = fs::remove_file(lock_path);
240 Ok(())
241 }
242
243 fn commit_metadata(&mut self) -> Result<()> {
244 self.metadata.generation = self.metadata.generation.checked_add(1).ok_or_else(|| {
245 TakanawaError::InvalidConfig("metadata generation overflow".to_owned())
246 })?;
247 self.active_slot = (self.metadata.generation % 2) as u8;
248 let slot = self.metadata.encode_slot(self.slot_size)?;
249 let offset = self.metadata.content_len + u64::from(self.active_slot) * self.slot_size;
250 self.file.seek(SeekFrom::Start(offset))?;
251 self.file.write_all(&slot)?;
252 self.file.sync_all()?;
253 Ok(())
254 }
255
256 fn verify_hash(&mut self) -> Result<bool> {
257 let Some(mut verifier) = HashVerifier::new(self.metadata.hash) else {
258 return Ok(true);
259 };
260
261 let mut remaining = self.metadata.content_len;
262 let mut buffer = vec![0; 1024 * 1024];
263 self.file.seek(SeekFrom::Start(0))?;
264 while remaining > 0 {
265 let read_len = usize::try_from(remaining.min(buffer.len() as u64))
266 .expect("bounded by buffer length");
267 self.file.read_exact(&mut buffer[..read_len])?;
268 verifier.update(&buffer[..read_len]);
269 remaining -= read_len as u64;
270 }
271 Ok(verifier.finish())
272 }
273}
274
275#[must_use]
276pub fn part_path_for(target_path: &Path) -> PathBuf {
278 let mut value: OsString = target_path.as_os_str().to_owned();
279 value.push(".part");
280 PathBuf::from(value)
281}
282
283#[must_use]
284pub fn part_lock_path_for(target_path: &Path) -> PathBuf {
286 let mut value: OsString = target_path.as_os_str().to_owned();
287 value.push(".part.lock");
288 PathBuf::from(value)
289}
290
291fn acquire_lock(lock_path: &Path) -> Result<File> {
292 let lock_file = OpenOptions::new()
293 .read(true)
294 .write(true)
295 .create(true)
296 .truncate(false)
297 .open(lock_path)?;
298 match lock_file.try_lock_exclusive() {
299 Ok(()) => Ok(lock_file),
300 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
301 Err(TakanawaError::PartBusy(lock_path.to_owned()))
302 }
303 Err(err) => Err(TakanawaError::Io(err)),
304 }
305}
306
307fn read_best_metadata(
308 file: &mut File,
309 content_len: u64,
310 slot_size: u64,
311) -> Result<(PartMetadata, u8)> {
312 let slot_len = usize::try_from(slot_size)
313 .map_err(|_| TakanawaError::PartCorrupt("slot size overflow".to_owned()))?;
314 let mut slots = Vec::new();
315
316 for slot_index in 0..2_u8 {
317 let offset = content_len + u64::from(slot_index) * slot_size;
318 let mut buffer = vec![0; slot_len];
319 file.seek(SeekFrom::Start(offset))?;
320 file.read_exact(&mut buffer)?;
321 if let Ok(metadata) = PartMetadata::decode_slot(&buffer) {
322 slots.push((metadata, slot_index));
323 }
324 }
325
326 slots
327 .into_iter()
328 .max_by_key(|(metadata, _)| metadata.generation)
329 .ok_or_else(|| TakanawaError::PartCorrupt("no valid metadata slot found".to_owned()))
330}
331
332#[cfg(unix)]
333fn sync_parent_dir(target_path: &Path) {
334 if let Some(parent) = target_path.parent() {
335 if let Ok(dir) = File::open(parent) {
336 let _ = dir.sync_all();
337 }
338 }
339}
340
341#[cfg(not(unix))]
342fn sync_parent_dir(_target_path: &Path) {}
343
344#[cfg(test)]
345mod tests {
346 use std::fs;
347
348 use tempfile::TempDir;
349
350 use super::*;
351
352 fn hex_array<const N: usize>(value: impl AsRef<str>) -> [u8; N] {
353 hex::decode(value.as_ref()).unwrap().try_into().unwrap()
354 }
355
356 fn remote(content_len: u64) -> RemoteInfo {
357 RemoteInfo {
358 content_len,
359 etag: Some("etag".to_owned()),
360 last_modified: Some("now".to_owned()),
361 }
362 }
363
364 #[test]
365 fn resumes_valid_part() {
366 let dir = TempDir::new().unwrap();
367 let target = dir.path().join("file.bin");
368 {
369 let mut part = PartFile::open_or_create(
370 &target,
371 "https://example.test/file",
372 &remote(6),
373 3,
374 HashConfig::None,
375 )
376 .unwrap();
377 part.write_chunk(0, b"abc").unwrap();
378 }
379
380 let part = PartFile::open_or_create(
381 &target,
382 "https://example.test/file",
383 &remote(6),
384 3,
385 HashConfig::None,
386 )
387 .unwrap();
388
389 assert_eq!(part.metadata().completed_chunks(), 1);
390 assert_eq!(part.incomplete_chunks(), vec![1]);
391 }
392
393 #[test]
394 fn partial_chunk_write_is_not_committed_on_reopen() {
395 let dir = TempDir::new().unwrap();
396 let target = dir.path().join("file.bin");
397 {
398 let mut part = PartFile::open_or_create(
399 &target,
400 "https://example.test/file",
401 &remote(6),
402 3,
403 HashConfig::None,
404 )
405 .unwrap();
406 part.write_chunk_bytes(0, 0, b"ab").unwrap();
407 }
408
409 let part = PartFile::open_or_create(
410 &target,
411 "https://example.test/file",
412 &remote(6),
413 3,
414 HashConfig::None,
415 )
416 .unwrap();
417
418 assert_eq!(part.metadata().completed_chunks(), 0);
419 assert_eq!(part.incomplete_chunks(), vec![0, 1]);
420 }
421
422 #[test]
423 fn partial_chunk_can_be_overwritten_and_committed() {
424 let dir = TempDir::new().unwrap();
425 let target = dir.path().join("file.bin");
426 let mut part = PartFile::open_or_create(
427 &target,
428 "https://example.test/file",
429 &remote(6),
430 3,
431 HashConfig::None,
432 )
433 .unwrap();
434 part.write_chunk_bytes(0, 0, b"xx").unwrap();
435 part.write_chunk_bytes(0, 0, b"abc").unwrap();
436 part.commit_chunk(0).unwrap();
437 part.write_chunk(1, b"def").unwrap();
438 part.finalize(&target).unwrap();
439
440 assert_eq!(fs::read(&target).unwrap(), b"abcdef");
441 }
442
443 #[test]
444 fn partial_chunk_write_rejects_out_of_bounds_ranges() {
445 let dir = TempDir::new().unwrap();
446 let target = dir.path().join("file.bin");
447 let mut part = PartFile::open_or_create(
448 &target,
449 "https://example.test/file",
450 &remote(6),
451 3,
452 HashConfig::None,
453 )
454 .unwrap();
455
456 let err = part.write_chunk_bytes(0, 2, b"bc").unwrap_err();
457
458 assert!(matches!(err, TakanawaError::InvalidConfig(_)));
459 }
460
461 #[test]
462 fn rejects_part_size_mismatch() {
463 let dir = TempDir::new().unwrap();
464 let target = dir.path().join("file.bin");
465 let part_path = part_path_for(&target);
466 fs::write(&part_path, b"too short").unwrap();
467
468 let err = PartFile::open_or_create(
469 &target,
470 "https://example.test/file",
471 &remote(6),
472 3,
473 HashConfig::None,
474 )
475 .unwrap_err();
476
477 assert!(matches!(err, TakanawaError::PartSizeMismatch { .. }));
478 }
479
480 #[test]
481 fn finalizes_with_supported_hashes() {
482 let cases = [
483 HashConfig::Sha1(hex_array::<20>("a9993e364706816aba3e25717850c26c9cd0d89d")),
484 HashConfig::Sha256(hex_array::<32>(
485 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
486 )),
487 HashConfig::Sha512(hex_array::<64>(concat!(
488 "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a",
489 "2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f",
490 ))),
491 HashConfig::Md5(hex_array::<16>("900150983cd24fb0d6963f7d28e17f72")),
492 HashConfig::Crc32(hex_array::<4>("352441c2")),
493 ];
494
495 for hash in cases {
496 let dir = TempDir::new().unwrap();
497 let target = dir.path().join("file.bin");
498 let mut part =
499 PartFile::open_or_create(&target, "https://example.test/file", &remote(3), 3, hash)
500 .unwrap();
501 part.write_chunk(0, b"abc").unwrap();
502 part.finalize(&target).unwrap();
503
504 assert_eq!(fs::read(&target).unwrap(), b"abc");
505 }
506 }
507
508 #[test]
509 fn finalizes_and_strips_metadata() {
510 let dir = TempDir::new().unwrap();
511 let target = dir.path().join("file.bin");
512 let mut part = PartFile::open_or_create(
513 &target,
514 "https://example.test/file",
515 &remote(6),
516 3,
517 HashConfig::None,
518 )
519 .unwrap();
520 part.write_chunk(1, b"def").unwrap();
521 part.write_chunk(0, b"abc").unwrap();
522 part.finalize(&target).unwrap();
523
524 assert_eq!(fs::read(&target).unwrap(), b"abcdef");
525 assert!(!part_path_for(&target).exists());
526 }
527}