feat(peer)!: cut over to authenticated catalog sharing
Replace address-only trust and pushed peer state with installation identities, SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned protocol-8 pulls. The runtime now owns each network generation and all admitted work through shutdown. Add exact bundled content identities, reproducible manifest publishing, capability-confined downloads, streaming BLAKE3 verification, quarantine and retry, and crash-recoverable download and install transactions. Ship generated fixture catalogs and fail closed when production manifests are absent. The Tauri backend exposes durable sharing policy, redacted identity state, and attempt-keyed transfer snapshots. Frontend consumption follows in the next commit. Repository-wide test certificates and protocol-7 paths are removed. BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts; protocol-7 frames and shared-certificate identities are no longer accepted. Test Plan: - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `just build` -- passed with fixture catalogs on the completed stack - `just catalog-check-production` -- failed closed because the external production manifest corpus is absent - `git diff --cached --check` -- passed
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fs::File,
|
||||
future::Future,
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
pin::Pin,
|
||||
process::Stdio,
|
||||
@@ -11,14 +12,32 @@ use std::{
|
||||
use bytes::Bytes;
|
||||
use crc32fast::Hasher;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use lanspread_proto::{Message, Request, StreamInstallFrame};
|
||||
use s2n_quic::{application, stream::SendStream};
|
||||
use lanspread_db::content_manifest::{
|
||||
Blake3Digest,
|
||||
CanonicalCatalogPath,
|
||||
CatalogContentManifest,
|
||||
CatalogEntryKind,
|
||||
ContentId,
|
||||
MAX_CATALOG_ENTRIES,
|
||||
MAX_CATALOG_TOTAL_BYTES,
|
||||
};
|
||||
use lanspread_proto::{
|
||||
ControlMessage,
|
||||
MAX_STREAM_INSTALL_FRAME_BYTES,
|
||||
Message,
|
||||
PeerEndpoint,
|
||||
Request,
|
||||
StreamInstallFrame,
|
||||
};
|
||||
use s2n_quic::{
|
||||
application,
|
||||
stream::{ReceiveStream, SendStream},
|
||||
};
|
||||
use tokio::{
|
||||
fs::File,
|
||||
io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
|
||||
io::{AsyncRead, AsyncReadExt},
|
||||
process::Command,
|
||||
sync::{mpsc, mpsc::UnboundedSender},
|
||||
time::{self, MissedTickBehavior},
|
||||
time::{self, Instant as TokioInstant, MissedTickBehavior},
|
||||
};
|
||||
use tokio_util::{
|
||||
codec::{FramedRead, FramedWrite, LengthDelimitedCodec},
|
||||
@@ -31,17 +50,71 @@ use crate::{
|
||||
install::root_eti_archives,
|
||||
network::connect_to_peer,
|
||||
path_validation::validate_game_file_path,
|
||||
quic_runtime::QuicConnector,
|
||||
scoped_blocking::scoped_blocking,
|
||||
scoped_process::{ReapedTokioChild, ScopedProcess},
|
||||
transfer_status::DownloadAttemptReporter,
|
||||
};
|
||||
|
||||
const FRAME_CHANNEL_DEPTH: usize = 16;
|
||||
const STREAM_INSTALL_PROGRESS_UPDATE_INTERVAL: Duration = Duration::from_millis(500);
|
||||
const STREAM_CHUNK_SIZE: usize = 256 * 1024;
|
||||
const UNRAR_LISTING_CAPTURE_LIMIT: usize = 64 * 1024 * 1024;
|
||||
const STREAM_INSTALL_INACTIVITY_TIMEOUT: Duration = Duration::from_mins(10);
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct StreamInstallInactivityDeadline {
|
||||
expires_at: TokioInstant,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl StreamInstallInactivityDeadline {
|
||||
fn ordinary() -> Self {
|
||||
Self::after(STREAM_INSTALL_INACTIVITY_TIMEOUT)
|
||||
}
|
||||
|
||||
fn after(timeout: Duration) -> Self {
|
||||
Self {
|
||||
expires_at: TokioInstant::now() + timeout,
|
||||
timeout,
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_after_frame(&mut self) {
|
||||
self.expires_at = TokioInstant::now() + self.timeout;
|
||||
}
|
||||
|
||||
fn timeout_error(self, game_id: &str, context: &str) -> StreamInstallReceiveError {
|
||||
let timeout = self.timeout;
|
||||
StreamInstallReceiveError::transport(eyre::eyre!(
|
||||
"streamed install for {game_id} made no frame progress for {timeout:?} {context}"
|
||||
))
|
||||
}
|
||||
|
||||
async fn run<T>(
|
||||
self,
|
||||
operation: impl Future<Output = T>,
|
||||
game_id: &str,
|
||||
cancel_token: &CancellationToken,
|
||||
context: &str,
|
||||
) -> StreamInstallReceiveResult<T> {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancel_token.cancelled() => {
|
||||
Err(StreamInstallReceiveError::cancelled(game_id, context))
|
||||
}
|
||||
() = time::sleep_until(self.expires_at) => {
|
||||
Err(self.timeout_error(game_id, context))
|
||||
}
|
||||
result = operation => Ok(result),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Integrity metadata advertised by the sender's RAR archive.
|
||||
///
|
||||
/// This catches transport corruption, truncation, and provider bugs. It is not
|
||||
/// a trusted-content guarantee because a malicious peer controls both the bytes
|
||||
/// and the archive metadata. Trusted content would need catalog-owned hashes.
|
||||
/// This remains an early corruption check only. The catalog-owned BLAKE3 digest
|
||||
/// in [`IncomingFile`] is the trusted-content boundary.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct SenderArchiveIntegrity {
|
||||
expected_size: u64,
|
||||
@@ -56,7 +129,12 @@ impl SenderArchiveIntegrity {
|
||||
}
|
||||
}
|
||||
|
||||
fn verify(self, relative_path: &str, received: u64, actual_crc32: u32) -> eyre::Result<()> {
|
||||
fn verify(
|
||||
self,
|
||||
relative_path: &CanonicalCatalogPath,
|
||||
received: u64,
|
||||
actual_crc32: u32,
|
||||
) -> eyre::Result<()> {
|
||||
if received != self.expected_size {
|
||||
eyre::bail!(
|
||||
"streamed file {relative_path} size mismatch: got {received}, expected {}",
|
||||
@@ -77,6 +155,76 @@ impl SenderArchiveIntegrity {
|
||||
|
||||
pub type StreamInstallFuture<'a> = Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub(crate) enum StreamInstallReceiveErrorKind {
|
||||
Integrity,
|
||||
Transport,
|
||||
Cancelled,
|
||||
Setup,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct StreamInstallReceiveError {
|
||||
kind: StreamInstallReceiveErrorKind,
|
||||
report: eyre::Report,
|
||||
}
|
||||
|
||||
impl StreamInstallReceiveError {
|
||||
#[must_use]
|
||||
pub(crate) const fn kind(&self) -> StreamInstallReceiveErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
fn integrity(error: impl Into<eyre::Report>) -> Self {
|
||||
Self::new(StreamInstallReceiveErrorKind::Integrity, error)
|
||||
}
|
||||
|
||||
fn transport(error: impl Into<eyre::Report>) -> Self {
|
||||
Self::new(StreamInstallReceiveErrorKind::Transport, error)
|
||||
}
|
||||
|
||||
fn setup(error: impl Into<eyre::Report>) -> Self {
|
||||
Self::new(StreamInstallReceiveErrorKind::Setup, error)
|
||||
}
|
||||
|
||||
fn cancelled(game_id: &str, context: &str) -> Self {
|
||||
Self::new(
|
||||
StreamInstallReceiveErrorKind::Cancelled,
|
||||
eyre::eyre!("streamed install for {game_id} was cancelled {context}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn transport_or_cancelled(
|
||||
error: impl Into<eyre::Report>,
|
||||
game_id: &str,
|
||||
cancel_token: &CancellationToken,
|
||||
context: &str,
|
||||
) -> Self {
|
||||
if cancel_token.is_cancelled() {
|
||||
Self::cancelled(game_id, context)
|
||||
} else {
|
||||
Self::transport(error)
|
||||
}
|
||||
}
|
||||
|
||||
fn new(kind: StreamInstallReceiveErrorKind, error: impl Into<eyre::Report>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
report: error.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StreamInstallReceiveError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
std::fmt::Display::fmt(&self.report, formatter)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StreamInstallReceiveError {}
|
||||
|
||||
type StreamInstallReceiveResult<T> = Result<T, StreamInstallReceiveError>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct StreamInstallFrameSink {
|
||||
frames: mpsc::Sender<StreamInstallFrame>,
|
||||
@@ -158,12 +306,8 @@ impl StreamInstallProvider for ExternalUnrarStreamProvider {
|
||||
cancel_token: CancellationToken,
|
||||
) -> StreamInstallFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let archive_name = archive_catalog_name(archive)?;
|
||||
let listing = unrar_listing(&self.program, archive, &cancel_token).await?;
|
||||
let archive_name = archive
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("archive.eti")
|
||||
.to_string();
|
||||
|
||||
frames
|
||||
.send(StreamInstallFrame::ArchiveBegin {
|
||||
@@ -189,6 +333,19 @@ impl StreamInstallProvider for ExternalUnrarStreamProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fn archive_catalog_name(archive: &Path) -> eyre::Result<CanonicalCatalogPath> {
|
||||
let name = archive
|
||||
.file_name()
|
||||
.ok_or_else(|| eyre::eyre!("archive path has no file name: {}", archive.display()))?;
|
||||
let name = name.to_str().ok_or_else(|| {
|
||||
eyre::eyre!(
|
||||
"archive file name is not valid UTF-8: {}",
|
||||
archive.display()
|
||||
)
|
||||
})?;
|
||||
CanonicalCatalogPath::new(name)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct RarListing {
|
||||
solid: bool,
|
||||
@@ -207,7 +364,7 @@ impl RarListing {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct RarEntry {
|
||||
relative_path: String,
|
||||
relative_path: CanonicalCatalogPath,
|
||||
kind: RarEntryKind,
|
||||
size: u64,
|
||||
crc32: Option<u32>,
|
||||
@@ -232,25 +389,19 @@ async fn unrar_listing(
|
||||
archive: &Path,
|
||||
cancel_token: &CancellationToken,
|
||||
) -> eyre::Result<RarListing> {
|
||||
let mut child = Command::new(program)
|
||||
.arg("lt")
|
||||
.arg("-cfg-")
|
||||
.arg(archive)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
let mut stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| eyre::eyre!("unrar listing stdout was not captured"))?;
|
||||
let mut stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| eyre::eyre!("unrar listing stderr was not captured"))?;
|
||||
|
||||
let output =
|
||||
capture_unrar_output(&mut child, &mut stdout, &mut stderr, cancel_token, archive).await?;
|
||||
let process = ScopedProcess::spawn(
|
||||
program,
|
||||
[
|
||||
std::ffi::OsString::from("lt"),
|
||||
std::ffi::OsString::from("-cfg-"),
|
||||
std::ffi::OsString::from("-p-"),
|
||||
archive.as_os_str().to_owned(),
|
||||
],
|
||||
cancel_token,
|
||||
UNRAR_LISTING_CAPTURE_LIMIT,
|
||||
)?;
|
||||
let output = process.wait().await?;
|
||||
reject_truncated_unrar_listing(output.stdout_truncated, output.stderr_truncated, archive)?;
|
||||
if !output.status.success() {
|
||||
eyre::bail!(
|
||||
"unrar lt failed for {} with status {}: {}",
|
||||
@@ -263,75 +414,16 @@ async fn unrar_listing(
|
||||
parse_unrar_listing(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CapturedProcessOutput {
|
||||
status: std::process::ExitStatus,
|
||||
stdout: Vec<u8>,
|
||||
stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
async fn capture_unrar_output(
|
||||
child: &mut tokio::process::Child,
|
||||
stdout: &mut (impl AsyncRead + Unpin),
|
||||
stderr: &mut (impl AsyncRead + Unpin),
|
||||
cancel_token: &CancellationToken,
|
||||
archive: &Path,
|
||||
) -> eyre::Result<CapturedProcessOutput> {
|
||||
let mut stdout_bytes = Vec::new();
|
||||
let mut stderr_bytes = Vec::new();
|
||||
|
||||
let capture_result = {
|
||||
let capture = async {
|
||||
let (status, _, _) = tokio::try_join!(
|
||||
child.wait(),
|
||||
stdout.read_to_end(&mut stdout_bytes),
|
||||
stderr.read_to_end(&mut stderr_bytes),
|
||||
)?;
|
||||
Ok::<_, std::io::Error>(status)
|
||||
};
|
||||
tokio::pin!(capture);
|
||||
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancel_token.cancelled() => None,
|
||||
result = &mut capture => Some(result),
|
||||
}
|
||||
};
|
||||
|
||||
match capture_result {
|
||||
Some(Ok(status)) => Ok(CapturedProcessOutput {
|
||||
status,
|
||||
stdout: stdout_bytes,
|
||||
stderr: stderr_bytes,
|
||||
}),
|
||||
Some(Err(capture_error)) => {
|
||||
if let Err(cleanup_error) = terminate_and_reap_unrar(child, archive).await {
|
||||
return Err(eyre::eyre!(
|
||||
"failed to capture unrar listing for {}: {capture_error}; cleanup also failed: {cleanup_error}",
|
||||
archive.display()
|
||||
));
|
||||
}
|
||||
Err(eyre::eyre!(
|
||||
"failed to capture unrar listing for {}: {capture_error}",
|
||||
archive.display()
|
||||
))
|
||||
}
|
||||
None => {
|
||||
terminate_and_reap_unrar(child, archive).await?;
|
||||
eyre::bail!("streamed archive {} was cancelled", archive.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn terminate_and_reap_unrar(
|
||||
child: &mut tokio::process::Child,
|
||||
fn reject_truncated_unrar_listing(
|
||||
stdout_truncated: bool,
|
||||
stderr_truncated: bool,
|
||||
archive: &Path,
|
||||
) -> eyre::Result<()> {
|
||||
let kill_error = child.start_kill().err();
|
||||
if let Err(wait_error) = child.wait().await {
|
||||
if stdout_truncated || stderr_truncated {
|
||||
eyre::bail!(
|
||||
"failed to reap unrar listing for {}: {wait_error}; kill error: {kill_error:?}",
|
||||
archive.display()
|
||||
"unrar lt output for {} exceeded the {} byte per-pipe metadata limit",
|
||||
archive.display(),
|
||||
UNRAR_LISTING_CAPTURE_LIMIT
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
@@ -405,7 +497,7 @@ fn push_rar_entry(entries: &mut Vec<RarEntry>, draft: RarEntryDraft) -> eyre::Re
|
||||
};
|
||||
|
||||
entries.push(RarEntry {
|
||||
relative_path,
|
||||
relative_path: CanonicalCatalogPath::new(relative_path)?,
|
||||
kind,
|
||||
size,
|
||||
crc32,
|
||||
@@ -420,20 +512,24 @@ async fn stream_unrar_entries(
|
||||
frames: &StreamInstallFrameSink,
|
||||
cancel_token: CancellationToken,
|
||||
) -> eyre::Result<()> {
|
||||
let mut child = Command::new(program)
|
||||
let mut command = Command::new(program);
|
||||
command
|
||||
.arg("p")
|
||||
.arg("-inul")
|
||||
.arg("-cfg-")
|
||||
.arg("-p-")
|
||||
.arg(archive)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
// Safety net: if this task is dropped before its cancel/error path runs
|
||||
// (e.g. on shutdown), tokio still kills unrar instead of orphaning it.
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
.stderr(Stdio::null());
|
||||
#[cfg(target_os = "windows")]
|
||||
command.creation_flags(crate::scoped_process::CREATE_NO_WINDOW);
|
||||
let child = command.spawn()?;
|
||||
let mut child = ReapedTokioChild::new(child);
|
||||
|
||||
let result = async {
|
||||
let mut stdout = child
|
||||
.child_mut()
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| eyre::eyre!("unrar stdout was not captured"))?;
|
||||
@@ -502,11 +598,17 @@ async fn stream_unrar_entries(
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let _ = child.kill().await;
|
||||
if let Err(error) = result {
|
||||
if let Err(cleanup_error) = child.terminate_and_wait().await {
|
||||
return Err(eyre::eyre!(
|
||||
"{error}; failed to settle unrar for {}: {cleanup_error}",
|
||||
archive.display()
|
||||
));
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stream_unrar_file_from_stdout(
|
||||
@@ -556,13 +658,13 @@ async fn read_unrar_stdout(
|
||||
}
|
||||
|
||||
async fn wait_unrar_child(
|
||||
child: &mut tokio::process::Child,
|
||||
child: &mut ReapedTokioChild,
|
||||
cancel_token: &CancellationToken,
|
||||
archive: &Path,
|
||||
) -> eyre::Result<std::process::ExitStatus> {
|
||||
tokio::select! {
|
||||
() = cancel_token.cancelled() => {
|
||||
let _ = child.kill().await;
|
||||
child.terminate_and_wait().await?;
|
||||
eyre::bail!("streamed archive {} was cancelled", archive.display());
|
||||
}
|
||||
status = child.wait() => Ok(status?),
|
||||
@@ -572,23 +674,10 @@ async fn wait_unrar_child(
|
||||
pub(crate) async fn send_stream_install_error(
|
||||
tx: SendStream,
|
||||
message: impl Into<String>,
|
||||
game_id: &str,
|
||||
cancel_token: &CancellationToken,
|
||||
) -> SendStream {
|
||||
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
if let Err(err) = framed_tx
|
||||
.send(
|
||||
StreamInstallFrame::Error {
|
||||
message: message.into(),
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to send streamed install error frame: {err}");
|
||||
}
|
||||
if let Err(err) = framed_tx.close().await {
|
||||
log::debug!("Failed to close streamed install error response: {err}");
|
||||
}
|
||||
framed_tx.into_inner()
|
||||
send_stream_install_error_cancellable(tx, message.into(), game_id, cancel_token).await
|
||||
}
|
||||
|
||||
pub(crate) async fn send_game_install_stream(
|
||||
@@ -596,9 +685,10 @@ pub(crate) async fn send_game_install_stream(
|
||||
tx: SendStream,
|
||||
game_root: &Path,
|
||||
game_id: &str,
|
||||
manifest: Arc<CatalogContentManifest>,
|
||||
cancel_token: CancellationToken,
|
||||
) -> (SendStream, eyre::Result<()>) {
|
||||
let archives = match root_eti_archives(game_root).await {
|
||||
let archives = match catalog_stream_archives(game_root, game_id, &manifest) {
|
||||
Ok(archives) => archives,
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
@@ -608,13 +698,6 @@ pub(crate) async fn send_game_install_stream(
|
||||
return (tx, Err(eyre::eyre!(message)));
|
||||
}
|
||||
};
|
||||
if archives.is_empty() {
|
||||
let message = format!("no .eti archives found for {game_id}");
|
||||
let tx = send_stream_install_error_cancellable(tx, message.clone(), game_id, &cancel_token)
|
||||
.await;
|
||||
return (tx, Err(eyre::eyre!(message)));
|
||||
}
|
||||
|
||||
let (frame_tx, mut frame_rx) = mpsc::channel(FRAME_CHANNEL_DEPTH);
|
||||
let producer_cancel = cancel_token.child_token();
|
||||
let frame_sink = StreamInstallFrameSink::new(frame_tx, producer_cancel.clone());
|
||||
@@ -679,6 +762,67 @@ pub(crate) async fn send_game_install_stream(
|
||||
(tx, result)
|
||||
}
|
||||
|
||||
fn catalog_stream_archives(
|
||||
game_root: &Path,
|
||||
game_id: &str,
|
||||
manifest: &CatalogContentManifest,
|
||||
) -> eyre::Result<Vec<PathBuf>> {
|
||||
if manifest.game_id() != game_id {
|
||||
eyre::bail!(
|
||||
"streamed-install catalog game mismatch: requested {game_id}, manifest is for {}",
|
||||
manifest.game_id()
|
||||
);
|
||||
}
|
||||
if !manifest.supports_streamed_install() {
|
||||
eyre::bail!("catalog game {game_id} does not support streamed install");
|
||||
}
|
||||
|
||||
let expected = manifest
|
||||
.files()
|
||||
.iter()
|
||||
.filter(|entry| entry.kind() == CatalogEntryKind::File)
|
||||
.map(|entry| entry.canonical_path().as_str())
|
||||
.filter(|path| {
|
||||
!path.contains('/')
|
||||
&& Path::new(path)
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == "eti")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if expected.is_empty() {
|
||||
eyre::bail!("catalog game {game_id} has streamed output but no root .eti archive");
|
||||
}
|
||||
|
||||
let archives = root_eti_archives(game_root)?;
|
||||
let mut actual = archives
|
||||
.into_iter()
|
||||
.map(|archive| {
|
||||
let name = archive
|
||||
.file_name()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| {
|
||||
eyre::eyre!(
|
||||
"streamed-install archive name is not valid UTF-8: {}",
|
||||
archive.display()
|
||||
)
|
||||
})?;
|
||||
Ok((name, archive))
|
||||
})
|
||||
.collect::<eyre::Result<Vec<_>>>()?;
|
||||
actual.sort_by(|(left, _), (right, _)| left.cmp(right));
|
||||
let actual_names = actual
|
||||
.iter()
|
||||
.map(|(name, _)| name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
if actual_names != expected {
|
||||
eyre::bail!(
|
||||
"streamed-install archive set does not match catalog for {game_id}: expected {expected:?}, found {actual_names:?}"
|
||||
);
|
||||
}
|
||||
Ok(actual.into_iter().map(|(_, archive)| archive).collect())
|
||||
}
|
||||
|
||||
async fn send_stream_install_error_cancellable(
|
||||
tx: SendStream,
|
||||
message: String,
|
||||
@@ -722,6 +866,24 @@ async fn forward_stream_install_frames<W>(
|
||||
frame_rx: &mut mpsc::Receiver<StreamInstallFrame>,
|
||||
cancel_token: &CancellationToken,
|
||||
) -> StreamInstallEgressOutcome
|
||||
where
|
||||
W: tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
forward_stream_install_frames_with_timeout(
|
||||
framed_tx,
|
||||
frame_rx,
|
||||
cancel_token,
|
||||
STREAM_INSTALL_INACTIVITY_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn forward_stream_install_frames_with_timeout<W>(
|
||||
framed_tx: &mut FramedWrite<W, LengthDelimitedCodec>,
|
||||
frame_rx: &mut mpsc::Receiver<StreamInstallFrame>,
|
||||
cancel_token: &CancellationToken,
|
||||
inactivity_timeout: Duration,
|
||||
) -> StreamInstallEgressOutcome
|
||||
where
|
||||
W: tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
@@ -731,6 +893,11 @@ where
|
||||
() = cancel_token.cancelled() => {
|
||||
return StreamInstallEgressOutcome::Cancelled;
|
||||
}
|
||||
() = tokio::time::sleep(inactivity_timeout) => {
|
||||
return StreamInstallEgressOutcome::Failed(eyre::eyre!(
|
||||
"streamed install producer timed out after {inactivity_timeout:?} without a frame"
|
||||
));
|
||||
}
|
||||
frame = frame_rx.recv() => frame,
|
||||
};
|
||||
let Some(frame) = frame else {
|
||||
@@ -742,6 +909,11 @@ where
|
||||
() = cancel_token.cancelled() => {
|
||||
return StreamInstallEgressOutcome::Cancelled;
|
||||
}
|
||||
() = tokio::time::sleep(inactivity_timeout) => {
|
||||
return StreamInstallEgressOutcome::Failed(eyre::eyre!(
|
||||
"streamed install frame send timed out after {inactivity_timeout:?}"
|
||||
));
|
||||
}
|
||||
result = framed_tx.send(frame.encode()) => result,
|
||||
};
|
||||
if let Err(err) = send_result {
|
||||
@@ -754,6 +926,11 @@ where
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancel_token.cancelled() => StreamInstallEgressOutcome::Cancelled,
|
||||
() = tokio::time::sleep(inactivity_timeout) => {
|
||||
StreamInstallEgressOutcome::Failed(eyre::eyre!(
|
||||
"streamed install close timed out after {inactivity_timeout:?}"
|
||||
))
|
||||
}
|
||||
result = framed_tx.close() => match result {
|
||||
Ok(()) => StreamInstallEgressOutcome::Complete,
|
||||
Err(err) => StreamInstallEgressOutcome::Failed(eyre::eyre!(
|
||||
@@ -769,120 +946,804 @@ fn reset_stream_install(tx: &mut SendStream, game_id: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_streamed_install(
|
||||
peer_addr: SocketAddr,
|
||||
game_id: &str,
|
||||
staging_dir: &Path,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
cancel_token: CancellationToken,
|
||||
) -> eyre::Result<()> {
|
||||
let staging_dir = tokio::fs::canonicalize(staging_dir)
|
||||
.await
|
||||
.unwrap_or_else(|_| staging_dir.to_path_buf());
|
||||
let mut conn = connect_to_peer(peer_addr).await?;
|
||||
let stream = conn.open_bidirectional_stream().await?;
|
||||
let (rx, tx) = stream.split();
|
||||
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
/// Catalog-owned acceptance state for one streamed-install response.
|
||||
///
|
||||
/// The sender's archive framing is useful for bounded extraction progress, but
|
||||
/// it is not authority. Every materialized path, shape, size, and file digest
|
||||
/// ultimately comes from `manifest`.
|
||||
#[derive(Debug)]
|
||||
struct CatalogStreamVerifier {
|
||||
manifest: Arc<CatalogContentManifest>,
|
||||
expected_archives: HashSet<CanonicalCatalogPath>,
|
||||
expected_file_bytes: u64,
|
||||
seen_archives: HashSet<CanonicalCatalogPath>,
|
||||
reported_unpacked_bytes: u64,
|
||||
active_archive: Option<ActiveArchive>,
|
||||
open_file: Option<usize>,
|
||||
seen_entries: Vec<bool>,
|
||||
entry_frames: usize,
|
||||
}
|
||||
|
||||
framed_tx
|
||||
.send(
|
||||
Request::StreamInstall {
|
||||
game_id: game_id.to_string(),
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await?;
|
||||
framed_tx.close().await?;
|
||||
#[derive(Debug)]
|
||||
struct ActiveArchive {
|
||||
name: CanonicalCatalogPath,
|
||||
}
|
||||
|
||||
let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new());
|
||||
let mut current_file: Option<IncomingFile> = None;
|
||||
let mut progress = StreamInstallProgress::new(game_id.to_string());
|
||||
let mut progress_interval = time::interval(STREAM_INSTALL_PROGRESS_UPDATE_INTERVAL);
|
||||
progress_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
progress_interval.tick().await;
|
||||
impl CatalogStreamVerifier {
|
||||
fn new(game_id: &str, manifest: Arc<CatalogContentManifest>) -> eyre::Result<Self> {
|
||||
if manifest.game_id() != game_id {
|
||||
eyre::bail!(
|
||||
"streamed install catalog game mismatch: requested {game_id}, manifest is for {}",
|
||||
manifest.game_id()
|
||||
);
|
||||
}
|
||||
if !manifest.supports_streamed_install() {
|
||||
eyre::bail!("catalog game {game_id} does not support streamed install");
|
||||
}
|
||||
|
||||
loop {
|
||||
let next = tokio::select! {
|
||||
() = cancel_token.cancelled() => eyre::bail!("streamed install for {game_id} was cancelled"),
|
||||
_ = progress_interval.tick() => {
|
||||
progress.emit_current(&tx_notify_ui);
|
||||
continue;
|
||||
}
|
||||
next = framed_rx.next() => next,
|
||||
let expected_archives = manifest
|
||||
.files()
|
||||
.iter()
|
||||
.filter(|entry| entry.kind() == CatalogEntryKind::File)
|
||||
.map(lanspread_db::content_manifest::CatalogFileEntry::canonical_path)
|
||||
.filter(|path| {
|
||||
!path.as_str().contains('/')
|
||||
&& Path::new(path.as_str())
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == "eti")
|
||||
})
|
||||
.cloned()
|
||||
.collect::<HashSet<_>>();
|
||||
if expected_archives.is_empty() {
|
||||
eyre::bail!("catalog game {game_id} has streamed output but no root .eti archive");
|
||||
}
|
||||
|
||||
let expected_file_bytes = manifest
|
||||
.streamed_install_files()
|
||||
.iter()
|
||||
.filter(|entry| entry.kind() == CatalogEntryKind::File)
|
||||
.try_fold(0_u64, |total, entry| total.checked_add(entry.size()))
|
||||
.ok_or_else(|| eyre::eyre!("catalog streamed file-size total overflow"))?;
|
||||
if expected_file_bytes > MAX_CATALOG_TOTAL_BYTES {
|
||||
eyre::bail!(
|
||||
"catalog streamed files exceed the {MAX_CATALOG_TOTAL_BYTES}-byte total limit"
|
||||
);
|
||||
}
|
||||
|
||||
let seen_entries = vec![false; manifest.streamed_install_files().len()];
|
||||
Ok(Self {
|
||||
manifest,
|
||||
expected_archives,
|
||||
expected_file_bytes,
|
||||
seen_archives: HashSet::new(),
|
||||
reported_unpacked_bytes: 0,
|
||||
active_archive: None,
|
||||
open_file: None,
|
||||
seen_entries,
|
||||
entry_frames: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const fn expected_file_bytes(&self) -> u64 {
|
||||
self.expected_file_bytes
|
||||
}
|
||||
|
||||
fn begin_archive(
|
||||
&mut self,
|
||||
archive_name: &CanonicalCatalogPath,
|
||||
unpacked_size: u64,
|
||||
) -> eyre::Result<()> {
|
||||
self.ensure_no_open_file("ArchiveBegin")?;
|
||||
if let Some(active) = &self.active_archive {
|
||||
eyre::bail!(
|
||||
"received ArchiveBegin for {archive_name} before ArchiveEnd for {}",
|
||||
active.name
|
||||
);
|
||||
}
|
||||
let reported_unpacked_bytes = self
|
||||
.reported_unpacked_bytes
|
||||
.checked_add(unpacked_size)
|
||||
.ok_or_else(|| eyre::eyre!("streamed archive unpacked-size total overflow"))?;
|
||||
if reported_unpacked_bytes > MAX_CATALOG_TOTAL_BYTES {
|
||||
eyre::bail!(
|
||||
"streamed archives exceed the {MAX_CATALOG_TOTAL_BYTES}-byte reported unpacked limit"
|
||||
);
|
||||
}
|
||||
|
||||
if !self.expected_archives.contains(archive_name) {
|
||||
eyre::bail!("received unknown streamed archive {archive_name}");
|
||||
}
|
||||
if !self.seen_archives.insert(archive_name.clone()) {
|
||||
eyre::bail!("received duplicate streamed archive {archive_name}");
|
||||
}
|
||||
|
||||
self.reported_unpacked_bytes = reported_unpacked_bytes;
|
||||
self.active_archive = Some(ActiveArchive {
|
||||
name: archive_name.clone(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_directory(&mut self, relative_path: &CanonicalCatalogPath) -> eyre::Result<()> {
|
||||
self.ensure_no_open_file("Directory")?;
|
||||
self.record_entry_frame()?;
|
||||
let entry_index = self.expected_entry_index(relative_path, CatalogEntryKind::Directory)?;
|
||||
debug_assert_eq!(
|
||||
self.manifest.streamed_install_files()[entry_index].size(),
|
||||
0
|
||||
);
|
||||
|
||||
self.active_archive_mut("Directory")?;
|
||||
self.seen_entries[entry_index] = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn begin_file(
|
||||
&mut self,
|
||||
relative_path: &CanonicalCatalogPath,
|
||||
size: u64,
|
||||
) -> eyre::Result<Blake3Digest> {
|
||||
self.ensure_no_open_file("FileBegin")?;
|
||||
self.record_entry_frame()?;
|
||||
self.active_archive_mut("FileBegin")?;
|
||||
let entry_index = self.expected_entry_index(relative_path, CatalogEntryKind::File)?;
|
||||
let expected = &self.manifest.streamed_install_files()[entry_index];
|
||||
let expected_size = expected.size();
|
||||
let expected_blake3 = expected.file_blake3().ok_or_else(|| {
|
||||
eyre::eyre!("catalog streamed file {relative_path} has no BLAKE3 digest")
|
||||
})?;
|
||||
if size != expected_size {
|
||||
eyre::bail!(
|
||||
"streamed file {relative_path} size mismatch: sender declared {size}, catalog expects {}",
|
||||
expected_size
|
||||
);
|
||||
}
|
||||
if self.seen_entries[entry_index] {
|
||||
eyre::bail!("streamed install repeated file {relative_path}");
|
||||
}
|
||||
self.seen_entries[entry_index] = true;
|
||||
self.open_file = Some(entry_index);
|
||||
Ok(expected_blake3)
|
||||
}
|
||||
|
||||
fn record_file_chunk(&self, length: usize) -> eyre::Result<()> {
|
||||
if self.open_file.is_none() {
|
||||
eyre::bail!("received FileChunk without FileBegin");
|
||||
}
|
||||
if length == 0 {
|
||||
eyre::bail!("received an empty FileChunk");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn end_file(&mut self, relative_path: &CanonicalCatalogPath) -> eyre::Result<()> {
|
||||
let Some(open_file) = self.open_file else {
|
||||
eyre::bail!("received FileEnd for {relative_path} without FileBegin");
|
||||
};
|
||||
let open_path = self.manifest.streamed_install_files()[open_file].canonical_path();
|
||||
if open_path != relative_path {
|
||||
eyre::bail!("streamed file end mismatch: began {open_path}, ended {relative_path}");
|
||||
}
|
||||
self.open_file = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let Some(frame) = next else {
|
||||
eyre::bail!("streamed install ended before Complete");
|
||||
fn end_archive(&mut self, archive_name: &CanonicalCatalogPath) -> eyre::Result<()> {
|
||||
self.ensure_no_open_file("ArchiveEnd")?;
|
||||
let Some(active) = self.active_archive.take() else {
|
||||
eyre::bail!("received ArchiveEnd for {archive_name} without ArchiveBegin");
|
||||
};
|
||||
let frame = frame?.freeze();
|
||||
let frame = StreamInstallFrame::decode(frame);
|
||||
if &active.name != archive_name {
|
||||
eyre::bail!(
|
||||
"streamed archive end mismatch: began {}, ended {archive_name}",
|
||||
active.name
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_complete(&self) -> eyre::Result<()> {
|
||||
self.ensure_no_open_file("Complete")?;
|
||||
if let Some(active) = &self.active_archive {
|
||||
eyre::bail!(
|
||||
"streamed install completed before ArchiveEnd for {}",
|
||||
active.name
|
||||
);
|
||||
}
|
||||
if let Some(missing) = self
|
||||
.expected_archives
|
||||
.difference(&self.seen_archives)
|
||||
.next()
|
||||
{
|
||||
eyre::bail!("streamed install completed before expected archive {missing}");
|
||||
}
|
||||
|
||||
for (entry, seen) in self
|
||||
.manifest
|
||||
.streamed_install_files()
|
||||
.iter()
|
||||
.zip(&self.seen_entries)
|
||||
{
|
||||
if !*seen {
|
||||
let path = entry.canonical_path();
|
||||
eyre::bail!("streamed install is missing catalog entry {path}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn expected_entry_index(
|
||||
&self,
|
||||
relative_path: &CanonicalCatalogPath,
|
||||
kind: CatalogEntryKind,
|
||||
) -> eyre::Result<usize> {
|
||||
let entry_index = self
|
||||
.manifest
|
||||
.streamed_install_files()
|
||||
.binary_search_by(|entry| entry.canonical_path().cmp(relative_path))
|
||||
.map_err(|_| eyre::eyre!("streamed install sent unknown path {relative_path}"))?;
|
||||
let entry = &self.manifest.streamed_install_files()[entry_index];
|
||||
if entry.kind() != kind {
|
||||
eyre::bail!(
|
||||
"streamed install path {relative_path} has kind {:?}, catalog expects {:?}",
|
||||
kind,
|
||||
entry.kind()
|
||||
);
|
||||
}
|
||||
Ok(entry_index)
|
||||
}
|
||||
|
||||
fn active_archive_mut(&mut self, frame: &str) -> eyre::Result<&mut ActiveArchive> {
|
||||
self.active_archive
|
||||
.as_mut()
|
||||
.ok_or_else(|| eyre::eyre!("received {frame} outside an archive"))
|
||||
}
|
||||
|
||||
fn ensure_no_open_file(&self, frame: &str) -> eyre::Result<()> {
|
||||
if let Some(open_file) = self.open_file {
|
||||
let open_path = self.manifest.streamed_install_files()[open_file]
|
||||
.canonical_path()
|
||||
.as_str();
|
||||
eyre::bail!("received {frame} while streamed file {open_path} is open");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_entry_frame(&mut self) -> eyre::Result<()> {
|
||||
// The publisher applies the same bound to the aggregate raw archive
|
||||
// listings before it folds repeated directories into final outputs.
|
||||
self.entry_frames = self
|
||||
.entry_frames
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| eyre::eyre!("streamed install entry-frame count overflow"))?;
|
||||
if self.entry_frames > MAX_CATALOG_ENTRIES {
|
||||
eyre::bail!("streamed install exceeds the {MAX_CATALOG_ENTRIES}-entry frame limit");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ReceiveFrameOutcome {
|
||||
Continue,
|
||||
Complete,
|
||||
}
|
||||
|
||||
struct StreamInstallReceiveState {
|
||||
verifier: CatalogStreamVerifier,
|
||||
staging_dir: PathBuf,
|
||||
current_file: Option<IncomingFile>,
|
||||
progress: StreamInstallProgress,
|
||||
}
|
||||
|
||||
impl StreamInstallReceiveState {
|
||||
fn new(
|
||||
game_id: &str,
|
||||
manifest: Arc<CatalogContentManifest>,
|
||||
staging_dir: &Path,
|
||||
attempt: DownloadAttemptReporter,
|
||||
) -> StreamInstallReceiveResult<Self> {
|
||||
let verifier = CatalogStreamVerifier::new(game_id, manifest)
|
||||
.map_err(StreamInstallReceiveError::setup)?;
|
||||
let staging_dir =
|
||||
scoped_blocking(|| std::fs::canonicalize(staging_dir)).map_err(|error| {
|
||||
StreamInstallReceiveError::setup(eyre::eyre!(
|
||||
"failed to resolve streamed install staging directory {}: {error}",
|
||||
staging_dir.display()
|
||||
))
|
||||
})?;
|
||||
let progress = StreamInstallProgress::new(attempt, verifier.expected_file_bytes());
|
||||
Ok(Self {
|
||||
verifier,
|
||||
staging_dir,
|
||||
current_file: None,
|
||||
progress,
|
||||
})
|
||||
}
|
||||
|
||||
fn emit_current_progress(&mut self) {
|
||||
self.progress.emit_current();
|
||||
}
|
||||
|
||||
fn handle_frame(
|
||||
&mut self,
|
||||
frame: StreamInstallFrame,
|
||||
game_id: &str,
|
||||
peer_endpoint: PeerEndpoint,
|
||||
content_id: ContentId,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
) -> StreamInstallReceiveResult<ReceiveFrameOutcome> {
|
||||
match frame {
|
||||
StreamInstallFrame::ArchiveBegin {
|
||||
archive_name,
|
||||
solid,
|
||||
unpacked_size,
|
||||
} => {
|
||||
progress.add_total(unpacked_size);
|
||||
progress.emit_snapshot(&tx_notify_ui, 0);
|
||||
self.verifier
|
||||
.begin_archive(&archive_name, unpacked_size)
|
||||
.map_err(StreamInstallReceiveError::integrity)?;
|
||||
self.progress.emit_snapshot(0);
|
||||
log::info!(
|
||||
"Receiving streamed install archive {archive_name} for {game_id} \
|
||||
(solid={solid}, unpacked_size={unpacked_size})"
|
||||
);
|
||||
}
|
||||
StreamInstallFrame::Directory { relative_path } => {
|
||||
let path = resolve_stream_path(&staging_dir, &relative_path)?;
|
||||
tokio::fs::create_dir_all(path).await?;
|
||||
self.verifier
|
||||
.record_directory(&relative_path)
|
||||
.map_err(StreamInstallReceiveError::integrity)?;
|
||||
let path = resolve_stream_path(&self.staging_dir, &relative_path)
|
||||
.map_err(StreamInstallReceiveError::setup)?;
|
||||
scoped_blocking(|| std::fs::create_dir_all(path))
|
||||
.map_err(StreamInstallReceiveError::setup)?;
|
||||
}
|
||||
StreamInstallFrame::FileBegin {
|
||||
relative_path,
|
||||
size,
|
||||
crc32,
|
||||
} => {
|
||||
if current_file.is_some() {
|
||||
eyre::bail!("received FileBegin for {relative_path} before previous FileEnd");
|
||||
}
|
||||
let path = resolve_stream_path(&staging_dir, &relative_path)?;
|
||||
let expected_blake3 = self
|
||||
.verifier
|
||||
.begin_file(&relative_path, size)
|
||||
.map_err(StreamInstallReceiveError::integrity)?;
|
||||
let path = resolve_stream_path(&self.staging_dir, &relative_path)
|
||||
.map_err(StreamInstallReceiveError::setup)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
scoped_blocking(|| std::fs::create_dir_all(parent))
|
||||
.map_err(StreamInstallReceiveError::setup)?;
|
||||
}
|
||||
let file = File::create(&path).await?;
|
||||
current_file = Some(IncomingFile::new(relative_path, path, size, crc32, file));
|
||||
let file = scoped_blocking(|| File::create(&path))
|
||||
.map_err(StreamInstallReceiveError::setup)?;
|
||||
self.current_file = Some(IncomingFile::new(
|
||||
relative_path,
|
||||
path,
|
||||
size,
|
||||
crc32,
|
||||
expected_blake3,
|
||||
file,
|
||||
));
|
||||
}
|
||||
StreamInstallFrame::FileChunk { bytes } => {
|
||||
let Some(file) = current_file.as_mut() else {
|
||||
eyre::bail!("received FileChunk without FileBegin");
|
||||
self.verifier
|
||||
.record_file_chunk(bytes.len())
|
||||
.map_err(StreamInstallReceiveError::integrity)?;
|
||||
let Some(file) = self.current_file.as_mut() else {
|
||||
unreachable!("verifier and incoming file state must stay in lockstep");
|
||||
};
|
||||
let length = file
|
||||
.write_chunk(game_id, peer_addr, &tx_notify_ui, bytes)
|
||||
.await?;
|
||||
progress.record_bytes(length);
|
||||
let length = file.write_chunk(&bytes)?;
|
||||
self.progress.record_bytes(length);
|
||||
}
|
||||
StreamInstallFrame::FileEnd { relative_path } => {
|
||||
let Some(file) = current_file.take() else {
|
||||
eyre::bail!("received FileEnd for {relative_path} without FileBegin");
|
||||
self.verifier
|
||||
.end_file(&relative_path)
|
||||
.map_err(StreamInstallReceiveError::integrity)?;
|
||||
let Some(file) = self.current_file.take() else {
|
||||
unreachable!("verifier and incoming file state must stay in lockstep");
|
||||
};
|
||||
file.finish(&relative_path).await?;
|
||||
file.finish(
|
||||
&relative_path,
|
||||
game_id,
|
||||
peer_endpoint,
|
||||
content_id,
|
||||
tx_notify_ui,
|
||||
)?;
|
||||
}
|
||||
StreamInstallFrame::ArchiveEnd { archive_name } => {
|
||||
self.verifier
|
||||
.end_archive(&archive_name)
|
||||
.map_err(StreamInstallReceiveError::integrity)?;
|
||||
log::info!("Finished streamed install archive {archive_name} for {game_id}");
|
||||
}
|
||||
StreamInstallFrame::Complete => {
|
||||
if current_file.is_some() {
|
||||
eyre::bail!("streamed install completed with an open file");
|
||||
}
|
||||
progress.emit_snapshot(&tx_notify_ui, 0);
|
||||
return Ok(());
|
||||
self.verifier
|
||||
.verify_complete()
|
||||
.map_err(StreamInstallReceiveError::integrity)?;
|
||||
debug_assert!(self.current_file.is_none());
|
||||
self.progress.emit_snapshot(0);
|
||||
return Ok(ReceiveFrameOutcome::Complete);
|
||||
}
|
||||
StreamInstallFrame::Error { message } => {
|
||||
eyre::bail!("streamed install sender failed: {message}");
|
||||
return Err(StreamInstallReceiveError::transport(eyre::eyre!(
|
||||
"streamed install sender failed: {message}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(ReceiveFrameOutcome::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ReceiveStreamedInstallRequest<'a> {
|
||||
pub(crate) endpoint: PeerEndpoint,
|
||||
pub(crate) game_id: &'a str,
|
||||
pub(crate) manifest: Arc<CatalogContentManifest>,
|
||||
pub(crate) staging_dir: &'a Path,
|
||||
pub(crate) attempt: DownloadAttemptReporter,
|
||||
pub(crate) tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
pub(crate) quic: &'a QuicConnector,
|
||||
pub(crate) cancel_token: CancellationToken,
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_streamed_install(
|
||||
request: ReceiveStreamedInstallRequest<'_>,
|
||||
) -> StreamInstallReceiveResult<()> {
|
||||
let ReceiveStreamedInstallRequest {
|
||||
endpoint,
|
||||
game_id,
|
||||
manifest,
|
||||
staging_dir,
|
||||
attempt,
|
||||
tx_notify_ui,
|
||||
quic,
|
||||
cancel_token,
|
||||
} = request;
|
||||
let content_id = manifest.content_id();
|
||||
let mut state = StreamInstallReceiveState::new(game_id, manifest, staging_dir, attempt)?;
|
||||
let mut conn = connect_to_peer(quic, &endpoint, &cancel_token)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
StreamInstallReceiveError::transport_or_cancelled(
|
||||
error,
|
||||
game_id,
|
||||
&cancel_token,
|
||||
"while connecting",
|
||||
)
|
||||
})?;
|
||||
let mut inactivity = StreamInstallInactivityDeadline::ordinary();
|
||||
let stream = await_stream_install_open(
|
||||
async { Ok(conn.open_bidirectional_stream().await?) },
|
||||
game_id,
|
||||
&cancel_token,
|
||||
inactivity,
|
||||
)
|
||||
.await?;
|
||||
let (rx, tx) = stream.split();
|
||||
let mut request_scope = StreamInstallRequestScope::new(rx, tx, game_id, content_id);
|
||||
request_scope
|
||||
.send_request(&cancel_token, inactivity)
|
||||
.await?;
|
||||
let rx = request_scope.into_receive();
|
||||
let mut framed_rx = StreamInstallReceiveScope::new(rx, game_id);
|
||||
let mut progress_interval = time::interval(STREAM_INSTALL_PROGRESS_UPDATE_INTERVAL);
|
||||
progress_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
progress_interval.tick().await;
|
||||
|
||||
loop {
|
||||
let next = match await_stream_install_input(
|
||||
framed_rx.next(),
|
||||
progress_interval.tick(),
|
||||
game_id,
|
||||
&cancel_token,
|
||||
inactivity,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
StreamInstallInput::ProgressTick => {
|
||||
state.emit_current_progress();
|
||||
continue;
|
||||
}
|
||||
StreamInstallInput::Frame(next) => next,
|
||||
};
|
||||
|
||||
let Some(frame) = next else {
|
||||
return Err(stream_ended_before_complete(game_id));
|
||||
};
|
||||
let frame = frame
|
||||
.map_err(|error| {
|
||||
classify_stream_install_read_error(
|
||||
error,
|
||||
game_id,
|
||||
&cancel_token,
|
||||
"while reading its response",
|
||||
)
|
||||
})?
|
||||
.freeze();
|
||||
inactivity.reset_after_frame();
|
||||
let frame = decode_received_stream_install_frame(frame)?;
|
||||
if state.handle_frame(frame, game_id, endpoint, content_id, &tx_notify_ui)?
|
||||
== ReceiveFrameOutcome::Complete
|
||||
{
|
||||
return framed_rx.drain_fin(&cancel_token, inactivity).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum StreamInstallInput<T> {
|
||||
ProgressTick,
|
||||
Frame(T),
|
||||
}
|
||||
|
||||
async fn await_stream_install_input<T, P>(
|
||||
frame: impl Future<Output = T>,
|
||||
progress_tick: impl Future<Output = P>,
|
||||
game_id: &str,
|
||||
cancel_token: &CancellationToken,
|
||||
inactivity: StreamInstallInactivityDeadline,
|
||||
) -> StreamInstallReceiveResult<StreamInstallInput<T>> {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancel_token.cancelled() => {
|
||||
Err(StreamInstallReceiveError::cancelled(game_id, "while receiving"))
|
||||
}
|
||||
() = time::sleep_until(inactivity.expires_at) => {
|
||||
Err(inactivity.timeout_error(game_id, "while waiting for a response frame"))
|
||||
}
|
||||
_ = progress_tick => Ok(StreamInstallInput::ProgressTick),
|
||||
frame = frame => Ok(StreamInstallInput::Frame(frame)),
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_ended_before_complete(game_id: &str) -> StreamInstallReceiveError {
|
||||
StreamInstallReceiveError::transport(eyre::eyre!(
|
||||
"streamed install for {game_id} ended before Complete"
|
||||
))
|
||||
}
|
||||
|
||||
fn data_after_complete(game_id: &str) -> StreamInstallReceiveError {
|
||||
StreamInstallReceiveError::integrity(eyre::eyre!(
|
||||
"streamed install for {game_id} sent data after Complete"
|
||||
))
|
||||
}
|
||||
|
||||
fn decode_received_stream_install_frame(
|
||||
frame: Bytes,
|
||||
) -> StreamInstallReceiveResult<StreamInstallFrame> {
|
||||
StreamInstallFrame::decode_checked(frame).map_err(|error| {
|
||||
StreamInstallReceiveError::integrity(eyre::eyre!(
|
||||
"invalid streamed install frame from peer: {error}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn classify_stream_install_read_error(
|
||||
error: std::io::Error,
|
||||
game_id: &str,
|
||||
cancel_token: &CancellationToken,
|
||||
context: &str,
|
||||
) -> StreamInstallReceiveError {
|
||||
if error.kind() == std::io::ErrorKind::InvalidData {
|
||||
return StreamInstallReceiveError::integrity(eyre::eyre!(
|
||||
"invalid or oversized streamed install frame from peer: {error}"
|
||||
));
|
||||
}
|
||||
StreamInstallReceiveError::transport_or_cancelled(error, game_id, cancel_token, context)
|
||||
}
|
||||
|
||||
async fn await_stream_install_open<T>(
|
||||
open: impl Future<Output = eyre::Result<T>>,
|
||||
game_id: &str,
|
||||
cancel_token: &CancellationToken,
|
||||
inactivity: StreamInstallInactivityDeadline,
|
||||
) -> StreamInstallReceiveResult<T> {
|
||||
inactivity
|
||||
.run(open, game_id, cancel_token, "while opening its stream")
|
||||
.await?
|
||||
.map_err(|error| {
|
||||
StreamInstallReceiveError::transport_or_cancelled(
|
||||
error,
|
||||
game_id,
|
||||
cancel_token,
|
||||
"while opening its stream",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_stream_install_request_frame<W>(
|
||||
framed_tx: &mut FramedWrite<W, LengthDelimitedCodec>,
|
||||
game_id: &str,
|
||||
content_id: ContentId,
|
||||
cancel_token: &CancellationToken,
|
||||
inactivity: StreamInstallInactivityDeadline,
|
||||
) -> StreamInstallReceiveResult<()>
|
||||
where
|
||||
W: tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let request = Request::StreamInstall {
|
||||
game_id: game_id.to_string(),
|
||||
content_id,
|
||||
}
|
||||
.encode()
|
||||
.map_err(StreamInstallReceiveError::setup)?;
|
||||
inactivity
|
||||
.run(
|
||||
framed_tx.send(request),
|
||||
game_id,
|
||||
cancel_token,
|
||||
"while sending its request",
|
||||
)
|
||||
.await?
|
||||
.map_err(|error| {
|
||||
StreamInstallReceiveError::transport_or_cancelled(
|
||||
error,
|
||||
game_id,
|
||||
cancel_token,
|
||||
"while sending its request",
|
||||
)
|
||||
})?;
|
||||
|
||||
inactivity
|
||||
.run(
|
||||
framed_tx.close(),
|
||||
game_id,
|
||||
cancel_token,
|
||||
"while closing its request",
|
||||
)
|
||||
.await?
|
||||
.map_err(|error| {
|
||||
StreamInstallReceiveError::transport_or_cancelled(
|
||||
error,
|
||||
game_id,
|
||||
cancel_token,
|
||||
"while closing its request",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct StreamInstallRequestScope<'a> {
|
||||
rx: Option<ReceiveStream>,
|
||||
framed_tx: Option<FramedWrite<SendStream, LengthDelimitedCodec>>,
|
||||
game_id: &'a str,
|
||||
content_id: ContentId,
|
||||
}
|
||||
|
||||
impl<'a> StreamInstallRequestScope<'a> {
|
||||
fn new(rx: ReceiveStream, tx: SendStream, game_id: &'a str, content_id: ContentId) -> Self {
|
||||
Self {
|
||||
rx: Some(rx),
|
||||
framed_tx: Some(FramedWrite::new(tx, LengthDelimitedCodec::new())),
|
||||
game_id,
|
||||
content_id,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_request(
|
||||
&mut self,
|
||||
cancel_token: &CancellationToken,
|
||||
inactivity: StreamInstallInactivityDeadline,
|
||||
) -> StreamInstallReceiveResult<()> {
|
||||
let framed_tx = self
|
||||
.framed_tx
|
||||
.as_mut()
|
||||
.expect("request scope should retain its send half until completion");
|
||||
send_stream_install_request_frame(
|
||||
framed_tx,
|
||||
self.game_id,
|
||||
self.content_id,
|
||||
cancel_token,
|
||||
inactivity,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn into_receive(mut self) -> ReceiveStream {
|
||||
let framed_tx = self
|
||||
.framed_tx
|
||||
.take()
|
||||
.expect("completed request should retain its send half");
|
||||
drop(framed_tx.into_inner());
|
||||
self.rx
|
||||
.take()
|
||||
.expect("completed request should retain its receive half")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StreamInstallRequestScope<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let Some(framed_tx) = self.framed_tx.take() {
|
||||
let mut tx = framed_tx.into_inner();
|
||||
reset_stream_install(&mut tx, self.game_id);
|
||||
}
|
||||
if let Some(mut rx) = self.rx.take() {
|
||||
stop_stream_install_receive(&mut rx, self.game_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_stream_install_receive(rx: &mut ReceiveStream, game_id: &str) {
|
||||
if let Err(err) = rx.stop_sending(application::Error::UNKNOWN) {
|
||||
log::debug!("Failed to stop streamed install receive for {game_id}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamInstallReceiveScope<'a> {
|
||||
framed_rx: FramedRead<ReceiveStream, LengthDelimitedCodec>,
|
||||
game_id: &'a str,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl<'a> StreamInstallReceiveScope<'a> {
|
||||
fn new(rx: ReceiveStream, game_id: &'a str) -> Self {
|
||||
Self {
|
||||
framed_rx: FramedRead::new(
|
||||
rx,
|
||||
LengthDelimitedCodec::builder()
|
||||
.max_frame_length(MAX_STREAM_INSTALL_FRAME_BYTES)
|
||||
.new_codec(),
|
||||
),
|
||||
game_id,
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn next(&mut self) -> Option<Result<bytes::BytesMut, std::io::Error>> {
|
||||
self.framed_rx.next().await
|
||||
}
|
||||
|
||||
async fn drain_fin(
|
||||
mut self,
|
||||
cancel_token: &CancellationToken,
|
||||
inactivity: StreamInstallInactivityDeadline,
|
||||
) -> StreamInstallReceiveResult<()> {
|
||||
let trailing = await_stream_install_fin(
|
||||
self.framed_rx.next(),
|
||||
self.game_id,
|
||||
cancel_token,
|
||||
inactivity,
|
||||
)
|
||||
.await?;
|
||||
match trailing {
|
||||
None => {
|
||||
self.finished = true;
|
||||
Ok(())
|
||||
}
|
||||
Some(Ok(_)) => Err(data_after_complete(self.game_id)),
|
||||
Some(Err(error)) => Err(classify_stream_install_read_error(
|
||||
error,
|
||||
self.game_id,
|
||||
cancel_token,
|
||||
"while draining its response",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn await_stream_install_fin(
|
||||
next: impl Future<Output = Option<Result<bytes::BytesMut, std::io::Error>>>,
|
||||
game_id: &str,
|
||||
cancel_token: &CancellationToken,
|
||||
inactivity: StreamInstallInactivityDeadline,
|
||||
) -> StreamInstallReceiveResult<Option<Result<bytes::BytesMut, std::io::Error>>> {
|
||||
inactivity
|
||||
.run(
|
||||
next,
|
||||
game_id,
|
||||
cancel_token,
|
||||
"while waiting for response FIN",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
impl Drop for StreamInstallReceiveScope<'_> {
|
||||
fn drop(&mut self) {
|
||||
if !self.finished {
|
||||
stop_stream_install_receive(self.framed_rx.get_mut(), self.game_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamInstallProgress {
|
||||
id: String,
|
||||
attempt: DownloadAttemptReporter,
|
||||
total_bytes: u64,
|
||||
downloaded_bytes: u64,
|
||||
last_downloaded_bytes: u64,
|
||||
@@ -890,25 +1751,21 @@ struct StreamInstallProgress {
|
||||
}
|
||||
|
||||
impl StreamInstallProgress {
|
||||
fn new(id: String) -> Self {
|
||||
fn new(attempt: DownloadAttemptReporter, total_bytes: u64) -> Self {
|
||||
Self {
|
||||
id,
|
||||
total_bytes: 0,
|
||||
attempt,
|
||||
total_bytes,
|
||||
downloaded_bytes: 0,
|
||||
last_downloaded_bytes: 0,
|
||||
last_at: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_total(&mut self, bytes: u64) {
|
||||
self.total_bytes = self.total_bytes.saturating_add(bytes);
|
||||
}
|
||||
|
||||
fn record_bytes(&mut self, bytes: u64) {
|
||||
self.downloaded_bytes = self.downloaded_bytes.saturating_add(bytes);
|
||||
}
|
||||
|
||||
fn emit_current(&mut self, tx_notify_ui: &UnboundedSender<PeerEvent>) {
|
||||
fn emit_current(&mut self) {
|
||||
let now = Instant::now();
|
||||
let speed = bytes_per_second(
|
||||
self.downloaded_bytes
|
||||
@@ -918,17 +1775,17 @@ impl StreamInstallProgress {
|
||||
|
||||
self.last_downloaded_bytes = self.downloaded_bytes;
|
||||
self.last_at = now;
|
||||
self.emit_snapshot(tx_notify_ui, speed);
|
||||
self.emit_snapshot(speed);
|
||||
}
|
||||
|
||||
fn emit_snapshot(&self, tx_notify_ui: &UnboundedSender<PeerEvent>, bytes_per_second: u64) {
|
||||
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFilesProgress(DownloadProgress {
|
||||
id: self.id.clone(),
|
||||
fn emit_snapshot(&self, bytes_per_second: u64) {
|
||||
self.attempt.emit_progress(DownloadProgress {
|
||||
attempt: self.attempt.key().clone(),
|
||||
downloaded_bytes: self.downloaded_bytes,
|
||||
total_bytes: self.total_bytes,
|
||||
bytes_per_second,
|
||||
active_peer_count: 1,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -938,75 +1795,93 @@ fn bytes_per_second(bytes: u64, elapsed: Duration) -> u64 {
|
||||
u64::try_from(rate).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
struct IncomingFile {
|
||||
relative_path: String,
|
||||
struct IncomingFile<W = File> {
|
||||
relative_path: CanonicalCatalogPath,
|
||||
path: PathBuf,
|
||||
integrity: SenderArchiveIntegrity,
|
||||
expected_blake3: Blake3Digest,
|
||||
received: u64,
|
||||
crc32: Hasher,
|
||||
file: File,
|
||||
blake3: blake3::Hasher,
|
||||
file: W,
|
||||
}
|
||||
|
||||
impl IncomingFile {
|
||||
impl<W: std::io::Write> IncomingFile<W> {
|
||||
fn new(
|
||||
relative_path: String,
|
||||
relative_path: CanonicalCatalogPath,
|
||||
path: PathBuf,
|
||||
expected_size: u64,
|
||||
expected_crc32: u32,
|
||||
file: File,
|
||||
expected_blake3: Blake3Digest,
|
||||
file: W,
|
||||
) -> Self {
|
||||
Self {
|
||||
relative_path,
|
||||
path,
|
||||
integrity: SenderArchiveIntegrity::new(expected_size, expected_crc32),
|
||||
expected_blake3,
|
||||
received: 0,
|
||||
crc32: Hasher::new(),
|
||||
blake3: blake3::Hasher::new(),
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_chunk(
|
||||
&mut self,
|
||||
game_id: &str,
|
||||
peer_addr: SocketAddr,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
bytes: Bytes,
|
||||
) -> eyre::Result<u64> {
|
||||
fn write_chunk(&mut self, bytes: &[u8]) -> StreamInstallReceiveResult<u64> {
|
||||
let offset = self.received;
|
||||
let length = u64::try_from(bytes.len())?;
|
||||
let length = u64::try_from(bytes.len()).map_err(StreamInstallReceiveError::setup)?;
|
||||
if offset.saturating_add(length) > self.integrity.expected_size {
|
||||
eyre::bail!(
|
||||
return Err(StreamInstallReceiveError::integrity(eyre::eyre!(
|
||||
"streamed file {} exceeded expected size {}",
|
||||
self.relative_path,
|
||||
self.integrity.expected_size
|
||||
);
|
||||
)));
|
||||
}
|
||||
self.file.write_all(&bytes).await?;
|
||||
self.crc32.update(&bytes);
|
||||
scoped_blocking(|| self.file.write_all(bytes)).map_err(StreamInstallReceiveError::setup)?;
|
||||
self.crc32.update(bytes);
|
||||
self.blake3.update(bytes);
|
||||
self.received = self.received.saturating_add(length);
|
||||
|
||||
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished {
|
||||
id: game_id.to_string(),
|
||||
peer_addr,
|
||||
relative_path: format!("{game_id}/.local.installing/{}", self.relative_path),
|
||||
offset,
|
||||
length,
|
||||
});
|
||||
Ok(length)
|
||||
}
|
||||
|
||||
async fn finish(mut self, relative_path: &str) -> eyre::Result<()> {
|
||||
if self.relative_path != relative_path {
|
||||
eyre::bail!(
|
||||
fn finish(
|
||||
mut self,
|
||||
relative_path: &CanonicalCatalogPath,
|
||||
game_id: &str,
|
||||
peer_endpoint: PeerEndpoint,
|
||||
content_id: ContentId,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
) -> StreamInstallReceiveResult<()> {
|
||||
if &self.relative_path != relative_path {
|
||||
return Err(StreamInstallReceiveError::integrity(eyre::eyre!(
|
||||
"streamed file end mismatch: began {}, ended {relative_path}",
|
||||
self.relative_path
|
||||
);
|
||||
)));
|
||||
}
|
||||
self.file.flush().await?;
|
||||
|
||||
let actual_crc32 = self.crc32.finalize();
|
||||
self.integrity
|
||||
.verify(&self.relative_path, self.received, actual_crc32)?;
|
||||
.verify(&self.relative_path, self.received, actual_crc32)
|
||||
.map_err(StreamInstallReceiveError::integrity)?;
|
||||
let actual_blake3 = Blake3Digest::from_bytes(*self.blake3.finalize().as_bytes());
|
||||
if actual_blake3 != self.expected_blake3 {
|
||||
return Err(StreamInstallReceiveError::integrity(eyre::eyre!(
|
||||
"streamed file {} catalog BLAKE3 mismatch: got {actual_blake3}, expected {}",
|
||||
self.relative_path,
|
||||
self.expected_blake3
|
||||
)));
|
||||
}
|
||||
scoped_blocking(|| self.file.flush()).map_err(StreamInstallReceiveError::setup)?;
|
||||
|
||||
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished {
|
||||
id: game_id.to_string(),
|
||||
peer_id: peer_endpoint.peer_id,
|
||||
peer_addr: peer_endpoint.addr,
|
||||
content_id,
|
||||
relative_path: self.relative_path.clone(),
|
||||
offset: 0,
|
||||
length: self.received,
|
||||
});
|
||||
|
||||
log::debug!(
|
||||
"Received streamed file {} -> {}",
|
||||
@@ -1017,8 +1892,11 @@ impl IncomingFile {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_stream_path(staging_dir: &Path, relative_path: &str) -> eyre::Result<PathBuf> {
|
||||
validate_game_file_path(staging_dir, relative_path)
|
||||
fn resolve_stream_path(
|
||||
staging_dir: &Path,
|
||||
relative_path: &CanonicalCatalogPath,
|
||||
) -> eyre::Result<PathBuf> {
|
||||
validate_game_file_path(staging_dir, relative_path.as_str())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1026,16 +1904,39 @@ mod tests {
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Condvar,
|
||||
Mutex,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
mpsc as std_mpsc,
|
||||
},
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use lanspread_db::content_manifest::{
|
||||
CatalogContentManifestBody,
|
||||
CatalogExtractedEntry,
|
||||
CatalogFileEntry,
|
||||
};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::TempDir;
|
||||
|
||||
fn canonical_path(value: &str) -> CanonicalCatalogPath {
|
||||
CanonicalCatalogPath::new(value).expect("test path should be canonical")
|
||||
}
|
||||
|
||||
fn content_id() -> ContentId {
|
||||
ContentId::from_bytes([7; 32])
|
||||
}
|
||||
|
||||
fn peer_endpoint() -> PeerEndpoint {
|
||||
PeerEndpoint::new(
|
||||
lanspread_proto::PeerId::from_bytes([9; 32]),
|
||||
"127.0.0.1:1".parse().expect("address should parse"),
|
||||
)
|
||||
}
|
||||
|
||||
struct PendingWriter {
|
||||
write_polls: Arc<AtomicUsize>,
|
||||
first_write: Arc<Notify>,
|
||||
@@ -1049,17 +1950,54 @@ mod tests {
|
||||
first_shutdown: Arc<Notify>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
struct FailingReader;
|
||||
struct DelayedFileWriter {
|
||||
gate: Arc<(Mutex<bool>, Condvar)>,
|
||||
entered: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
quiesced: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl tokio::io::AsyncRead for FailingReader {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
_buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
Poll::Ready(Err(std::io::Error::other("synthetic pipe read failure")))
|
||||
struct RollbackCanary {
|
||||
quiesced: Arc<std::sync::atomic::AtomicBool>,
|
||||
rollback_started: Arc<std::sync::atomic::AtomicBool>,
|
||||
rollback_before_quiescence: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
struct DropProbe(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for DropProbe {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Write for DelayedFileWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
if let Some(entered) = self.entered.take() {
|
||||
let _ = entered.send(());
|
||||
}
|
||||
let (gate_open, wake) = &*self.gate;
|
||||
let gate_open = gate_open.lock().expect("write gate must not be poisoned");
|
||||
let _gate_open = wake
|
||||
.wait_while(gate_open, |gate_open| !*gate_open)
|
||||
.expect("write gate must not be poisoned");
|
||||
self.quiesced
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RollbackCanary {
|
||||
fn drop(&mut self) {
|
||||
if !self.quiesced.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
self.rollback_before_quiescence
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
self.rollback_started
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1121,6 +2059,80 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn aborted_incoming_write_quiesces_before_rollback_can_start() {
|
||||
let gate = Arc::new((Mutex::new(false), Condvar::new()));
|
||||
let gate_for_release = gate.clone();
|
||||
let (request_release, release_requested) = std_mpsc::channel();
|
||||
let release_thread = std::thread::spawn(move || {
|
||||
let _ = release_requested.recv_timeout(Duration::from_secs(2));
|
||||
let (gate_open, wake) = &*gate_for_release;
|
||||
let mut gate_open = gate_open.lock().expect("write gate must not be poisoned");
|
||||
*gate_open = true;
|
||||
wake.notify_one();
|
||||
});
|
||||
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
|
||||
let quiesced = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let rollback_started = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let rollback_before_quiescence = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let mut task = tokio::spawn({
|
||||
let quiesced = quiesced.clone();
|
||||
let rollback_started = rollback_started.clone();
|
||||
let rollback_before_quiescence = rollback_before_quiescence.clone();
|
||||
async move {
|
||||
let _rollback = RollbackCanary {
|
||||
quiesced: quiesced.clone(),
|
||||
rollback_started,
|
||||
rollback_before_quiescence,
|
||||
};
|
||||
let bytes = Bytes::from_static(b"x");
|
||||
let mut incoming = IncomingFile::new(
|
||||
canonical_path("payload.bin"),
|
||||
PathBuf::from("payload.bin"),
|
||||
1,
|
||||
crc32_of(&bytes),
|
||||
Blake3Digest::hash(&bytes),
|
||||
DelayedFileWriter {
|
||||
gate,
|
||||
entered: Some(entered_tx),
|
||||
quiesced,
|
||||
},
|
||||
);
|
||||
incoming
|
||||
.write_chunk(&bytes)
|
||||
.expect("controlled write should succeed");
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
});
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(2), entered_rx)
|
||||
.await
|
||||
.expect("controlled write should start")
|
||||
.expect("controlled writer should retain entry sender");
|
||||
task.abort();
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(50), &mut task)
|
||||
.await
|
||||
.is_err(),
|
||||
"aborted receiver must remain alive while its write is in progress"
|
||||
);
|
||||
assert!(!quiesced.load(std::sync::atomic::Ordering::SeqCst));
|
||||
assert!(!rollback_started.load(std::sync::atomic::Ordering::SeqCst));
|
||||
|
||||
request_release
|
||||
.send(())
|
||||
.expect("release thread should remain available");
|
||||
release_thread
|
||||
.join()
|
||||
.expect("release thread should not panic");
|
||||
task.await
|
||||
.expect_err("aborted receiver should stop after write quiescence");
|
||||
|
||||
assert!(quiesced.load(std::sync::atomic::Ordering::SeqCst));
|
||||
assert!(rollback_started.load(std::sync::atomic::Ordering::SeqCst));
|
||||
assert!(!rollback_before_quiescence.load(std::sync::atomic::Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn producer_sink_cancellation_wins_over_channel_capacity() {
|
||||
let (frame_tx, mut frame_rx) = mpsc::channel(1);
|
||||
@@ -1140,12 +2152,286 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incoming_open_cancellation_settles_its_inline_scope() {
|
||||
let cancelled = CancellationToken::new();
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let probe = DropProbe(dropped.clone());
|
||||
let open = async move {
|
||||
let _probe = probe;
|
||||
std::future::pending::<eyre::Result<()>>().await
|
||||
};
|
||||
cancelled.cancel();
|
||||
|
||||
let error = await_stream_install_open(
|
||||
open,
|
||||
"game",
|
||||
&cancelled,
|
||||
StreamInstallInactivityDeadline::ordinary(),
|
||||
)
|
||||
.await
|
||||
.expect_err("cancelled stream open should fail");
|
||||
|
||||
assert!(error.to_string().contains("opening its stream"));
|
||||
assert!(dropped.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incoming_open_has_an_application_inactivity_deadline() {
|
||||
let cancellation = CancellationToken::new();
|
||||
let error = await_stream_install_open(
|
||||
std::future::pending::<eyre::Result<()>>(),
|
||||
"game",
|
||||
&cancellation,
|
||||
StreamInstallInactivityDeadline::after(Duration::from_millis(25)),
|
||||
)
|
||||
.await
|
||||
.expect_err("pending stream open must time out");
|
||||
|
||||
assert_eq!(error.kind(), StreamInstallReceiveErrorKind::Transport);
|
||||
assert!(error.to_string().contains("no frame progress"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn progress_ticks_do_not_reset_a_pending_frame_deadline() {
|
||||
let cancellation = CancellationToken::new();
|
||||
let inactivity = StreamInstallInactivityDeadline::after(Duration::from_millis(35));
|
||||
let mut progress_ticks = 0;
|
||||
|
||||
let error = loop {
|
||||
match await_stream_install_input(
|
||||
std::future::pending::<()>(),
|
||||
time::sleep(Duration::from_millis(5)),
|
||||
"game",
|
||||
&cancellation,
|
||||
inactivity,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(StreamInstallInput::ProgressTick) => progress_ticks += 1,
|
||||
Ok(StreamInstallInput::Frame(())) => {
|
||||
panic!("pending frame future must not complete")
|
||||
}
|
||||
Err(error) => break error,
|
||||
}
|
||||
};
|
||||
|
||||
assert!(
|
||||
progress_ticks > 1,
|
||||
"test must observe periodic progress ticks"
|
||||
);
|
||||
assert_eq!(error.kind(), StreamInstallReceiveErrorKind::Transport);
|
||||
assert!(error.to_string().contains("no frame progress"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_complete_fin_has_the_same_inactivity_deadline() {
|
||||
let cancellation = CancellationToken::new();
|
||||
let error = await_stream_install_fin(
|
||||
std::future::pending::<Option<Result<bytes::BytesMut, std::io::Error>>>(),
|
||||
"game",
|
||||
&cancellation,
|
||||
StreamInstallInactivityDeadline::after(Duration::from_millis(25)),
|
||||
)
|
||||
.await
|
||||
.expect_err("pending response FIN must time out");
|
||||
|
||||
assert_eq!(error.kind(), StreamInstallReceiveErrorKind::Transport);
|
||||
assert!(error.to_string().contains("response FIN"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incoming_request_cancellation_interrupts_a_blocked_send() {
|
||||
let write_polls = Arc::new(AtomicUsize::new(0));
|
||||
let first_write = Arc::new(Notify::new());
|
||||
let mut framed_tx = FramedWrite::new(
|
||||
PendingWriter {
|
||||
write_polls: write_polls.clone(),
|
||||
first_write: first_write.clone(),
|
||||
},
|
||||
LengthDelimitedCodec::new(),
|
||||
);
|
||||
let cancel_token = CancellationToken::new();
|
||||
let cancellation = tokio::spawn({
|
||||
let cancel_token = cancel_token.clone();
|
||||
async move {
|
||||
first_write.notified().await;
|
||||
cancel_token.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
let error = send_stream_install_request_frame(
|
||||
&mut framed_tx,
|
||||
"game",
|
||||
content_id(),
|
||||
&cancel_token,
|
||||
StreamInstallInactivityDeadline::ordinary(),
|
||||
)
|
||||
.await
|
||||
.expect_err("blocked request send should observe cancellation");
|
||||
cancellation
|
||||
.await
|
||||
.expect("cancellation helper should finish");
|
||||
|
||||
assert!(error.to_string().contains("sending its request"));
|
||||
let polls_after_return = write_polls.load(Ordering::SeqCst);
|
||||
assert!(polls_after_return > 0, "test must reach the blocked send");
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(write_polls.load(Ordering::SeqCst), polls_after_return);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incoming_request_send_has_an_application_inactivity_deadline() {
|
||||
let write_polls = Arc::new(AtomicUsize::new(0));
|
||||
let mut framed_tx = FramedWrite::new(
|
||||
PendingWriter {
|
||||
write_polls: write_polls.clone(),
|
||||
first_write: Arc::new(Notify::new()),
|
||||
},
|
||||
LengthDelimitedCodec::new(),
|
||||
);
|
||||
|
||||
let error = send_stream_install_request_frame(
|
||||
&mut framed_tx,
|
||||
"game",
|
||||
content_id(),
|
||||
&CancellationToken::new(),
|
||||
StreamInstallInactivityDeadline::after(Duration::from_millis(25)),
|
||||
)
|
||||
.await
|
||||
.expect_err("pending request send must time out");
|
||||
|
||||
assert_eq!(error.kind(), StreamInstallReceiveErrorKind::Transport);
|
||||
assert!(error.to_string().contains("sending its request"));
|
||||
assert!(error.to_string().contains("no frame progress"));
|
||||
assert!(write_polls.load(Ordering::SeqCst) > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incoming_request_cancellation_interrupts_a_blocked_close() {
|
||||
let first_shutdown = Arc::new(Notify::new());
|
||||
let mut framed_tx = FramedWrite::new(
|
||||
PendingShutdownWriter {
|
||||
first_shutdown: first_shutdown.clone(),
|
||||
},
|
||||
LengthDelimitedCodec::new(),
|
||||
);
|
||||
let cancel_token = CancellationToken::new();
|
||||
let cancellation = tokio::spawn({
|
||||
let cancel_token = cancel_token.clone();
|
||||
async move {
|
||||
first_shutdown.notified().await;
|
||||
cancel_token.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
let error = send_stream_install_request_frame(
|
||||
&mut framed_tx,
|
||||
"game",
|
||||
content_id(),
|
||||
&cancel_token,
|
||||
StreamInstallInactivityDeadline::ordinary(),
|
||||
)
|
||||
.await
|
||||
.expect_err("blocked request close should observe cancellation");
|
||||
cancellation
|
||||
.await
|
||||
.expect("cancellation helper should finish");
|
||||
|
||||
assert!(error.to_string().contains("closing its request"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incoming_request_fin_has_an_application_inactivity_deadline() {
|
||||
let mut framed_tx = FramedWrite::new(
|
||||
PendingShutdownWriter {
|
||||
first_shutdown: Arc::new(Notify::new()),
|
||||
},
|
||||
LengthDelimitedCodec::new(),
|
||||
);
|
||||
|
||||
let error = send_stream_install_request_frame(
|
||||
&mut framed_tx,
|
||||
"game",
|
||||
content_id(),
|
||||
&CancellationToken::new(),
|
||||
StreamInstallInactivityDeadline::after(Duration::from_millis(25)),
|
||||
)
|
||||
.await
|
||||
.expect_err("pending request FIN must time out");
|
||||
|
||||
assert_eq!(error.kind(), StreamInstallReceiveErrorKind::Transport);
|
||||
assert!(error.to_string().contains("closing its request"));
|
||||
assert!(error.to_string().contains("no frame progress"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incoming_request_success_waits_for_its_close_completion() {
|
||||
let cancel_token = CancellationToken::new();
|
||||
let mut framed_tx = FramedWrite::new(
|
||||
CancelOnShutdownWriter {
|
||||
cancel_token: cancel_token.clone(),
|
||||
},
|
||||
LengthDelimitedCodec::new(),
|
||||
);
|
||||
|
||||
send_stream_install_request_frame(
|
||||
&mut framed_tx,
|
||||
"game",
|
||||
content_id(),
|
||||
&cancel_token,
|
||||
StreamInstallInactivityDeadline::ordinary(),
|
||||
)
|
||||
.await
|
||||
.expect("request close that completes in the current poll should succeed");
|
||||
|
||||
assert!(
|
||||
cancel_token.is_cancelled(),
|
||||
"test writer must have observed the request close"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incoming_request_carries_the_exact_catalog_content_id() {
|
||||
let expected_content_id = content_id();
|
||||
let cancel_token = CancellationToken::new();
|
||||
let (writer, reader) = tokio::io::duplex(4_096);
|
||||
let mut framed_tx = FramedWrite::new(writer, LengthDelimitedCodec::new());
|
||||
|
||||
send_stream_install_request_frame(
|
||||
&mut framed_tx,
|
||||
"game",
|
||||
expected_content_id,
|
||||
&cancel_token,
|
||||
StreamInstallInactivityDeadline::ordinary(),
|
||||
)
|
||||
.await
|
||||
.expect("request should send and close");
|
||||
|
||||
let mut framed_rx = FramedRead::new(reader, LengthDelimitedCodec::new());
|
||||
let encoded = framed_rx
|
||||
.next()
|
||||
.await
|
||||
.expect("request frame should exist")
|
||||
.expect("request frame should decode")
|
||||
.freeze();
|
||||
assert_eq!(
|
||||
<Request as ControlMessage>::decode(encoded)
|
||||
.expect("request should pass strict control decoding"),
|
||||
Request::StreamInstall {
|
||||
game_id: "game".to_owned(),
|
||||
content_id: expected_content_id,
|
||||
}
|
||||
);
|
||||
assert!(framed_rx.next().await.is_none(), "request must end at FIN");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn outbound_cancellation_wins_over_already_queued_frames() {
|
||||
let (frame_tx, mut frame_rx) = mpsc::channel(2);
|
||||
frame_tx
|
||||
.try_send(StreamInstallFrame::Directory {
|
||||
relative_path: "bin".to_string(),
|
||||
relative_path: canonical_path("bin"),
|
||||
})
|
||||
.expect("first frame should fit");
|
||||
frame_tx
|
||||
@@ -1163,6 +2449,29 @@ mod tests {
|
||||
assert_eq!(frame_rx.len(), 2, "cancelled egress must not drain frames");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stalled_provider_cannot_hold_stream_install_authority_forever() {
|
||||
let (_frame_tx, mut frame_rx) = mpsc::channel(1);
|
||||
let mut framed_tx = FramedWrite::new(tokio::io::sink(), LengthDelimitedCodec::new());
|
||||
|
||||
let outcome = forward_stream_install_frames_with_timeout(
|
||||
&mut framed_tx,
|
||||
&mut frame_rx,
|
||||
&CancellationToken::new(),
|
||||
Duration::from_millis(25),
|
||||
)
|
||||
.await;
|
||||
|
||||
let StreamInstallEgressOutcome::Failed(error) = outcome else {
|
||||
panic!("stalled producer should hit the application inactivity deadline");
|
||||
};
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("producer timed out after 25ms without a frame")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn outbound_cancellation_interrupts_a_blocked_frame_write() {
|
||||
let (frame_tx, mut frame_rx) = mpsc::channel(1);
|
||||
@@ -1205,6 +2514,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slow_reader_cannot_hold_stream_install_frame_send_forever() {
|
||||
let (frame_tx, mut frame_rx) = mpsc::channel(1);
|
||||
frame_tx
|
||||
.try_send(StreamInstallFrame::Complete)
|
||||
.expect("frame should fit");
|
||||
let mut framed_tx = FramedWrite::new(
|
||||
PendingWriter {
|
||||
write_polls: Arc::new(AtomicUsize::new(0)),
|
||||
first_write: Arc::new(Notify::new()),
|
||||
},
|
||||
LengthDelimitedCodec::new(),
|
||||
);
|
||||
|
||||
let outcome = forward_stream_install_frames_with_timeout(
|
||||
&mut framed_tx,
|
||||
&mut frame_rx,
|
||||
&CancellationToken::new(),
|
||||
Duration::from_millis(25),
|
||||
)
|
||||
.await;
|
||||
|
||||
let StreamInstallEgressOutcome::Failed(error) = outcome else {
|
||||
panic!("blocked send should hit the application inactivity deadline");
|
||||
};
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("frame send timed out after 25ms")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn successful_close_is_the_egress_completion_point() {
|
||||
let (frame_tx, mut frame_rx) = mpsc::channel(1);
|
||||
@@ -1253,40 +2594,29 @@ mod tests {
|
||||
assert!(matches!(outcome, StreamInstallEgressOutcome::Cancelled));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn unrar_capture_error_kills_and_reaps_child_before_returning() {
|
||||
let mut child = Command::new("sleep")
|
||||
.arg("30")
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.expect("sleep process should start");
|
||||
assert!(
|
||||
child
|
||||
.try_wait()
|
||||
.expect("initial process status should be readable")
|
||||
.is_none(),
|
||||
"test child must still be running before capture"
|
||||
async fn slow_reader_cannot_hold_stream_install_close_forever() {
|
||||
let (frame_tx, mut frame_rx) = mpsc::channel(1);
|
||||
drop(frame_tx);
|
||||
let mut framed_tx = FramedWrite::new(
|
||||
PendingShutdownWriter {
|
||||
first_shutdown: Arc::new(Notify::new()),
|
||||
},
|
||||
LengthDelimitedCodec::new(),
|
||||
);
|
||||
|
||||
let err = capture_unrar_output(
|
||||
&mut child,
|
||||
&mut FailingReader,
|
||||
&mut tokio::io::empty(),
|
||||
let outcome = forward_stream_install_frames_with_timeout(
|
||||
&mut framed_tx,
|
||||
&mut frame_rx,
|
||||
&CancellationToken::new(),
|
||||
Path::new("broken.eti"),
|
||||
Duration::from_millis(25),
|
||||
)
|
||||
.await
|
||||
.expect_err("synthetic pipe failure should fail capture");
|
||||
.await;
|
||||
|
||||
assert!(err.to_string().contains("synthetic pipe read failure"));
|
||||
assert!(
|
||||
child
|
||||
.try_wait()
|
||||
.expect("reaped process status should be readable")
|
||||
.is_some(),
|
||||
"capture errors must kill and reap the process before returning"
|
||||
);
|
||||
let StreamInstallEgressOutcome::Failed(error) = outcome else {
|
||||
panic!("blocked close should hit the application inactivity deadline");
|
||||
};
|
||||
assert!(error.to_string().contains("close timed out after 25ms"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1296,10 +2626,599 @@ mod tests {
|
||||
std::fs::create_dir_all(&staging).expect("staging should be created");
|
||||
let staging = std::fs::canonicalize(staging).expect("staging should canonicalize");
|
||||
|
||||
assert!(resolve_stream_path(&staging, "bin/game.exe").is_ok());
|
||||
assert!(resolve_stream_path(&staging, "../outside").is_err());
|
||||
assert!(resolve_stream_path(&staging, "/absolute").is_err());
|
||||
assert!(resolve_stream_path(&staging, "C:/windows").is_err());
|
||||
assert!(resolve_stream_path(&staging, &canonical_path("bin/game.exe")).is_ok());
|
||||
for invalid in ["../outside", "/absolute", "C:/windows"] {
|
||||
assert!(CanonicalCatalogPath::new(invalid).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_verifier_accepts_exact_output_and_cross_archive_directories() {
|
||||
let payload = b"catalog payload";
|
||||
let manifest = test_catalog_manifest(
|
||||
&["a.eti", "b.eti"],
|
||||
vec![
|
||||
CatalogExtractedEntry::directory("bin").expect("directory entry should validate"),
|
||||
CatalogExtractedEntry::file(
|
||||
"bin/payload.bin",
|
||||
u64::try_from(payload.len()).expect("payload size should fit"),
|
||||
Blake3Digest::hash(payload),
|
||||
)
|
||||
.expect("file entry should validate"),
|
||||
],
|
||||
);
|
||||
let mut verifier =
|
||||
CatalogStreamVerifier::new("game", manifest).expect("streamed catalog should verify");
|
||||
|
||||
verifier
|
||||
.begin_archive(&canonical_path("a.eti"), 0)
|
||||
.expect("first archive should begin");
|
||||
verifier
|
||||
.record_directory(&canonical_path("bin"))
|
||||
.expect("catalog directory should be accepted");
|
||||
verifier
|
||||
.end_archive(&canonical_path("a.eti"))
|
||||
.expect("first archive should end");
|
||||
verifier
|
||||
.begin_archive(
|
||||
&canonical_path("b.eti"),
|
||||
u64::try_from(payload.len()).expect("payload size should fit"),
|
||||
)
|
||||
.expect("second archive should begin");
|
||||
verifier
|
||||
.record_directory(&canonical_path("bin"))
|
||||
.expect("a directory may recur in another bounded archive");
|
||||
assert_eq!(
|
||||
verifier
|
||||
.begin_file(
|
||||
&canonical_path("bin/payload.bin"),
|
||||
u64::try_from(payload.len()).expect("payload size should fit")
|
||||
)
|
||||
.expect("catalog file should begin"),
|
||||
Blake3Digest::hash(payload)
|
||||
);
|
||||
verifier
|
||||
.record_file_chunk(payload.len())
|
||||
.expect("nonempty file chunk should be accepted");
|
||||
verifier
|
||||
.end_file(&canonical_path("bin/payload.bin"))
|
||||
.expect("catalog file should end");
|
||||
verifier
|
||||
.end_archive(&canonical_path("b.eti"))
|
||||
.expect("second archive should end");
|
||||
verifier
|
||||
.verify_complete()
|
||||
.expect("the exact catalog output should complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_verifier_rejects_unknown_shape_size_and_duplicate_files() {
|
||||
let payload = b"payload";
|
||||
let extracted = vec![
|
||||
CatalogExtractedEntry::directory("bin").expect("directory should validate"),
|
||||
CatalogExtractedEntry::file(
|
||||
"bin/payload.bin",
|
||||
u64::try_from(payload.len()).expect("payload size should fit"),
|
||||
Blake3Digest::hash(payload),
|
||||
)
|
||||
.expect("file should validate"),
|
||||
];
|
||||
|
||||
let mut unknown = CatalogStreamVerifier::new(
|
||||
"game",
|
||||
test_catalog_manifest(&["a.eti"], extracted.clone()),
|
||||
)
|
||||
.expect("manifest should authorize streaming");
|
||||
unknown
|
||||
.begin_archive(&canonical_path("a.eti"), 0)
|
||||
.expect("archive should begin");
|
||||
assert!(
|
||||
unknown
|
||||
.record_directory(&canonical_path("unknown"))
|
||||
.expect_err("unknown path must fail")
|
||||
.to_string()
|
||||
.contains("unknown path")
|
||||
);
|
||||
|
||||
let mut wrong_shape = CatalogStreamVerifier::new(
|
||||
"game",
|
||||
test_catalog_manifest(&["a.eti"], extracted.clone()),
|
||||
)
|
||||
.expect("manifest should authorize streaming");
|
||||
wrong_shape
|
||||
.begin_archive(&canonical_path("a.eti"), 0)
|
||||
.expect("archive should begin");
|
||||
assert!(
|
||||
wrong_shape
|
||||
.record_directory(&canonical_path("bin/payload.bin"))
|
||||
.expect_err("file-as-directory must fail")
|
||||
.to_string()
|
||||
.contains("catalog expects File")
|
||||
);
|
||||
|
||||
let mut wrong_size = CatalogStreamVerifier::new(
|
||||
"game",
|
||||
test_catalog_manifest(&["a.eti"], extracted.clone()),
|
||||
)
|
||||
.expect("manifest should authorize streaming");
|
||||
wrong_size
|
||||
.begin_archive(&canonical_path("a.eti"), 0)
|
||||
.expect("archive should begin");
|
||||
assert!(
|
||||
wrong_size
|
||||
.begin_file(&canonical_path("bin/payload.bin"), 1)
|
||||
.expect_err("catalog size mismatch must fail")
|
||||
.to_string()
|
||||
.contains("catalog expects")
|
||||
);
|
||||
|
||||
let mut duplicate = CatalogStreamVerifier::new(
|
||||
"game",
|
||||
test_catalog_manifest(&["a.eti", "b.eti"], extracted),
|
||||
)
|
||||
.expect("manifest should authorize streaming");
|
||||
duplicate
|
||||
.begin_archive(&canonical_path("a.eti"), 0)
|
||||
.expect("first archive should begin");
|
||||
duplicate
|
||||
.begin_file(
|
||||
&canonical_path("bin/payload.bin"),
|
||||
u64::try_from(payload.len()).expect("payload size should fit"),
|
||||
)
|
||||
.expect("first file occurrence should begin");
|
||||
duplicate
|
||||
.end_file(&canonical_path("bin/payload.bin"))
|
||||
.expect("first file occurrence should end");
|
||||
duplicate
|
||||
.end_archive(&canonical_path("a.eti"))
|
||||
.expect("first archive should end");
|
||||
duplicate
|
||||
.begin_archive(&canonical_path("b.eti"), 0)
|
||||
.expect("second archive should begin");
|
||||
assert!(
|
||||
duplicate
|
||||
.begin_file(
|
||||
&canonical_path("bin/payload.bin"),
|
||||
u64::try_from(payload.len()).expect("payload size should fit")
|
||||
)
|
||||
.expect_err("duplicate file across archives must fail")
|
||||
.to_string()
|
||||
.contains("repeated file")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_verifier_rejects_missing_and_unbalanced_completion() {
|
||||
let payload = b"payload";
|
||||
let extracted = vec![
|
||||
CatalogExtractedEntry::file(
|
||||
"payload.bin",
|
||||
u64::try_from(payload.len()).expect("payload size should fit"),
|
||||
Blake3Digest::hash(payload),
|
||||
)
|
||||
.expect("file should validate"),
|
||||
];
|
||||
|
||||
let mut missing = CatalogStreamVerifier::new(
|
||||
"game",
|
||||
test_catalog_manifest(&["a.eti"], extracted.clone()),
|
||||
)
|
||||
.expect("manifest should authorize streaming");
|
||||
missing
|
||||
.begin_archive(&canonical_path("a.eti"), 0)
|
||||
.expect("archive should begin");
|
||||
missing
|
||||
.end_archive(&canonical_path("a.eti"))
|
||||
.expect("archive should end");
|
||||
assert!(
|
||||
missing
|
||||
.verify_complete()
|
||||
.expect_err("missing catalog file must fail")
|
||||
.to_string()
|
||||
.contains("missing catalog entry")
|
||||
);
|
||||
|
||||
let mut open_archive = CatalogStreamVerifier::new(
|
||||
"game",
|
||||
test_catalog_manifest(&["a.eti"], extracted.clone()),
|
||||
)
|
||||
.expect("manifest should authorize streaming");
|
||||
open_archive
|
||||
.begin_archive(&canonical_path("a.eti"), 0)
|
||||
.expect("archive should begin");
|
||||
assert!(
|
||||
open_archive
|
||||
.verify_complete()
|
||||
.expect_err("Complete before ArchiveEnd must fail")
|
||||
.to_string()
|
||||
.contains("before ArchiveEnd")
|
||||
);
|
||||
|
||||
let mut open_file =
|
||||
CatalogStreamVerifier::new("game", test_catalog_manifest(&["a.eti"], extracted))
|
||||
.expect("manifest should authorize streaming");
|
||||
assert!(
|
||||
open_file
|
||||
.begin_archive(&canonical_path("unknown.eti"), 0)
|
||||
.expect_err("archive outside the catalog set must fail")
|
||||
.to_string()
|
||||
.contains("unknown streamed archive")
|
||||
);
|
||||
open_file
|
||||
.begin_archive(&canonical_path("a.eti"), 0)
|
||||
.expect("archive should begin");
|
||||
assert!(
|
||||
open_file
|
||||
.record_file_chunk(1)
|
||||
.expect_err("chunk before FileBegin must fail")
|
||||
.to_string()
|
||||
.contains("without FileBegin")
|
||||
);
|
||||
open_file
|
||||
.begin_file(
|
||||
&canonical_path("payload.bin"),
|
||||
u64::try_from(payload.len()).expect("payload size should fit"),
|
||||
)
|
||||
.expect("file should begin");
|
||||
assert!(
|
||||
open_file
|
||||
.record_file_chunk(0)
|
||||
.expect_err("empty chunks must not make an open file unbounded")
|
||||
.to_string()
|
||||
.contains("empty FileChunk")
|
||||
);
|
||||
assert!(
|
||||
open_file
|
||||
.end_archive(&canonical_path("a.eti"))
|
||||
.expect_err("ArchiveEnd with an open file must fail")
|
||||
.to_string()
|
||||
.contains("while streamed file payload.bin is open")
|
||||
);
|
||||
assert!(
|
||||
open_file
|
||||
.verify_complete()
|
||||
.expect_err("Complete with an open file must fail")
|
||||
.to_string()
|
||||
.contains("while streamed file payload.bin is open")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_verifier_requires_stream_support_and_bounds_entry_frames() {
|
||||
let unsupported = test_catalog_manifest(&["a.eti"], Vec::new());
|
||||
assert!(
|
||||
CatalogStreamVerifier::new("game", unsupported)
|
||||
.expect_err("missing extracted manifest must disable streaming")
|
||||
.to_string()
|
||||
.contains("does not support")
|
||||
);
|
||||
|
||||
let directory_manifest = || {
|
||||
test_catalog_manifest(
|
||||
&["a.eti"],
|
||||
vec![CatalogExtractedEntry::directory("bin").expect("directory should validate")],
|
||||
)
|
||||
};
|
||||
let mut repeated = CatalogStreamVerifier::new("game", directory_manifest())
|
||||
.expect("manifest should authorize streaming");
|
||||
repeated
|
||||
.begin_archive(&canonical_path("a.eti"), 0)
|
||||
.expect("archive should begin");
|
||||
repeated
|
||||
.record_directory(&canonical_path("bin"))
|
||||
.expect("first directory frame should be accepted");
|
||||
repeated
|
||||
.record_directory(&canonical_path("bin"))
|
||||
.expect("a repeated expected directory should be accepted within the frame bound");
|
||||
repeated
|
||||
.end_archive(&canonical_path("a.eti"))
|
||||
.expect("archive should end after repeated directories");
|
||||
repeated
|
||||
.verify_complete()
|
||||
.expect("repeated expected directories should still verify exactly");
|
||||
|
||||
let mut bounded = CatalogStreamVerifier::new("game", directory_manifest())
|
||||
.expect("manifest should authorize streaming");
|
||||
bounded
|
||||
.begin_archive(&canonical_path("a.eti"), 0)
|
||||
.expect("archive should begin");
|
||||
bounded.entry_frames = MAX_CATALOG_ENTRIES;
|
||||
assert!(
|
||||
bounded
|
||||
.record_directory(&canonical_path("bin"))
|
||||
.expect_err("entry frames beyond the catalog bound must fail")
|
||||
.to_string()
|
||||
.contains("entry frame limit")
|
||||
);
|
||||
|
||||
let mut telemetry = CatalogStreamVerifier::new(
|
||||
"game",
|
||||
test_catalog_manifest(
|
||||
&["a.eti", "b.eti"],
|
||||
vec![CatalogExtractedEntry::directory("bin").expect("directory should validate")],
|
||||
),
|
||||
)
|
||||
.expect("manifest should authorize streaming");
|
||||
telemetry
|
||||
.begin_archive(&canonical_path("a.eti"), MAX_CATALOG_TOTAL_BYTES)
|
||||
.expect("reported total at the bound should be accepted");
|
||||
telemetry
|
||||
.end_archive(&canonical_path("a.eti"))
|
||||
.expect("first archive should end");
|
||||
assert!(
|
||||
telemetry
|
||||
.begin_archive(&canonical_path("b.eti"), 1)
|
||||
.expect_err("aggregate sender telemetry must remain bounded")
|
||||
.to_string()
|
||||
.contains("reported unpacked limit")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_install_progress_total_is_catalog_owned() {
|
||||
let payload = b"catalog-sized payload";
|
||||
let manifest = test_catalog_manifest(
|
||||
&["a.eti"],
|
||||
vec![
|
||||
CatalogExtractedEntry::file(
|
||||
"payload.bin",
|
||||
u64::try_from(payload.len()).expect("payload size should fit"),
|
||||
Blake3Digest::hash(payload),
|
||||
)
|
||||
.expect("file entry should validate"),
|
||||
],
|
||||
);
|
||||
let mut verifier = CatalogStreamVerifier::new("game", manifest)
|
||||
.expect("manifest should authorize streaming");
|
||||
verifier
|
||||
.begin_archive(&canonical_path("a.eti"), 1)
|
||||
.expect("sender telemetry need not equal the catalog-owned total");
|
||||
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let status = crate::transfer_status::DownloadAttemptStatus::new(
|
||||
crate::DownloadAttemptKey::next("game".to_owned()),
|
||||
CancellationToken::new(),
|
||||
tx,
|
||||
);
|
||||
let progress =
|
||||
StreamInstallProgress::new(status.reporter(), verifier.expected_file_bytes());
|
||||
assert_eq!(
|
||||
progress.total_bytes,
|
||||
u64::try_from(payload.len()).expect("payload size should fit")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_boundary_classifies_disconnects_without_quarantining_them() {
|
||||
assert_eq!(
|
||||
stream_ended_before_complete("game").kind(),
|
||||
StreamInstallReceiveErrorKind::Transport
|
||||
);
|
||||
|
||||
let cancel_token = CancellationToken::new();
|
||||
let read_error = StreamInstallReceiveError::transport_or_cancelled(
|
||||
std::io::Error::new(std::io::ErrorKind::ConnectionReset, "peer disconnected"),
|
||||
"game",
|
||||
&cancel_token,
|
||||
"while reading its response",
|
||||
);
|
||||
assert_eq!(read_error.kind(), StreamInstallReceiveErrorKind::Transport);
|
||||
|
||||
assert_eq!(
|
||||
data_after_complete("game").kind(),
|
||||
StreamInstallReceiveErrorKind::Integrity
|
||||
);
|
||||
|
||||
let malformed_path =
|
||||
Bytes::from_static(b"\0{\"Directory\":{\"relative_path\":\"../escape\"}}");
|
||||
assert_eq!(
|
||||
decode_received_stream_install_frame(malformed_path)
|
||||
.expect_err("malformed typed path must fail strict decoding")
|
||||
.kind(),
|
||||
StreamInstallReceiveErrorKind::Integrity
|
||||
);
|
||||
|
||||
let explicit_error = StreamInstallFrame::Error {
|
||||
message: "sender failed".to_owned(),
|
||||
};
|
||||
assert_eq!(
|
||||
decode_received_stream_install_frame(explicit_error.encode())
|
||||
.expect("explicit sender Error must remain a valid frame"),
|
||||
explicit_error
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_length_delimited_frame_is_an_integrity_failure() {
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
|
||||
let (mut writer, reader) = tokio::io::duplex(16);
|
||||
let oversized = u32::try_from(MAX_STREAM_INSTALL_FRAME_BYTES + 1)
|
||||
.expect("stream frame bound should fit u32");
|
||||
writer
|
||||
.write_all(&oversized.to_be_bytes())
|
||||
.await
|
||||
.expect("oversized length prefix should write");
|
||||
drop(writer);
|
||||
|
||||
let mut framed_rx = FramedRead::new(
|
||||
reader,
|
||||
LengthDelimitedCodec::builder()
|
||||
.max_frame_length(MAX_STREAM_INSTALL_FRAME_BYTES)
|
||||
.new_codec(),
|
||||
);
|
||||
let codec_error = framed_rx
|
||||
.next()
|
||||
.await
|
||||
.expect("oversized prefix should produce a codec result")
|
||||
.expect_err("oversized frame must fail before allocation");
|
||||
assert_eq!(codec_error.kind(), std::io::ErrorKind::InvalidData);
|
||||
|
||||
let cancellation = CancellationToken::new();
|
||||
let classified = classify_stream_install_read_error(
|
||||
codec_error,
|
||||
"game",
|
||||
&cancellation,
|
||||
"while reading its response",
|
||||
);
|
||||
assert_eq!(classified.kind(), StreamInstallReceiveErrorKind::Integrity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incoming_file_requires_catalog_blake3_after_sender_crc_passes() {
|
||||
let payload = b"payload";
|
||||
let endpoint = peer_endpoint();
|
||||
let exact_content_id = content_id();
|
||||
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut accepted = IncomingFile::new(
|
||||
canonical_path("accepted.bin"),
|
||||
PathBuf::from("accepted.bin"),
|
||||
u64::try_from(payload.len()).expect("payload size should fit"),
|
||||
crc32_of(payload),
|
||||
Blake3Digest::hash(payload),
|
||||
Vec::new(),
|
||||
);
|
||||
accepted
|
||||
.write_chunk(payload)
|
||||
.expect("payload write should succeed");
|
||||
assert!(
|
||||
event_rx.try_recv().is_err(),
|
||||
"unverified streamed bytes must not emit a completion event"
|
||||
);
|
||||
accepted
|
||||
.finish(
|
||||
&canonical_path("accepted.bin"),
|
||||
"game",
|
||||
endpoint,
|
||||
exact_content_id,
|
||||
&event_tx,
|
||||
)
|
||||
.expect("matching catalog hash should finish");
|
||||
assert!(matches!(
|
||||
event_rx.try_recv(),
|
||||
Ok(PeerEvent::DownloadGameFileChunkFinished {
|
||||
peer_id,
|
||||
content_id,
|
||||
relative_path,
|
||||
offset: 0,
|
||||
length,
|
||||
..
|
||||
}) if peer_id == endpoint.peer_id
|
||||
&& content_id == exact_content_id
|
||||
&& relative_path.as_str() == "accepted.bin"
|
||||
&& length == u64::try_from(payload.len()).expect("payload length should fit")
|
||||
));
|
||||
|
||||
let mut incoming = IncomingFile::new(
|
||||
canonical_path("payload.bin"),
|
||||
PathBuf::from("payload.bin"),
|
||||
u64::try_from(payload.len()).expect("payload size should fit"),
|
||||
crc32_of(payload),
|
||||
Blake3Digest::hash(b"different trusted bytes"),
|
||||
Vec::new(),
|
||||
);
|
||||
incoming
|
||||
.write_chunk(payload)
|
||||
.expect("payload write should succeed");
|
||||
|
||||
let error = incoming
|
||||
.finish(
|
||||
&canonical_path("payload.bin"),
|
||||
"game",
|
||||
endpoint,
|
||||
exact_content_id,
|
||||
&event_tx,
|
||||
)
|
||||
.expect_err("catalog digest mismatch must fail after matching sender CRC");
|
||||
assert_eq!(error.kind(), StreamInstallReceiveErrorKind::Integrity);
|
||||
assert!(error.to_string().contains("catalog BLAKE3 mismatch"));
|
||||
assert!(
|
||||
event_rx.try_recv().is_err(),
|
||||
"catalog-rejected bytes must never emit completion"
|
||||
);
|
||||
}
|
||||
|
||||
fn test_catalog_manifest(
|
||||
archives: &[&str],
|
||||
streamed_install_files: Vec<CatalogExtractedEntry>,
|
||||
) -> Arc<CatalogContentManifest> {
|
||||
let archive_digest = Blake3Digest::hash(b"archive");
|
||||
let mut files = archives
|
||||
.iter()
|
||||
.map(|archive| {
|
||||
CatalogFileEntry::file(*archive, 7, archive_digest, vec![archive_digest])
|
||||
.expect("archive entry should validate")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let version = b"20240101";
|
||||
let version_digest = Blake3Digest::hash(version);
|
||||
files.push(
|
||||
CatalogFileEntry::file(
|
||||
"version.ini",
|
||||
u64::try_from(version.len()).expect("version size should fit"),
|
||||
version_digest,
|
||||
vec![version_digest],
|
||||
)
|
||||
.expect("version entry should validate"),
|
||||
);
|
||||
files.sort_by(|left, right| left.canonical_path().cmp(right.canonical_path()));
|
||||
|
||||
Arc::new(
|
||||
CatalogContentManifest::seal(
|
||||
CatalogContentManifestBody::new("game", "20240101", files, streamed_install_files)
|
||||
.expect("manifest body should validate"),
|
||||
)
|
||||
.expect("manifest should seal"),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sender_uses_only_the_exact_catalog_archive_set() {
|
||||
let temp = TempDir::new("lanspread-stream-install-sender-archives");
|
||||
std::fs::write(temp.path().join("a.eti"), b"archive")
|
||||
.expect("catalog archive should be written");
|
||||
let extracted = vec![
|
||||
CatalogExtractedEntry::file("payload.bin", 1, Blake3Digest::hash(b"x"))
|
||||
.expect("extracted file should validate"),
|
||||
];
|
||||
let manifest = test_catalog_manifest(&["a.eti"], extracted.clone());
|
||||
|
||||
assert_eq!(
|
||||
catalog_stream_archives(temp.path(), "game", &manifest)
|
||||
.expect("the exact catalog archive set should be admitted"),
|
||||
vec![temp.path().join("a.eti")]
|
||||
);
|
||||
|
||||
std::fs::write(temp.path().join("b.eti"), b"extra")
|
||||
.expect("extra archive should be written");
|
||||
assert!(
|
||||
catalog_stream_archives(temp.path(), "game", &manifest).is_err(),
|
||||
"a root archive outside the catalog must never be streamed"
|
||||
);
|
||||
|
||||
let unsupported = test_catalog_manifest(&["a.eti", "b.eti"], Vec::new());
|
||||
assert!(
|
||||
catalog_stream_archives(temp.path(), "game", &unsupported).is_err(),
|
||||
"a game without verified extracted output must not offer Stream Install"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_catalog_name_never_fabricates_a_missing_name() {
|
||||
let error = archive_catalog_name(Path::new("/"))
|
||||
.expect_err("a path without a file name must be rejected");
|
||||
assert!(error.to_string().contains("has no file name"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn archive_catalog_name_rejects_non_utf8_before_provider_work() {
|
||||
use std::{ffi::OsString, os::unix::ffi::OsStringExt as _};
|
||||
|
||||
let archive = PathBuf::from(OsString::from_vec(vec![b'a', 0xff, b'.', b'e', b't', b'i']));
|
||||
let error =
|
||||
archive_catalog_name(&archive).expect_err("a non-UTF-8 archive name must be rejected");
|
||||
assert!(error.to_string().contains("not valid UTF-8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1325,13 +3244,13 @@ Details: RAR 5, solid
|
||||
listing.entries,
|
||||
vec![
|
||||
RarEntry {
|
||||
relative_path: "bin/payload.bin".to_string(),
|
||||
relative_path: canonical_path("bin/payload.bin"),
|
||||
kind: RarEntryKind::File,
|
||||
size: 123,
|
||||
crc32: Some(0x38B4_88A7),
|
||||
},
|
||||
RarEntry {
|
||||
relative_path: "bin".to_string(),
|
||||
relative_path: canonical_path("bin"),
|
||||
kind: RarEntryKind::Directory,
|
||||
size: 0,
|
||||
crc32: None,
|
||||
@@ -1340,6 +3259,14 @@ Details: RAR 5, solid
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_unrar_listing_metadata_fails_closed() {
|
||||
let error = reject_truncated_unrar_listing(true, false, Path::new("large.eti"))
|
||||
.expect_err("truncated listing stdout must not authorize a partial manifest");
|
||||
|
||||
assert!(error.to_string().contains("metadata limit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unrar_file_entries_without_crc32() {
|
||||
let err = parse_unrar_listing(
|
||||
@@ -1374,7 +3301,7 @@ Details: RAR 5
|
||||
assert_eq!(
|
||||
listing.entries,
|
||||
vec![RarEntry {
|
||||
relative_path: "bin/empty.cfg".to_string(),
|
||||
relative_path: canonical_path("bin/empty.cfg"),
|
||||
kind: RarEntryKind::File,
|
||||
size: 0,
|
||||
crc32: Some(0),
|
||||
@@ -1389,7 +3316,11 @@ Details: RAR 5
|
||||
let integrity = SenderArchiveIntegrity::new(byte_len, crc32_of(bytes));
|
||||
|
||||
integrity
|
||||
.verify("bin/payload.bin", byte_len, crc32_of(bytes))
|
||||
.verify(
|
||||
&canonical_path("bin/payload.bin"),
|
||||
byte_len,
|
||||
crc32_of(bytes),
|
||||
)
|
||||
.expect("matching sender archive metadata should verify");
|
||||
}
|
||||
|
||||
@@ -1397,7 +3328,7 @@ Details: RAR 5
|
||||
fn sender_archive_integrity_rejects_size_mismatch() {
|
||||
let integrity = SenderArchiveIntegrity::new(7, crc32_of(b"payload"));
|
||||
let err = integrity
|
||||
.verify("bin/payload.bin", 6, crc32_of(b"payload"))
|
||||
.verify(&canonical_path("bin/payload.bin"), 6, crc32_of(b"payload"))
|
||||
.expect_err("truncated file should fail sender archive integrity");
|
||||
|
||||
assert!(err.to_string().contains("size mismatch"));
|
||||
@@ -1407,7 +3338,7 @@ Details: RAR 5
|
||||
fn sender_archive_integrity_rejects_crc32_mismatch() {
|
||||
let integrity = SenderArchiveIntegrity::new(7, crc32_of(b"payload"));
|
||||
let err = integrity
|
||||
.verify("bin/payload.bin", 7, crc32_of(b"paylord"))
|
||||
.verify(&canonical_path("bin/payload.bin"), 7, crc32_of(b"paylord"))
|
||||
.expect_err("mutated file should fail sender archive integrity");
|
||||
|
||||
assert!(err.to_string().contains("sender RAR CRC32 mismatch"));
|
||||
|
||||
Reference in New Issue
Block a user