node-app-build 6.5.0

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! RGS snapshot summary reader.
//!
//! Reads the binary snapshot from the RGS container filesystem via
//! `docker exec <container> cat /res/symlinks/0.bin` and parses the header.

use super::InfraContext;
use anyhow::{bail, Context, Result};
use std::process::Command;

#[derive(Debug, Clone)]
pub struct SnapshotSummary {
    pub version: u8,
    pub chain_hash: String,
    pub timestamp: u32,
    pub timestamp_str: String,
    pub node_count: usize,
    pub channel_count: usize,
    pub update_count: usize,
}

/// Fetch and parse the RGS snapshot summary.
///
/// `snapshot_path` defaults to `/res/symlinks/0.bin` when `None`.
pub fn fetch_snapshot_summary(
    ctx: &InfraContext,
    snapshot_path: Option<&str>,
) -> Result<SnapshotSummary> {
    let container = super::ops::rgs_container_name(ctx);
    let path = snapshot_path.unwrap_or("/res/symlinks/0.bin");

    let out = Command::new("docker")
        .args(["exec", &container, "cat", path])
        .output()
        .context("docker exec cat snapshot")?;

    if !out.status.success() {
        let stderr = String::from_utf8_lossy(&out.stderr);
        bail!("docker exec failed: {}", stderr.trim());
    }

    let data = &out.stdout;
    parse_snapshot_header(data)
}

/// Parse the RGS binary snapshot header.
///
/// RGS snapshot format (simplified):
/// - 4 bytes: magic "RLGS"
/// - 1 byte:  version (currently 2)
/// - 32 bytes: chain hash
/// - 4 bytes:  latest seen timestamp (BE u32)
/// - 3 bytes:  node announcement count (BE u24)
/// - 4 bytes:  channel announcement count (BE u32)
/// - ... channel entries ...
/// - 4 bytes:  channel update count (BE u32)
fn parse_snapshot_header(data: &[u8]) -> Result<SnapshotSummary> {
    if data.len() < 48 {
        bail!("snapshot too short ({} bytes)", data.len());
    }

    // Magic check.
    if &data[0..4] != b"RLGS" {
        bail!(
            "invalid snapshot magic: {:?}",
            &data[..4.min(data.len())]
        );
    }

    let version = data[4];
    let chain_hash = hex_encode(&data[5..37]);
    let timestamp = u32::from_be_bytes([data[37], data[38], data[39], data[40]]);
    let timestamp_str = format_unix_timestamp(timestamp);

    // Node count: 3-byte big-endian at offset 41.
    let node_count = if data.len() >= 44 {
        ((data[41] as usize) << 16) | ((data[42] as usize) << 8) | (data[43] as usize)
    } else {
        0
    };

    // Channel count at offset 44 (4 bytes BE).
    let channel_count = if data.len() >= 48 {
        u32::from_be_bytes([data[44], data[45], data[46], data[47]]) as usize
    } else {
        0
    };

    // We don't parse deeper into the binary blob to count updates —
    // use a rough estimate from the total file size.
    let update_count = data.len().saturating_sub(48) / 136;

    Ok(SnapshotSummary {
        version,
        chain_hash,
        timestamp,
        timestamp_str,
        node_count,
        channel_count,
        update_count,
    })
}

fn hex_encode(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{:02x}", b)).collect()
}

fn format_unix_timestamp(ts: u32) -> String {
    // Simple UTC formatting without external time crate.
    // We just show the raw unix timestamp for now; full formatting
    // would require chrono which we don't have as a dependency.
    format!("unix:{}", ts)
}