chore: initial sanitized public snapshot
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
use adler2::Adler32;
|
||||
use crc32fast::Hasher as Crc32Hasher;
|
||||
use md5::Md5;
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha224, Sha256, Sha384, Sha512};
|
||||
|
||||
use super::model::{ChecksumSpec, ResponseBody};
|
||||
|
||||
impl ChecksumSpec {
|
||||
#[must_use]
|
||||
/// Returns whether the observed and expected digests match.
|
||||
pub fn is_verified(&self) -> bool {
|
||||
self.actual_hex
|
||||
.as_deref()
|
||||
.is_some_and(|actual| actual.eq_ignore_ascii_case(&self.expected_hex))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Computes the payload digest using the configured algorithm.
|
||||
pub fn compute_actual_hex(&self, payload: &[u8]) -> Option<String> {
|
||||
checksum_hex(&self.algorithm, payload)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns whether `payload` matches the expected digest when supported.
|
||||
pub fn verify_payload(&self, payload: &[u8]) -> Option<bool> {
|
||||
self.compute_actual_hex(payload)
|
||||
.map(|actual| actual.eq_ignore_ascii_case(&self.expected_hex))
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares a streamed body's observed digest with the expected checksum.
|
||||
pub(super) fn streamed_checksum_verification(
|
||||
checksum: &ChecksumSpec,
|
||||
body: &ResponseBody,
|
||||
) -> Option<bool> {
|
||||
match body {
|
||||
ResponseBody::Streamed {
|
||||
observed_digest: Some(actual),
|
||||
..
|
||||
} => Some(actual.eq_ignore_ascii_case(&checksum.expected_hex)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes a lowercase hexadecimal digest for the requested checksum algorithm.
|
||||
#[must_use]
|
||||
pub(super) fn checksum_hex(algorithm: &str, payload: &[u8]) -> Option<String> {
|
||||
let algorithm = algorithm.trim();
|
||||
let digest = if matches_checksum_algorithm(algorithm, &["sha1", "sha-1", "sha"]) {
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(payload);
|
||||
hasher.finalize().to_vec()
|
||||
} else if matches_checksum_algorithm(algorithm, &["sha224", "sha-224"]) {
|
||||
let mut hasher = Sha224::new();
|
||||
hasher.update(payload);
|
||||
hasher.finalize().to_vec()
|
||||
} else if matches_checksum_algorithm(algorithm, &["sha256", "sha-256"]) {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(payload);
|
||||
hasher.finalize().to_vec()
|
||||
} else if matches_checksum_algorithm(algorithm, &["sha384", "sha-384"]) {
|
||||
let mut hasher = Sha384::new();
|
||||
hasher.update(payload);
|
||||
hasher.finalize().to_vec()
|
||||
} else if matches_checksum_algorithm(algorithm, &["sha512", "sha-512"]) {
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(payload);
|
||||
hasher.finalize().to_vec()
|
||||
} else if algorithm.eq_ignore_ascii_case("md5") {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(payload);
|
||||
hasher.finalize().to_vec()
|
||||
} else if algorithm.eq_ignore_ascii_case("adler32") {
|
||||
let mut hasher = Adler32::new();
|
||||
hasher.write_slice(payload);
|
||||
hasher.checksum().to_be_bytes().to_vec()
|
||||
} else if algorithm.eq_ignore_ascii_case("crc32") {
|
||||
let mut hasher = Crc32Hasher::new();
|
||||
hasher.update(payload);
|
||||
hasher.finalize().to_be_bytes().to_vec()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(bytes_to_hex(&digest))
|
||||
}
|
||||
|
||||
/// Returns whether a checksum algorithm matches any accepted spelling.
|
||||
fn matches_checksum_algorithm(algorithm: &str, accepted: &[&str]) -> bool {
|
||||
accepted
|
||||
.iter()
|
||||
.any(|candidate| algorithm.eq_ignore_ascii_case(candidate))
|
||||
}
|
||||
|
||||
/// Hex-encodes digest bytes using lowercase hexadecimal.
|
||||
#[must_use]
|
||||
fn bytes_to_hex(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
out.push(char::from(HEX[usize::from(byte >> 4)]));
|
||||
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Saturates a `usize` length into `u64`.
|
||||
pub(super) fn usize_to_u64(value: usize) -> u64 {
|
||||
u64::try_from(value).unwrap_or(u64::MAX)
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
pub use crate::auth::{
|
||||
AuthChallengeModel as AuthChallenge, AuthCredentialModel as AuthCredential, AuthScheme,
|
||||
};
|
||||
|
||||
/// Classifies how one header participates in an HTTP exchange.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum HeaderKind {
|
||||
/// Header belongs to the request.
|
||||
Request,
|
||||
/// Header belongs to the response.
|
||||
Response,
|
||||
/// Header is valid for both directions.
|
||||
General,
|
||||
}
|
||||
|
||||
/// One normalized HTTP header field.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HttpHeader {
|
||||
/// Lower-level header name.
|
||||
pub name: String,
|
||||
/// Raw header value.
|
||||
pub value: String,
|
||||
/// Header classification within the exchange.
|
||||
pub kind: HeaderKind,
|
||||
}
|
||||
|
||||
/// HTTP methods supported by the protocol layer.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum HttpMethod {
|
||||
/// `GET`
|
||||
Get,
|
||||
/// `HEAD`
|
||||
Head,
|
||||
/// `POST`
|
||||
Post,
|
||||
/// `PUT`
|
||||
Put,
|
||||
/// `DELETE`
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// HTTP versions surfaced by the transport layer.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum HttpVersion {
|
||||
/// HTTP/1.0
|
||||
Http10,
|
||||
/// HTTP/1.1
|
||||
Http11,
|
||||
/// HTTP/2
|
||||
Http2,
|
||||
/// HTTP/3
|
||||
Http3,
|
||||
}
|
||||
|
||||
/// One request byte or piece range.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct RangeSpec {
|
||||
/// Inclusive start offset.
|
||||
pub start: u64,
|
||||
/// Optional inclusive end offset.
|
||||
pub end_inclusive: Option<u64>,
|
||||
/// Unit used by the range.
|
||||
pub unit: RangeUnit,
|
||||
}
|
||||
|
||||
impl RangeSpec {
|
||||
#[must_use]
|
||||
/// Returns whether the range omits an explicit end bound.
|
||||
pub const fn is_open_ended(&self) -> bool {
|
||||
self.end_inclusive.is_none()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns the requested length when the end bound is known.
|
||||
pub fn length_hint(&self) -> Option<u64> {
|
||||
self.end_inclusive
|
||||
.map(|end| end.saturating_sub(self.start).saturating_add(1))
|
||||
}
|
||||
}
|
||||
|
||||
/// Units supported by HTTP-style range models.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RangeUnit {
|
||||
/// Byte-oriented ranges.
|
||||
Bytes,
|
||||
/// Piece-oriented ranges used by higher-level scheduling.
|
||||
Pieces,
|
||||
}
|
||||
|
||||
/// Parsed `Content-Range` response metadata.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct ContentRangeSpec {
|
||||
/// Unit reported by the server.
|
||||
pub unit: RangeUnit,
|
||||
/// Inclusive start offset returned by the server.
|
||||
pub start: u64,
|
||||
/// Inclusive end offset returned by the server.
|
||||
pub end_inclusive: u64,
|
||||
/// Total object size when known.
|
||||
pub total_size: Option<u64>,
|
||||
/// Whether the response represents an unsatisfied range.
|
||||
pub unsatisfied: bool,
|
||||
}
|
||||
|
||||
impl ContentRangeSpec {
|
||||
#[must_use]
|
||||
/// Returns the completed length implied by the range payload.
|
||||
pub const fn completed_length(&self) -> u64 {
|
||||
if self.unsatisfied {
|
||||
0
|
||||
} else {
|
||||
self.end_inclusive.saturating_add(1)
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns whether the range is explicitly unsatisfied.
|
||||
pub const fn is_unsatisfied(&self) -> bool {
|
||||
self.unsatisfied
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume metadata carried into one HTTP transfer attempt.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct ResumeState {
|
||||
/// Requested starting offset for the retry or resumed request.
|
||||
pub requested_offset: u64,
|
||||
/// Offset actually accepted by the remote server.
|
||||
pub accepted_offset: Option<u64>,
|
||||
/// Whether the attempt truly resumed instead of restarting from zero.
|
||||
pub resumed: bool,
|
||||
}
|
||||
|
||||
/// Retry policy knobs applied to HTTP work.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct RetryPolicy {
|
||||
/// Maximum number of attempts.
|
||||
pub max_attempts: u32,
|
||||
/// Initial backoff delay in milliseconds.
|
||||
pub initial_backoff_ms: u64,
|
||||
/// Maximum backoff delay in milliseconds.
|
||||
pub max_backoff_ms: u64,
|
||||
/// Whether `3xx` responses are retryable.
|
||||
pub retry_on_3xx: bool,
|
||||
/// Whether `4xx` responses are retryable.
|
||||
pub retry_on_4xx: bool,
|
||||
/// Whether `5xx` responses are retryable.
|
||||
pub retry_on_5xx: bool,
|
||||
/// Whether transport-level network errors are retryable.
|
||||
pub retry_on_network_error: bool,
|
||||
/// Whether timeout failures are retryable.
|
||||
pub retry_on_timeout: bool,
|
||||
}
|
||||
|
||||
/// Fully-resolved retry behavior for one request.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct RetryStrategy {
|
||||
/// Base retry policy.
|
||||
pub policy: RetryPolicy,
|
||||
/// Optional jitter value in milliseconds.
|
||||
pub jitter: Option<u64>,
|
||||
/// Optional upper bound on total retry elapsed time in milliseconds.
|
||||
pub max_elapsed_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Normalized reasons for retrying one transfer attempt.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RetryReason {
|
||||
/// A transport-level network failure occurred.
|
||||
NetworkError,
|
||||
/// The request timed out.
|
||||
Timeout,
|
||||
/// The server responded with a retryable `3xx`.
|
||||
Http3xx,
|
||||
/// The server responded with a retryable `4xx`.
|
||||
Http4xx,
|
||||
/// The server responded with a retryable `5xx`.
|
||||
Http5xx,
|
||||
/// Partial-content semantics did not match the requested resume state.
|
||||
PartialContentMismatch,
|
||||
/// Another retryable condition occurred.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// One recorded retry attempt.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct RetryAttempt {
|
||||
/// Attempt number starting at one.
|
||||
pub attempt: u32,
|
||||
/// Retry reason for the attempt.
|
||||
pub reason: RetryReason,
|
||||
/// Optional HTTP status observed during the attempt.
|
||||
pub status: Option<u16>,
|
||||
/// Optional backoff delay in milliseconds before the next attempt.
|
||||
pub backoff_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Final or intermediate completion state for one HTTP response.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum HttpCompletionState {
|
||||
/// Response is not yet complete.
|
||||
Incomplete,
|
||||
/// Response is usable but only partial.
|
||||
Partial,
|
||||
/// Response is complete without checksum verification.
|
||||
Complete,
|
||||
/// Response is complete and checksum-verified.
|
||||
Verified,
|
||||
}
|
||||
|
||||
/// Derived completion summary for one HTTP response.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct HttpCompletionModel {
|
||||
/// Overall completion state.
|
||||
pub state: HttpCompletionState,
|
||||
/// Total payload length when known.
|
||||
pub total_length: Option<u64>,
|
||||
/// Number of completed bytes.
|
||||
pub completed_length: u64,
|
||||
/// Whether the response used partial-content semantics.
|
||||
pub partial_content: bool,
|
||||
/// Whether the status code indicates terminal success.
|
||||
pub terminal_success: bool,
|
||||
/// Whether checksum metadata was present.
|
||||
pub checksum_seen: bool,
|
||||
/// Whether the checksum could be verified successfully.
|
||||
pub checksum_verified: bool,
|
||||
}
|
||||
|
||||
/// Segment-level progress view for one transfer snapshot.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct HttpSegmentProgressModel {
|
||||
/// Range originally requested from the server.
|
||||
pub requested_range: Option<RangeSpec>,
|
||||
/// Requested starting offset.
|
||||
pub requested_offset: u64,
|
||||
/// Offset accepted by the server when present.
|
||||
pub accepted_offset: Option<u64>,
|
||||
/// Completed offset derived from the current response.
|
||||
pub completed_offset: Option<u64>,
|
||||
/// Whether the transfer is actively resuming instead of restarting.
|
||||
pub resumed: bool,
|
||||
}
|
||||
|
||||
/// Retry-attempt detail enriched with segment and resume context.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct HttpRetryAttemptDetailModel {
|
||||
/// Base retry-attempt data.
|
||||
pub base: RetryAttempt,
|
||||
/// Range requested for the attempt.
|
||||
pub requested_range: Option<RangeSpec>,
|
||||
/// Requested starting offset for the attempt.
|
||||
pub requested_offset: u64,
|
||||
/// Offset accepted by the server when present.
|
||||
pub accepted_offset: Option<u64>,
|
||||
/// Resume metadata captured for the attempt.
|
||||
pub resume_state: Option<ResumeState>,
|
||||
}
|
||||
|
||||
/// Snapshot of one in-flight or completed HTTP transfer.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HttpTransferProgressModel {
|
||||
/// Stable task identifier.
|
||||
pub task_id: String,
|
||||
/// Request model associated with the transfer.
|
||||
pub request: HttpRequestModel,
|
||||
/// Segment-level progress details.
|
||||
pub segment: HttpSegmentProgressModel,
|
||||
/// Retry-attempt history with contextual detail.
|
||||
pub retry_attempts: Vec<HttpRetryAttemptDetailModel>,
|
||||
/// Maximum number of concurrent connections permitted for the task.
|
||||
pub max_connections: u16,
|
||||
/// Optional checksum hook attached to the transfer.
|
||||
pub checksum_hook: Option<ChecksumHookModel>,
|
||||
/// Derived completion summary when a response exists.
|
||||
pub completion: Option<HttpCompletionModel>,
|
||||
}
|
||||
|
||||
/// Proxy configuration projected into HTTP requests.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ProxyConfig {
|
||||
/// Proxy scheme such as `http` or `socks5`.
|
||||
pub scheme: String,
|
||||
/// Proxy host name or IP.
|
||||
pub host: String,
|
||||
/// Proxy port.
|
||||
pub port: u16,
|
||||
/// Optional proxy username.
|
||||
pub username: Option<String>,
|
||||
/// Optional proxy password.
|
||||
pub password: Option<String>,
|
||||
/// Hosts that should bypass the proxy.
|
||||
pub bypass_hosts: Vec<String>,
|
||||
/// Whether proxying is disabled for the request.
|
||||
pub no_proxy: bool,
|
||||
}
|
||||
|
||||
/// One normalized HTTP cookie.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Cookie {
|
||||
/// Cookie name.
|
||||
pub name: String,
|
||||
/// Cookie value.
|
||||
pub value: String,
|
||||
/// Optional domain constraint.
|
||||
pub domain: Option<String>,
|
||||
/// Optional path constraint.
|
||||
pub path: Option<String>,
|
||||
/// Whether the cookie requires a secure transport.
|
||||
pub secure: bool,
|
||||
/// Whether the cookie is `HttpOnly`.
|
||||
pub http_only: bool,
|
||||
/// Optional same-site policy marker.
|
||||
pub same_site: Option<String>,
|
||||
/// Expiration time as a Unix timestamp when present.
|
||||
pub expires_unix_epoch: Option<u64>,
|
||||
}
|
||||
|
||||
/// TLS behavior attached to one HTTP session.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct TlsConfig {
|
||||
/// Whether peer certificates must be verified.
|
||||
pub verify_peer: bool,
|
||||
/// Whether host name verification is enabled.
|
||||
pub verify_host: bool,
|
||||
/// Minimum TLS version when constrained.
|
||||
pub min_version: Option<String>,
|
||||
/// Maximum TLS version when constrained.
|
||||
pub max_version: Option<String>,
|
||||
/// Optional CA bundle path.
|
||||
pub ca_file: Option<String>,
|
||||
/// Optional client certificate path.
|
||||
pub cert_file: Option<String>,
|
||||
/// Optional client key path.
|
||||
pub key_file: Option<String>,
|
||||
}
|
||||
|
||||
/// Ordered collection of request headers.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HttpRequestHeaders {
|
||||
/// Stored request headers.
|
||||
pub headers: Vec<HttpHeader>,
|
||||
}
|
||||
|
||||
/// Ordered collection of response headers.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HttpResponseHeaders {
|
||||
/// Stored response headers.
|
||||
pub headers: Vec<HttpHeader>,
|
||||
}
|
||||
|
||||
/// Request-body representation for HTTP transfers.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum HttpBody {
|
||||
/// No request body.
|
||||
Empty,
|
||||
/// UTF-8 text request body.
|
||||
Text(String),
|
||||
/// Arbitrary binary request body.
|
||||
Binary(Vec<u8>),
|
||||
/// Streaming body with an optional declared length.
|
||||
Stream {
|
||||
/// Declared body length when the caller knows it.
|
||||
expected_len: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Expected and observed checksum metadata for one payload.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ChecksumSpec {
|
||||
/// Hash algorithm name.
|
||||
pub algorithm: String,
|
||||
/// Expected digest hex string.
|
||||
pub expected_hex: String,
|
||||
/// Observed digest hex string when known.
|
||||
pub actual_hex: Option<String>,
|
||||
}
|
||||
|
||||
/// Optional checksum hook attached to a transfer.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ChecksumHookModel {
|
||||
/// Checksum specification to evaluate.
|
||||
pub spec: ChecksumSpec,
|
||||
/// Whether the hook is enabled.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Optional direct-write target for one live HTTP response body.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HttpResponseSinkTarget {
|
||||
/// Final output path that should receive the response body directly.
|
||||
pub target_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Fully normalized HTTP request model.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HttpRequestModel {
|
||||
/// HTTP method.
|
||||
pub method: HttpMethod,
|
||||
/// Fully-qualified request URL.
|
||||
pub url: String,
|
||||
/// Requested HTTP version.
|
||||
pub version: HttpVersion,
|
||||
/// Explicit request headers.
|
||||
pub headers: HttpRequestHeaders,
|
||||
/// Query parameters to attach to the URL.
|
||||
pub query: HashMap<String, String>,
|
||||
/// Optional range metadata.
|
||||
pub range: Option<RangeSpec>,
|
||||
/// Request body.
|
||||
pub body: HttpBody,
|
||||
/// Retry strategy for the request.
|
||||
pub retry: RetryStrategy,
|
||||
/// Optional origin credential.
|
||||
pub auth: Option<AuthCredential>,
|
||||
/// Optional proxy configuration.
|
||||
pub proxy: Option<ProxyConfig>,
|
||||
/// Optional direct-write sink for live response persistence.
|
||||
pub response_sink: Option<HttpResponseSinkTarget>,
|
||||
}
|
||||
|
||||
/// Session-scoped defaults that shape HTTP execution.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HttpSessionModel {
|
||||
/// Stable session identifier.
|
||||
pub session_id: String,
|
||||
/// Optional user-agent string.
|
||||
pub user_agent: Option<String>,
|
||||
/// Default headers applied to requests.
|
||||
pub default_headers: Vec<HttpHeader>,
|
||||
/// Cookies carried by the session.
|
||||
pub cookies: Vec<Cookie>,
|
||||
/// Optional default credential.
|
||||
pub auth: Option<AuthCredential>,
|
||||
/// Optional default proxy configuration.
|
||||
pub proxy: Option<ProxyConfig>,
|
||||
/// Optional TLS behavior for the session.
|
||||
pub tls: Option<TlsConfig>,
|
||||
/// Default retry strategy for the session.
|
||||
pub retry: RetryStrategy,
|
||||
}
|
||||
|
||||
/// Response-body representation used by the protocol layer.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ResponseBody {
|
||||
/// No response payload.
|
||||
Empty,
|
||||
/// Inline retained payload bytes.
|
||||
Inline(Vec<u8>),
|
||||
/// Streamed payload metadata with optional retained artifacts.
|
||||
Streamed {
|
||||
/// Declared content length when known.
|
||||
expected_len: Option<u64>,
|
||||
/// Observed byte count written through the sink.
|
||||
observed_len: Option<u64>,
|
||||
/// Observed digest when computed by the sink.
|
||||
observed_digest: Option<String>,
|
||||
/// Optional temporary file path holding the streamed body.
|
||||
temp_path: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Executable HTTP transfer task passed into downloaders.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HttpTransferTaskModel {
|
||||
/// Stable task identifier.
|
||||
pub task_id: String,
|
||||
/// Request model for the task.
|
||||
pub request: HttpRequestModel,
|
||||
/// Response headers already associated with the task.
|
||||
pub response_headers: HttpResponseHeaders,
|
||||
/// Current response body state.
|
||||
pub body: ResponseBody,
|
||||
/// Resume metadata when resuming is in play.
|
||||
pub resume_state: Option<ResumeState>,
|
||||
/// Retry-attempt history.
|
||||
pub retry_attempts: Vec<RetryAttempt>,
|
||||
/// Optional checksum hook.
|
||||
pub checksum_hook: Option<ChecksumHookModel>,
|
||||
/// Maximum allowed concurrent connections.
|
||||
pub max_connections: u16,
|
||||
/// Retry strategy for the task.
|
||||
pub retry: RetryStrategy,
|
||||
}
|
||||
|
||||
/// Normalized HTTP response model produced by connectors and fixtures.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HttpResponseModel {
|
||||
/// Numeric HTTP status code.
|
||||
pub status: u16,
|
||||
/// Human-readable reason phrase.
|
||||
pub reason: String,
|
||||
/// Negotiated HTTP version.
|
||||
pub version: HttpVersion,
|
||||
/// Response headers.
|
||||
pub headers: HttpResponseHeaders,
|
||||
/// Response body representation.
|
||||
pub body: ResponseBody,
|
||||
/// Parsed `Content-Range` metadata when present.
|
||||
pub content_range: Option<ContentRangeSpec>,
|
||||
/// Whether the response used partial-content semantics.
|
||||
pub partial_content: bool,
|
||||
/// Optional checksum metadata.
|
||||
pub checksum: Option<ChecksumSpec>,
|
||||
/// Original URL before redirects when one occurred.
|
||||
pub redirected_from: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use super::{
|
||||
checksum::{streamed_checksum_verification, usize_to_u64},
|
||||
model::{
|
||||
HttpCompletionModel, HttpCompletionState, HttpResponseModel, HttpRetryAttemptDetailModel,
|
||||
HttpSegmentProgressModel, HttpTransferProgressModel, HttpTransferTaskModel, ResponseBody,
|
||||
},
|
||||
};
|
||||
|
||||
impl HttpTransferTaskModel {
|
||||
#[must_use]
|
||||
/// Returns the effective requested offset for the task.
|
||||
pub fn requested_offset(&self) -> u64 {
|
||||
self.resume_state
|
||||
.as_ref()
|
||||
.map(|state| state.requested_offset)
|
||||
.or_else(|| self.request.range.as_ref().map(|range| range.start))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns retry attempts enriched with resume and range context.
|
||||
pub fn retry_attempt_details(&self) -> Vec<HttpRetryAttemptDetailModel> {
|
||||
let requested_offset = self.requested_offset();
|
||||
let requested_range = self.request.range;
|
||||
let accepted_offset = self
|
||||
.resume_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.accepted_offset);
|
||||
let resume_state = self.resume_state;
|
||||
|
||||
self.retry_attempts
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|base| HttpRetryAttemptDetailModel {
|
||||
base,
|
||||
requested_range,
|
||||
requested_offset,
|
||||
accepted_offset,
|
||||
resume_state,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Builds a progress snapshot from the current task and optional response.
|
||||
pub fn progress_snapshot(
|
||||
&self,
|
||||
response: Option<&HttpResponseModel>,
|
||||
) -> HttpTransferProgressModel {
|
||||
let completion = response.map(HttpResponseModel::completion_model);
|
||||
let completed_offset = response.map(HttpResponseModel::completed_length);
|
||||
let (accepted_offset, resumed) = response.map_or_else(
|
||||
|| {
|
||||
(
|
||||
self.resume_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.accepted_offset),
|
||||
self.resume_state
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.resumed),
|
||||
)
|
||||
},
|
||||
|response| {
|
||||
if response.status == 206 {
|
||||
let accepted_offset = response
|
||||
.content_range
|
||||
.as_ref()
|
||||
.and_then(|range| (!range.is_unsatisfied()).then_some(range.start));
|
||||
(
|
||||
accepted_offset,
|
||||
accepted_offset.is_some()
|
||||
|| self
|
||||
.resume_state
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.resumed),
|
||||
)
|
||||
} else {
|
||||
(None, false)
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
HttpTransferProgressModel {
|
||||
task_id: self.task_id.clone(),
|
||||
request: self.request.clone(),
|
||||
segment: HttpSegmentProgressModel {
|
||||
requested_range: self.request.range,
|
||||
requested_offset: self.requested_offset(),
|
||||
accepted_offset,
|
||||
completed_offset,
|
||||
resumed,
|
||||
},
|
||||
retry_attempts: self.retry_attempt_details(),
|
||||
max_connections: self.max_connections,
|
||||
checksum_hook: self.checksum_hook.clone(),
|
||||
completion,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpResponseModel {
|
||||
#[must_use]
|
||||
/// Returns the total payload length when the response exposes it.
|
||||
pub fn total_length(&self) -> Option<u64> {
|
||||
self.content_range
|
||||
.as_ref()
|
||||
.and_then(|range| range.total_size)
|
||||
.or_else(|| match &self.body {
|
||||
ResponseBody::Inline(bytes) => Some(usize_to_u64(bytes.len())),
|
||||
ResponseBody::Streamed {
|
||||
expected_len,
|
||||
observed_len,
|
||||
..
|
||||
} => expected_len.or(*observed_len),
|
||||
ResponseBody::Empty => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns the completed payload length represented by the response.
|
||||
pub fn completed_length(&self) -> u64 {
|
||||
self.content_range
|
||||
.as_ref()
|
||||
.map(super::model::ContentRangeSpec::completed_length)
|
||||
.or_else(|| match &self.body {
|
||||
ResponseBody::Inline(bytes) => Some(usize_to_u64(bytes.len())),
|
||||
ResponseBody::Streamed { observed_len, .. } => *observed_len,
|
||||
ResponseBody::Empty => Some(0),
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Returns inline body bytes when they are retained in memory.
|
||||
pub fn body_bytes(&self) -> Option<&[u8]> {
|
||||
match &self.body {
|
||||
ResponseBody::Empty => Some(&[]),
|
||||
ResponseBody::Inline(bytes) => Some(bytes),
|
||||
ResponseBody::Streamed { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Derives a completion summary from the response payload and metadata.
|
||||
pub fn completion_model(&self) -> HttpCompletionModel {
|
||||
let total_length = self.total_length();
|
||||
let completed_length = self.completed_length();
|
||||
let checksum_seen = self.checksum.is_some();
|
||||
let checksum_verified = self.checksum.as_ref().is_some_and(|checksum| {
|
||||
self.body_bytes()
|
||||
.and_then(|bytes| checksum.verify_payload(bytes))
|
||||
.or_else(|| streamed_checksum_verification(checksum, &self.body))
|
||||
.unwrap_or_else(|| checksum.is_verified())
|
||||
});
|
||||
let terminal_success = (200..300).contains(&self.status);
|
||||
let complete_enough = total_length.is_none_or(|total| completed_length >= total);
|
||||
let state = if !terminal_success {
|
||||
HttpCompletionState::Incomplete
|
||||
} else if checksum_verified && complete_enough {
|
||||
HttpCompletionState::Verified
|
||||
} else if complete_enough {
|
||||
HttpCompletionState::Complete
|
||||
} else {
|
||||
HttpCompletionState::Partial
|
||||
};
|
||||
|
||||
HttpCompletionModel {
|
||||
state,
|
||||
total_length,
|
||||
completed_length,
|
||||
partial_content: self.partial_content,
|
||||
terminal_success,
|
||||
checksum_seen,
|
||||
checksum_verified,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn sample_retry_strategy() -> RetryStrategy {
|
||||
RetryStrategy {
|
||||
policy: RetryPolicy {
|
||||
max_attempts: 5,
|
||||
initial_backoff_ms: 100,
|
||||
max_backoff_ms: 5_000,
|
||||
retry_on_3xx: false,
|
||||
retry_on_4xx: false,
|
||||
retry_on_5xx: true,
|
||||
retry_on_network_error: true,
|
||||
retry_on_timeout: true,
|
||||
},
|
||||
jitter: Some(25),
|
||||
max_elapsed_ms: Some(60_000),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_request() -> HttpRequestModel {
|
||||
HttpRequestModel {
|
||||
method: HttpMethod::Get,
|
||||
url: "https://example.invalid/file.bin".to_string(),
|
||||
version: HttpVersion::Http11,
|
||||
headers: HttpRequestHeaders { headers: vec![] },
|
||||
query: HashMap::new(),
|
||||
range: Some(RangeSpec {
|
||||
start: 4096,
|
||||
end_inclusive: None,
|
||||
unit: RangeUnit::Bytes,
|
||||
}),
|
||||
body: HttpBody::Empty,
|
||||
retry: sample_retry_strategy(),
|
||||
auth: None,
|
||||
proxy: None,
|
||||
response_sink: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_model_carries_partial_content_and_content_range() {
|
||||
let response = HttpResponseModel {
|
||||
status: 206,
|
||||
reason: "Partial Content".to_string(),
|
||||
version: HttpVersion::Http11,
|
||||
headers: HttpResponseHeaders { headers: vec![] },
|
||||
body: ResponseBody::Streamed {
|
||||
expected_len: Some(1024),
|
||||
observed_len: Some(1024),
|
||||
observed_digest: None,
|
||||
temp_path: None,
|
||||
},
|
||||
content_range: Some(ContentRangeSpec {
|
||||
unit: RangeUnit::Bytes,
|
||||
start: 4096,
|
||||
end_inclusive: 5119,
|
||||
total_size: Some(10_000),
|
||||
unsatisfied: false,
|
||||
}),
|
||||
partial_content: true,
|
||||
checksum: None,
|
||||
redirected_from: None,
|
||||
};
|
||||
|
||||
assert!(response.partial_content);
|
||||
assert_eq!(
|
||||
response.content_range,
|
||||
Some(ContentRangeSpec {
|
||||
unit: RangeUnit::Bytes,
|
||||
start: 4096,
|
||||
end_inclusive: 5119,
|
||||
total_size: Some(10_000),
|
||||
unsatisfied: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_completion_model_distinguishes_partial_and_verified() {
|
||||
let partial = HttpResponseModel {
|
||||
status: 206,
|
||||
reason: "Partial Content".to_string(),
|
||||
version: HttpVersion::Http11,
|
||||
headers: HttpResponseHeaders { headers: vec![] },
|
||||
body: ResponseBody::Inline(b"12345".to_vec()),
|
||||
content_range: Some(ContentRangeSpec {
|
||||
unit: RangeUnit::Bytes,
|
||||
start: 0,
|
||||
end_inclusive: 4,
|
||||
total_size: Some(10),
|
||||
unsatisfied: false,
|
||||
}),
|
||||
partial_content: true,
|
||||
checksum: None,
|
||||
redirected_from: None,
|
||||
};
|
||||
let verified = HttpResponseModel {
|
||||
status: 200,
|
||||
reason: "OK".to_string(),
|
||||
version: HttpVersion::Http11,
|
||||
headers: HttpResponseHeaders { headers: vec![] },
|
||||
body: ResponseBody::Inline(b"abc".to_vec()),
|
||||
content_range: None,
|
||||
partial_content: false,
|
||||
checksum: Some(ChecksumSpec {
|
||||
algorithm: "sha-1".to_string(),
|
||||
expected_hex: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(),
|
||||
actual_hex: None,
|
||||
}),
|
||||
redirected_from: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
partial.completion_model().state,
|
||||
HttpCompletionState::Partial
|
||||
);
|
||||
assert_eq!(partial.completion_model().completed_length, 5);
|
||||
assert_eq!(
|
||||
verified.completion_model().state,
|
||||
HttpCompletionState::Verified
|
||||
);
|
||||
assert!(verified.completion_model().checksum_verified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_rejection_keeps_total_length_truth_without_reporting_progress() {
|
||||
let response = HttpResponseModel {
|
||||
status: 416,
|
||||
reason: "Range Not Satisfiable".to_string(),
|
||||
version: HttpVersion::Http11,
|
||||
headers: HttpResponseHeaders { headers: vec![] },
|
||||
body: ResponseBody::Empty,
|
||||
content_range: Some(ContentRangeSpec {
|
||||
unit: RangeUnit::Bytes,
|
||||
start: 0,
|
||||
end_inclusive: 0,
|
||||
total_size: Some(8192),
|
||||
unsatisfied: true,
|
||||
}),
|
||||
partial_content: false,
|
||||
checksum: None,
|
||||
redirected_from: None,
|
||||
};
|
||||
|
||||
let completion = response.completion_model();
|
||||
assert_eq!(completion.total_length, Some(8192));
|
||||
assert_eq!(completion.completed_length, 0);
|
||||
assert_eq!(completion.state, HttpCompletionState::Incomplete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checksum_verification_uses_inline_payload_bytes() {
|
||||
let checksum = ChecksumSpec {
|
||||
algorithm: "sha-256".to_string(),
|
||||
expected_hex: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
.to_string(),
|
||||
actual_hex: None,
|
||||
};
|
||||
|
||||
assert_eq!(checksum.verify_payload(b"abc"), Some(true));
|
||||
assert_eq!(checksum.verify_payload(b"abcd"), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_response_completion_uses_observed_length_and_digest() {
|
||||
let response = HttpResponseModel {
|
||||
status: 200,
|
||||
reason: "OK".to_string(),
|
||||
version: HttpVersion::Http11,
|
||||
headers: HttpResponseHeaders { headers: vec![] },
|
||||
body: ResponseBody::Streamed {
|
||||
expected_len: Some(12),
|
||||
observed_len: Some(12),
|
||||
observed_digest: Some("9251ad9cddb52f55d2c6b96280c781e7".to_string()),
|
||||
temp_path: None,
|
||||
},
|
||||
content_range: None,
|
||||
partial_content: false,
|
||||
checksum: Some(ChecksumSpec {
|
||||
algorithm: "md5".to_string(),
|
||||
expected_hex: "9251ad9cddb52f55d2c6b96280c781e7".to_string(),
|
||||
actual_hex: None,
|
||||
}),
|
||||
redirected_from: None,
|
||||
};
|
||||
|
||||
let completion = response.completion_model();
|
||||
assert_eq!(completion.completed_length, 12);
|
||||
assert_eq!(completion.total_length, Some(12));
|
||||
assert_eq!(completion.state, HttpCompletionState::Verified);
|
||||
assert!(completion.checksum_verified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_completion_uses_observed_len_without_expected_len() {
|
||||
let response = HttpResponseModel {
|
||||
status: 200,
|
||||
reason: "OK".to_string(),
|
||||
version: HttpVersion::Http11,
|
||||
headers: HttpResponseHeaders { headers: vec![] },
|
||||
body: ResponseBody::Streamed {
|
||||
expected_len: None,
|
||||
observed_len: Some(4096),
|
||||
observed_digest: None,
|
||||
temp_path: None,
|
||||
},
|
||||
content_range: None,
|
||||
partial_content: false,
|
||||
checksum: None,
|
||||
redirected_from: None,
|
||||
};
|
||||
|
||||
let completion = response.completion_model();
|
||||
assert_eq!(completion.total_length, Some(4096));
|
||||
assert_eq!(completion.completed_length, 4096);
|
||||
assert_eq!(completion.state, HttpCompletionState::Complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_completion_does_not_treat_expected_len_as_completed_len() {
|
||||
let response = HttpResponseModel {
|
||||
status: 200,
|
||||
reason: "OK".to_string(),
|
||||
version: HttpVersion::Http11,
|
||||
headers: HttpResponseHeaders { headers: vec![] },
|
||||
body: ResponseBody::Streamed {
|
||||
expected_len: Some(4096),
|
||||
observed_len: None,
|
||||
observed_digest: None,
|
||||
temp_path: None,
|
||||
},
|
||||
content_range: None,
|
||||
partial_content: false,
|
||||
checksum: None,
|
||||
redirected_from: None,
|
||||
};
|
||||
|
||||
let completion = response.completion_model();
|
||||
assert_eq!(completion.total_length, Some(4096));
|
||||
assert_eq!(completion.completed_length, 0);
|
||||
assert_eq!(completion.state, HttpCompletionState::Partial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_checksum_verifies_from_observed_digest_without_inline_body() {
|
||||
let response = HttpResponseModel {
|
||||
status: 200,
|
||||
reason: "OK".to_string(),
|
||||
version: HttpVersion::Http11,
|
||||
headers: HttpResponseHeaders { headers: vec![] },
|
||||
body: ResponseBody::Streamed {
|
||||
expected_len: None,
|
||||
observed_len: Some(128),
|
||||
observed_digest: Some(
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad".to_string(),
|
||||
),
|
||||
temp_path: None,
|
||||
},
|
||||
content_range: None,
|
||||
partial_content: false,
|
||||
checksum: Some(ChecksumSpec {
|
||||
algorithm: "sha-256".to_string(),
|
||||
expected_hex: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
.to_string(),
|
||||
actual_hex: None,
|
||||
}),
|
||||
redirected_from: None,
|
||||
};
|
||||
|
||||
let completion = response.completion_model();
|
||||
assert!(completion.checksum_seen);
|
||||
assert!(completion.checksum_verified);
|
||||
assert_eq!(completion.state, HttpCompletionState::Verified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_task_tracks_resume_offsets() {
|
||||
let task = HttpTransferTaskModel {
|
||||
task_id: "task-resume-1".to_string(),
|
||||
request: sample_request(),
|
||||
response_headers: HttpResponseHeaders { headers: vec![] },
|
||||
body: ResponseBody::Streamed {
|
||||
expected_len: None,
|
||||
observed_len: None,
|
||||
observed_digest: None,
|
||||
temp_path: None,
|
||||
},
|
||||
resume_state: Some(ResumeState {
|
||||
requested_offset: 8192,
|
||||
accepted_offset: Some(8192),
|
||||
resumed: true,
|
||||
}),
|
||||
retry_attempts: vec![],
|
||||
checksum_hook: None,
|
||||
max_connections: 4,
|
||||
retry: sample_retry_strategy(),
|
||||
};
|
||||
|
||||
let resume_state = task.resume_state.expect("resume state should exist");
|
||||
assert!(resume_state.resumed);
|
||||
assert_eq!(resume_state.requested_offset, 8192);
|
||||
assert_eq!(resume_state.accepted_offset, Some(8192));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_task_tracks_retry_attempt_history() {
|
||||
let task = HttpTransferTaskModel {
|
||||
task_id: "task-retry-1".to_string(),
|
||||
request: sample_request(),
|
||||
response_headers: HttpResponseHeaders { headers: vec![] },
|
||||
body: ResponseBody::Streamed {
|
||||
expected_len: None,
|
||||
observed_len: None,
|
||||
observed_digest: None,
|
||||
temp_path: None,
|
||||
},
|
||||
resume_state: None,
|
||||
retry_attempts: vec![
|
||||
RetryAttempt {
|
||||
attempt: 1,
|
||||
reason: RetryReason::Timeout,
|
||||
status: None,
|
||||
backoff_ms: Some(100),
|
||||
},
|
||||
RetryAttempt {
|
||||
attempt: 2,
|
||||
reason: RetryReason::Http5xx,
|
||||
status: Some(503),
|
||||
backoff_ms: Some(250),
|
||||
},
|
||||
],
|
||||
checksum_hook: None,
|
||||
max_connections: 4,
|
||||
retry: sample_retry_strategy(),
|
||||
};
|
||||
|
||||
assert_eq!(task.retry_attempts.len(), 2);
|
||||
assert_eq!(task.retry_attempts[0].reason, RetryReason::Timeout);
|
||||
assert_eq!(task.retry_attempts[1].status, Some(503));
|
||||
assert_eq!(task.retry_attempts[1].backoff_ms, Some(250));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_task_progress_snapshot_tracks_segment_progress_and_retry_context() {
|
||||
let request = HttpRequestModel {
|
||||
method: HttpMethod::Get,
|
||||
url: "https://example.invalid/segment.bin".to_string(),
|
||||
version: HttpVersion::Http11,
|
||||
headers: HttpRequestHeaders { headers: vec![] },
|
||||
query: HashMap::new(),
|
||||
range: Some(RangeSpec {
|
||||
start: 8192,
|
||||
end_inclusive: Some(12_287),
|
||||
unit: RangeUnit::Bytes,
|
||||
}),
|
||||
body: HttpBody::Empty,
|
||||
retry: sample_retry_strategy(),
|
||||
auth: None,
|
||||
proxy: None,
|
||||
response_sink: None,
|
||||
};
|
||||
let task = HttpTransferTaskModel {
|
||||
task_id: "task-progress-1".to_string(),
|
||||
request,
|
||||
response_headers: HttpResponseHeaders { headers: vec![] },
|
||||
body: ResponseBody::Streamed {
|
||||
expected_len: None,
|
||||
observed_len: None,
|
||||
observed_digest: None,
|
||||
temp_path: None,
|
||||
},
|
||||
resume_state: Some(ResumeState {
|
||||
requested_offset: 8192,
|
||||
accepted_offset: Some(8192),
|
||||
resumed: true,
|
||||
}),
|
||||
retry_attempts: vec![
|
||||
RetryAttempt {
|
||||
attempt: 1,
|
||||
reason: RetryReason::Timeout,
|
||||
status: None,
|
||||
backoff_ms: Some(100),
|
||||
},
|
||||
RetryAttempt {
|
||||
attempt: 2,
|
||||
reason: RetryReason::Http5xx,
|
||||
status: Some(503),
|
||||
backoff_ms: Some(250),
|
||||
},
|
||||
],
|
||||
checksum_hook: Some(ChecksumHookModel {
|
||||
spec: ChecksumSpec {
|
||||
algorithm: "sha-256".to_string(),
|
||||
expected_hex: "abc123".to_string(),
|
||||
actual_hex: None,
|
||||
},
|
||||
enabled: true,
|
||||
}),
|
||||
max_connections: 4,
|
||||
retry: sample_retry_strategy(),
|
||||
};
|
||||
let response = HttpResponseModel {
|
||||
status: 206,
|
||||
reason: "Partial Content".to_string(),
|
||||
version: HttpVersion::Http11,
|
||||
headers: HttpResponseHeaders { headers: vec![] },
|
||||
body: ResponseBody::Inline(b"abcdefghijkl".to_vec()),
|
||||
content_range: Some(ContentRangeSpec {
|
||||
unit: RangeUnit::Bytes,
|
||||
start: 8192,
|
||||
end_inclusive: 12_203,
|
||||
total_size: Some(16_384),
|
||||
unsatisfied: false,
|
||||
}),
|
||||
partial_content: true,
|
||||
checksum: Some(ChecksumSpec {
|
||||
algorithm: "sha-256".to_string(),
|
||||
expected_hex: "abc123".to_string(),
|
||||
actual_hex: Some("abc123".to_string()),
|
||||
}),
|
||||
redirected_from: None,
|
||||
};
|
||||
|
||||
let progress = task.progress_snapshot(Some(&response));
|
||||
|
||||
assert_eq!(progress.task_id, "task-progress-1");
|
||||
assert_eq!(progress.segment.requested_offset, 8192);
|
||||
assert_eq!(progress.segment.accepted_offset, Some(8192));
|
||||
assert_eq!(progress.segment.completed_offset, Some(12_204));
|
||||
assert!(progress.segment.resumed);
|
||||
assert_eq!(progress.retry_attempts.len(), 2);
|
||||
assert_eq!(progress.retry_attempts[0].requested_offset, 8192);
|
||||
assert_eq!(progress.retry_attempts[1].accepted_offset, Some(8192));
|
||||
assert_eq!(
|
||||
progress
|
||||
.completion
|
||||
.as_ref()
|
||||
.expect("completion should be present")
|
||||
.state,
|
||||
HttpCompletionState::Partial
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_snapshot_clears_resume_truth_when_server_ignores_requested_range() {
|
||||
let task = HttpTransferTaskModel {
|
||||
task_id: "task-progress-range-ignored".to_string(),
|
||||
request: sample_request(),
|
||||
response_headers: HttpResponseHeaders { headers: vec![] },
|
||||
body: ResponseBody::Empty,
|
||||
resume_state: Some(ResumeState {
|
||||
requested_offset: 4096,
|
||||
accepted_offset: Some(4096),
|
||||
resumed: true,
|
||||
}),
|
||||
retry_attempts: vec![],
|
||||
checksum_hook: None,
|
||||
max_connections: 1,
|
||||
retry: sample_retry_strategy(),
|
||||
};
|
||||
let response = HttpResponseModel {
|
||||
status: 200,
|
||||
reason: "OK".to_string(),
|
||||
version: HttpVersion::Http11,
|
||||
headers: HttpResponseHeaders { headers: vec![] },
|
||||
body: ResponseBody::Inline(vec![b'x'; 16_384]),
|
||||
content_range: None,
|
||||
partial_content: false,
|
||||
checksum: None,
|
||||
redirected_from: None,
|
||||
};
|
||||
|
||||
let progress = task.progress_snapshot(Some(&response));
|
||||
|
||||
assert_eq!(progress.segment.requested_offset, 4096);
|
||||
assert_eq!(progress.segment.accepted_offset, None);
|
||||
assert_eq!(progress.segment.completed_offset, Some(16_384));
|
||||
assert!(!progress.segment.resumed);
|
||||
assert_eq!(
|
||||
progress
|
||||
.completion
|
||||
.as_ref()
|
||||
.expect("completion should exist")
|
||||
.state,
|
||||
HttpCompletionState::Complete
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user