Commit 8c18c5caea for frr

commit 8c18c5caeaa028bc0c5cb1fe8724803fa8a70624
Author: David Lamparter <equinox@diac24.net>
Date:   Tue Nov 3 02:44:32 2026 +0100

    Revert "ospfd/ospf6d: cache ASE forwarding-address locality check per calculation pass"

diff --git a/ospf6d/ospf6_area.c b/ospf6d/ospf6_area.c
index 73f368c959..606de46906 100644
--- a/ospf6d/ospf6_area.c
+++ b/ospf6d/ospf6_area.c
@@ -96,7 +96,7 @@ static void ospf6_area_lsdb_hook_add(struct ospf6_lsa *lsa)
 		break;

 	case OSPF6_LSTYPE_TYPE_7:
-		ospf6_asbr_lsa_add(lsa, NULL);
+		ospf6_asbr_lsa_add(lsa);
 		break;

 	default:
diff --git a/ospf6d/ospf6_asbr.c b/ospf6d/ospf6_asbr.c
index dcc29da2b1..549910ca89 100644
--- a/ospf6d/ospf6_asbr.c
+++ b/ospf6d/ospf6_asbr.c
@@ -466,29 +466,13 @@ void ospf6_asbr_update_route_ecmp_path(struct ospf6_route *old,

 /* Check if the forwarding address is local address */
 static int ospf6_ase_forward_address_check(struct ospf6 *ospf6,
-					   struct in6_addr *fwd_addr,
-					   struct route_table *fwd_cache)
+					   struct in6_addr *fwd_addr)
 {
 	struct listnode *anode, *node;
 	struct ospf6_interface *oi;
 	struct ospf6_area *oa;
 	struct interface *ifp;
 	struct connected *c;
-	struct route_node *rn;
-	struct prefix p;
-	int ret = 1;
-
-	if (fwd_cache) {
-		p.family = AF_INET6;
-		p.prefixlen = IPV6_MAX_BITLEN;
-		p.u.prefix6 = *fwd_addr;
-		rn = route_node_lookup(fwd_cache, &p);
-		if (rn) {
-			ret = (int)((uintptr_t)rn->info - 1);
-			route_unlock_node(rn);
-			return ret;
-		}
-	}

 	for (ALL_LIST_ELEMENTS_RO(ospf6->area_list, anode, oa)) {
 		for (ALL_LIST_ELEMENTS_RO(oa->if_list, node, oi)) {
@@ -499,30 +483,16 @@ static int ospf6_ase_forward_address_check(struct ospf6 *ospf6,
 			ifp = oi->interface;
 			frr_each (if_connected, ifp->connected, c) {
 				if (IPV6_ADDR_SAME(&c->address->u.prefix6,
-						   fwd_addr)) {
-					ret = 0;
-					break;
-				}
+						   fwd_addr))
+					return 0;
 			}
-			if (ret == 0)
-				break;
 		}
-		if (ret == 0)
-			break;
 	}

-	if (fwd_cache) {
-		rn = route_node_get(fwd_cache, &p);
-		if (rn->info == NULL)
-			rn->info = (void *)(uintptr_t)(ret + 1);
-		else
-			route_unlock_node(rn);
-	}
-
-	return ret;
+	return 1;
 }

