diff --git a/smartcontract/cli/src/resource/verify.rs b/smartcontract/cli/src/resource/verify.rs index efa3235b6..898939d37 100644 --- a/smartcontract/cli/src/resource/verify.rs +++ b/smartcontract/cli/src/resource/verify.rs @@ -20,7 +20,7 @@ use doublezero_serviceability::{ }; use solana_sdk::pubkey::Pubkey; use std::{ - collections::{hash_map::Entry, HashMap, HashSet}, + collections::{HashMap, HashSet}, io::{BufRead, Write}, }; @@ -41,14 +41,12 @@ pub enum ResourceDiscrepancy { }, /// Resource extension account not found ExtensionNotFound { resource_type: ResourceType }, - /// Resource is used by multiple accounts (duplicate usage) + /// Resource is used by multiple accounts (duplicate usage). `accounts` + /// lists every owner in insertion order; `accounts.len() >= 2`. DuplicateUsage { resource_type: ResourceType, value: IdOrIp, - first_account_pubkey: Pubkey, - first_account_type: String, - second_account_pubkey: Pubkey, - second_account_type: String, + accounts: Vec<(Pubkey, String)>, }, /// ResourceExtension account exists onchain but does not correspond to any /// currently-expected PDA (global singleton or per-device extension for a @@ -74,6 +72,9 @@ pub struct VerifyResourceResult { pub link_ids_checked: usize, pub multicast_group_block_checked: usize, pub multicast_publisher_block_checked: usize, + /// Pubkey → human-readable code, populated for devices and links so the + /// display layer can print `code (pubkey)` instead of raw pubkeys. + pub pubkey_labels: HashMap, } impl VerifyResourceResult { @@ -224,7 +225,10 @@ impl VerifyResourceCliCommand { writeln!( out, " {} = {} (used by {} {})", - resource_type, value, account_type, account_pubkey + resource_type, + value, + account_type, + format_pubkey(account_pubkey, &result.pubkey_labels) )?; } } @@ -251,7 +255,9 @@ impl VerifyResourceCliCommand { writeln!( out, " {} (allocator={}, associated_with={})", - pubkey, allocator_kind, associated_with + pubkey, + allocator_kind, + format_pubkey(associated_with, &result.pubkey_labels) )?; } } @@ -277,21 +283,23 @@ impl VerifyResourceCliCommand { if let ResourceDiscrepancy::DuplicateUsage { resource_type, value, - first_account_pubkey, - first_account_type, - second_account_pubkey, - second_account_type, + accounts, } = d { + let owners = accounts + .iter() + .map(|(pk, ty)| { + format!("{} {}", ty, format_pubkey(pk, &result.pubkey_labels)) + }) + .collect::>() + .join(", "); writeln!( out, - " {} = {} (used by {} {} AND {} {})", + " {} = {} (used by {} owners: {})", resource_type, value, - first_account_type, - first_account_pubkey, - second_account_type, - second_account_pubkey + accounts.len(), + owners )?; } } @@ -369,21 +377,23 @@ impl VerifyResourceCliCommand { if let ResourceDiscrepancy::DuplicateUsage { resource_type, value, - first_account_pubkey, - first_account_type, - second_account_pubkey, - second_account_type, + accounts, } = d { + let owners = accounts + .iter() + .map(|(pk, ty)| { + format!("{} {}", ty, format_pubkey(pk, &result.pubkey_labels)) + }) + .collect::>() + .join(", "); writeln!( out, - " {} = {} (used by {} {} AND {} {})", + " {} = {} (used by {} owners: {})", resource_type, value, - first_account_type, - first_account_pubkey, - second_account_type, - second_account_pubkey + accounts.len(), + owners )?; duplicate_values.push((*resource_type, value.clone())); } @@ -594,6 +604,19 @@ fn verify_resources(client: &C) -> eyre::Result = extension.iter_allocated().into_iter().collect(); // Build set of in-use IPs from users - let resource_type = ResourceType::UserTunnelBlock; - let mut in_use: HashMap = HashMap::new(); + let mut in_use: HashMap> = HashMap::new(); for (user_pk, user) in users { let tunnel_ip = user.tunnel_net.ip(); if !tunnel_ip.is_unspecified() && user.tunnel_net.prefix() > 0 { @@ -739,14 +761,7 @@ fn verify_user_tunnel_block( if let Some(ip) = user.tunnel_net.nth(i) { let ip_net = NetworkV4::new(ip, 32).unwrap(); let id_or_ip = IdOrIp::Ip(ip_net); - insert_usage( - &mut in_use, - resource_type, - id_or_ip, - *user_pk, - "User".to_string(), - result, - ); + insert_usage(&mut in_use, id_or_ip, *user_pk, "User".to_string()); } } result.user_tunnel_block_checked += 1; @@ -789,19 +804,12 @@ fn verify_tunnel_ids( let allocated: HashSet = extension.iter_allocated().into_iter().collect(); - let mut in_use: HashMap = HashMap::new(); + let mut in_use: HashMap> = HashMap::new(); if let Some(device_users) = users_by_device.get(device_pk) { for (user_pk, user) in device_users { if user.tunnel_id != 0 { let id_or_ip = IdOrIp::Id(user.tunnel_id); - insert_usage( - &mut in_use, - resource_type, - id_or_ip, - *user_pk, - "User".to_string(), - result, - ); + insert_usage(&mut in_use, id_or_ip, *user_pk, "User".to_string()); result.tunnel_ids_checked += 1; } } @@ -834,18 +842,16 @@ fn verify_dz_prefix_block( let allocated: HashSet = extension.iter_allocated().into_iter().collect(); // Find users whose dz_ip falls within this prefix - let mut in_use: HashMap = HashMap::new(); + let mut in_use: HashMap> = HashMap::new(); // First IP is reserved for the device itself (Loopback100) let first_ip = prefix.ip(); let first_ip_net = NetworkV4::new(first_ip, 32).unwrap(); insert_usage( &mut in_use, - resource_type, IdOrIp::Ip(first_ip_net), *device_pk, "Device (reserved)".to_string(), - result, ); for (user_pk, user) in users { @@ -863,14 +869,7 @@ fn verify_dz_prefix_block( if prefix.contains(dz_ip) { let ip_net = NetworkV4::new(dz_ip, 32).unwrap(); let id_or_ip = IdOrIp::Ip(ip_net); - insert_usage( - &mut in_use, - resource_type, - id_or_ip, - *user_pk, - "User".to_string(), - result, - ); + insert_usage(&mut in_use, id_or_ip, *user_pk, "User".to_string()); result.dz_prefix_block_checked += 1; } } @@ -897,9 +896,22 @@ fn verify_device_tunnel_block( return; }; + // Pull the base network so we can ignore loopback/link IPs that fall + // outside the block's range. Both interface and link processors honor a + // caller-supplied ip_net/tunnel_net and skip the allocator in that path + // (see `processors/device/interface/create.rs` and + // `processors/link/resource_onchain_helpers.rs`) — most commonly for + // user-tunnel-endpoint loopbacks that land on a globally routable IP. + // Those IPs aren't in the DeviceTunnelBlock bitmap and would otherwise + // be falsely reported as `UsedButNotAllocated`. + let base_net = match &extension.allocator { + Allocator::Ip(ip_alloc) => ip_alloc.base_net, + Allocator::Id(_) => return, + }; + let allocated: HashSet = extension.iter_allocated().into_iter().collect(); - let mut in_use: HashMap = HashMap::new(); + let mut in_use: HashMap> = HashMap::new(); // Check device loopback interfaces (only vpnv4/ipv4 loopback types) for (device_pk, device) in devices { @@ -913,15 +925,16 @@ fn verify_device_tunnel_block( // Iterate over all IPs in the network for i in 0..iface.ip_net.size() { if let Some(ip) = iface.ip_net.nth(i) { + if !base_net.contains(ip) { + continue; + } let ip_net = NetworkV4::new(ip, 32).unwrap(); let id_or_ip = IdOrIp::Ip(ip_net); insert_usage( &mut in_use, - resource_type, id_or_ip, *device_pk, format!("Device interface {}", iface.name), - result, ); } } @@ -938,16 +951,12 @@ fn verify_device_tunnel_block( // Iterate over all IPs in the network (e.g., /31 has 2 IPs) for i in 0..link.tunnel_net.size() { if let Some(ip) = link.tunnel_net.nth(i) { + if !base_net.contains(ip) { + continue; + } let ip_net = NetworkV4::new(ip, 32).unwrap(); let id_or_ip = IdOrIp::Ip(ip_net); - insert_usage( - &mut in_use, - resource_type, - id_or_ip, - *link_pk, - "Link".to_string(), - result, - ); + insert_usage(&mut in_use, id_or_ip, *link_pk, "Link".to_string()); } } result.device_tunnel_block_checked += 1; @@ -975,26 +984,43 @@ fn verify_segment_routing_ids( let allocated: HashSet = extension.iter_allocated().into_iter().collect(); - let mut in_use: HashMap = HashMap::new(); + let mut in_use: HashMap> = HashMap::new(); for (device_pk, device) in devices { for iface in &device.interfaces { - // Only check vpnv4/ipv4 loopbacks, and node_segment_idx == 0 means not allocated + // Only check vpnv4/ipv4 loopbacks; node_segment_idx == 0 means + // unallocated. flex_algo_node_segments is only populated on Vpnv4 + // loopbacks (one entry per topology) and pulls from the same + // SegmentRoutingIds allocator, so it must be counted here too — + // otherwise every flex-algo segment ID looks allocated-but-unused. if iface.interface_type == InterfaceType::Loopback && (iface.loopback_type == LoopbackType::Vpnv4 || iface.loopback_type == LoopbackType::Ipv4) - && iface.node_segment_idx != 0 { - let id_or_ip = IdOrIp::Id(iface.node_segment_idx); - insert_usage( - &mut in_use, - resource_type, - id_or_ip, - *device_pk, - format!("Device interface {}", iface.name), - result, - ); - result.segment_routing_ids_checked += 1; + if iface.node_segment_idx != 0 { + insert_usage( + &mut in_use, + IdOrIp::Id(iface.node_segment_idx), + *device_pk, + format!("Device interface {}", iface.name), + ); + result.segment_routing_ids_checked += 1; + } + for segment in &iface.flex_algo_node_segments { + if segment.node_segment_idx == 0 { + continue; + } + insert_usage( + &mut in_use, + IdOrIp::Id(segment.node_segment_idx), + *device_pk, + format!( + "Device interface {} flex-algo segment (topology {})", + iface.name, segment.topology + ), + ); + result.segment_routing_ids_checked += 1; + } } } } @@ -1020,18 +1046,11 @@ fn verify_link_ids( let allocated: HashSet = extension.iter_allocated().into_iter().collect(); - let mut in_use: HashMap = HashMap::new(); + let mut in_use: HashMap> = HashMap::new(); for (link_pk, link) in links { let id_or_ip = IdOrIp::Id(link.tunnel_id); - insert_usage( - &mut in_use, - resource_type, - id_or_ip, - *link_pk, - "Link".to_string(), - result, - ); + insert_usage(&mut in_use, id_or_ip, *link_pk, "Link".to_string()); result.link_ids_checked += 1; } @@ -1056,7 +1075,7 @@ fn verify_multicast_group_block( let allocated: HashSet = extension.iter_allocated().into_iter().collect(); - let mut in_use: HashMap = HashMap::new(); + let mut in_use: HashMap> = HashMap::new(); for (group_pk, group) in multicast_groups { let ip = group.multicast_ip; @@ -1065,11 +1084,9 @@ fn verify_multicast_group_block( let id_or_ip = IdOrIp::Ip(ip_net); insert_usage( &mut in_use, - resource_type, id_or_ip, *group_pk, "MulticastGroup".to_string(), - result, ); result.multicast_group_block_checked += 1; } @@ -1103,7 +1120,7 @@ fn verify_multicast_publisher_block( let allocated: HashSet = extension.iter_allocated().into_iter().collect(); - let mut in_use: HashMap = HashMap::new(); + let mut in_use: HashMap> = HashMap::new(); for (user_pk, user) in users { if user.user_type != UserType::Multicast || user.publishers.is_empty() { continue; @@ -1121,11 +1138,9 @@ fn verify_multicast_publisher_block( let ip_net = NetworkV4::new(dz_ip, 32).unwrap(); insert_usage( &mut in_use, - resource_type, IdOrIp::Ip(ip_net), *user_pk, "User".to_string(), - result, ); result.multicast_publisher_block_checked += 1; } @@ -1133,39 +1148,32 @@ fn verify_multicast_publisher_block( check_discrepancies(resource_type, &allocated, &in_use, result); } -/// Insert a resource usage into the in_use map, detecting duplicates +/// Format a pubkey for display, prefixing the device/link code when known. +fn format_pubkey(pk: &Pubkey, labels: &HashMap) -> String { + match labels.get(pk) { + Some(code) => format!("{} ({})", code, pk), + None => pk.to_string(), + } +} + +/// Append a resource usage to the in_use map. Duplicates are detected later in +/// `check_discrepancies` by inspecting `accounts.len() >= 2`. fn insert_usage( - in_use: &mut HashMap, - resource_type: ResourceType, + in_use: &mut HashMap>, value: IdOrIp, account_pubkey: Pubkey, account_type: String, - result: &mut VerifyResourceResult, ) { - match in_use.entry(value) { - Entry::Occupied(entry) => { - let (first_pk, first_type) = entry.get(); - result - .discrepancies - .push(ResourceDiscrepancy::DuplicateUsage { - resource_type, - value: entry.key().clone(), - first_account_pubkey: *first_pk, - first_account_type: first_type.clone(), - second_account_pubkey: account_pubkey, - second_account_type: account_type, - }); - } - Entry::Vacant(entry) => { - entry.insert((account_pubkey, account_type)); - } - } + in_use + .entry(value) + .or_default() + .push((account_pubkey, account_type)); } fn check_discrepancies( resource_type: ResourceType, allocated: &HashSet, - in_use: &HashMap, + in_use: &HashMap>, result: &mut VerifyResourceResult, ) { // Find allocated but not used @@ -1180,9 +1188,22 @@ fn check_discrepancies( } } - // Find used but not allocated - for (id_or_ip, (account_pk, account_type)) in in_use { + // Emit one DuplicateUsage per value with multiple owners; otherwise check + // used-but-not-allocated. Suppressing the second report avoids the same + // value showing up under both sections. + for (id_or_ip, owners) in in_use { + if owners.len() >= 2 { + result + .discrepancies + .push(ResourceDiscrepancy::DuplicateUsage { + resource_type, + value: id_or_ip.clone(), + accounts: owners.clone(), + }); + continue; + } if !allocated.contains(id_or_ip) { + let (account_pk, account_type) = &owners[0]; result .discrepancies .push(ResourceDiscrepancy::UsedButNotAllocated { @@ -2111,4 +2132,410 @@ mod tests { assert!(output_str.contains(&dead_device_pk.to_string())); assert!(output_str.contains("Hint: use --fix to close orphaned resource extensions.")); } + + fn make_segment_routing_ext( + program_id: &Pubkey, + allocated_ids: &[u16], + ) -> (Pubkey, ResourceExtensionOwned) { + let (pda, mut ext) = create_resource_extension_id( + program_id, + ResourceType::SegmentRoutingIds, + (0, 100), + vec![0; 13], + ); + if let Allocator::Id(ref mut a) = ext.allocator { + for id in allocated_ids { + a.allocate_specific(&mut ext.storage, *id).unwrap(); + } + } else { + panic!("expected Id allocator"); + } + (pda, ext) + } + + fn make_vpnv4_loopback( + name: &str, + node_segment_idx: u16, + flex_algo_segments: Vec, + ) -> doublezero_serviceability::state::interface::Interface { + use doublezero_serviceability::state::interface::{ + Interface, InterfaceStatus, InterfaceType, LoopbackType, + }; + Interface { + status: InterfaceStatus::Activated, + name: name.to_string(), + interface_type: InterfaceType::Loopback, + loopback_type: LoopbackType::Vpnv4, + node_segment_idx, + flex_algo_node_segments: flex_algo_segments, + ..Interface::default() + } + } + + fn make_vpnv4_loopback_with_ip( + name: &str, + ip_net: &str, + ) -> doublezero_serviceability::state::interface::Interface { + let mut iface = make_vpnv4_loopback(name, 0, vec![]); + iface.ip_net = ip_net.parse().unwrap(); + iface + } + + fn segment_routing_discrepancies(result: &VerifyResourceResult) -> Vec<&ResourceDiscrepancy> { + result + .discrepancies + .iter() + .filter(|d| match d { + ResourceDiscrepancy::AllocatedButNotUsed { resource_type, .. } + | ResourceDiscrepancy::UsedButNotAllocated { resource_type, .. } + | ResourceDiscrepancy::DuplicateUsage { resource_type, .. } => { + matches!(resource_type, ResourceType::SegmentRoutingIds) + } + _ => false, + }) + .collect() + } + + #[test] + fn test_verify_segment_routing_ids_counts_flex_algo_segments() { + use doublezero_serviceability::state::topology::FlexAlgoNodeSegment; + + let mut mock_client = MockCliCommand::new(); + let program_id = Pubkey::new_unique(); + + // Override the default SegmentRoutingIds extension to mark IDs 7 and 8 + // as allocated. 7 is the loopback's base segment, 8 is the flex-algo + // segment for one topology. + let mut accounts: HashMap, Box> = HashMap::new(); + insert_all_globals(&mut accounts, &program_id); + let sr = make_segment_routing_ext(&program_id, &[7, 8]); + accounts.insert( + Box::new(sr.0), + Box::new(AccountData::ResourceExtension(sr.1)), + ); + + let device_pk = Pubkey::new_unique(); + let topology_pk = Pubkey::new_unique(); + let device = Device { + interfaces: vec![make_vpnv4_loopback( + "Loopback0", + 7, + vec![FlexAlgoNodeSegment { + topology: topology_pk, + node_segment_idx: 8, + }], + )], + ..Device::default() + }; + accounts.insert(Box::new(device_pk), Box::new(AccountData::Device(device))); + + mock_client + .expect_get_program_id() + .returning(move || program_id); + mock_client + .expect_get_all() + .returning(move || Ok(accounts.clone())); + + let result = verify_resources(&mock_client).unwrap(); + let discrepancies = segment_routing_discrepancies(&result); + assert!( + discrepancies.is_empty(), + "expected no SegmentRoutingIds discrepancies, got {:?}", + discrepancies + ); + // Both the base segment and the flex-algo segment should be counted. + assert_eq!(result.segment_routing_ids_checked, 2); + } + + #[test] + fn test_verify_segment_routing_ids_flex_algo_used_but_not_allocated() { + use doublezero_serviceability::state::topology::FlexAlgoNodeSegment; + + let mut mock_client = MockCliCommand::new(); + let program_id = Pubkey::new_unique(); + + // Allocator has only the base segment (7), not the flex-algo one (8). + let mut accounts: HashMap, Box> = HashMap::new(); + insert_all_globals(&mut accounts, &program_id); + let sr = make_segment_routing_ext(&program_id, &[7]); + accounts.insert( + Box::new(sr.0), + Box::new(AccountData::ResourceExtension(sr.1)), + ); + + let device_pk = Pubkey::new_unique(); + let topology_pk = Pubkey::new_unique(); + let device = Device { + interfaces: vec![make_vpnv4_loopback( + "Loopback0", + 7, + vec![FlexAlgoNodeSegment { + topology: topology_pk, + node_segment_idx: 8, + }], + )], + ..Device::default() + }; + accounts.insert(Box::new(device_pk), Box::new(AccountData::Device(device))); + + mock_client + .expect_get_program_id() + .returning(move || program_id); + mock_client + .expect_get_all() + .returning(move || Ok(accounts.clone())); + + let result = verify_resources(&mock_client).unwrap(); + let discrepancies = segment_routing_discrepancies(&result); + assert_eq!(discrepancies.len(), 1, "got {:?}", discrepancies); + match discrepancies[0] { + ResourceDiscrepancy::UsedButNotAllocated { + value, + account_type, + .. + } => { + assert_eq!(*value, IdOrIp::Id(8)); + assert!( + account_type.contains("flex-algo"), + "account_type should mention flex-algo: {}", + account_type + ); + assert!(account_type.contains(&topology_pk.to_string())); + } + other => panic!("unexpected discrepancy: {:?}", other), + } + } + + #[test] + fn test_duplicate_usage_not_double_reported_as_used_but_not_allocated() { + let mut mock_client = MockCliCommand::new(); + let program_id = Pubkey::new_unique(); + + // Allocator has nothing — the shared ID is used by two devices but + // not allocated. We want exactly one DuplicateUsage and zero + // UsedButNotAllocated for that value. + let mut accounts: HashMap, Box> = HashMap::new(); + insert_all_globals(&mut accounts, &program_id); + let sr = make_segment_routing_ext(&program_id, &[]); + accounts.insert( + Box::new(sr.0), + Box::new(AccountData::ResourceExtension(sr.1)), + ); + + let dev_a = Pubkey::new_unique(); + let dev_b = Pubkey::new_unique(); + for pk in [dev_a, dev_b] { + let device = Device { + interfaces: vec![make_vpnv4_loopback("Loopback0", 42, vec![])], + ..Device::default() + }; + accounts.insert(Box::new(pk), Box::new(AccountData::Device(device))); + } + + mock_client + .expect_get_program_id() + .returning(move || program_id); + mock_client + .expect_get_all() + .returning(move || Ok(accounts.clone())); + + let result = verify_resources(&mock_client).unwrap(); + let discrepancies = segment_routing_discrepancies(&result); + + let dup_count = discrepancies + .iter() + .filter(|d| { + matches!( + d, + ResourceDiscrepancy::DuplicateUsage { + value: IdOrIp::Id(42), + .. + } + ) + }) + .count(); + let used_not_alloc_count = discrepancies + .iter() + .filter(|d| { + matches!( + d, + ResourceDiscrepancy::UsedButNotAllocated { + value: IdOrIp::Id(42), + .. + } + ) + }) + .count(); + assert_eq!(dup_count, 1, "discrepancies: {:?}", discrepancies); + assert_eq!( + used_not_alloc_count, 0, + "discrepancies: {:?}", + discrepancies + ); + + // And the single DuplicateUsage should list both owners. + let dup = discrepancies + .iter() + .find_map(|d| match d { + ResourceDiscrepancy::DuplicateUsage { accounts, .. } => Some(accounts), + _ => None, + }) + .expect("DuplicateUsage present"); + assert_eq!(dup.len(), 2); + } + + #[test] + fn test_duplicate_usage_with_three_owners_emits_single_entry() { + let mut mock_client = MockCliCommand::new(); + let program_id = Pubkey::new_unique(); + + // Allocator has the ID, so the only expected discrepancy is the + // duplicate report. + let mut accounts: HashMap, Box> = HashMap::new(); + insert_all_globals(&mut accounts, &program_id); + let sr = make_segment_routing_ext(&program_id, &[42]); + accounts.insert( + Box::new(sr.0), + Box::new(AccountData::ResourceExtension(sr.1)), + ); + + for _ in 0..3 { + let device_pk = Pubkey::new_unique(); + let device = Device { + interfaces: vec![make_vpnv4_loopback("Loopback0", 42, vec![])], + ..Device::default() + }; + accounts.insert(Box::new(device_pk), Box::new(AccountData::Device(device))); + } + + mock_client + .expect_get_program_id() + .returning(move || program_id); + mock_client + .expect_get_all() + .returning(move || Ok(accounts.clone())); + + let result = verify_resources(&mock_client).unwrap(); + let dup_entries: Vec<&Vec<(Pubkey, String)>> = result + .discrepancies + .iter() + .filter_map(|d| match d { + ResourceDiscrepancy::DuplicateUsage { + resource_type: ResourceType::SegmentRoutingIds, + value: IdOrIp::Id(42), + accounts, + } => Some(accounts), + _ => None, + }) + .collect(); + assert_eq!( + dup_entries.len(), + 1, + "want one DuplicateUsage, got {:?}", + dup_entries + ); + assert_eq!(dup_entries[0].len(), 3); + } + + #[test] + fn test_output_includes_device_and_link_codes() { + // A duplicate-usage on a SegmentRoutingId across two devices with + // codes "dz1" and "dz2" should show both codes alongside their + // pubkeys in the rendered "Duplicate usage" section. + let mut mock_client = MockCliCommand::new(); + let program_id = Pubkey::new_unique(); + + let mut accounts: HashMap, Box> = HashMap::new(); + insert_all_globals(&mut accounts, &program_id); + let sr = make_segment_routing_ext(&program_id, &[42]); + accounts.insert( + Box::new(sr.0), + Box::new(AccountData::ResourceExtension(sr.1)), + ); + + let dev_a_pk = Pubkey::new_unique(); + let dev_b_pk = Pubkey::new_unique(); + for (pk, code) in [(dev_a_pk, "dz1"), (dev_b_pk, "dz2")] { + let device = Device { + code: code.to_string(), + interfaces: vec![make_vpnv4_loopback("Loopback0", 42, vec![])], + ..Device::default() + }; + accounts.insert(Box::new(pk), Box::new(AccountData::Device(device))); + } + + mock_client + .expect_get_program_id() + .returning(move || program_id); + mock_client + .expect_get_all() + .returning(move || Ok(accounts.clone())); + + let cmd = VerifyResourceCliCommand { fix: false }; + let mut output = Cursor::new(Vec::new()); + cmd.execute(&mock_client, &mut output).unwrap(); + let output_str = String::from_utf8(output.into_inner()).unwrap(); + + assert!( + output_str.contains(&format!("dz1 ({})", dev_a_pk)), + "expected `dz1 ({})` in output, got:\n{}", + dev_a_pk, + output_str + ); + assert!( + output_str.contains(&format!("dz2 ({})", dev_b_pk)), + "expected `dz2 ({})` in output, got:\n{}", + dev_b_pk, + output_str + ); + } + + #[test] + fn test_verify_device_tunnel_block_ignores_loopback_ip_outside_base_net() { + // User-tunnel-endpoint Vpnv4 loopbacks land on a caller-supplied + // globally routable IP and skip the DeviceTunnelBlock allocator + // (see processors/device/interface/create.rs). The verifier must + // not report those IPs as `UsedButNotAllocated`. + let mut mock_client = MockCliCommand::new(); + let program_id = Pubkey::new_unique(); + + let mut accounts: HashMap, Box> = HashMap::new(); + insert_all_globals(&mut accounts, &program_id); + + // The DeviceTunnelBlock from insert_all_globals is 172.16.0.0/24. + // Give the loopback a globally routable IP outside that block. + let device_pk = Pubkey::new_unique(); + let device = Device { + interfaces: vec![make_vpnv4_loopback_with_ip("Loopback0", "203.0.113.5/32")], + ..Device::default() + }; + accounts.insert(Box::new(device_pk), Box::new(AccountData::Device(device))); + + mock_client + .expect_get_program_id() + .returning(move || program_id); + mock_client + .expect_get_all() + .returning(move || Ok(accounts.clone())); + + let result = verify_resources(&mock_client).unwrap(); + let dtb_used_not_alloc: Vec<_> = result + .discrepancies + .iter() + .filter(|d| { + matches!( + d, + ResourceDiscrepancy::UsedButNotAllocated { + resource_type: ResourceType::DeviceTunnelBlock, + .. + } + ) + }) + .collect(); + assert!( + dtb_used_not_alloc.is_empty(), + "loopback IP outside the DeviceTunnelBlock base_net should not produce a UsedButNotAllocated entry, got {:?}", + dtb_used_not_alloc + ); + } } diff --git a/smartcontract/programs/CLAUDE.md b/smartcontract/programs/CLAUDE.md index 4a0bc54a5..cce863d13 100644 --- a/smartcontract/programs/CLAUDE.md +++ b/smartcontract/programs/CLAUDE.md @@ -46,5 +46,9 @@ 1. **Use standard interfaces**: Use `solana-loader-v3-interface` to parse `UpgradeableLoaderState` rather than implementing your own parser. The interface crate provides well-tested, maintained implementations. +### Resource Allocation + +1. **Keep `doublezero resource verify` in sync**: Anytime a processor allocates from or deallocates against a `ResourceExtension` (any field that pulls from `SegmentRoutingIds`, `TunnelIds`, `LinkIds`, `UserTunnelBlock`, `DeviceTunnelBlock`, `DzPrefixBlock`, `MulticastGroupBlock`, `MulticastPublisherBlock`, etc.), update the corresponding `verify_*` function in `smartcontract/cli/src/resource/verify.rs` so that field is counted as "in use." Otherwise the verifier will report the allocation as `AllocatedButNotUsed` (or miss a leak when a deallocation is dropped). Add coverage in the verify test module for the new field. + ### Pull Requests - Make sure `make rust-lint` and `make rust-test` both pass before posting pull requests \ No newline at end of file