pray-core 1.22.0

Core library for Prayfile, the package manager for the language placed before inference
Documentation
use crate::hashing::{prefixed_hex_digest, sha256_prefixed};
use crate::registry_http::{http_get, http_get_with_headers, join_url};
use crate::resource_limits::MAX_TORRENT_ARTIFACT_BYTES;
use crate::{PrayError, PrayResult};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs::{self, OpenOptions};
use std::io::{Seek, SeekFrom, Write};
use std::path::Path;

const TORRENT_MANIFEST_SPEC: &str = "pray-torrent-v1";

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct TorrentManifest {
    spec: String,
    name: String,
    version: String,
    artifact_url: String,
    artifact_hash: String,
    piece_size: usize,
    length: usize,
    pieces: Vec<String>,
    #[serde(default)]
    sources: Vec<String>,
    #[serde(default)]
    trackers: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct TorrentPieceRange {
    start: usize,
    end: usize,
    hash: String,
}

impl TorrentManifest {
    fn validate(&self) -> PrayResult<()> {
        if self.spec != TORRENT_MANIFEST_SPEC {
            return Err(PrayError::Parse {
                kind: "torrent manifest",
                message: format!("unexpected spec: {}", self.spec),
            });
        }
        if self.piece_size == 0 {
            return Err(PrayError::Parse {
                kind: "torrent manifest",
                message: "piece size must be greater than zero".to_string(),
            });
        }
        if self.length > MAX_TORRENT_ARTIFACT_BYTES {
            return Err(PrayError::Integrity(format!(
                "torrent artifact length exceeds {MAX_TORRENT_ARTIFACT_BYTES} bytes"
            )));
        }
        let expected_piece_count = if self.length == 0 {
            0
        } else {
            self.length.div_ceil(self.piece_size)
        };
        if self.pieces.len() != expected_piece_count {
            return Err(PrayError::Parse {
                kind: "torrent manifest",
                message: format!(
                    "expected {} piece hash(es), found {}",
                    expected_piece_count,
                    self.pieces.len()
                ),
            });
        }
        Ok(())
    }

    fn piece_ranges(&self) -> Vec<TorrentPieceRange> {
        self.pieces
            .iter()
            .enumerate()
            .map(|(index, hash)| {
                let start = index * self.piece_size;
                let end = self
                    .length
                    .saturating_sub(1)
                    .min(start + self.piece_size - 1);
                TorrentPieceRange {
                    start,
                    end,
                    hash: hash.clone(),
                }
            })
            .collect()
    }
}

impl TorrentPieceRange {
    fn length(&self) -> usize {
        self.end.saturating_sub(self.start) + 1
    }
}

pub(crate) fn fetch_torrent_manifest(
    source_url: &str,
    artifact_path: &str,
) -> PrayResult<Option<TorrentManifest>> {
    let url = join_url(source_url, &format!("{}.praytorrent.json", artifact_path));
    match http_get(&url) {
        Ok(response) => {
            let manifest: TorrentManifest =
                serde_json::from_slice(&response).map_err(|error| PrayError::Parse {
                    kind: "torrent manifest",
                    message: error.to_string(),
                })?;
            manifest.validate()?;
            Ok(Some(manifest))
        }
        Err(PrayError::Resolution(message) | PrayError::Network(message))
            if message.contains("HTTP 404") =>
        {
            Ok(None)
        }
        Err(error) => Err(error),
    }
}

/// Streams verified torrent pieces into `destination`, then returns the artifact bytes for
/// integrity checks that still require an in-memory view.
pub(crate) fn fetch_torrent_artifact_to_path(
    source_url: &str,
    artifact_path: &str,
    manifest: &TorrentManifest,
    destination: &Path,
) -> PrayResult<Vec<u8>> {
    crate::fetch::reject_absolute_artifact_path(&manifest.artifact_url)?;
    let artifact_url = join_url(source_url, &manifest.artifact_url);

    let sources = if manifest.sources.is_empty() {
        vec![artifact_url]
    } else {
        let mut resolved = Vec::with_capacity(manifest.sources.len());
        for source in &manifest.sources {
            crate::fetch::reject_absolute_artifact_path(source)?;
            resolved.push(join_url(source_url, source));
        }
        resolved
    };

    if manifest.length > MAX_TORRENT_ARTIFACT_BYTES {
        return Err(PrayError::Integrity(format!(
            "torrent artifact length exceeds {MAX_TORRENT_ARTIFACT_BYTES} bytes"
        )));
    }
    if let Some(parent) = destination.parent() {
        fs::create_dir_all(parent)?;
    }
    let mut file = OpenOptions::new()
        .create(true)
        .write(true)
        .truncate(true)
        .open(destination)?;
    file.set_len(manifest.length as u64)?;

    let mut digest = Sha256::new();
    let mut written = 0usize;
    let mut cached_artifact: Option<Vec<u8>> = None;
    for piece in manifest.piece_ranges() {
        let piece_bytes = match cached_artifact.as_ref() {
            Some(bytes) => bytes[piece.start..=piece.end].to_vec(),
            None => {
                let downloaded = download_torrent_piece(&sources, &piece, manifest.length)?;
                if downloaded.len() == manifest.length {
                    let slice = downloaded[piece.start..=piece.end].to_vec();
                    cached_artifact = Some(downloaded);
                    slice
                } else {
                    downloaded
                }
            }
        };
        if sha256_prefixed(&piece_bytes) != piece.hash {
            return Err(PrayError::Integrity(format!(
                "torrent piece hash mismatch for {artifact_path} {}..{}",
                piece.start, piece.end
            )));
        }
        file.seek(SeekFrom::Start(piece.start as u64))?;
        file.write_all(&piece_bytes)?;
        digest.update(&piece_bytes);
        written = written.saturating_add(piece_bytes.len());
    }
    file.flush()?;
    drop(file);

    if written != manifest.length {
        return Err(PrayError::Integrity(format!(
            "torrent artifact length mismatch for {artifact_path}: wrote {written}, expected {}",
            manifest.length
        )));
    }
    let computed = prefixed_hex_digest(digest.finalize());
    if computed != manifest.artifact_hash {
        return Err(PrayError::Integrity(format!(
            "torrent artifact hash mismatch for {artifact_path}"
        )));
    }
    fs::read(destination).map_err(PrayError::from)
}

fn download_torrent_piece(
    sources: &[String],
    piece: &TorrentPieceRange,
    artifact_length: usize,
) -> PrayResult<Vec<u8>> {
    let range_header = format!("bytes={}-{}", piece.start, piece.end);
    for source in sources {
        match http_get_with_headers(source, &[("Range", &range_header)]) {
            Ok((response, _))
                if response.len() == piece.length() || response.len() == artifact_length =>
            {
                return Ok(response);
            }
            Ok(_) => continue,
            Err(_) => continue,
        }
    }

    Err(PrayError::Resolution(format!(
        "unable to download torrent piece {}-{}",
        piece.start, piece.end
    )))
}