assetpack-core 0.3.0

Content-addressed asset packing, chunking, recipe, and SQLite storage primitives.
Documentation
use std::collections::BTreeMap;

#[cfg(feature = "sealed-encryption")]
use zeroize::Zeroizing;

use super::{
  CRYPTO_CHACHA20_POLY1305_FRAMES, CRYPTO_PLAIN_SHA3, DirectoryRecord, Header, MAX_DECODED_OBJECT_BYTES, MAX_OBJECT_COUNT,
  MAX_STORED_OBJECT_BYTES, SealedPackTag, index, invalid, logical_pack_id_records, stored_index_digest,
};
#[cfg(feature = "sealed-encryption")]
use super::{FrameSealer, SealedRecordContext, seal_payload};
use crate::{
  codec::decompress,
  error::{Error, Result},
  hash::Hash32,
  object::{ObjectKind, ObjectRecord},
  recipe::parse_recipe,
};

pub(super) fn build_plain_bytes(tag: SealedPackTag, root_recipe: Hash32, objects: Vec<ObjectRecord>) -> Result<Vec<u8>> {
  let records = records(&objects);
  let index = index::compress_index(&index::encode_decoded_index(root_recipe, &records))?;
  let mut header = header(
    tag,
    logical_pack_id_records(root_recipe, &records),
    false,
    [0; 32],
    [0; 16],
    index.len(),
  )?;
  header.index_digest = stored_index_digest(&header, &index);
  let mut output = header.encode().to_vec();
  output.extend_from_slice(&index);
  for object in &objects {
    output.extend_from_slice(&object.stored_bytes);
  }
  Ok(output)
}

#[cfg(feature = "sealed-encryption")]
pub(super) fn build_encrypted_bytes(
  tag: SealedPackTag,
  root_recipe: Hash32,
  objects: Vec<ObjectRecord>,
  sealer: &dyn FrameSealer,
) -> Result<Vec<u8>> {
  let mut pack_salt = [0_u8; 32];
  getrandom::fill(&mut pack_salt).map_err(|error| Error::Other(error.to_string()))?;
  let records = records(&objects);
  let mut index = Zeroizing::new(index::compress_index(&index::encode_decoded_index(root_recipe, &records))?);
  let mut header = header(
    tag,
    logical_pack_id_records(root_recipe, &records),
    true,
    pack_salt,
    sealer.key_slot(),
    index.len() + 16,
  )?;
  let context = SealedRecordContext::index(&header);
  let tag = sealer.seal_record(&context, &mut index)?;
  index.extend_from_slice(&tag);
  header.index_digest = stored_index_digest(&header, &index);
  let mut output = header.encode().to_vec();
  output.extend_from_slice(&index);
  for (object, record) in objects.iter().zip(&records) {
    seal_payload(&mut output, &header, record, &object.stored_bytes, sealer)?;
  }
  Ok(output)
}

fn header(
  tag: SealedPackTag,
  pack_id: Hash32,
  encrypted: bool,
  pack_salt: [u8; 32],
  key_slot: [u8; 16],
  index_stored_length: usize,
) -> Result<Header> {
  Ok(Header {
    tag,
    crypto_suite: if encrypted {
      CRYPTO_CHACHA20_POLY1305_FRAMES
    } else {
      CRYPTO_PLAIN_SHA3
    },
    index_stored_length: index_stored_length.try_into().map_err(|_| invalid("index length exceeds u64"))?,
    pack_id,
    pack_salt,
    key_slot,
    index_digest: Hash32::new([0; 32]),
  })
}

fn records(objects: &[ObjectRecord]) -> Vec<DirectoryRecord> {
  objects
    .iter()
    .map(|object| DirectoryRecord {
      hash: object.hash,
      kind: object.kind,
      codec: object.codec,
      decoded_length: object.decoded_len,
      stored_length: object.stored_bytes.len() as u64,
      payload_offset: 0,
    })
    .collect()
}

pub(super) fn validated_objects(root_recipe: Hash32, objects: impl IntoIterator<Item = ObjectRecord>) -> Result<Vec<ObjectRecord>> {
  let mut by_hash = BTreeMap::new();
  for object in objects {
    if object.decoded_len > MAX_DECODED_OBJECT_BYTES || object.stored_bytes.len() as u64 > MAX_STORED_OBJECT_BYTES {
      return Err(invalid("object exceeds hard limit"));
    }
    let decoded = decompress(object.codec, &object.stored_bytes)?;
    if decoded.len() as u64 != object.decoded_len || Hash32::sha3_256(&decoded) != object.hash {
      return Err(Error::HashMismatch);
    }
    if by_hash.insert(object.hash, object).is_some() {
      return Err(invalid("duplicate object hash"));
    }
  }
  if by_hash.len() as u64 > MAX_OBJECT_COUNT {
    return Err(invalid("object count exceeds hard limit"));
  }
  let root = by_hash.get(&root_recipe).ok_or_else(|| invalid("root recipe is missing"))?;
  if root.kind != ObjectKind::Recipe {
    return Err(invalid("root object is not a recipe"));
  }
  for object in by_hash.values().filter(|object| object.kind == ObjectKind::Recipe) {
    let decoded = decompress(object.codec, &object.stored_bytes)?;
    let recipe = parse_recipe(&decoded)?;
    for (chunk_hash, chunk_length) in recipe.chunks {
      let chunk = by_hash
        .get(&chunk_hash)
        .ok_or_else(|| invalid("recipe references a missing chunk"))?;
      if chunk.kind != ObjectKind::Chunk || chunk.decoded_len != u64::from(chunk_length) {
        return Err(invalid("recipe chunk kind or length mismatch"));
      }
    }
  }
  Ok(by_hash.into_values().collect())
}