-void ospf6_asbr_lsa_add(struct ospf6_lsa *lsa, struct route_table *fwd_cache)
+void ospf6_asbr_lsa_add(struct ospf6_lsa *lsa)
 {
 	struct ospf6_as_external_lsa *external;
 	struct prefix asbr_id;
@@ -634,7 +604,7 @@ void ospf6_asbr_lsa_add(struct ospf6_lsa *lsa, struct route_table *fwd_cache)

 		if (!IN6_IS_ADDR_UNSPECIFIED(&fwd_addr.u.prefix6)) {
 			if (!ospf6_ase_forward_address_check(
-				    ospf6, &fwd_addr.u.prefix6, fwd_cache)) {
+				    ospf6, &fwd_addr.u.prefix6)) {
 				if (IS_OSPF6_DEBUG_EXAMIN(AS_EXTERNAL))
 					zlog_debug("Fwd address (%s:%u:%u)%pFX is local address",
 						   OSPF6_DEBUG_VRF(ospf6), &fwd_addr);
@@ -1021,7 +991,7 @@ void ospf6_asbr_lsentry_add(struct ospf6_route *asbr_entry, struct ospf6 *ospf6)
 	router = ospf6_linkstate_prefix_adv_router(&asbr_entry->prefix);
 	for (ALL_LSDB_TYPED_ADVRTR(ospf6->lsdb, type, router, lsa)) {
 		if (!OSPF6_LSA_IS_MAXAGE(lsa))
-			ospf6_asbr_lsa_add(lsa, NULL);
+			ospf6_asbr_lsa_add(lsa);
 	}
 }

@@ -1041,14 +1011,6 @@ void ospf6_asbr_lsentry_remove(struct ospf6_route *asbr_entry,
 void ospf6_asbr_recalculate_external_routes(struct ospf6 *ospf6)
 {
 	struct ospf6_lsa *lsa, *lsanext;
-	struct route_table *fwd_cache;
-
-	/* The forwarding-address locality check reads the area/interface and
-	 * connected lists, which cannot change while this loop runs.  Cache
-	 * the verdict per address so that external LSAs carrying the same
-	 * forwarding address do not re-scan them.
-	 */
-	fwd_cache = route_table_init();

 	for (ALL_LSDB(ospf6->lsdb, lsa, lsanext)) {
 		if (ntohs(lsa->header->type) != OSPF6_LSTYPE_AS_EXTERNAL ||
@@ -1061,10 +1023,8 @@ void ospf6_asbr_recalculate_external_routes(struct ospf6 *ospf6)
 		 * installation in sync with current topology.
 		 */
 		ospf6_asbr_lsa_remove(lsa, NULL);
-		ospf6_asbr_lsa_add(lsa, fwd_cache);
+		ospf6_asbr_lsa_add(lsa);
 	}
-
-	route_table_finish(fwd_cache);
 }


diff --git a/ospf6d/ospf6_asbr.h b/ospf6d/ospf6_asbr.h
index 7ac5a8c6cd..1f23fca975 100644
--- a/ospf6d/ospf6_asbr.h
+++ b/ospf6d/ospf6_asbr.h
@@ -92,8 +92,7 @@ struct ospf6_external_aggr_rt {
 				    htonl(C);                                  \
 	}

-extern void ospf6_asbr_lsa_add(struct ospf6_lsa *lsa,
-			       struct route_table *fwd_cache);
+extern void ospf6_asbr_lsa_add(struct ospf6_lsa *lsa);

 extern void ospf6_asbr_lsa_remove(struct ospf6_lsa *lsa,
 				  struct ospf6_route *asbr_entry);
diff --git a/ospf6d/ospf6_spf.c b/ospf6d/ospf6_spf.c
index a0e281e367..75de07c695 100644
--- a/ospf6d/ospf6_spf.c
+++ b/ospf6d/ospf6_spf.c
@@ -1106,8 +1106,7 @@ void ospf6_remove_temp_router_lsa(struct ospf6_area *area)
 }

 int ospf6_ase_calculate_route(struct ospf6 *ospf6, struct ospf6_lsa *lsa,
-			      struct ospf6_area *area,
-			      struct route_table *fwd_cache)
+			      struct ospf6_area *area)
 {
 	struct ospf6_route *route;
 	struct ospf6_as_external_lsa *external;
@@ -1144,7 +1143,7 @@ int ospf6_ase_calculate_route(struct ospf6 *ospf6, struct ospf6_lsa *lsa,
 		ospf6->route_table->hook_remove = NULL;

 		if (!OSPF6_LSA_IS_MAXAGE(lsa))
-			ospf6_asbr_lsa_add(lsa, fwd_cache);
+			ospf6_asbr_lsa_add(lsa);

 		ospf6->route_table->hook_add = hook_add;
 		ospf6->route_table->hook_remove = hook_remove;
@@ -1181,7 +1180,7 @@ int ospf6_ase_calculate_route(struct ospf6 *ospf6, struct ospf6_lsa *lsa,
 		area->route_table->hook_remove = NULL;

 		if (!OSPF6_LSA_IS_MAXAGE(lsa))
-			ospf6_asbr_lsa_add(lsa, fwd_cache);
+			ospf6_asbr_lsa_add(lsa);

 		area->route_table->hook_add = hook_add;
 		area->route_table->hook_remove = hook_remove;
@@ -1226,22 +1225,14 @@ static void ospf6_ase_calculate_timer(struct event *t)
 	struct ospf6_lsa *lsa;
 	struct listnode *node, *nnode;
 	struct ospf6_area *area;
-	struct route_table *fwd_cache;
 	uint16_t type;

 	ospf6 = EVENT_ARG(t);

-	/* The forwarding-address locality check reads the area/interface and
-	 * connected lists, which cannot change while this pass runs.  Cache
-	 * the verdict per address so that a batch of external LSAs carrying
-	 * the same forwarding address does not re-scan them.
-	 */
-	fwd_cache = route_table_init();
-
 	/* Calculate external route for each AS-external-LSA */
 	type = htons(OSPF6_LSTYPE_AS_EXTERNAL);
 	for (ALL_LSDB_TYPED(ospf6->lsdb, type, lsa))
-		ospf6_ase_calculate_route(ospf6, lsa, NULL, fwd_cache);
+		ospf6_ase_calculate_route(ospf6, lsa, NULL);

 	/*  This version simple adds to the table all NSSA areas  */
 	if (ospf6->anyNSSA) {
@@ -1252,13 +1243,10 @@ static void ospf6_ase_calculate_timer(struct event *t)

 			type = htons(OSPF6_LSTYPE_TYPE_7);
 			for (ALL_LSDB_TYPED(area->lsdb, type, lsa))
-				ospf6_ase_calculate_route(ospf6, lsa, area,
-							  fwd_cache);
+				ospf6_ase_calculate_route(ospf6, lsa, area);
 		}
 	}

-	route_table_finish(fwd_cache);
-
 	if (ospf6->gr_info.finishing_restart) {
 		/*
 		 * The routing table computation is complete. Uninstall remnant
diff --git a/ospf6d/ospf6_spf.h b/ospf6d/ospf6_spf.h
index 60b9aacb95..c2fd5d9ef1 100644
--- a/ospf6d/ospf6_spf.h
+++ b/ospf6d/ospf6_spf.h
@@ -151,8 +151,7 @@ extern struct ospf6_lsa *ospf6_create_single_router_lsa(struct ospf6_area *area,
 extern void ospf6_remove_temp_router_lsa(struct ospf6_area *area);
 extern void ospf6_ase_calculate_timer_add(struct ospf6 *ospf6);
 extern int ospf6_ase_calculate_route(struct ospf6 *ospf6, struct ospf6_lsa *lsa,
-				     struct ospf6_area *area,
-				     struct route_table *fwd_cache);
+				     struct ospf6_area *area);
 extern bool
 ospf6_merge_parents_nh_to_child(struct ospf6_vertex *v,
 				struct ospf6_route *route,
diff --git a/ospf6d/ospf6_top.c b/ospf6d/ospf6_top.c
index 1fa9f807fe..9f049afcbc 100644
--- a/ospf6d/ospf6_top.c
+++ b/ospf6d/ospf6_top.c
@@ -297,7 +297,7 @@ static void ospf6_top_lsdb_hook_add(struct ospf6_lsa *lsa)
 {
 	switch (ntohs(lsa->header->type)) {
 	case OSPF6_LSTYPE_AS_EXTERNAL:
-		ospf6_asbr_lsa_add(lsa, NULL);
+		ospf6_asbr_lsa_add(lsa);
 		break;

 	default:
diff --git a/ospfd/ospf_ase.c b/ospfd/ospf_ase.c
index 1f91295482..0267f26955 100644
--- a/ospfd/ospf_ase.c
+++ b/ospfd/ospf_ase.c
@@ -126,45 +126,19 @@ static void ospf_ase_complete_direct_routes(struct ospf_route *ro,
 }

 static int ospf_ase_forward_address_check(struct ospf *ospf,
-					  struct in_addr fwd_addr,
-					  struct route_table *fwd_cache)
+					  struct in_addr fwd_addr)
 {
 	struct listnode *ifn;
 	struct ospf_interface *oi;
-	struct route_node *rn;
-	struct prefix p;
-	int ret = 1;
-
-	if (fwd_cache) {
-		p.family = AF_INET;
-		p.prefixlen = IPV4_MAX_BITLEN;
-		p.u.prefix4 = fwd_addr;
-		rn = route_node_lookup(fwd_cache, &p);
-		if (rn) {
-			ret = (int)((uintptr_t)rn->info - 1);
-			route_unlock_node(rn);
-			return ret;
-		}
-	}

 	for (ALL_LIST_ELEMENTS_RO(ospf->oiflist, ifn, oi))
 		if (if_is_operative(oi->ifp))
 			if (oi->type != OSPF_IFTYPE_VIRTUALLINK)
 				if (IPV4_ADDR_SAME(&oi->address->u.prefix4,
-						   &fwd_addr)) {
-					ret = 0;
-					break;
-				}
+						   &fwd_addr))
+					return 0;

-	if (fwd_cache) {
-		rn = route_node_get(fwd_cache, &p);
-		if (rn->info == NULL)
-			rn->info = (void *)(uintptr_t)(ret + 1);
-		else
-			route_unlock_node(rn);
-	}
-
-	return ret;
+	return 1;
 }

 static struct ospf_route *
@@ -210,8 +184,7 @@ ospf_ase_calculate_new_route(struct ospf_lsa *lsa,

 #define OSPF_ASE_CALC_INTERVAL 1

-int ospf_ase_calculate_route(struct ospf *ospf, struct ospf_lsa *lsa,
-			     struct route_table *fwd_cache)
+int ospf_ase_calculate_route(struct ospf *ospf, struct ospf_lsa *lsa)
 {
 	uint32_t metric;
 	struct as_external_lsa *al;
@@ -332,8 +305,7 @@ int ospf_ase_calculate_route(struct ospf *ospf, struct ospf_lsa *lsa,
 		   routing table entry must specify an intra-area or inter-area
 		   path; if no such path exists, do nothing with the LSA and
 		   consider the next in the list. */
-		if (!ospf_ase_forward_address_check(ospf, al->e[0].fwd_addr,
-						    fwd_cache)) {
+		if (!ospf_ase_forward_address_check(ospf, al->e[0].fwd_addr)) {
 			if (IS_DEBUG_OSPF(lsa, LSA))
 				zlog_debug(
 					"Route[External]: Forwarding address is our router address");
@@ -593,7 +565,6 @@ static void ospf_ase_calculate_timer(struct event *t)
 	struct route_node *rn;
 	struct listnode *node;
 	struct ospf_area *area;
-	struct route_table *fwd_cache;
 	struct timeval start_time, stop_time;

 	ospf = EVENT_ARG(t);
@@ -603,16 +574,9 @@ static void ospf_ase_calculate_timer(struct event *t)

 		monotime(&start_time);

-		/* The forwarding-address locality check reads oiflist, which
-		 * cannot change while this pass runs.  Cache the verdict per
-		 * address so that a batch of external LSAs carrying the same
-		 * forwarding address does not re-scan the interface list.
-		 */
-		fwd_cache = route_table_init();
-
 		/* Calculate external route for each AS-external-LSA */
 		LSDB_LOOP (EXTERNAL_LSDB(ospf), rn, lsa)
-			ospf_ase_calculate_route(ospf, lsa, fwd_cache);
+			ospf_ase_calculate_route(ospf, lsa);

 		/*  This version simple adds to the table all NSSA areas  */
 		if (ospf->anyNSSA)
@@ -624,14 +588,11 @@ static void ospf_ase_calculate_timer(struct event *t)
 				if (area->external_routing == OSPF_AREA_NSSA)
 					LSDB_LOOP (NSSA_LSDB(area), rn, lsa)
 						ospf_ase_calculate_route(ospf,
-									 lsa,
-									 fwd_cache);
+									 lsa);
 			}
 		/* kevinm: And add the NSSA routes in ospf_top */
 		LSDB_LOOP (NSSA_LSDB(ospf), rn, lsa)
-			ospf_ase_calculate_route(ospf, lsa, fwd_cache);
-
-		route_table_finish(fwd_cache);
+			ospf_ase_calculate_route(ospf, lsa);

 		/* Compare old and new external routing table and install the
 		   difference info zebra/kernel */
@@ -789,7 +750,7 @@ void ospf_ase_incremental_update(struct ospf *ospf, struct ospf_lsa *lsa)
 	route_unlock_node(rn);

 	for (ALL_LIST_ELEMENTS_RO(lsas, node, lsa))
-		ospf_ase_calculate_route(ospf, lsa, NULL);
+		ospf_ase_calculate_route(ospf, lsa);

 	/* prepare temporary old routing table for compare */
 	tmp_old = route_table_init();
diff --git a/ospfd/ospf_ase.h b/ospfd/ospf_ase.h
index 3b568e3dd0..d444e160a8 100644
--- a/ospfd/ospf_ase.h
+++ b/ospfd/ospf_ase.h
@@ -13,8 +13,7 @@ extern struct ospf_route *ospf_find_asbr_route_through_area(struct route_table *
 							    struct prefix_ipv4 *asbr,
 							    struct ospf_area *area);

-extern int ospf_ase_calculate_route(struct ospf *ospf, struct ospf_lsa *lsa,
-				    struct route_table *fwd_cache);
+extern int ospf_ase_calculate_route(struct ospf *ospf, struct ospf_lsa *lsa);
 extern void ospf_ase_calculate_schedule(struct ospf *ospf);
 extern void ospf_ase_calculate_timer_add(struct ospf *ospf);

diff --git a/tests/topotests/ospf6_ase_forward_scan/__init__.py b/tests/topotests/ospf6_ase_forward_scan/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/tests/topotests/ospf6_ase_forward_scan/inject_lsu.py b/tests/topotests/ospf6_ase_forward_scan/inject_lsu.py
deleted file mode 100644
index cc0302959f..0000000000
--- a/tests/topotests/ospf6_ase_forward_scan/inject_lsu.py
+++ /dev/null
@@ -1,171 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: ISC
-#
-# inject_lsu.py
-#
-# Copyright (c) 2026 ATCorp
-#
-"""Build and send raw OSPFv3 Link State Update packets.
-
-Used by test_ospf6_ase_forward_scan.py to inject AS-external LSAs with the
-F-bit set and a chosen forwarding address, bypassing the well-formedness
-of high-level packet helpers.
-"""
-
-import argparse
-import socket
-import struct
-import sys
-import time
-
-from scapy.all import Ether, IPv6, Raw, conf, sendp
-
-conf.verb = 0
-
-# 20-byte LSA header + 40-byte AS-external body (F-bit, /128 prefix).
-LSA_CHUNK = 16
-
-
-def lsa_id(index):
-    return "198.18.%d.%d" % (index // 256, index % 256)
-
-
-def fletcher_checksum(lsa):
-    data = bytearray(lsa[2:])
-    data[14:16] = b"\x00\x00"
-    c0 = 0
-    c1 = 0
-    for value in data:
-        c0 = (c0 + value) % 255
-        c1 = (c1 + c0) % 255
-    x = ((len(data) - 14 - 1) * c0 - c1) % 255
-    if x == 0:
-        x = 255
-    y = 510 - c0 - x
-    if y > 255:
-        y -= 255
-    data[14:16] = bytes((x, y))
-    valid = bytearray(data)
-    c0 = 0
-    c1 = 0
-    for value in valid:
-        c0 = (c0 + value) % 255
-        c1 = (c1 + c0) % 255
-    if (c1 << 8) + c0 != 0:
-        raise ValueError("invalid generated LSA Fletcher checksum")
-    return (x << 8) | y
-
-
-def internet_checksum(data):
-    if len(data) % 2:
-        data += b"\x00"
-    total = sum(struct.unpack("!%dH" % (len(data) // 2), data))
-    total = (total & 0xFFFF) + (total >> 16)
-    total = (total & 0xFFFF) + (total >> 16)
-    return (~total) & 0xFFFF
-
-
-def ipv6_ph_checksum(src, dst, payload):
-    pseudo = (
-        socket.inet_pton(socket.AF_INET6, src)
-        + socket.inet_pton(socket.AF_INET6, dst)
-        + struct.pack("!I", len(payload))
-        + b"\x00\x00\x00"
-        + bytes((89,))
-    )
-    return internet_checksum(pseudo + payload)
-
-
-def make_external_lsa(index, adv_router, fwd, corrupt):
-    # bits_metric: F bit (wire value 0x02000000) + 24-bit metric 10.
-    body = (
-        struct.pack("!I", 0x0200000A)
-        + bytes((128, 0))
-        + struct.pack("!H", 0)
-        + socket.inet_pton(socket.AF_INET6, "2001:db8:ff::%x" % index)
-        + socket.inet_pton(socket.AF_INET6, fwd)
-    )
-    lsa = bytearray(
-        struct.pack(
-            "!HH4s4sIHH",
-            1,
-            0x4005,
-            socket.inet_aton(lsa_id(index)),
-            socket.inet_aton(adv_router),
-            0x80000001,
-            0,
-            20 + len(body),
-        )
-        + body
-    )
-    struct.pack_into("!H", lsa, 16, fletcher_checksum(lsa))
-    if corrupt:
-        lsa[17] ^= 0x5A
-    return bytes(lsa)
-
-
-def make_lsu(lsa_blobs, src_rid, src_ip, dst_ip):
-    body = struct.pack("!I", len(lsa_blobs)) + b"".join(lsa_blobs)
-    packet = bytearray(
-        struct.pack(
-            "!BBH4s4sHBB",
-            3,
-            4,
-            16 + len(body),
-            socket.inet_aton(src_rid),
-            socket.inet_aton("0.0.0.0"),
-            0,
-            0,
-            0,
-        )
-        + body
-    )
-    checksum = ipv6_ph_checksum(src_ip, dst_ip, bytes(packet))
-    struct.pack_into("!H", packet, 12, checksum)
-    if ipv6_ph_checksum(src_ip, dst_ip, bytes(packet)) != 0:
-        raise ValueError("invalid generated OSPFv3 pseudo-header checksum")
-    return bytes(packet)
-
-
-def main():
-    parser = argparse.ArgumentParser()
-    parser.add_argument("--iface", required=True)
-    parser.add_argument("--src-mac", required=True)
-    parser.add_argument("--dst-mac", required=True)
-    parser.add_argument("--src-ip", required=True)
-    parser.add_argument("--dst-ip", required=True)
-    parser.add_argument("--src-rid", required=True)
-    parser.add_argument("--adv-router", required=True)
-    parser.add_argument("--fwd", required=True)
-    parser.add_argument("--count", type=int, default=16)
-    parser.add_argument("--first-index", type=int, default=1)
-    parser.add_argument("--corrupt", action="store_true")
-    parser.add_argument("--spacing", type=float, default=0.2)
-    args = parser.parse_args()
-
-    blobs = [
-        make_external_lsa(index, args.adv_router, args.fwd, args.corrupt)
-        for index in range(args.first_index, args.first_index + args.count)
-    ]
-    packets = [
-        make_lsu(blobs[off : off + LSA_CHUNK], args.src_rid, args.src_ip,
-                 args.dst_ip)
-        for off in range(0, len(blobs), LSA_CHUNK)
-    ]
-    for index, payload in enumerate(packets):
-        if index:
-            time.sleep(args.spacing)
-        frame = (
-            Ether(src=args.src_mac, dst=args.dst_mac)
-            / IPv6(src=args.src_ip, dst=args.dst_ip, nh=89, hlim=1)
-            / Raw(payload)
-        )
-        sendp(frame, iface=args.iface, count=1, verbose=False)
-    print(
-        "OA2_INJECT packets=%d lsas=%d fwd=%s corrupt=%s first=%d"
-        % (len(packets), args.count, args.fwd, args.corrupt, args.first_index)
-    )
-
-
-if __name__ == "__main__":
-    sys.exit(main())
diff --git a/tests/topotests/ospf6_ase_forward_scan/r1/frr.conf b/tests/topotests/ospf6_ase_forward_scan/r1/frr.conf
deleted file mode 100644
index 8d7135660b..0000000000
--- a/tests/topotests/ospf6_ase_forward_scan/r1/frr.conf
+++ /dev/null
@@ -1,18 +0,0 @@
-!
-hostname r1
-!
-debug ospf6 lsa as-external examine
-!
-interface eth1
- ipv6 address 2001:db8::1/64
- ipv6 ospf6 area 0
-!
-interface eth2
- ipv6 address 2001:db8:1::1/64
- ipv6 ospf6 area 0
- ipv6 ospf6 passive
-!
-router ospf6
- ospf6 router-id 1.1.1.1
- log-adjacency-changes detail
-!
diff --git a/tests/topotests/ospf6_ase_forward_scan/r2/frr.conf b/tests/topotests/ospf6_ase_forward_scan/r2/frr.conf
deleted file mode 100644
index 917cdb4e8a..0000000000
--- a/tests/topotests/ospf6_ase_forward_scan/r2/frr.conf
+++ /dev/null
@@ -1,15 +0,0 @@
-!
-hostname r2
-!
-interface lo
- ipv6 address 2001:db8:ffff::9/128
-!
-interface eth1
- ipv6 address 2001:db8::2/64
- ipv6 ospf6 area 0
-!
-router ospf6
- ospf6 router-id 2.2.2.2
- redistribute connected
- log-adjacency-changes detail
-!
diff --git a/tests/topotests/ospf6_ase_forward_scan/test_ospf6_ase_forward_scan.py b/tests/topotests/ospf6_ase_forward_scan/test_ospf6_ase_forward_scan.py
deleted file mode 100644
index 9742ee2bc1..0000000000
--- a/tests/topotests/ospf6_ase_forward_scan/test_ospf6_ase_forward_scan.py
+++ /dev/null
@@ -1,449 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 eval: (blacken-mode 1) -*-
-# SPDX-License-Identifier: ISC
-#
-# test_ospf6_ase_forward_scan.py
-#
-# Copyright (c) 2026 ATCorp
-#
-
-"""
-test_ospf6_ase_forward_scan.py
-==============================
-
-Regression for per-LSA repeated forwarding-address locality scans in the
-OSPFv3 AS-external route calculation (RFC 5340 section 4.4.4, RFC 3101
-section 2.5).
-
-Every AS-external-LSA with the F-bit set and a non-zero forwarding address
-makes ospf6_asbr_lsa_add() call ospf6_ase_forward_address_check(), which
-walks every area, interface and connected address.  A batch of M external
-LSAs that carry the same non-local forwarding address therefore re-scans
-the identical address M times per ASE calculation pass, and again on every
-later pass.  The interface and connected lists cannot change while a pass
-runs, so the verdict must be cached per address for the duration of the
-pass.
-
-Topology
---------
-
-    r1 (RID 1.1.1.1, victim) ---- s1 ---- r2 (RID 2.2.2.2, ASBR)
-              |                          |
-              h1 (scapy injector)         (isolated after baseline)
-              |
-              s2 (stub, forces SPF passes via link toggle)
-
-Test plan
----------
-
-1. test_adjacency_full
-     Baseline: LAN adjacency reaches Full and the ASBR route exists.
-
-2. test_same_fwd_scan_once_per_pass
-     r2 is isolated (link down, within the raised dead interval) and h1
-     injects 16 AS-external LSAs with distinct LS IDs, the F-bit set and
-     the same non-local forwarding address.  gdb counts the scan-loop
-     iterations on r1 while an r1 link toggle forces ASE passes.  Expect
-     at most one interface scan per pass, not one per LSA.
-
-3. test_zero_fwd_and_corrupt_controls
-     Same-shaped LSUs with the unspecified forwarding address (no scan,
-     LSAs installed) and with a broken Fletcher checksum (rejected, no
-     scan).
-
-The gdb-based scan counts are the primary observable; tests 2-3 skip when
-gdb or the required source lines are unavailable.
-"""
-
-import json
-import os
-import re
-import shlex
-import sys
-import time
-
-import pytest
-
-from lib.topogen import Topogen, get_topogen
-from lib.topolog import logger
-
-
-CWD = os.path.dirname(os.path.realpath(__file__))
-sys.path.append(os.path.join(CWD, "../"))
-
-pytestmark = [pytest.mark.ospf6d]
-
-R1_RID = "1.1.1.1"
-R2_RID = "2.2.2.2"
-R1_IP = "2001:db8::1"
-R2_IP = "2001:db8::2"
-H1_IP = "2001:db8::3"
-FWD = "2001:db8:99::1"
-LSA_ID_PREFIX = "198.18."
-BATCH = 16
-
-GDB_SCRIPT = """set pagination off
-set confirm off
-handle SIGINT stop print nopass
-set $calls = 0
-set $visits = 0
-set $pcalls = 0
-set $pvisits = 0
-break ospf6_asbr.c:%(call)d
-commands
-  silent
-  set $calls = $calls + 1
-  set $pcalls = $pcalls + 1
-  continue
-end
-break ospf6_asbr.c:%(loop)d
-commands
-  silent
-  set $visits = $visits + 1
-  set $pvisits = $pvisits + 1
-  continue
-end
-break ospf6_ase_calculate_timer
-commands
-  silent
-  set $pcalls = 0
-  set $pvisits = 0
-  continue
-end
-break ospf6_spf.c:%(exit)d
-commands
-  silent
-  printf "OA2_PASS calls=%%d visits=%%d\\n", $pcalls, $pvisits
-  continue
-end
-continue
-printf "OA2_TOTAL calls=%%d visits=%%d\\n", $calls, $visits
-detach
-quit
-"""
-
-
-def lsa_id(index):
-    return "198.18.%d.%d" % (index // 256, index % 256)
-
-
-def build_topo(tgen):
-    r1 = tgen.add_router("r1")
-    r2 = tgen.add_router("r2")
-    h1 = tgen.add_host("h1", H1_IP + "/64", "via " + R1_IP)
-    s1 = tgen.add_switch("s1")
-    s1.add_link(r1, nodeif="eth1")
-    s1.add_link(r2, nodeif="eth1")
-    s1.add_link(h1, nodeif="eth1")
-    s2 = tgen.add_switch("s2")
-    s2.add_link(r1, nodeif="eth2")
-
-
-def setup_module(mod):
-    tgen = Topogen(build_topo, mod.__name__)
-    tgen.start_topology()
-
-    for router in tgen.routers().values():
-        router.load_frr_config()
-
-    tgen.start_router()
-
-
-def teardown_module():
-    tgen = get_topogen()
-    tgen.stop_topology()
-
-
-def _vty_json(router, command):
-    return json.loads(router.vtysh_cmd(command))
-
-
-def _wait_until(func, timeout_s=20, interval_s=0.5):
-    deadline = time.time() + timeout_s
-    while time.time() < deadline:
-        if func():
-            return True
-        time.sleep(interval_s)
-    return bool(func())
-
-
-def _neighbor_full(router, rid):
-    try:
-        data = _vty_json(router, "show ipv6 ospf6 neighbor json")
-    except (json.JSONDecodeError, ValueError):
-        return False
-
-    def search(value):
-        if isinstance(value, dict):
-            if value.get("neighborId") == rid and value.get("state") == "Full":
-                return True
-            for child in value.values():
-                if search(child):
-                    return True
-        elif isinstance(value, list):
-            for child in value:
-                if search(child):
-                    return True
-        return False
-
-    return search(data)
-
-
-def _asbr_route_present(router):
-    return R2_RID in router.vtysh_cmd("show ipv6 ospf6 border-routers")
-
-
-def _external_ids(router):
-    data = _vty_json(router, "show ipv6 ospf6 database as-external json")
-    ids = set()
-
-    def walk(value):
-        if isinstance(value, dict):
-            if (value.get("lsId", "").startswith(LSA_ID_PREFIX)
-                    and value.get("advRouter") == R2_RID):
-                ids.add(value["lsId"])
-            for child in value.values():
-                walk(child)
-        elif isinstance(value, list):
-            for child in value:
-                walk(child)
-
-    walk(data)
-    return ids
-
-
-def _mac(gear):
-    return gear.cmd("cat /sys/class/net/eth1/address").strip()
-
-
-def _source_lines():
-    """Locate the breakpoint lines at runtime, tolerant to line shifts."""
-    asbr = os.path.join(CWD, "..", "..", "..", "ospf6d", "ospf6_asbr.c")
-    spf = os.path.join(CWD, "..", "..", "..", "ospf6d", "ospf6_spf.c")
-    patterns = {
-        "call": (asbr, r"if \(!ospf6_ase_forward_address_check\("),
-        "loop": (asbr, r"if \(!if_is_operative\(oi->interface\)"),
-        "exit": (spf, r"if \(ospf6->gr_info\.finishing_restart\) \{"),
-    }
-    lines = {}
-    for key, (source, pattern) in patterns.items():
-        with open(source) as fp:
-            content = fp.readlines()
-        for lineno, text in enumerate(content, 1):
-            if re.search(pattern, text):
-                lines[key] = lineno
-                break
-        else:
-            raise LookupError("pattern %r not found in %s" % (pattern, source))
-    return lines
-
-
-def _gdb_usable(router):
-    if not router.cmd("which gdb || true").strip():
-        return False
-    try:
-        _source_lines()
-    except (OSError, LookupError):
-        return False
-    return True
-
-
-def _ospf6d_pid(router):
-    # pidof is ambiguous: all routers share the pid namespace and run
-    # identical ospf6d cmdlines.  The pid file lives in the router's own
-    # mount namespace and names exactly this router's ospf6d.
-    return router.cmd("cat /var/run/frr/ospf6d.pid").strip().splitlines()[-1]
-
-
-def _gdb_start(router, logdir):
-    script = os.path.join(logdir, "oa2_count.gdb")
-    log = os.path.join(logdir, "oa2_count.log")
-    with open(script, "w") as fp:
-        fp.write(GDB_SCRIPT % _source_lines())
-    router.cmd("rm -f %s" % shlex.quote(log))
-    pid = router.cmd(
-        "nohup gdb -q -batch -x %s -p %s > %s 2>&1 </dev/null & echo $!"
-        % (shlex.quote(script), _ospf6d_pid(router), shlex.quote(log))
-    ).strip()
-    pid = pid.splitlines()[-1]
-    assert pid.isdigit(), "gdb did not start on %s (pid %r)" % (router.name, pid)
-    assert _wait_until(
-        lambda: os.path.exists(log)
-        and "Breakpoint 4 at" in open(log, errors="replace").read()
-    ), "gdb breakpoints not ready on %s" % router.name
-    return pid, log
-
-
-def _gdb_stop(router, pid, log):
-    router.cmd("kill -INT %s >/dev/null 2>&1 || true" % pid)
-
-    def _totals():
-        if not os.path.exists(log):
-            return None
-        match = re.search(
-            r"OA2_TOTAL calls=(\d+) visits=(\d+)", open(log, errors="replace").read()
-        )
-        return (int(match.group(1)), int(match.group(2))) if match else None
-
-    def _passes():
-        if not os.path.exists(log):
-            return []
-        return [
-            (int(m.group(1)), int(m.group(2)))
-            for m in re.finditer(
-                r"OA2_PASS calls=(\d+) visits=(\d+)",
-                open(log, errors="replace").read(),
-            )
-        ]
-
-    assert _wait_until(lambda: _totals() is not None, timeout_s=15), (
-        "gdb did not report counts on %s" % router.name
-    )
-    # Safety net: never leave the daemon stopped.
-    router.cmd("kill -CONT %s 2>/dev/null || true" % _ospf6d_pid(router))
-    return _totals(), _passes()
-
-
-def _inject(h1, r1, r2, count, fwd, first_index, corrupt=False):
-    h1.cmd_raises(
-        "python3 %s/inject_lsu.py --iface eth1 --src-mac %s --dst-mac %s "
-        "--src-ip %s --dst-ip %s --src-rid %s --adv-router %s --fwd %s "
-        "--count %d --first-index %d %s"
-        % (
-            shlex.quote(CWD),
-            _mac(r2),
-            _mac(r1),
-            R2_IP,
-            R1_IP,
-            R2_RID,
-            R2_RID,
-            fwd,
-            count,
-            first_index,
-            "--corrupt" if corrupt else "",
-        )
-    )
-
-
-def _raise_dead_interval(router, iface, seconds=300):
-    router.vtysh_cmd(
-        "configure terminal\ninterface %s\nipv6 ospf6 dead-interval %d\nend"
-        % (iface, seconds)
-    )
-
-
-def test_adjacency_full():
-    tgen = get_topogen()
-    if tgen.routers_have_failure():
-        pytest.skip(tgen.errors)
-    r1 = tgen.gears["r1"]
-    assert _wait_until(lambda: _neighbor_full(r1, R2_RID), timeout_s=90), (
-        "r1-r2 adjacency did not reach Full"
-    )
-    assert _wait_until(lambda: _asbr_route_present(r1), timeout_s=30), (
-        "r1 has no ASBR route to r2"
-    )
-
-
-def test_same_fwd_scan_once_per_pass():
-    """M same-fwd LSAs must cause at most one scan per ASE pass."""
-    tgen = get_topogen()
-    if tgen.routers_have_failure():
-        pytest.skip(tgen.errors)
-    r1 = tgen.gears["r1"]
-    r2 = tgen.gears["r2"]
-    h1 = tgen.gears["h1"]
-    assert _wait_until(lambda: _neighbor_full(r1, R2_RID), timeout_s=90)
-    assert _wait_until(lambda: _asbr_route_present(r1), timeout_s=30)
-    if not _gdb_usable(r1):
-        pytest.skip("gdb or the ospf6 source lines are unavailable")
-
-    _raise_dead_interval(r1, "eth1")
-    _raise_dead_interval(r2, "eth1")
-    # Isolate r2 so it cannot fight back the forged LSAs; r1 keeps the
-    # neighbor and the ASBR route inside the dead interval.
-    r2.cmd_raises("ip link set dev eth1 down")
-    assert _wait_until(lambda: _neighbor_full(r1, R2_RID), timeout_s=10), (
-        "r1 lost the r2 neighbor after isolation"
-    )
-    assert _wait_until(lambda: _asbr_route_present(r1), timeout_s=10), (
-        "r1 lost the ASBR route after isolation"
-    )
-
-    ids = [lsa_id(index) for index in range(1, BATCH + 1)]
-    assert not any(lsa_id in _external_ids(r1) for lsa_id in ids)
-    _inject(h1, r1, r2, BATCH, FWD, 1)
-    assert _wait_until(
-        lambda: all(ls_id in _external_ids(r1) for ls_id in ids), timeout_s=15
-    ), "the 16 injected LSAs did not reach the r1 LSDB"
-
-    gdb_pid, gdb_log = _gdb_start(r1, tgen.logdir)
-    try:
-        # Two link toggles force two SPF runs, hence two ASE passes.
-        r1.cmd_raises("ip link set dev eth2 down")
-        time.sleep(3)
-        r1.cmd_raises("ip link set dev eth2 up")
-        time.sleep(3)
-    finally:
-        (calls, visits), passes = _gdb_stop(r1, gdb_pid, gdb_log)
-
-    logger.info(
-        "forward-address check: calls=%d visits=%d passes=%s", calls, visits, passes
-    )
-    batch_passes = [p for p in passes if p[0] == BATCH]
-    assert batch_passes, "no ASE pass processed the injected batch"
-    # Each pass must scan the shared forwarding address at most once
-    # (2 interfaces before the toggle, 1 while eth2 is down), not once
-    # per LSA.
-    for pass_calls, pass_visits in batch_passes:
-        assert pass_visits <= 2, (
-            "ASE pass scanned %d interface entries for %d same-address LSAs; "
-            "the forwarding-address verdict must be cached per pass"
-            % (pass_visits, pass_calls)
-        )
-    # All 16 LSAs were still processed exactly once per pass.
-    assert calls >= BATCH
-    assert _wait_until(
-        lambda: all(ls_id in _external_ids(r1) for ls_id in ids), timeout_s=10
-    ), "injected LSAs vanished from the LSDB"
-
-
-def test_zero_fwd_and_corrupt_controls():
-    """Unspecified forwarding address and corrupt LSAs must not be scanned."""
-    tgen = get_topogen()
-    if tgen.routers_have_failure():
-        pytest.skip(tgen.errors)
-    r1 = tgen.gears["r1"]
-    r2 = tgen.gears["r2"]
-    h1 = tgen.gears["h1"]
-    assert _wait_until(lambda: _neighbor_full(r1, R2_RID), timeout_s=90)
-    if not _gdb_usable(r1):
-        pytest.skip("gdb or the ospf6 source lines are unavailable")
-
-    zero_ids = [lsa_id(index) for index in range(101, 109)]
-    gdb_pid, gdb_log = _gdb_start(r1, tgen.logdir)
-    try:
-        _inject(h1, r1, r2, 8, "::", 101)
-        time.sleep(3)
-    finally:
-        (calls, visits), _ = _gdb_stop(r1, gdb_pid, gdb_log)
-    assert calls == 0 and visits == 0, (
-        "unspecified forwarding address must skip the locality scan "
-        "(calls=%d visits=%d)" % (calls, visits)
-    )
-    assert all(ls_id in _external_ids(r1) for ls_id in zero_ids), (
-        "unspecified-fwd LSAs must be installed normally"
-    )
-
-    bad_id = lsa_id(201)
-    gdb_pid, gdb_log = _gdb_start(r1, tgen.logdir)
-    try:
-        _inject(h1, r1, r2, 1, FWD, 201, corrupt=True)
-        time.sleep(3)
-    finally:
-        (calls, visits), _ = _gdb_stop(r1, gdb_pid, gdb_log)
-    assert calls == 0 and visits == 0, (
-        "checksum-invalid LSA must be rejected before the scan (calls=%d visits=%d)"
-        % (calls, visits)
-    )
-    assert bad_id not in _external_ids(r1)
diff --git a/tests/topotests/ospf_ase_forward_scan/__init__.py b/tests/topotests/ospf_ase_forward_scan/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/tests/topotests/ospf_ase_forward_scan/inject_lsu.py b/tests/topotests/ospf_ase_forward_scan/inject_lsu.py
deleted file mode 100644
index 8c5e452352..0000000000
--- a/tests/topotests/ospf_ase_forward_scan/inject_lsu.py
+++ /dev/null
@@ -1,158 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: ISC
-#
-# inject_lsu.py
-#
-# Copyright (c) 2026 ATCorp
-#
-"""Build and send raw OSPF Link State Update packets.
-
-Used by test_ospf_ase_forward_scan.py to inject AS-external LSAs with a
-chosen forwarding address, bypassing the well-formedness of high-level
-packet helpers.
-"""
-
-import argparse
-import socket
-import struct
-import sys
-import time
-
-from scapy.all import Ether, IP, Raw, conf, sendp
-
-conf.verb = 0
-
-# 20-byte LSA header + 16-byte AS-external body; 16 LSAs stay under MTU 1500.
-LSA_CHUNK = 16
-
-
-def lsa_id(index):
-    return "198.18.%d.%d" % (index // 256, index % 256)
-
-
-def fletcher_checksum(lsa):
-    data = bytearray(lsa[2:])
-    data[14:16] = b"\x00\x00"
-    c0 = 0
-    c1 = 0
-    for value in data:
-        c0 = (c0 + value) % 255
-        c1 = (c1 + c0) % 255
-    x = ((len(data) - 14 - 1) * c0 - c1) % 255
-    if x == 0:
-        x = 255
-    y = 510 - c0 - x
-    if y > 255:
-        y -= 255
-    data[14:16] = bytes((x, y))
-    valid = bytearray(data)
-    c0 = 0
-    c1 = 0
-    for value in valid:
-        c0 = (c0 + value) % 255
-        c1 = (c1 + c0) % 255
-    if (c1 << 8) + c0 != 0:
-        raise ValueError("invalid generated LSA Fletcher checksum")
-    return (x << 8) | y
-
-
-def internet_checksum(data):
-    if len(data) % 2:
-        data += b"\x00"
-    total = sum(struct.unpack("!%dH" % (len(data) // 2), data))
-    total = (total & 0xFFFF) + (total >> 16)
-    total = (total & 0xFFFF) + (total >> 16)
-    return (~total) & 0xFFFF
-
-
-def make_external_lsa(index, adv_router, fwd, corrupt):
-    body = (
-        socket.inet_aton("255.255.255.255")
-        + bytes((0x00,))
-        + (10).to_bytes(3, "big")
-        + socket.inet_aton(fwd)
-        + (0).to_bytes(4, "big")
-    )
-    lsa = bytearray(
-        struct.pack(
-            "!HBB4s4sIHH",
-            1,
-            0x02,
-            5,
-            socket.inet_aton(lsa_id(index)),
-            socket.inet_aton(adv_router),
-            0x80000001,
-            0,
-            36,
-        )
-        + body
-    )
-    struct.pack_into("!H", lsa, 16, fletcher_checksum(lsa))
-    if corrupt:
-        lsa[17] ^= 0x5A
-    return bytes(lsa)
-
-
-def make_lsu(lsa_blobs, src_rid):
-    body = struct.pack("!I", len(lsa_blobs)) + b"".join(lsa_blobs)
-    packet = bytearray(
-        struct.pack(
-            "!BBH4s4sHH8s",
-            2,
-            4,
-            24 + len(body),
-            socket.inet_aton(src_rid),
-            socket.inet_aton("0.0.0.0"),
-            0,
-            0,
-            b"\x00" * 8,
-        )
-        + body
-    )
-    struct.pack_into("!H", packet, 12, internet_checksum(bytes(packet)))
-    if internet_checksum(bytes(packet)) != 0:
-        raise ValueError("invalid generated OSPF checksum")
-    return bytes(packet)
-
-
-def main():
-    parser = argparse.ArgumentParser()
-    parser.add_argument("--iface", required=True)
-    parser.add_argument("--src-mac", required=True)
-    parser.add_argument("--dst-mac", required=True)
-    parser.add_argument("--src-ip", required=True)
-    parser.add_argument("--dst-ip", required=True)
-    parser.add_argument("--src-rid", required=True)
-    parser.add_argument("--adv-router", required=True)
-    parser.add_argument("--fwd", required=True)
-    parser.add_argument("--count", type=int, default=16)
-    parser.add_argument("--first-index", type=int, default=1)
-    parser.add_argument("--corrupt", action="store_true")
-    parser.add_argument("--spacing", type=float, default=0.2)
-    args = parser.parse_args()
-
-    blobs = [
-        make_external_lsa(index, args.adv_router, args.fwd, args.corrupt)
-        for index in range(args.first_index, args.first_index + args.count)
-    ]
-    packets = [
-        make_lsu(blobs[off : off + LSA_CHUNK], args.src_rid)
-        for off in range(0, len(blobs), LSA_CHUNK)
-    ]
-    for index, payload in enumerate(packets):
-        if index:
-            time.sleep(args.spacing)
-        frame = (
-            Ether(src=args.src_mac, dst=args.dst_mac)
-            / IP(src=args.src_ip, dst=args.dst_ip, ttl=1, proto=89)
-            / Raw(payload)
-        )
-        sendp(frame, iface=args.iface, count=1, verbose=False)
-    print(
-        "OA2_INJECT packets=%d lsas=%d fwd=%s corrupt=%s first=%d"
-        % (len(packets), args.count, args.fwd, args.corrupt, args.first_index)
-    )
-
-
-if __name__ == "__main__":
-    sys.exit(main())
diff --git a/tests/topotests/ospf_ase_forward_scan/r1/frr.conf b/tests/topotests/ospf_ase_forward_scan/r1/frr.conf
deleted file mode 100644
index 56273548ea..0000000000
--- a/tests/topotests/ospf_ase_forward_scan/r1/frr.conf
+++ /dev/null
@@ -1,18 +0,0 @@
-!
-hostname r1
-!
-debug ospf lsa
-!
-interface eth1
- ip address 10.0.0.1/24
- ip ospf area 0
-!
-interface eth2
- ip address 10.0.1.1/24
- ip ospf area 0
- ip ospf passive
-!
-router ospf
- ospf router-id 1.1.1.1
- log-adjacency-changes detail
-!
diff --git a/tests/topotests/ospf_ase_forward_scan/r2/frr.conf b/tests/topotests/ospf_ase_forward_scan/r2/frr.conf
deleted file mode 100644
index d8f3674df1..0000000000
--- a/tests/topotests/ospf_ase_forward_scan/r2/frr.conf
+++ /dev/null
@@ -1,15 +0,0 @@
-!
-hostname r2
-!
-interface lo
- ip address 10.9.9.9/32
-!
-interface eth1
- ip address 10.0.0.2/24
- ip ospf area 0
-!
-router ospf
- ospf router-id 2.2.2.2
- redistribute connected metric 10 metric-type 1
- log-adjacency-changes detail
-!
diff --git a/tests/topotests/ospf_ase_forward_scan/test_ospf_ase_forward_scan.py b/tests/topotests/ospf_ase_forward_scan/test_ospf_ase_forward_scan.py
deleted file mode 100644
index 01a760d6f0..0000000000
--- a/tests/topotests/ospf_ase_forward_scan/test_ospf_ase_forward_scan.py
+++ /dev/null
@@ -1,441 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 eval: (blacken-mode 1) -*-
-# SPDX-License-Identifier: ISC
-#
-# test_ospf_ase_forward_scan.py
-#
-# Copyright (c) 2026 ATCorp
-#
-
-"""
-test_ospf_ase_forward_scan.py
-=============================
-
-Regression for per-LSA repeated forwarding-address locality scans in the
-OSPFv2 AS-external route calculation (RFC 2328 section 16.4).
-
-Every AS-external-LSA with a non-zero forwarding address makes
-ospf_ase_calculate_route() call ospf_ase_forward_address_check(), which
-walks the whole oiflist.  A batch of M external LSAs that carry the same
-non-local forwarding address therefore re-scans the identical address M
-times per ASE calculation pass, and again on every later pass.  The
-interface list cannot change while a pass runs, so the verdict must be
-cached per address for the duration of the pass.
-
-Topology
---------
-
-    r1 (RID 1.1.1.1, victim) ---- s1 ---- r2 (RID 2.2.2.2, ASBR)
-              |                          |
-              h1 (scapy injector)         (isolated after baseline)
-              |
-              s2 (stub, forces SPF passes via link toggle)
-
-Test plan
----------
-
-1. test_adjacency_full
-     Baseline: LAN adjacency reaches Full and the ASBR route exists.
-
-2. test_same_fwd_scan_once_per_pass
-     r2 is isolated (link down, within the raised dead interval) and h1
-     injects 16 AS-external LSAs with distinct LS IDs and the same
-     non-local forwarding address.  gdb counts the scan-loop iterations
-     on r1 while an r1 link toggle forces ASE passes.  Expect at most
-     one interface scan per pass, not one per LSA.
-
-3. test_zero_fwd_and_corrupt_controls
-     Same-shaped LSUs with forwarding address 0.0.0.0 (no scan, LSAs
-     installed) and with a broken Fletcher checksum (rejected, no scan).
-
-The gdb-based scan counts are the primary observable; tests 2-3 skip when
-gdb or the required source lines are unavailable.
-"""
-
-import json
-import os
-import re
-import shlex
-import sys
-import time
-from functools import partial
-
-import pytest
-
-from lib.topogen import Topogen, get_topogen
-from lib.topolog import logger
-
-
-CWD = os.path.dirname(os.path.realpath(__file__))
-sys.path.append(os.path.join(CWD, "../"))
-
-pytestmark = [pytest.mark.ospfd]
-
-R1_RID = "1.1.1.1"
-R2_RID = "2.2.2.2"
-R1_IP = "10.0.0.1"
-R2_IP = "10.0.0.2"
-H1_IP = "10.0.0.3"
-FWD = "10.99.99.99"
-LSA_ID_PREFIX = "198.18."
-BATCH = 16
-
-GDB_SCRIPT = """set pagination off
-set confirm off
-handle SIGINT stop print nopass
-set $calls = 0
-set $visits = 0
-set $pcalls = 0
-set $pvisits = 0
-break ospf_ase.c:%(call)d
-commands
-  silent
-  set $calls = $calls + 1
-  set $pcalls = $pcalls + 1
-  continue
-end
-break ospf_ase.c:%(loop)d
-commands
-  silent
-  set $visits = $visits + 1
-  set $pvisits = $pvisits + 1
-  continue
-end
-break ospf_ase_calculate_timer
-commands
-  silent
-  set $pcalls = 0
-  set $pvisits = 0
-  continue
-end
-break ospf_ase.c:%(exit)d
-commands
-  silent
-  printf "OA2_PASS calls=%%d visits=%%d\\n", $pcalls, $pvisits
-  continue
-end
-continue
-printf "OA2_TOTAL calls=%%d visits=%%d\\n", $calls, $visits
-detach
-quit
-"""
-
-
-def lsa_id(index):
-    return "198.18.%d.%d" % (index // 256, index % 256)
-
-
-def build_topo(tgen):
-    r1 = tgen.add_router("r1")
-    r2 = tgen.add_router("r2")
-    h1 = tgen.add_host("h1", H1_IP + "/24", "via " + R1_IP)
-    s1 = tgen.add_switch("s1")
-    s1.add_link(r1, nodeif="eth1")
-    s1.add_link(r2, nodeif="eth1")
-    s1.add_link(h1, nodeif="eth1")
-    s2 = tgen.add_switch("s2")
-    s2.add_link(r1, nodeif="eth2")
-
-
-def setup_module(mod):
-    tgen = Topogen(build_topo, mod.__name__)
-    tgen.start_topology()
-
-    for router in tgen.routers().values():
-        router.load_frr_config()
-
-    tgen.start_router()
-
-
-def teardown_module():
-    tgen = get_topogen()
-    tgen.stop_topology()
-
-
-def _vty_json(router, command):
-    return json.loads(router.vtysh_cmd(command))
-
-
-def _wait_until(func, timeout_s=20, interval_s=0.5):
-    deadline = time.time() + timeout_s
-    while time.time() < deadline:
-        if func():
-            return True
-        time.sleep(interval_s)
-    return bool(func())
-
-
-def _neighbor_full(router, rid):
-    try:
-        data = _vty_json(router, "show ip ospf neighbor json")
-    except (json.JSONDecodeError, ValueError):
-        return False
-    entries = data.get("neighbors", {}).get(rid, [])
-    return bool(entries) and entries[0].get("converged") == "Full"
-
-
-def _asbr_route_present(router):
-    return R2_RID in router.vtysh_cmd("show ip ospf border-routers")
-
-
-def _external_ids(router):
-    data = _vty_json(router, "show ip ospf database external json")
-    ids = set()
-
-    def walk(value):
-        if isinstance(value, dict):
-            if (value.get("linkStateId", "").startswith(LSA_ID_PREFIX)
-                    and value.get("advertisingRouter") == R2_RID):
-                ids.add(value["linkStateId"])
-            for child in value.values():
-                walk(child)
-        elif isinstance(value, list):
-            for child in value:
-                walk(child)
-
-    walk(data)
-    return ids
-
-
-def _mac(gear):
-    return gear.cmd("cat /sys/class/net/eth1/address").strip()
-
-
-def _source_lines():
-    """Locate the breakpoint lines in ospfd/ospf_ase.c at runtime.
-
-    The scan-call site, the scan-loop body and the ASE-pass exit marker are
-    found by pattern so the same test works on any tree that contains the
-    check, including this fix (which shifts line numbers).
-    """
-    source = os.path.join(CWD, "..", "..", "..", "ospfd", "ospf_ase.c")
-    patterns = {
-        "call": r"if \(!ospf_ase_forward_address_check\(ospf,",
-        "loop": r"if \(if_is_operative\(oi->ifp\)\)",
-        "exit": r"if \(ospf->gr_info\.finishing_restart\) \{",
-    }
-    lines = {}
-    with open(source) as fp:
-        content = fp.readlines()
-    for key, pattern in patterns.items():
-        for lineno, text in enumerate(content, 1):
-            if re.search(pattern, text):
-                lines[key] = lineno
-                break
-        else:
-            raise LookupError("pattern %r not found in ospf_ase.c" % pattern)
-    return lines
-
-
-def _gdb_usable(router):
-    if not router.cmd("which gdb || true").strip():
-        return False
-    try:
-        _source_lines()
-    except (OSError, LookupError):
-        return False
-    return True
-
-
-def _ospfd_pid(router):
-    # pidof is ambiguous: all routers share the pid namespace and run
-    # identical ospfd cmdlines.  The pid file lives in the router's own
-    # mount namespace and names exactly this router's ospfd.
-    return router.cmd("cat /var/run/frr/ospfd.pid").strip().splitlines()[-1]
-
-
-def _gdb_start(router, logdir):
-    script = os.path.join(logdir, "oa2_count.gdb")
-    log = os.path.join(logdir, "oa2_count.log")
-    with open(script, "w") as fp:
-        fp.write(GDB_SCRIPT % _source_lines())
-    router.cmd("rm -f %s" % shlex.quote(log))
-    pid = router.cmd(
-        "nohup gdb -q -batch -x %s -p %s > %s 2>&1 </dev/null & echo $!"
-        % (shlex.quote(script), _ospfd_pid(router), shlex.quote(log))
-    ).strip()
-    pid = pid.splitlines()[-1]
-    assert pid.isdigit(), "gdb did not start on %s (pid %r)" % (router.name, pid)
-    assert _wait_until(
-        lambda: os.path.exists(log)
-        and "Breakpoint 4 at" in open(log, errors="replace").read()
-    ), "gdb breakpoints not ready on %s" % router.name
-    return pid, log
-
-
-def _gdb_stop(router, pid, log):
-    router.cmd("kill -INT %s >/dev/null 2>&1 || true" % pid)
-
-    def _totals():
-        if not os.path.exists(log):
-            return None
-        match = re.search(
-            r"OA2_TOTAL calls=(\d+) visits=(\d+)", open(log, errors="replace").read()
-        )
-        return (int(match.group(1)), int(match.group(2))) if match else None
-
-    def _passes():
-        if not os.path.exists(log):
-            return []
-        return [
-            (int(m.group(1)), int(m.group(2)))
-            for m in re.finditer(
-                r"OA2_PASS calls=(\d+) visits=(\d+)",
-                open(log, errors="replace").read(),
-            )
-        ]
-
-    assert _wait_until(lambda: _totals() is not None, timeout_s=15), (
-        "gdb did not report counts on %s" % router.name
-    )
-    # Safety net: never leave the daemon stopped.
-    router.cmd("kill -CONT %s 2>/dev/null || true" % _ospfd_pid(router))
-    return _totals(), _passes()
-
-
-def _inject(h1, r1, r2, count, fwd, first_index, corrupt=False):
-    h1.cmd_raises(
-        "python3 %s/inject_lsu.py --iface eth1 --src-mac %s --dst-mac %s "
-        "--src-ip %s --dst-ip %s --src-rid %s --adv-router %s --fwd %s "
-        "--count %d --first-index %d %s"
-        % (
-            shlex.quote(CWD),
-            _mac(r2),
-            _mac(r1),
-            R2_IP,
-            R1_IP,
-            R2_RID,
-            R2_RID,
-            fwd,
-            count,
-            first_index,
-            "--corrupt" if corrupt else "",
-        )
-    )
-
-
-def _raise_dead_interval(router, iface, seconds=300):
-    router.vtysh_cmd(
-        "configure terminal\ninterface %s\nip ospf dead-interval %d\nend"
-        % (iface, seconds)
-    )
-
-
-def test_adjacency_full():
-    tgen = get_topogen()
-    if tgen.routers_have_failure():
-        pytest.skip(tgen.errors)
-    r1 = tgen.gears["r1"]
-    assert _wait_until(lambda: _neighbor_full(r1, R2_RID), timeout_s=90), (
-        "r1-r2 adjacency did not reach Full"
-    )
-    assert _wait_until(lambda: _asbr_route_present(r1), timeout_s=30), (
-        "r1 has no ASBR route to r2"
-    )
-
-
-def test_same_fwd_scan_once_per_pass():
-    """M same-fwd LSAs must cause at most one scan per ASE pass."""
-    tgen = get_topogen()
-    if tgen.routers_have_failure():
-        pytest.skip(tgen.errors)
-    r1 = tgen.gears["r1"]
-    r2 = tgen.gears["r2"]
-    h1 = tgen.gears["h1"]
-    assert _wait_until(lambda: _neighbor_full(r1, R2_RID), timeout_s=90)
-    assert _wait_until(lambda: _asbr_route_present(r1), timeout_s=30)
-    if not _gdb_usable(r1):
-        pytest.skip("gdb or the ospf_ase.c source lines are unavailable")
-
-    _raise_dead_interval(r1, "eth1")
-    _raise_dead_interval(r2, "eth1")
-    # Isolate r2 so it cannot fight back the forged LSAs; r1 keeps the
-    # neighbor and the ASBR route inside the dead interval.
-    r2.cmd_raises("ip link set dev eth1 down")
-    assert _wait_until(lambda: _neighbor_full(r1, R2_RID), timeout_s=10), (
-        "r1 lost the r2 neighbor after isolation"
-    )
-    assert _wait_until(lambda: _asbr_route_present(r1), timeout_s=10), (
-        "r1 lost the ASBR route after isolation"
-    )
-
-    ids = [lsa_id(index) for index in range(1, BATCH + 1)]
-    assert not any(lsa_id in _external_ids(r1) for lsa_id in ids)
-    _inject(h1, r1, r2, BATCH, FWD, 1)
-    assert _wait_until(
-        lambda: all(ls_id in _external_ids(r1) for ls_id in ids), timeout_s=15
-    ), "the 16 injected LSAs did not reach the r1 LSDB"
-
-    gdb_pid, gdb_log = _gdb_start(r1, tgen.logdir)
-    try:
-        # Two link toggles force two SPF runs, hence two ASE passes.
-        r1.cmd_raises("ip link set dev eth2 down")
-        time.sleep(3)
-        r1.cmd_raises("ip link set dev eth2 up")
-        time.sleep(3)
-    finally:
-        (calls, visits), passes = _gdb_stop(r1, gdb_pid, gdb_log)
-
-    logger.info(
-        "forward-address check: calls=%d visits=%d passes=%s", calls, visits, passes
-    )
-    batch_passes = [p for p in passes if p[0] == BATCH]
-    assert batch_passes, "no ASE pass processed the injected batch"
-    # Each pass must scan the shared forwarding address at most once
-    # (2 interfaces before the toggle, 1 while eth2 is down), not once
-    # per LSA.
-    for pass_calls, pass_visits in batch_passes:
-        assert pass_visits <= 2, (
-            "ASE pass scanned %d interface entries for %d same-address LSAs; "
-            "the forwarding-address verdict must be cached per pass"
-            % (pass_visits, pass_calls)
-        )
-    # All 16 LSAs were still processed exactly once per pass.
-    assert calls >= BATCH
-    # The LSDB presence (asserted above) plus the per-pass call counts show
-    # the target processed every injected LSA; the scan counts are the
-    # evidence for the deduplication requirement.
-    assert _wait_until(
-        lambda: all(ls_id in _external_ids(r1) for ls_id in ids), timeout_s=10
-    ), "injected LSAs vanished from the LSDB"
-
-
-def test_zero_fwd_and_corrupt_controls():
-    """Zero forwarding address and corrupt LSAs must not reach the scan."""
-    tgen = get_topogen()
-    if tgen.routers_have_failure():
-        pytest.skip(tgen.errors)
-    r1 = tgen.gears["r1"]
-    r2 = tgen.gears["r2"]
-    h1 = tgen.gears["h1"]
-    assert _wait_until(lambda: _neighbor_full(r1, R2_RID), timeout_s=90)
-    if not _gdb_usable(r1):
-        pytest.skip("gdb or the ospf_ase.c source lines are unavailable")
-
-    zero_ids = [lsa_id(index) for index in range(101, 109)]
-    gdb_pid, gdb_log = _gdb_start(r1, tgen.logdir)
-    try:
-        _inject(h1, r1, r2, 8, "0.0.0.0", 101)
-        time.sleep(3)
-    finally:
-        (calls, visits), _ = _gdb_stop(r1, gdb_pid, gdb_log)
-    assert calls == 0 and visits == 0, (
-        "zero forwarding address must skip the locality scan (calls=%d visits=%d)"
-        % (calls, visits)
-    )
-    assert all(ls_id in _external_ids(r1) for ls_id in zero_ids), (
-        "zero-fwd LSAs must be installed normally"
-    )
-
-    bad_id = lsa_id(201)
-    gdb_pid, gdb_log = _gdb_start(r1, tgen.logdir)
-    try:
-        _inject(h1, r1, r2, 1, FWD, 201, corrupt=True)
-        time.sleep(3)
-    finally:
-        (calls, visits), _ = _gdb_stop(r1, gdb_pid, gdb_log)
-    assert calls == 0 and visits == 0, (
-        "checksum-invalid LSA must be rejected before the scan (calls=%d visits=%d)"
-        % (calls, visits)
-    )
-    assert bad_id not in _external_ids(r1)