weavatrix-worktree 0.2.1

Bounded, crash-recoverable multi-file worktree edits for source refactoring
Documentation
use std::io::{Read, Seek, SeekFrom};

use crate::{
    error::{TransactionPhase, WorktreeError, WorktreeErrorCode},
    filesystem::{ControlDir, SlotEvidence},
    hash::serialized_hash,
    options::WorktreeOptions,
};

use super::{
    ReceiptBody, ReceiptEnvelope, StoredReceipt, corrupt, encode_error, snapshot, store_io,
    undo_error, undo_io,
};
use crate::operation::undo::types::{UndoId, UndoStoreUsage};

pub(in crate::operation) fn inspect(
    control: &ControlDir,
    id: &UndoId,
    options: WorktreeOptions,
) -> Result<StoredReceipt, WorktreeError> {
    read_exact(control, id.as_str(), options)?.ok_or_else(|| {
        undo_error(
            WorktreeErrorCode::UndoNotFound,
            TransactionPhase::Validate,
            "the exact undo receipt does not exist",
        )
    })
}

pub(in crate::operation) fn store_usage(
    control: &ControlDir,
    options: WorktreeOptions,
) -> Result<UndoStoreUsage, WorktreeError> {
    let ids = control.undo_receipt_ids().map_err(store_io)?;
    if ids.len() > options.limits.max_undo_receipts {
        return Err(corrupt("undo receipt count exceeds the configured bound"));
    }
    let mut bytes = 0_u64;
    for id in &ids {
        let receipt = read_exact(control, id, options)?
            .ok_or_else(|| corrupt("an enumerated undo receipt vanished"))?;
        bytes = bytes
            .checked_add(receipt.retained_bytes())
            .and_then(|value| value.checked_add(receipt.stored_bytes))
            .ok_or_else(|| corrupt("undo store byte accounting overflowed"))?;
        if bytes > options.limits.max_total_undo_bytes as u64 {
            return Err(corrupt("undo store exceeds the configured byte bound"));
        }
    }
    Ok(UndoStoreUsage {
        receipts: ids.len(),
        bytes,
    })
}

pub(in crate::operation) fn discard(
    control: &ControlDir,
    receipt: &StoredReceipt,
    options: WorktreeOptions,
) -> Result<usize, WorktreeError> {
    let mut removed = 0;
    for (position, path) in receipt.paths().iter().enumerate() {
        let Some(name) = path.backup_name.as_deref() else {
            continue;
        };
        let expected = path
            .backup
            .ok_or_else(|| corrupt("receipt backup evidence is missing"))?;
        let actual = control
            .backup_evidence(name, options.limits.max_source_bytes_per_file)
            .map_err(|error| {
                undo_io(
                    TransactionPhase::Cleanup,
                    "failed to verify undo artifact",
                    error,
                )
                .at_path(path.path.clone())
                .at_file(position)
            })?;
        if actual != expected {
            return Err(corrupt("undo artifact does not match receipt evidence")
                .at_path(path.path.clone())
                .at_file(position));
        }
        removed += usize::from(control.remove_backup(name).map_err(|error| {
            undo_io(
                TransactionPhase::Cleanup,
                "failed to remove exact undo artifact",
                error,
            )
            .at_path(path.path.clone())
            .at_file(position)
        })?);
    }
    control
        .remove_undo_receipt(receipt.id())
        .map_err(store_io)?;
    Ok(removed)
}

pub(in crate::operation) fn read_exact(
    control: &ControlDir,
    id: &str,
    options: WorktreeOptions,
) -> Result<Option<StoredReceipt>, WorktreeError> {
    let Some(mut file) = control.open_undo_receipt(id).map_err(store_io)? else {
        return Ok(None);
    };
    let metadata = file.metadata().map_err(store_io)?;
    if !metadata.is_file() || metadata.len() > options.limits.max_journal_bytes as u64 {
        return Err(corrupt("undo receipt is not a bounded regular file"));
    }
    file.seek(SeekFrom::Start(0)).map_err(store_io)?;
    let mut bytes = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or(0));
    file.take(options.limits.max_journal_bytes as u64 + 1)
        .read_to_end(&mut bytes)
        .map_err(store_io)?;
    if bytes.len() > options.limits.max_journal_bytes {
        return Err(corrupt("undo receipt exceeds the configured byte bound"));
    }
    let envelope: ReceiptEnvelope =
        blazingly_json::from_slice(&bytes).map_err(|_| corrupt("undo receipt JSON is invalid"))?;
    if envelope.body.schema != super::SCHEMA
        || envelope.body.transaction_id != id
        || serialized_hash(&envelope.body).map_err(encode_error)? != envelope.checksum
    {
        return Err(corrupt("undo receipt identity or checksum is invalid"));
    }
    validate_body(&envelope.body, options)?;
    Ok(Some(StoredReceipt {
        body: envelope.body,
        checksum: envelope.checksum,
        stored_bytes: bytes.len() as u64,
    }))
}

fn validate_body(body: &ReceiptBody, options: WorktreeOptions) -> Result<(), WorktreeError> {
    if body.paths.is_empty() || body.paths.len() > options.limits.max_files {
        return Err(corrupt("undo receipt path count is invalid"));
    }
    let mut previous = None;
    let mut retained = 0_u64;
    for (position, path) in body.paths.iter().enumerate() {
        if path.index as usize != position
            || weavatrix_refactor_plan::validate_plan_path(&path.path, 4_096).is_err()
        {
            return Err(corrupt("undo receipt contains an invalid path or index"));
        }
        let key = weavatrix_refactor_plan::portable_path_key(&path.path);
        if previous
            .as_ref()
            .is_some_and(|value: &String| value >= &key)
        {
            return Err(corrupt("undo receipt paths alias or are not ordered"));
        }
        previous = Some(key);
        match (path.before, path.backup_name.as_deref(), path.backup) {
            (SlotEvidence::Present(before), Some(name), Some(backup))
                if name
                    == format!(
                        ".weavatrix-{}-{:04}.backup",
                        body.transaction_id, path.index
                    )
                    && before.sha256 == backup.sha256
                    && before.bytes == backup.bytes
                    && before.permissions == backup.permissions =>
            {
                retained = retained
                    .checked_add(backup.bytes)
                    .ok_or_else(|| corrupt("undo retained bytes overflow"))?;
            }
            (SlotEvidence::Absent, None, None) => {}
            _ => return Err(corrupt("undo receipt backup contract is inconsistent")),
        }
    }
    if retained != body.retained_bytes
        || snapshot(&body.paths, true)? != body.before_fingerprint
        || snapshot(&body.paths, false)? != body.after_fingerprint
    {
        return Err(corrupt("undo receipt aggregate evidence is inconsistent"));
    }
    Ok(())
}