assetpack-core 0.3.0

Content-addressed asset packing, chunking, recipe, and SQLite storage primitives.
Documentation
use crate::{Codec, Error, Hash32, ObjectKind, ObjectRecord, Result, VerifiedObject, compress, decompress};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MerkleMode {
  Disabled,
  #[default]
  Enabled,
}

#[derive(Debug, Clone, Default)]
pub struct SqliteStoreOptions {
  pub namespace: Option<String>,
  pub merkle: MerkleMode,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MerkleProof {
  pub leaf_pos: u64,
  pub siblings: Vec<Hash32>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MerkleSummary {
  pub root: Hash32,
  pub leaf_count: u64,
}

#[derive(Debug, Clone)]
pub(in crate::sqlite) struct SqliteLayout {
  pub namespace: Option<String>,
  pub objects: String,
  pub file_recipe_cache: String,
  pub merkle_leaves: String,
  pub merkle_nodes: String,
  pub merkle_meta: String,
  pub merkle_leaves_pos_index: String,
}

impl SqliteLayout {
  pub fn new(namespace: Option<&str>) -> Result<Self> {
    let namespace = normalize_namespace(namespace)?;
    let prefix = namespace.as_ref().map(|value| format!("{value}_")).unwrap_or_default();
    let index_prefix = namespace
      .as_ref()
      .map(|value| format!("idx_{value}_"))
      .unwrap_or_else(|| "idx_".into());
    Ok(Self {
      namespace,
      objects: format!("{prefix}objects"),
      file_recipe_cache: format!("{prefix}file_recipe_cache"),
      merkle_leaves: format!("{prefix}merkle_leaves"),
      merkle_nodes: format!("{prefix}merkle_nodes"),
      merkle_meta: format!("{prefix}merkle_meta"),
      merkle_leaves_pos_index: format!("{index_prefix}merkle_leaves_pos"),
    })
  }
}

fn normalize_namespace(namespace: Option<&str>) -> Result<Option<String>> {
  let Some(namespace) = namespace else {
    return Ok(None);
  };
  if namespace.is_empty() {
    return Err(Error::InvalidConfig("store namespace cannot be empty".into()));
  }
  if namespace.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
    Ok(Some(namespace.to_string()))
  } else {
    Err(Error::InvalidConfig(format!(
      "store namespace must contain only ASCII letters, digits, or underscores: {namespace}"
    )))
  }
}

pub(in crate::sqlite) fn schema_statements(layout: &SqliteLayout, merkle: MerkleMode) -> Vec<String> {
  let mut statements = vec![
    format!(
      "CREATE TABLE IF NOT EXISTS {} (hash BLOB PRIMARY KEY, kind INTEGER NOT NULL, size INTEGER NOT NULL, stored_size INTEGER NOT NULL, \
       codec TEXT NOT NULL, content BLOB NOT NULL, created_at INTEGER NOT NULL);",
      layout.objects
    ),
    format!(
      "CREATE TABLE IF NOT EXISTS {} (file_hash BLOB PRIMARY KEY, recipe_hash BLOB NOT NULL, updated_at INTEGER NOT NULL);",
      layout.file_recipe_cache
    ),
  ];
  if merkle == MerkleMode::Enabled {
    statements.extend([
      format!(
        "CREATE TABLE IF NOT EXISTS {} (hash BLOB PRIMARY KEY, pos INTEGER NOT NULL);",
        layout.merkle_leaves
      ),
      format!(
        "CREATE UNIQUE INDEX IF NOT EXISTS {} ON {}(pos);",
        layout.merkle_leaves_pos_index, layout.merkle_leaves
      ),
      format!(
        "CREATE TABLE IF NOT EXISTS {} (level INTEGER NOT NULL, pos INTEGER NOT NULL, hash BLOB NOT NULL, PRIMARY KEY(level, pos));",
        layout.merkle_nodes
      ),
      format!(
        "CREATE TABLE IF NOT EXISTS {} (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
        layout.merkle_meta
      ),
    ]);
  }
  statements
}

pub(in crate::sqlite) struct StoredObjectRow {
  pub hash: Hash32,
  pub kind: i64,
  pub decoded_len: i64,
  pub codec: String,
  pub stored_bytes: Vec<u8>,
}

pub(in crate::sqlite) fn prepare_object(hash: Hash32, kind: ObjectKind, content: &[u8], codec: Codec) -> Result<ObjectRecord> {
  if Hash32::sha3_256(content) != hash {
    return Err(Error::HashMismatch);
  }
  let mut selected = codec;
  let stored_bytes = if codec == Codec::Raw {
    content.to_vec()
  } else {
    let candidate = compress(codec, content)?;
    if candidate.len() as f64 / content.len().max(1) as f64 >= 0.98 {
      selected = Codec::Raw;
      content.to_vec()
    } else {
      candidate
    }
  };
  Ok(ObjectRecord {
    hash,
    kind,
    decoded_len: content.len() as u64,
    codec: selected,
    stored_bytes,
  })
}

pub(in crate::sqlite) fn verified_object_from_row(row: StoredObjectRow) -> Result<VerifiedObject> {
  let kind = ObjectKind::from_i64(row.kind).ok_or_else(|| Error::Integrity("invalid kind".into()))?;
  let expected = u64::try_from(row.decoded_len).map_err(|_| Error::Integrity("negative object size".into()))?;
  let bytes = decompress(codec_from_str(&row.codec)?, &row.stored_bytes)?;
  let actual = bytes.len() as u64;
  if actual != expected {
    return Err(Error::ObjectLengthMismatch { expected, actual });
  }
  let actual_hash = Hash32::sha3_256(&bytes);
  if actual_hash != row.hash {
    return Err(Error::ObjectHashMismatch {
      expected: row.hash,
      actual: actual_hash,
    });
  }
  Ok(VerifiedObject {
    hash: row.hash,
    kind,
    bytes,
  })
}

pub(in crate::sqlite) fn object_record_from_row(row: StoredObjectRow) -> Result<ObjectRecord> {
  Ok(ObjectRecord {
    hash: row.hash,
    kind: ObjectKind::from_i64(row.kind).ok_or_else(|| Error::Integrity("invalid kind".into()))?,
    decoded_len: u64::try_from(row.decoded_len).map_err(|_| Error::Integrity("negative object size".into()))?,
    codec: codec_from_str(&row.codec)?,
    stored_bytes: row.stored_bytes,
  })
}

pub(in crate::sqlite) fn codec_to_str(codec: Codec) -> &'static str {
  match codec {
    Codec::Raw => "raw",
    Codec::Zstd => "zstd",
    Codec::Brotli => "brotli",
  }
}

pub(in crate::sqlite) fn codec_from_str(value: &str) -> Result<Codec> {
  match value {
    "raw" => Ok(Codec::Raw),
    "zstd" => Ok(Codec::Zstd),
    "brotli" => Ok(Codec::Brotli),
    other => Err(Error::InvalidCodec(other.to_string())),
  }
}

pub(in crate::sqlite) fn hash_pair(left: &Hash32, right: &Hash32) -> Hash32 {
  let mut bytes = Vec::with_capacity(64);
  bytes.extend_from_slice(left.as_bytes());
  bytes.extend_from_slice(right.as_bytes());
  Hash32::sha3_256(bytes)
}

pub(in crate::sqlite) fn merkle_root_from_hashes(hashes: &[Hash32]) -> MerkleSummary {
  if hashes.is_empty() {
    return MerkleSummary {
      root: Hash32::sha3_256([]),
      leaf_count: 0,
    };
  }
  let mut current = hashes.to_vec();
  while current.len() > 1 {
    current = current
      .chunks(2)
      .map(|pair| hash_pair(&pair[0], pair.get(1).unwrap_or(&pair[0])))
      .collect();
  }
  MerkleSummary {
    root: current[0],
    leaf_count: hashes.len() as u64,
  }
}