acropolis 0.2.1

UNDER CONSTRUCTION: A safe local-first Rust Cardano node scaffold.
Documentation
use crate::chain::{ChainPoint, ChainTip};
use crate::events::{Event, EventPayload};
use crate::ledger::blocks::{BlockBundle, BlockDecodeError, BlockEraRule};
use std::fmt;

pub const BOOTSTRAP_BLOCKED_EVENT: &str = "bootstrap.blocked";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BootstrapRequest {
    pub subject: String,
    pub location: String,
}

impl BootstrapRequest {
    pub fn new(subject: impl Into<String>, location: impl Into<String>) -> Self {
        Self {
            subject: subject.into(),
            location: location.into(),
        }
    }
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct BootstrapPolicy {
    pub allow_external: bool,
    pub allow_private_addresses: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BootstrapPlan {
    Blocked { reason: String },
    WouldFetch { request: BootstrapRequest },
    LocalFixture { snapshot: BootstrapSnapshot },
}

impl BootstrapPlan {
    pub fn events(&self) -> Vec<Event> {
        match self {
            Self::Blocked { reason } => vec![Event::new(
                BOOTSTRAP_BLOCKED_EVENT,
                EventPayload::Text(format!("reason={reason}")),
            )],
            Self::WouldFetch { .. } | Self::LocalFixture { .. } => Vec::new(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BootstrapSnapshot {
    pub subject: String,
    pub network_magic: u32,
    pub tip: ChainTip,
    pub payload_bytes: Vec<u8>,
}

impl BootstrapSnapshot {
    pub fn new(
        subject: impl Into<String>,
        network_magic: u32,
        tip: ChainTip,
        payload_bytes: Vec<u8>,
    ) -> Self {
        Self {
            subject: subject.into(),
            network_magic,
            tip,
            payload_bytes,
        }
    }

    pub fn validate(&self, expected_network_magic: u32) -> Result<(), BootstrapError> {
        if self.subject.trim().is_empty() {
            return Err(BootstrapError::EmptySubject);
        }
        if self.network_magic != expected_network_magic {
            return Err(BootstrapError::NetworkMismatch {
                expected: expected_network_magic,
                actual: self.network_magic,
            });
        }
        if self.payload_bytes.is_empty() {
            return Err(BootstrapError::EmptySnapshotPayload);
        }
        if self.tip.point != ChainPoint::ORIGIN && self.tip.point.hash == [0; 32] {
            return Err(BootstrapError::EmptyTipHash);
        }
        Ok(())
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalBootstrapReport {
    pub subject: String,
    pub tip: ChainTip,
    pub payload_bytes: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalBootstrapPayloadInspection {
    pub subject: String,
    pub tip: ChainTip,
    pub payload_bytes: usize,
    pub item_count: usize,
}

pub fn plan_bootstrap_request(
    request: BootstrapRequest,
    policy: BootstrapPolicy,
) -> Result<BootstrapPlan, BootstrapError> {
    if request.subject.trim().is_empty() {
        return Err(BootstrapError::EmptySubject);
    }
    if !request.location.starts_with("https://") {
        return Ok(BootstrapPlan::Blocked {
            reason: "location must use a secure scheme".to_string(),
        });
    }
    if !policy.allow_external {
        return Ok(BootstrapPlan::Blocked {
            reason: "external bootstrap is disabled".to_string(),
        });
    }
    if !policy.allow_private_addresses && is_private_address(&request.location) {
        return Ok(BootstrapPlan::Blocked {
            reason: "private bootstrap address is disabled".to_string(),
        });
    }
    Ok(BootstrapPlan::WouldFetch { request })
}

pub fn plan_local_bootstrap_snapshot(
    snapshot: BootstrapSnapshot,
    expected_network_magic: u32,
) -> Result<BootstrapPlan, BootstrapError> {
    snapshot.validate(expected_network_magic)?;
    Ok(BootstrapPlan::LocalFixture { snapshot })
}

pub fn apply_local_bootstrap_snapshot(
    snapshot: &BootstrapSnapshot,
    expected_network_magic: u32,
) -> Result<LocalBootstrapReport, BootstrapError> {
    snapshot.validate(expected_network_magic)?;
    Ok(LocalBootstrapReport {
        subject: snapshot.subject.clone(),
        tip: snapshot.tip,
        payload_bytes: snapshot.payload_bytes.len(),
    })
}

pub fn inspect_local_bootstrap_block_payload(
    snapshot: &BootstrapSnapshot,
    expected_network_magic: u32,
    previous: Option<ChainTip>,
    rule: BlockEraRule,
) -> Result<LocalBootstrapPayloadInspection, BootstrapError> {
    snapshot.validate(expected_network_magic)?;
    let bundle =
        BlockBundle::decode_local_fixture_and_validate(&snapshot.payload_bytes, previous, rule)
            .map_err(BootstrapError::LocalBlockPayload)?;
    let actual_tip = bundle.header.tip();
    if actual_tip != snapshot.tip {
        return Err(BootstrapError::SnapshotTipMismatch {
            expected: snapshot.tip,
            actual: actual_tip,
        });
    }
    Ok(LocalBootstrapPayloadInspection {
        subject: snapshot.subject.clone(),
        tip: actual_tip,
        payload_bytes: snapshot.payload_bytes.len(),
        item_count: bundle.items.len(),
    })
}

fn is_private_address(location: &str) -> bool {
    let address = location.trim_start_matches("https://");
    address.starts_with("localhost")
        || address.starts_with("127.")
        || address.starts_with("10.")
        || address.starts_with("192.168.")
        || address.starts_with("172.16.")
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BootstrapError {
    EmptySubject,
    NetworkMismatch {
        expected: u32,
        actual: u32,
    },
    EmptySnapshotPayload,
    EmptyTipHash,
    LocalBlockPayload(BlockDecodeError),
    SnapshotTipMismatch {
        expected: ChainTip,
        actual: ChainTip,
    },
}

impl fmt::Display for BootstrapError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::EmptySubject => f.write_str("bootstrap subject must not be empty"),
            Self::NetworkMismatch { expected, actual } => write!(
                f,
                "bootstrap network mismatch: expected={expected} actual={actual}"
            ),
            Self::EmptySnapshotPayload => {
                f.write_str("bootstrap snapshot payload must not be empty")
            }
            Self::EmptyTipHash => f.write_str("bootstrap snapshot tip hash must not be empty"),
            Self::LocalBlockPayload(err) => {
                write!(f, "bootstrap local block payload invalid: {err}")
            }
            Self::SnapshotTipMismatch { expected, actual } => write!(
                f,
                "bootstrap snapshot tip mismatch: expected bundle={} actual bundle={}",
                expected.bundle_number, actual.bundle_number
            ),
        }
    }
}

impl std::error::Error for BootstrapError {}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ledger::blocks::{
        local_fixture_body_hash, local_ledger_fixture_catalog, BlockHeader, BlockItem,
        BlockValidationError,
    };

    fn tip(slot: u64, bundle_number: u64, hash: u8) -> ChainTip {
        ChainTip::new(ChainPoint::new(slot, [hash; 32]), bundle_number)
    }

    fn fixture_bundle_after(previous: ChainTip, hash: u8) -> BlockBundle {
        let items = vec![BlockItem::new(format!("tx-{hash}"), vec![b't', b'x', hash])];
        BlockBundle {
            header: BlockHeader {
                point: ChainPoint::new(previous.point.slot + 1, [hash; 32]),
                bundle_number: previous.bundle_number + 1,
                parent_hash: previous.point.hash,
                body_hash: local_fixture_body_hash(&items),
            },
            items,
        }
    }

    #[test]
    fn bootstrap_plan_blocks_by_default_without_fetching() {
        let plan = plan_bootstrap_request(
            BootstrapRequest::new("pool", "https://example.invalid/bootstrap"),
            BootstrapPolicy::default(),
        )
        .unwrap();
        assert!(matches!(plan, BootstrapPlan::Blocked { .. }));
        let events = plan.events();
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].name.as_str(), BOOTSTRAP_BLOCKED_EVENT);
        assert_eq!(
            events[0].payload,
            EventPayload::Text("reason=external bootstrap is disabled".to_string())
        );
    }

    #[test]
    fn bootstrap_plan_blocks_private_addresses() {
        let plan = plan_bootstrap_request(
            BootstrapRequest::new("pool", "https://127.0.0.1/bootstrap"),
            BootstrapPolicy {
                allow_external: true,
                allow_private_addresses: false,
            },
        )
        .unwrap();
        assert_eq!(
            plan,
            BootstrapPlan::Blocked {
                reason: "private bootstrap address is disabled".to_string(),
            }
        );
    }

    #[test]
    fn local_bootstrap_snapshot_plans_without_fetching() {
        let snapshot = BootstrapSnapshot::new("local-chain", 2, tip(10, 10, 7), vec![1, 2, 3]);
        let plan = plan_local_bootstrap_snapshot(snapshot.clone(), 2).unwrap();

        assert_eq!(plan, BootstrapPlan::LocalFixture { snapshot });
    }

    #[test]
    fn local_bootstrap_snapshot_applies_to_report_only() {
        let snapshot = BootstrapSnapshot::new("local-chain", 2, tip(10, 10, 7), vec![1, 2, 3]);
        let report = apply_local_bootstrap_snapshot(&snapshot, 2).unwrap();

        assert_eq!(report.subject, "local-chain");
        assert_eq!(report.tip, tip(10, 10, 7));
        assert_eq!(report.payload_bytes, 3);
    }

    #[test]
    fn local_bootstrap_snapshot_rejects_network_mismatch() {
        let snapshot = BootstrapSnapshot::new("local-chain", 1, tip(10, 10, 7), vec![1]);

        assert_eq!(
            plan_local_bootstrap_snapshot(snapshot, 2),
            Err(BootstrapError::NetworkMismatch {
                expected: 2,
                actual: 1,
            })
        );
    }

    #[test]
    fn local_bootstrap_snapshot_inspects_encoded_block_payload() {
        let previous = tip(9, 9, 4);
        let bundle = fixture_bundle_after(previous, 7);
        let snapshot = BootstrapSnapshot::new(
            "local-chain",
            2,
            bundle.header.tip(),
            bundle.encode_local().unwrap(),
        );
        let rule = local_ledger_fixture_catalog()
            .rule_for_major_version(6)
            .unwrap();

        let inspection =
            inspect_local_bootstrap_block_payload(&snapshot, 2, Some(previous), rule).unwrap();

        assert_eq!(inspection.subject, "local-chain");
        assert_eq!(inspection.tip, tip(10, 10, 7));
        assert_eq!(inspection.item_count, 1);
        assert_eq!(inspection.payload_bytes, snapshot.payload_bytes.len());
    }

    #[test]
    fn local_bootstrap_snapshot_rejects_payload_tip_mismatch() {
        let previous = tip(9, 9, 4);
        let bundle = fixture_bundle_after(previous, 7);
        let snapshot = BootstrapSnapshot::new(
            "local-chain",
            2,
            tip(10, 10, 8),
            bundle.encode_local().unwrap(),
        );
        let rule = local_ledger_fixture_catalog()
            .rule_for_major_version(6)
            .unwrap();

        assert_eq!(
            inspect_local_bootstrap_block_payload(&snapshot, 2, Some(previous), rule),
            Err(BootstrapError::SnapshotTipMismatch {
                expected: tip(10, 10, 8),
                actual: tip(10, 10, 7),
            })
        );
    }

    #[test]
    fn local_bootstrap_snapshot_rejects_invalid_block_payload() {
        let previous = tip(9, 9, 4);
        let mut bundle = fixture_bundle_after(previous, 7);
        bundle.header.body_hash = [8; 32];
        let snapshot = BootstrapSnapshot::new(
            "local-chain",
            2,
            bundle.header.tip(),
            bundle.encode_local().unwrap(),
        );
        let rule = local_ledger_fixture_catalog()
            .rule_for_major_version(6)
            .unwrap();

        assert!(matches!(
            inspect_local_bootstrap_block_payload(&snapshot, 2, Some(previous), rule),
            Err(BootstrapError::LocalBlockPayload(
                BlockDecodeError::Validation(BlockValidationError::BodyHashMismatch { .. })
            ))
        ));
    }
}