irontide-session 0.165.0

BitTorrent session management: peers, torrents, and piece selection
Documentation
//! Resume file persistence: serialize, deserialize, atomic write, and directory helpers.
//!
//! Resume files store [`FastResumeData`] as bencode on disk, one file per
//! torrent, named by hex-encoded info hash with a `.resume` extension.

use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use bytes::Bytes;
use irontide_core::{FastResumeData, Id20, Id32, InfoDict, InfoHashes, Magnet, TorrentMetaV1};

/// Errors that can occur during resume file operations.
#[derive(Debug, thiserror::Error)]
pub enum ResumeFileError {
    /// Bencode serialization or deserialization failed.
    #[error("bencode error: {0}")]
    Bencode(#[from] irontide_bencode::Error),

    /// Filesystem I/O failed.
    #[error("I/O error: {0}")]
    Io(#[from] io::Error),
}

/// Serialize [`FastResumeData`] to bencode bytes.
///
/// # Errors
///
/// Returns [`ResumeFileError::Bencode`] if serialization fails.
pub fn serialize_resume(data: &FastResumeData) -> Result<Vec<u8>, ResumeFileError> {
    irontide_bencode::to_bytes(data).map_err(ResumeFileError::from)
}

/// Deserialize [`FastResumeData`] from bencode bytes.
///
/// # Errors
///
/// Returns [`ResumeFileError::Bencode`] if the input is not valid bencode
/// or does not match the [`FastResumeData`] schema.
pub fn deserialize_resume(data: &[u8]) -> Result<FastResumeData, ResumeFileError> {
    irontide_bencode::from_bytes(data).map_err(ResumeFileError::from)
}

/// Atomically write `data` to `path` by writing to a temporary file first,
/// then renaming.
///
/// The temporary file is placed at `path.with_extension("resume.tmp")` so it
/// resides on the same filesystem, guaranteeing that `fs::rename` is atomic.
///
/// # Errors
///
/// Returns [`io::Error`] if writing or renaming fails.
pub fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> {
    let tmp_path = path.with_extension("resume.tmp");
    fs::write(&tmp_path, data)?;
    fs::rename(&tmp_path, path)?;
    Ok(())
}

/// Compute the path for a resume file: `dir/torrents/{hex}.resume`.
pub fn resume_file_path(dir: &Path, info_hash: &Id20) -> PathBuf {
    dir.join("torrents")
        .join(format!("{}.resume", hex::encode(info_hash.as_bytes())))
}

/// Scan `dir/torrents/` and collect all paths ending in `.resume`.
///
/// Returns an empty `Vec` if the directory does not exist or cannot be read.
pub fn scan_resume_dir(dir: &Path) -> Vec<PathBuf> {
    let torrents_dir = dir.join("torrents");
    let entries = match fs::read_dir(&torrents_dir) {
        Ok(entries) => entries,
        Err(_) => return Vec::new(),
    };

    entries
        .filter_map(|entry| {
            let entry = entry.ok()?;
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) == Some("resume") {
                Some(path)
            } else {
                None
            }
        })
        .collect()
}

/// Return the default resume directory.
///
/// Uses `$XDG_STATE_HOME/irontide` when set, otherwise falls back to
/// `$HOME/.local/state/irontide`.
pub fn default_resume_dir() -> PathBuf {
    if let Ok(state_home) = std::env::var("XDG_STATE_HOME")
        && !state_home.is_empty()
    {
        return PathBuf::from(state_home).join("irontide");
    }
    if let Ok(home) = std::env::var("HOME") {
        return PathBuf::from(home)
            .join(".local")
            .join("state")
            .join("irontide");
    }
    // Last-resort fallback when HOME is unset (unlikely on real systems).
    PathBuf::from(".local/state/irontide")
}

/// Delete the `.resume` file for a torrent identified by `info_hash`.
///
/// # Errors
///
/// Returns [`io::Error`] if the file cannot be removed (e.g. it does not exist
/// or the caller lacks permissions).
pub fn delete_resume_file(dir: &Path, info_hash: &Id20) -> io::Result<()> {
    let path = resume_file_path(dir, info_hash);
    fs::remove_file(path)
}

/// Reconstruct a [`TorrentMetaV1`] from stored resume data.
///
/// Uses Decision 1A: the info hash is taken directly from the resume file,
/// never recomputed. Returns `None` if `rd.info` is `None` (unresolved magnet)
/// or if the stored info bytes fail to parse as an [`InfoDict`].
pub fn reconstruct_torrent_meta(rd: &FastResumeData) -> Option<TorrentMetaV1> {
    let info_bytes = rd.info.as_ref()?;
    let info: InfoDict = irontide_bencode::from_bytes(info_bytes).ok()?;
    let info_hash = Id20::from_bytes(&rd.info_hash).ok()?;

    let announce = rd.trackers.first().and_then(|tier| tier.first()).cloned();

    let announce_list = if rd.trackers.is_empty() {
        None
    } else {
        Some(rd.trackers.clone())
    };

    Some(TorrentMetaV1 {
        info_hash,
        announce,
        announce_list,
        comment: None,
        created_by: None,
        creation_date: None,
        info,
        url_list: rd.url_seeds.clone(),
        httpseeds: rd.http_seeds.clone(),
        info_bytes: Some(Bytes::from(info_bytes.clone())),
        ssl_cert: None,
    })
}

/// Reconstruct a [`Magnet`] from resume data for unresolved magnets.
///
/// Returns `None` if the info hash is malformed.
pub fn reconstruct_magnet(rd: &FastResumeData) -> Option<Magnet> {
    let v1 = Id20::from_bytes(&rd.info_hash).ok()?;
    let v2 = rd
        .info_hash2
        .as_ref()
        .and_then(|ih2| Id32::from_bytes(ih2).ok());

    let info_hashes = InfoHashes { v1: Some(v1), v2 };

    let display_name = if rd.name.is_empty() {
        None
    } else {
        Some(rd.name.clone())
    };

    let trackers = rd
        .trackers
        .iter()
        .flat_map(|tier| tier.iter().cloned())
        .collect();

    Some(Magnet {
        info_hashes,
        display_name,
        trackers,
        peers: Vec::new(),
        selected_files: None,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;
    use tempfile::TempDir;

    /// Helper: build a minimal [`FastResumeData`] for testing.
    fn sample_resume_data() -> FastResumeData {
        let mut data =
            FastResumeData::new(vec![0xAB; 20], "test-torrent".into(), "/downloads".into());
        data.total_uploaded = 1024;
        data.total_downloaded = 2048;
        data.active_time = 300;
        data.added_time = 1_700_000_000;
        data.pieces = vec![0xFF; 8];
        data
    }

    #[test]
    fn bencode_round_trip() {
        let original = sample_resume_data();
        let bytes = serialize_resume(&original).expect("serialize should succeed");
        let decoded = deserialize_resume(&bytes).expect("deserialize should succeed");
        assert_eq!(original, decoded);
    }

    #[test]
    fn empty_resume_data_round_trip() {
        let original = FastResumeData::new(vec![0x00; 20], "empty".into(), "/tmp".into());
        let bytes = serialize_resume(&original).expect("serialize should succeed");
        let decoded = deserialize_resume(&bytes).expect("deserialize should succeed");
        assert_eq!(original, decoded);
    }

    #[test]
    fn atomic_write_no_tmp_remains() {
        let dir = TempDir::new().expect("failed to create temp dir");
        let target = dir.path().join("test.resume");

        atomic_write(&target, b"hello world").expect("atomic_write should succeed");

        // The target file must exist with correct contents.
        let contents = fs::read(&target).expect("should read target");
        assert_eq!(contents, b"hello world");

        // The temporary file must not remain.
        let tmp_path = target.with_extension("resume.tmp");
        assert!(
            !tmp_path.exists(),
            ".tmp file should not remain after write"
        );
    }

    #[test]
    fn scan_resume_dir_filters_extensions() {
        let dir = TempDir::new().expect("failed to create temp dir");
        let torrents = dir.path().join("torrents");
        fs::create_dir_all(&torrents).expect("failed to create torrents dir");

        // Create files with various extensions.
        fs::write(torrents.join("aabb.resume"), b"r1").expect("write");
        fs::write(torrents.join("ccdd.resume"), b"r2").expect("write");
        fs::write(torrents.join("eeff.dat"), b"d1").expect("write");
        fs::write(torrents.join("0011.resume.tmp"), b"t1").expect("write");
        fs::write(torrents.join("notes.txt"), b"n1").expect("write");

        let mut found = scan_resume_dir(dir.path());
        // Sort for deterministic comparison.
        found.sort();

        assert_eq!(found.len(), 2);
        assert!(found[0].ends_with("aabb.resume") || found[0].ends_with("ccdd.resume"));
        assert!(found[1].ends_with("aabb.resume") || found[1].ends_with("ccdd.resume"));

        // Double-check none of the non-.resume files snuck in.
        for path in &found {
            assert_eq!(
                path.extension().and_then(|e| e.to_str()),
                Some("resume"),
                "only .resume files should be returned"
            );
        }
    }
}