[networking] MAC address resolver component (#5622)

Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>
This commit is contained in:
Andrew Fiddian-Green
2026-06-14 14:42:45 +02:00
committed by GitHub
parent 561e920365
commit 344979b2e6
2 changed files with 809 additions and 0 deletions
@@ -0,0 +1,586 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.io.net.mac;
import java.io.BufferedReader;
import java.io.File;
import java.io.StringReader;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.common.ThreadPoolManager;
import org.openhab.core.io.net.exec.ExecUtil;
import org.openhab.core.net.NetUtil;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Utility class for resolving MAC addresses from IPv4 addresses via the operating system's ARP cache. The main method
* {@link #resolveMac(String)} provides an asynchronous API to get the MAC address for a given IPv4 address. If the MAC
* address is cached and valid, it returns immediately. Otherwise, it starts a front end process that involves probing
* the IPv4 address to trigger the OS to populate its ARP table, plus a back end process that bulk loads the OS ARP
* cache into the in-memory cache and completes the pending future when the MAC address becomes available. The
* implementation includes optimizations to avoid unnecessary ARP cache loads for non-local or unreachable IP addresses,
* and to share pending resolution tasks for the same IP address to avoid redundant work. Resolved MAC addresses are
* cached in-memory with an expiration time to avoid frequent lookups, and the back end process is scheduled to run only
* when there are pending resolutions to avoid unnecessary resource usage. This class is designed to be thread-safe and
* efficient for typical home network environments where devices may come and go, and ARP cache entries may expire or
* change over time.
*
* @author Andrew Fiddian-Green - Initial contribution
*/
@NonNullByDefault
@Component(service = MacResolver.class)
public class MacResolver {
private static final Duration ARP_LOAD_PROCESS_TIMEOUT = Duration.ofMillis(1500);
private static final Duration CACHE_VALIDITY_DURATION = Duration.ofMinutes(7);
private static final Duration BACKEND_TASK_RUN_INITIAL_DELAY = Duration.ofMillis(20);
private static final Duration BACKEND_TASK_RUN_INTERVAL = Duration.ofMillis(1200);
private static final Duration RESOLVE_MAC_TIMEOUT = Duration.ofSeconds(4);
private static final Pattern MAC_PATTERN = Pattern.compile("([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}");
private static final Pattern IP_PATTERN = Pattern
.compile("\\b((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)\\b");
private final Logger logger = LoggerFactory.getLogger(MacResolver.class);
// cache of IP / MAC mappings with expiration time stamps; prevents hitting the OS ARP cache too often
protected final Map<String, ExpiringMac> arpCache = new ConcurrentHashMap<>();
// map of pending MAC resolution futures for each IP; allows sharing of pending resolutions for the same IP
private final Map<String, Set<CompletableFuture<@Nullable String>>> pendingFutureMacs = new ConcurrentHashMap<>();
private @NonNullByDefault({}) ExecutorService frontEndExecutor;
private @NonNullByDefault({}) ScheduledExecutorService backEndScheduler;
private @Nullable ScheduledFuture<?> backEndTaskSchedule;
// operating system type
private enum OSType {
LINUX,
MAC_OS,
WINDOWS,
UNKNOWN;
static OSType from(String osName) {
String name = osName.toLowerCase(Locale.ROOT);
if (name.contains("linux")) {
return LINUX;
}
if (name.contains("mac") || name.contains("darwin")) {
return MAC_OS;
}
if (name.contains("win")) {
return WINDOWS;
}
return UNKNOWN;
}
}
private static final String OS_NAME = Objects.requireNonNull(System.getProperty("os.name", ""));
private static final OSType OS_TYPE = OSType.from(OS_NAME);
private static final byte[] ARP_TRIGGER_BUF = new byte[1];
private static final int ARP_TRIGGER_BUF_SIZE = ARP_TRIGGER_BUF.length;
private static final int DISCARD_PORT = 9;
private String windowsArp = "arp";
private volatile boolean log2WarnDone = false;
/**
* Simple wrapper class to hold a MAC address with its expiration time-stamp.
*/
protected static class ExpiringMac {
private final String mac;
private final Instant expires;
/**
* Creates a new expiring MAC entry.
*/
public ExpiringMac(String mac) {
this(mac, Instant.now().plus(CACHE_VALIDITY_DURATION));
}
/**
* For Unit tests only: Creates a new expiring MAC entry with a given explicit expires time.
*/
ExpiringMac(String mac, Instant expires) {
this.mac = mac;
this.expires = expires;
}
/**
* Returns the MAC address if not expired, otherwise {@code null}.
*/
public @Nullable String getMac() {
return isExpired() ? null : mac;
}
/**
* Checks whether this entry has expired.
*/
public boolean isExpired() {
return Instant.now().isAfter(expires);
}
}
@Activate
public MacResolver() {
frontEndExecutor = ThreadPoolManager.getPool("OH-MacResolver-FrontEnd");
backEndScheduler = ThreadPoolManager.getScheduledPool("OH-MacResolver-BackEnd");
if (OS_TYPE == OSType.UNKNOWN) {
logger.warn("Unknown OS '{}' MacResolver may not work.", OS_NAME);
}
if (OS_TYPE == OSType.WINDOWS) {
String path = System.getenv("SystemRoot");
if (path != null) {
path += "\\System32\\arp.exe";
if (new File(path).exists()) {
windowsArp = path;
}
}
}
}
@Deactivate
protected void deactivate() {
stopBackEndTaskSchedule();
pendingFutureMacs.values().forEach(futureMacs -> {
futureMacs.forEach(futureMac -> futureMac.complete(null));
futureMacs.clear();
});
pendingFutureMacs.clear();
}
/**
* Schedules a periodic task to load the ARP cache and complete pending futures. The scheduler is started
* when the first resolution request is made, and stopped when there are no more pending resolutions to
* avoid unnecessary resource usage.
*/
private synchronized void startBackEndTaskSchedule() {
if (backEndTaskSchedule != null) {
return;
}
logger.trace("Starting back end");
backEndTaskSchedule = backEndScheduler.scheduleWithFixedDelay(this::backEndTask,
BACKEND_TASK_RUN_INITIAL_DELAY.toMillis(), BACKEND_TASK_RUN_INTERVAL.toMillis(), TimeUnit.MILLISECONDS);
}
/**
* Stops the ARP cache loading schedule if it is running. This is called when there are no more pending
* resolution tasks to avoid unnecessary resource usage.
*/
private synchronized void stopBackEndTaskSchedule() {
ScheduledFuture<?> task;
task = backEndTaskSchedule;
backEndTaskSchedule = null;
if (task != null) {
logger.trace("Stopping back end");
task.cancel(false);
}
}
/**
* Resolves the MAC address for a given IPv4 address. If the MAC address is cached and valid, it is returned
* immediately. Otherwise, an asynchronous resolution process is started that involves a front end process that
* probes the IPv4 target device to trigger the OS to populate its ARP table, and a back end process that bulk
* loads the ARP table and completes the future when the MAC address becomes available. The future completes
* with {@code null} if resolution fails or takes too long. The method also includes optimizations to avoid
* unnecessary ARP cache loads for invalid, non-local, or unreachable IPv4 addresses by checking these conditions
* before scheduling the asynchronous resolution.
*
* @param ipv4Address the IPv4 address to resolve e.g. "192.168.1.1" or "192.168.1.1:port"
* @return a future that completes with the resolved MAC address or {@code null} if resolution fails
* or times out
*/
public CompletableFuture<@Nullable String> resolveMac(String ipv4Address) {
if (!beginsWithValidIp(ipv4Address)) {
logger.debug("'{}' is an invalid IP", ipv4Address);
return CompletableFuture.completedFuture(null);
}
String ip = normalizeIp(ipv4Address);
// FAST PATH: check cache before doing any async work, and complete immediately if present and valid
String cachedMac = cacheGet(ip);
if (cachedMac != null) {
logger.trace("{} -> {} (fast-path)", ip, cachedMac);
// complete and remove any existing pending futures for this IP
Set<CompletableFuture<@Nullable String>> pending = pendingFutureMacs.remove(ip);
if (pending != null && !pending.isEmpty()) {
pending.forEach(f -> f.complete(cachedMac));
}
// if no pending futures remain globally, stop the backend
if (pendingFutureMacs.isEmpty()) {
stopBackEndTaskSchedule();
}
// return a completed future for this call
return CompletableFuture.completedFuture(cachedMac);
}
// SLOW PATH: validate IP and schedule async resolution process
InetAddress addr;
try {
addr = InetAddress.getByName(ip);
} catch (UnknownHostException e) {
addr = null;
}
if (addr == null || addr.isLoopbackAddress() || addr.isAnyLocalAddress() || addr.isMulticastAddress()
|| NetUtil.getAllBroadcastAddresses().contains(ip)) {
logger.debug("'{}' is an invalid, loopback, 'any', multicast, or broadcast IP", ip);
return CompletableFuture.completedFuture(null);
}
if (!isOnLocalSubnet(addr)) {
logger.debug("{} not on local sub-net", ip);
return CompletableFuture.completedFuture(null);
}
// create this call's independent future
CompletableFuture<@Nullable String> futureMac = new CompletableFuture<@Nullable String>()
.completeOnTimeout(null, RESOLVE_MAC_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
futureMac.whenComplete((mac, ex) -> handleFutureCompletion(ip, futureMac));
pendingFutureMacs.compute(ip, (key, existingSet) -> {
// if MAC is now already cached, complete immediately and do NOT register
String nowCached = cacheGet(ip);
if (nowCached != null) {
futureMac.complete(nowCached);
return existingSet; // no change to map
}
// otherwise, register this future
if (existingSet == null) {
existingSet = ConcurrentHashMap.newKeySet();
}
existingSet.add(futureMac);
return existingSet;
});
// continue the slow path if the future was not completed due to a concurrent cachePut, otherwise return it
if (!futureMac.isDone()) {
startBackEndTaskSchedule();
frontEndExecutor.submit(() -> triggerArpTableUpdate(ip));
}
return futureMac;
}
/**
* Handles the completion of a MAC resolution future by removing it from the pending set for the given IP, and
* stopping the back end schedule if there are no more pending futures. This is called when a future completes
* either with a resolved MAC address or with {@code null} due to failure or timeout.
*
* @param ip the IP address associated with the completed future
* @param futureMac the future that has completed
*/
private void handleFutureCompletion(String ip, CompletableFuture<@Nullable String> futureMac) {
pendingFutureMacs.computeIfPresent(ip, (key, futureMacs) -> {
futureMacs.remove(futureMac);
// if still empty after removal, drop the entry
return futureMacs.isEmpty() ? null : futureMacs;
});
// if map is now empty, stop backend
if (pendingFutureMacs.isEmpty()) {
stopBackEndTaskSchedule();
}
}
/**
* Triggers the operating system to update its ARP table for the given IP address by performing probes on all valid
* network interfaces. The method sends a single UDP data-gram to the discard port 9 to do the probe. However before
* the OS can actually try to send the data-gram, it first has to check if it has the target MAC address. And if not
* it must send an ARP request packet to resolve the MAC. In other words, by doing the probe we immediately trigger
* the ARP resolution process, and for our purposes it does not matter how or if the target device responds. Each
* probe is scoped to the candidate interfaces that are up, non-loopback, and on the same sub-net as the target IP.
*
* @param ipv4 the target IPv4 address to trigger ARP resolution for
*/
protected void triggerArpTableUpdate(String ipv4) {
InetAddress targetIpv4;
try {
targetIpv4 = InetAddress.getByName(ipv4);
} catch (UnknownHostException e) {
return; // IP is already validated so this can't occur
}
if (NetUtil.getSameSubnetInterfaceAddress(targetIpv4) == null) {
return; // no local interface can reach the target IP
}
List<NetworkInterface> nifs;
try {
nifs = Collections.list(NetworkInterface.getNetworkInterfaces());
} catch (SocketException e) {
return; // no network interfaces (should not happen, but just in case)
}
DatagramPacket packet = new DatagramPacket(ARP_TRIGGER_BUF, ARP_TRIGGER_BUF_SIZE, targetIpv4, DISCARD_PORT);
for (NetworkInterface nif : nifs) {
try {
if (!nif.isUp() || nif.isLoopback()) {
continue;
}
for (InterfaceAddress nifAddr : nif.getInterfaceAddresses()) {
if (nifAddr.getAddress() instanceof Inet4Address nifIpv4) {
short nifPrefixLen = nifAddr.getNetworkPrefixLength();
if (nifPrefixLen < 1 || nifPrefixLen > 32) {
continue;
}
String nifAddress = NetUtil.getIpv4NetAddress(nifIpv4.getHostAddress(), nifPrefixLen);
String targetAddress = NetUtil.getIpv4NetAddress(targetIpv4.getHostAddress(), nifPrefixLen);
if (nifAddress.equals(targetAddress)) {
try (DatagramSocket socket = new DatagramSocket(new InetSocketAddress(nifIpv4, 0))) {
socket.send(packet);
} catch (Exception ignore) {
// don't care if send fails; all that matters is the prior ARP resolution
}
break; // need only send one packet per interface even if there are multiple addresses
}
}
}
} catch (SocketException e) {
// ignore this interface and continue
}
}
}
/**
* Checks if the given IPv4 address is on the same local sub-net as any of the host's network interfaces. This
* avoids ARP cache loads for IPv4 addresses that are not local which therefore cannot be resolved to a MAC address
* via the OS ARP table.
*/
protected boolean isOnLocalSubnet(InetAddress address) {
return NetUtil.getSameSubnetInterfaceAddress(address) != null;
}
/**
* Periodic task that is run by a back end scheduler that loads the ARP cache from the operating system. If
* there are no more pending futures, the back end scheduler is stopped to avoid unnecessary resource usage.
*/
protected void backEndTask() {
// if there are no pending futures, skip loading and stop the scheduler
if (pendingFutureMacs.isEmpty()) {
stopBackEndTaskSchedule();
return;
}
// load new OS ARP table entries into the in-memory cache
arpCacheLoad();
// remove any remaining expired in-memory cache entries
cacheFlush();
}
/**
* Executes a bulk load of the operating system's ARP cache into the in-memory cache.
*/
private void arpCacheLoad() {
switch (OS_TYPE) {
case LINUX -> linuxArpCacheLoad();
case MAC_OS -> runCommandAndParse(ARP_LOAD_PROCESS_TIMEOUT, "/usr/sbin/arp", "-n");
case WINDOWS -> runCommandAndParse(ARP_LOAD_PROCESS_TIMEOUT, windowsArp, "-a");
default -> {
return;
}
}
}
/**
* Loads ARP entries from Linux's {@code /proc/net/arp} file.
*/
private void linuxArpCacheLoad() {
File arpFile = new File("/proc/net/arp");
if (!arpFile.exists()) {
log2WarnOnce("ARP file {} does not exist", arpFile.getAbsolutePath());
return;
}
try (BufferedReader br = Files.newBufferedReader(arpFile.toPath(), StandardCharsets.UTF_8)) {
br.readLine(); // skip header
String line;
while ((line = br.readLine()) != null) {
parseLine(line);
}
} catch (Exception e) {
log2WarnOnce("Error reading /proc/net/arp", e);
}
}
/**
* Removes all expired entries from the in-memory cache.
*/
private void cacheFlush() {
arpCache.entrySet().removeIf(e -> e.getValue().isExpired());
}
/**
* Retrieves a MAC address from the in-memory cache, if present and not expired.
*/
protected @Nullable String cacheGet(String ip) {
ExpiringMac entry = arpCache.get(ip);
if (entry == null) {
return null;
}
if (entry.isExpired()) {
arpCache.remove(ip, entry); // remove only if same instance
return null;
}
return entry.getMac();
}
/**
* Stores an IP => MAC mapping in the cache with expiration and if possible eagerly resolves any pending MAC
* future(s).
*/
protected void cachePut(String ip, String mac) {
arpCache.put(ip, new ExpiringMac(mac));
// eager execution: check if a running future can be completed early
Set<CompletableFuture<@Nullable String>> futureMacs = pendingFutureMacs.remove(ip);
if (futureMacs != null && !futureMacs.isEmpty()) {
logger.trace("{} -> {} (eager for {} future{})", ip, mac, futureMacs.size(),
futureMacs.size() > 1 ? "s" : "");
futureMacs.forEach(futureMac -> futureMac.complete(mac));
}
// if no pending futures remain globally, stop scheduler immediately
if (pendingFutureMacs.isEmpty()) {
stopBackEndTaskSchedule();
}
}
/**
* Checks whether the given MAC address matches typical OS-supported formats, such as {@code AA:BB:CC:DD:EE:FF},
* {@code AA-BB-CC-DD-EE-FF}, or {@code aa-bb-cc-dd-ee-ff}. Mixed separators are also accepted. The method
* additionally rejects the all-zero MAC {@code 00:00:00:00:00:00}.
*/
protected static boolean isValidMac(String mac) {
return MAC_PATTERN.matcher(mac).matches() && !"00:00:00:00:00:00".equalsIgnoreCase(normalizeMac(mac));
}
/**
* Converts a MAC address to the standard format {@code XX:XX:XX:XX:XX:XX}.
*/
protected static String normalizeMac(String mac) {
return mac.toUpperCase(Locale.ROOT).replaceAll("[^A-F0-9]", "").replaceAll("(.{2})(?=.)", "$1:");
}
/**
* Checks if the text begins with a standard format and valid IP address. e.g. {@code 192.168.1.1} and
* {@code 192.168.1.1:1234} are valid whereas {@code 999.999.999.999} or {@code foo 192.168.1.1} are not.
*
* @param ip the IP address to check
* @return true if the text begins with a valid IP address, false otherwise
*/
protected static boolean beginsWithValidIp(String ip) {
return IP_PATTERN.matcher(ip).lookingAt();
}
/**
* Extracts IP part of a string. e.g. both {@code 192.168.1.1:8080} and {@code 192.168.1.1} produce the
* output {@code 192.168.1.1}
*
* @param ip the IP address to normalize
* @return the normalized IP address, or the original string if it cannot be normalized
*/
protected static String normalizeIp(String ip) {
Matcher m = IP_PATTERN.matcher(ip);
return m.lookingAt() ? m.group() : ip; // fallback: return original
}
/**
* Parses a single line from ARP output, extracts the IP MAC mapping if present, and caches it.
*
* @param line the line to parse
*/
protected void parseLine(String line) {
if (line.isBlank()) {
return;
}
Matcher ipMatcher = IP_PATTERN.matcher(line);
Matcher macMatcher = MAC_PATTERN.matcher(line);
if (ipMatcher.find() && macMatcher.find()) {
String ip = ipMatcher.group();
String mac = normalizeMac(macMatcher.group());
if (isValidMac(mac)) {
cachePut(ip, mac);
}
}
}
/**
* Executes an OS process with the given timeout and command (String... args) and parses its output line by line
* using {@link #parseLine(String)}. Timeout is enforced.
*
* @param timeout the duration to wait for the process to finish
* @param command the command and its arguments to execute
*/
private void runCommandAndParse(Duration timeout, String... command) {
String response = ExecUtil.executeCommandLineAndWaitResponse(timeout, command);
if (response == null) {
log2WarnOnce("No response from command: {}", String.join(" ", command));
return;
}
try (BufferedReader reader = new BufferedReader(new StringReader(response))) {
String line;
while ((line = reader.readLine()) != null) {
parseLine(line);
}
} catch (Exception e) {
log2WarnOnce("Error reading result of command: {}", String.join(" ", command), e);
}
}
/**
* Logs ARP-related messages to 'warn' one time only and thereafter subsequently to 'debug'
* in order to avoid spamming the logs.
*/
private void log2WarnOnce(String format, Object... args) {
if (!log2WarnDone) {
logger.warn(format, args);
log2WarnDone = true;
} else {
logger.debug(format, args);
}
}
}
@@ -0,0 +1,223 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.io.net.mac;
import static org.junit.jupiter.api.Assertions.*;
import java.net.InetAddress;
import java.time.Instant;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Test cases for the {@link MacResolver} class.
*
* @author Andrew Fiddian-Green - Initial contribution
*/
@NonNullByDefault
class MacResolverTest {
class TestMacResolver extends MacResolver {
@Override
protected boolean isOnLocalSubnet(InetAddress addr) {
return true; // force all IPs to be treated as local
}
protected void testPutCached(String ip, String mac, Instant expires) {
ExpiringMac entry = new ExpiringMac(mac, expires);
arpCache.put(ip, entry);
}
@Override
protected void triggerArpTableUpdate(String ip) {
// no-op to prevent background resolution logic from interfering with our tests
}
@Override
protected void backEndTask() {
// no-op to prevent background resolution logic from interfering with our tests
}
}
final TestMacResolver macResolver = new TestMacResolver();
@BeforeEach
void setup() throws Exception {
macResolver.arpCache.clear();
}
@AfterEach
void teardown() throws Exception {
macResolver.deactivate();
}
// -----------------------------
// Normalization + Validation
// -----------------------------
@Test
void testNormalizeMac() throws Exception {
assertEquals("AA:BB:CC:DD:EE:FF", MacResolver.normalizeMac("aa-bb-cc-dd-ee-ff"));
assertEquals("AA:BB:CC:DD:EE:FF", MacResolver.normalizeMac("AA:BB:CC:DD:EE:FF"));
assertEquals("AA:BB:CC:DD:EE:FF", MacResolver.normalizeMac("aa:bb:cc:dd:ee:ff"));
}
@Test
void testIsValidMac() throws Exception {
assertTrue(MacResolver.isValidMac("AA:BB:CC:DD:EE:FF"));
assertFalse(MacResolver.isValidMac("00:00:00:00:00:00"));
assertFalse(MacResolver.isValidMac("AA:BB:CC:DD:EE")); // too short
assertFalse(MacResolver.isValidMac("GG:HH:II:JJ:KK:LL")); // invalid hex
}
@Test
void testNormalizeIP() throws Exception {
assertEquals("192.168.1.1", MacResolver.normalizeIp("192.168.1.1:1234"));
assertEquals("192.168.1.1", MacResolver.normalizeIp("192.168.1.1"));
assertEquals("foo 192.168.1.1 bar", MacResolver.normalizeIp("foo 192.168.1.1 bar"));
}
@Test
void testIsValidIp() throws Exception {
assertTrue(MacResolver.beginsWithValidIp("192.168.1.1"));
assertTrue(MacResolver.beginsWithValidIp("192.168.1.1:1234"));
assertTrue(MacResolver.beginsWithValidIp(MacResolver.normalizeIp("192.168.1.1:1234")));
assertFalse(MacResolver.beginsWithValidIp("999.999.999.999"));
assertFalse(MacResolver.beginsWithValidIp("foobar 192.168.1.1"));
}
// -----------------------------
// parseLine() tests
// -----------------------------
@Test
void testParseLineLinuxStyle() throws Exception {
macResolver.arpCache.clear();
String line = "192.168.1.10 0x1 0x2 aa:bb:cc:dd:ee:ff * br0";
macResolver.parseLine(line);
assertEquals("AA:BB:CC:DD:EE:FF", macResolver.cacheGet("192.168.1.10"));
}
@Test
void testParseLineWindowsStyle() throws Exception {
macResolver.arpCache.clear();
String line = " 192.168.1.50 aa-bb-cc-dd-ee-ff dynamic";
macResolver.parseLine(line);
assertEquals("AA:BB:CC:DD:EE:FF", macResolver.cacheGet("192.168.1.50"));
}
@Test
void testParseLineIgnoresInvalid() throws Exception {
macResolver.arpCache.clear();
macResolver.parseLine("this is not an arp entry");
macResolver.parseLine("999.999.999.999 aa:bb:cc:dd:ee:ff");
assertTrue(macResolver.arpCache.isEmpty(), "Cache should remain empty after parsing invalid lines");
}
// -----------------------------
// Cache behaviour
// -----------------------------
@Test
void testCacheHitShortCircuitsLookup() throws Exception {
macResolver.arpCache.clear();
macResolver.testPutCached("1.2.3.4", "AA:BB:CC:DD:EE:FF", Instant.now().plusSeconds(60));
String mac = macResolver.resolveMac("1.2.3.4").get(1, TimeUnit.SECONDS);
assertEquals("AA:BB:CC:DD:EE:FF", mac);
}
@Test
void testCacheExpiry() throws Exception {
macResolver.arpCache.clear();
macResolver.testPutCached("1.2.3.4", "AA:BB:CC:DD:EE:FF", Instant.now().minusSeconds(120));
assertNull(macResolver.cacheGet("1.2.3.4"));
}
@Test
void testBlankIpReturnsNull() throws Exception {
assertNull(macResolver.resolveMac("").get(1, TimeUnit.SECONDS));
assertNull(macResolver.resolveMac(" ").get(1, TimeUnit.SECONDS));
}
@Test
void testResolveMacCompletesImmediatelyWhenCached() throws Exception {
macResolver.arpCache.clear();
String ip = "1.2.3.4";
String mac = "AA:BB:CC:DD:EE:FF";
// Simulate ARP output line
String arpLine = ip + " " + mac.replace(":", "-").toLowerCase() + " dynamic";
macResolver.parseLine(arpLine);
// Now resolveMac should return a completed future
CompletableFuture<@Nullable String> futureMac = macResolver.resolveMac(ip);
assertTrue(futureMac.isDone(), "Future should be completed immediately");
assertEquals(mac, futureMac.get(1, TimeUnit.SECONDS));
}
@Test
void testResolveMacLoopbackReturnsNull() throws Exception {
macResolver.arpCache.clear();
String ip = "127.0.0.1";
CompletableFuture<@Nullable String> futureMac = macResolver.resolveMac(ip);
assertTrue(futureMac.isDone(), "Future should be completed immediately for loopback");
assertNull(futureMac.get(1, TimeUnit.SECONDS), "Loopback IP should return null MAC");
}
@Test
void testParallelResolveMacSharesPendingFutureEntry() throws Exception {
macResolver.arpCache.clear();
String ip = "1.2.3.4";
// Trigger two parallel resolveMac calls
CompletableFuture<@Nullable String> futureMac1 = macResolver.resolveMac(ip);
CompletableFuture<@Nullable String> futureMac2 = macResolver.resolveMac(ip);
// Assert: two distinct CompletableFuture objects returned
assertNotSame(futureMac1, futureMac2);
// Assert: neither future is completed yet (no MAC resolved)
assertFalse(futureMac1.isDone());
assertFalse(futureMac2.isDone());
// Now resolve the MAC
macResolver.cachePut(ip, "AA:BB:CC:DD:EE:FF");
// Assert: both futures complete with the same MAC
assertEquals("AA:BB:CC:DD:EE:FF", futureMac1.get(1, TimeUnit.SECONDS));
assertEquals("AA:BB:CC:DD:EE:FF", futureMac2.get(1, TimeUnit.SECONDS));
}
}