use crate::model::evidence_digest::EvidenceDigest;
use super::artifacts::{ArtifactChecksumRecord, ChecksumError, canonical_hash};
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const MAX_DOWNLOAD_JOURNAL_BYTES: u64 = 1024 * 1024;
pub const MAX_SNAPSHOT_ID_BYTES: usize = 256;
#[derive(Clone, Debug)]
pub struct DownloadArtifactRequest {
pub canister_id: String,
pub snapshot_id: String,
pub snapshot_taken_at_timestamp: u64,
pub snapshot_total_size_bytes: u64,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum ArtifactStateRecord {
Created,
ChecksumVerified,
Durable,
}
pub(crate) enum ArtifactTransition {
ChecksumVerified(ArtifactChecksumRecord),
Durable,
}
impl ArtifactTransition {
const fn state(&self) -> ArtifactStateRecord {
match self {
Self::ChecksumVerified(_) => ArtifactStateRecord::ChecksumVerified,
Self::Durable => ArtifactStateRecord::Durable,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(try_from = "ArtifactFields")]
pub struct DownloadArtifactRecord {
canister_id: String,
snapshot_id: String,
snapshot_taken_at_timestamp: u64,
snapshot_total_size_bytes: u64,
staging_path: String,
artifact_path: String,
#[serde(flatten)]
progress: ArtifactProgress,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum ArtifactProgress {
Created,
ChecksumVerified(ArtifactChecksumRecord),
Durable(ArtifactChecksumRecord),
}
impl ArtifactProgress {
const fn state(&self) -> ArtifactStateRecord {
match self {
Self::Created => ArtifactStateRecord::Created,
Self::ChecksumVerified(_) => ArtifactStateRecord::ChecksumVerified,
Self::Durable(_) => ArtifactStateRecord::Durable,
}
}
const fn checksum(&self) -> Option<&ArtifactChecksumRecord> {
match self {
Self::ChecksumVerified(checksum) | Self::Durable(checksum) => Some(checksum),
Self::Created => None,
}
}
}
impl Serialize for ArtifactProgress {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut fields = serializer.serialize_struct("ArtifactProgress", 2)?;
fields.serialize_field("state", &self.state())?;
fields.serialize_field("checksum", &self.checksum())?;
fields.end()
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ArtifactFields {
canister_id: String,
snapshot_id: String,
snapshot_taken_at_timestamp: u64,
snapshot_total_size_bytes: u64,
staging_path: String,
artifact_path: String,
state: ArtifactStateRecord,
#[serde(deserialize_with = "crate::model::required_option::deserialize")]
checksum: Option<ArtifactChecksumRecord>,
}
impl TryFrom<ArtifactFields> for DownloadArtifactRecord {
type Error = DownloadJournalRecordError;
fn try_from(fields: ArtifactFields) -> Result<Self, Self::Error> {
let mut record = Self::new(DownloadArtifactRequest {
canister_id: fields.canister_id,
snapshot_id: fields.snapshot_id,
snapshot_taken_at_timestamp: fields.snapshot_taken_at_timestamp,
snapshot_total_size_bytes: fields.snapshot_total_size_bytes,
})?;
if fields.staging_path != record.staging_path
|| fields.artifact_path != record.artifact_path
{
return Err(DownloadJournalRecordError::ArtifactPathMismatch);
}
record.progress = match (fields.state, fields.checksum) {
(ArtifactStateRecord::Created, None) => ArtifactProgress::Created,
(ArtifactStateRecord::ChecksumVerified, Some(checksum)) => {
ArtifactProgress::ChecksumVerified(checksum)
}
(ArtifactStateRecord::Durable, Some(checksum)) => ArtifactProgress::Durable(checksum),
(state, _) => return Err(DownloadJournalRecordError::InvalidChecksumState(state)),
};
Ok(record)
}
}
impl DownloadArtifactRecord {
fn new(request: DownloadArtifactRequest) -> Result<Self, DownloadJournalRecordError> {
let canister_id = normalize_principal(&request.canister_id)?;
if request.snapshot_id.is_empty()
|| request.snapshot_id.len() > MAX_SNAPSHOT_ID_BYTES
|| !request
.snapshot_id
.bytes()
.all(|byte| byte.is_ascii_graphic())
{
return Err(DownloadJournalRecordError::InvalidSnapshotId);
}
Ok(Self {
staging_path: format!("artifacts/{canister_id}.tmp"),
artifact_path: format!("artifacts/{canister_id}"),
canister_id,
snapshot_id: request.snapshot_id,
snapshot_taken_at_timestamp: request.snapshot_taken_at_timestamp,
snapshot_total_size_bytes: request.snapshot_total_size_bytes,
progress: ArtifactProgress::Created,
})
}
#[must_use]
pub fn canister_id(&self) -> &str {
&self.canister_id
}
#[must_use]
pub fn snapshot_id(&self) -> &str {
&self.snapshot_id
}
#[must_use]
pub const fn snapshot_taken_at_timestamp(&self) -> u64 {
self.snapshot_taken_at_timestamp
}
#[must_use]
pub const fn snapshot_total_size_bytes(&self) -> u64 {
self.snapshot_total_size_bytes
}
#[must_use]
pub fn staging_path(&self) -> &str {
&self.staging_path
}
#[must_use]
pub fn artifact_path(&self) -> &str {
&self.artifact_path
}
#[must_use]
pub const fn state(&self) -> ArtifactStateRecord {
self.progress.state()
}
#[must_use]
pub const fn checksum(&self) -> Option<&ArtifactChecksumRecord> {
self.progress.checksum()
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(try_from = "JournalFields")]
pub struct DownloadJournalRecord {
version: u16,
intent: String,
artifacts: [DownloadArtifactRecord; 1],
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct JournalFields {
version: u16,
intent: String,
artifacts: [DownloadArtifactRecord; 1],
}
impl TryFrom<JournalFields> for DownloadJournalRecord {
type Error = DownloadJournalRecordError;
fn try_from(fields: JournalFields) -> Result<Self, Self::Error> {
if fields.version != 1 {
return Err(DownloadJournalRecordError::UnsupportedVersion(
fields.version,
));
}
Ok(Self {
version: 1,
intent: canonical_hash(&fields.intent)?,
artifacts: fields.artifacts,
})
}
}
impl DownloadJournalRecord {
pub fn new(
intent: &str,
request: DownloadArtifactRequest,
) -> Result<Self, DownloadJournalRecordError> {
JournalFields {
version: 1,
intent: intent.to_owned(),
artifacts: [DownloadArtifactRecord::new(request)?],
}
.try_into()
}
#[must_use]
pub fn intent(&self) -> &str {
&self.intent
}
#[must_use]
pub const fn artifacts(&self) -> &[DownloadArtifactRecord; 1] {
&self.artifacts
}
#[must_use]
pub fn digest(&self) -> ArtifactChecksumRecord {
let mut bytes = EvidenceDigest::new(b"ic-backup/download-journal/v1\0");
bytes.append(self.intent.as_bytes());
bytes.u64_be(self.artifacts.len() as u64);
for entry in &self.artifacts {
for value in [
entry.canister_id(),
entry.snapshot_id(),
entry.staging_path(),
entry.artifact_path(),
] {
bytes.bytes_u32(value.as_bytes());
}
bytes.u64_be(entry.snapshot_taken_at_timestamp);
bytes.u64_be(entry.snapshot_total_size_bytes);
bytes.byte(match entry.state() {
ArtifactStateRecord::Created => 0,
ArtifactStateRecord::ChecksumVerified => 2,
ArtifactStateRecord::Durable => 3,
});
bytes.byte(u8::from(entry.checksum().is_some()));
if let Some(checksum) = entry.checksum() {
bytes.checksum(checksum);
}
}
bytes.finish()
}
pub(crate) fn artifact(
&self,
canister: &str,
snapshot: &str,
) -> Result<&DownloadArtifactRecord, DownloadJournalRecordError> {
self.admit_identity(canister, snapshot)?;
Ok(&self.artifacts[0])
}
fn admit_identity(
&self,
canister: &str,
snapshot: &str,
) -> Result<(), DownloadJournalRecordError> {
let canister = normalize_principal(canister)?;
if self.artifacts[0].canister_id != canister {
return Err(DownloadJournalRecordError::UnknownArtifact);
}
if self.artifacts[0].snapshot_id != snapshot {
return Err(DownloadJournalRecordError::SnapshotMismatch);
}
Ok(())
}
pub(crate) fn advance(
&mut self,
canister: &str,
snapshot: &str,
transition: ArtifactTransition,
) -> Result<(), DownloadJournalRecordError> {
self.admit_identity(canister, snapshot)?;
let entry = &mut self.artifacts[0];
entry.progress = match (&entry.progress, transition) {
(ArtifactProgress::Created, ArtifactTransition::ChecksumVerified(checksum)) => {
ArtifactProgress::ChecksumVerified(checksum)
}
(ArtifactProgress::ChecksumVerified(checksum), ArtifactTransition::Durable) => {
ArtifactProgress::Durable(checksum.clone())
}
(_, transition) => {
return Err(DownloadJournalRecordError::InvalidStateTransition {
from: entry.state(),
to: transition.state(),
});
}
};
Ok(())
}
}
fn normalize_principal(value: &str) -> Result<String, DownloadJournalRecordError> {
super::principal::canonical_text(value).ok_or(DownloadJournalRecordError::InvalidPrincipal)
}
#[derive(Debug, Error)]
pub enum DownloadJournalRecordError {
#[error("unsupported download journal version {0}")]
UnsupportedVersion(u16),
#[error("invalid download canister principal")]
InvalidPrincipal,
#[error("invalid download snapshot token")]
InvalidSnapshotId,
#[error("download artifact path does not match source identity")]
ArtifactPathMismatch,
#[error("unknown download artifact")]
UnknownArtifact,
#[error("download snapshot identity mismatch")]
SnapshotMismatch,
#[error("invalid download state transition from {from:?} to {to:?}")]
InvalidStateTransition {
from: ArtifactStateRecord,
to: ArtifactStateRecord,
},
#[error("invalid checksum evidence for download state {0:?}")]
InvalidChecksumState(ArtifactStateRecord),
#[error(transparent)]
Checksum(#[from] ChecksumError),
}
#[cfg(test)]
mod tests;