diff --git a/bundles/org.openhab.core.io.net/src/main/java/org/openhab/core/io/net/mac/MacResolver.java b/bundles/org.openhab.core.io.net/src/main/java/org/openhab/core/io/net/mac/MacResolver.java new file mode 100644 index 00000000000..f6dfc6441c4 --- /dev/null +++ b/bundles/org.openhab.core.io.net/src/main/java/org/openhab/core/io/net/mac/MacResolver.java @@ -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 arpCache = new ConcurrentHashMap<>(); + + // map of pending MAC resolution futures for each IP; allows sharing of pending resolutions for the same IP + private final Map>> 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> 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 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> 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); + } + } +} diff --git a/bundles/org.openhab.core.io.net/src/test/java/org/openhab/core/io/net/mac/MacResolverTest.java b/bundles/org.openhab.core.io.net/src/test/java/org/openhab/core/io/net/mac/MacResolverTest.java new file mode 100644 index 00000000000..770add6975f --- /dev/null +++ b/bundles/org.openhab.core.io.net/src/test/java/org/openhab/core/io/net/mac/MacResolverTest.java @@ -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)); + } +}