diff --git a/src/lib/utils/ip.ts b/src/lib/utils/ip.ts index f804bf3..a2edca1 100644 --- a/src/lib/utils/ip.ts +++ b/src/lib/utils/ip.ts @@ -1,6 +1,8 @@ /** - * Convert IP address (with optional CIDR) to numeric value for sorting - * e.g., "192.168.1.0/24" -> 3232235776, "10.0.0.1" -> 167772161 + * Convert an IPv4 address (with optional CIDR) to a numeric value for sorting. + * e.g. "192.168.1.0/24" -> 3232235776, "10.0.0.1" -> 167772161. + * Returns Infinity for empty values or anything that is not IPv4 (IPv6 included) - + * for mixed v4/v6 sorting use compareIps, which orders both families correctly. */ export function ipToNumber(ip: string | undefined | null): number { if (!ip || ip === '-') return Infinity; // Push empty IPs to the end @@ -13,3 +15,77 @@ export function ipToNumber(ip: string | undefined | null): number { return isNaN(num) ? Infinity : (acc << 8) + num; }, 0) >>> 0; // Convert to unsigned 32-bit } + +/** + * Parse an IPv6 address (CIDR stripped) to its 128-bit value, or null if it is not + * a valid IPv6 address. Handles `::` zero-compression and a trailing IPv4 tail + * (e.g. `::ffff:192.168.0.1`). + */ +export function ipv6ToBigInt(ip: string): bigint | null { + let s = ip.split('/')[0].trim(); + if (s === '' || !s.includes(':')) return null; + + // A trailing dotted-quad (IPv4-in-IPv6) becomes two hextets. + const lastColon = s.lastIndexOf(':'); + const tail = s.slice(lastColon + 1); + if (tail.includes('.')) { + const o = tail.split('.'); + if (o.length !== 4) return null; + const n = o.map((p) => parseInt(p, 10)); + if (n.some((x) => isNaN(x) || x < 0 || x > 255)) return null; + const hi = ((n[0] << 8) | n[1]).toString(16); + const lo = ((n[2] << 8) | n[3]).toString(16); + s = s.slice(0, lastColon + 1) + hi + ':' + lo; + } + + const halves = s.split('::'); + if (halves.length > 2) return null; // more than one '::' is invalid + + const parseGroups = (part: string): string[] | null => { + if (part === '') return []; + const g = part.split(':'); + for (const h of g) { + if (h === '' || h.length > 4 || !/^[0-9a-fA-F]+$/.test(h)) return null; + } + return g; + }; + + const head = parseGroups(halves[0]); + const rest = parseGroups(halves.length === 2 ? halves[1] : ''); + if (head === null || rest === null) return null; + + let groups: string[]; + if (halves.length === 2) { + const fill = 8 - head.length - rest.length; + if (fill < 0) return null; + groups = [...head, ...Array(fill).fill('0'), ...rest]; + } else { + groups = head; + } + if (groups.length !== 8) return null; + + let value = 0n; + for (const h of groups) value = (value << 16n) + BigInt(parseInt(h, 16)); + return value; +} + +/** + * Comparator for IP strings (subnet/gateway/address, with or without CIDR) that + * orders IPv4 and IPv6 correctly - IPv4 first, then IPv6, each numerically, with + * empty/invalid values last. Fixes the "random" IPv6 subnet ordering (#1453) that + * ipToNumber alone caused by collapsing every IPv6 value to Infinity. + */ +export function compareIps(a: string | undefined | null, b: string | undefined | null): number { + const rank = (ip: string | undefined | null): { fam: number; val: bigint } => { + if (!ip || ip === '-') return { fam: 2, val: 0n }; + const v4 = ipToNumber(ip); + if (v4 !== Infinity) return { fam: 0, val: BigInt(v4) }; + const v6 = ipv6ToBigInt(ip); + if (v6 !== null) return { fam: 1, val: v6 }; + return { fam: 2, val: 0n }; // unparseable -> last + }; + const ra = rank(a); + const rb = rank(b); + if (ra.fam !== rb.fam) return ra.fam - rb.fam; + return ra.val < rb.val ? -1 : ra.val > rb.val ? 1 : 0; +} diff --git a/src/routes/networks/+page.svelte b/src/routes/networks/+page.svelte index b9b3946..bd66beb 100644 --- a/src/routes/networks/+page.svelte +++ b/src/routes/networks/+page.svelte @@ -24,7 +24,7 @@ import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state'; import PageHeader from '$lib/components/PageHeader.svelte'; import { DataGrid } from '$lib/components/data-grid'; - import { ipToNumber } from '$lib/utils/ip'; + import { compareIps } from '$lib/utils/ip'; import NetworkGraphModal from './NetworkGraphModal.svelte'; type SortField = 'name' | 'driver' | 'containers' | 'subnet' | 'gateway'; @@ -226,10 +226,10 @@ cmp = Object.keys(a.containers || {}).length - Object.keys(b.containers || {}).length; break; case 'subnet': - cmp = ipToNumber(getNetworkSubnet(a)) - ipToNumber(getNetworkSubnet(b)); + cmp = compareIps(getNetworkSubnet(a), getNetworkSubnet(b)); break; case 'gateway': - cmp = ipToNumber(getNetworkGateway(a)) - ipToNumber(getNetworkGateway(b)); + cmp = compareIps(getNetworkGateway(a), getNetworkGateway(b)); break; } // Secondary sort by name for stability when primary values are equal diff --git a/tests/ip-sort.test.ts b/tests/ip-sort.test.ts index 26ced16..ee9bd9c 100644 --- a/tests/ip-sort.test.ts +++ b/tests/ip-sort.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test'; -import { ipToNumber } from '../src/lib/utils/ip'; +import { ipToNumber, ipv6ToBigInt, compareIps } from '../src/lib/utils/ip'; describe('ipToNumber', () => { test('converts standard IPv4 addresses', () => { @@ -31,3 +31,68 @@ describe('ipToNumber', () => { expect(sorted).toEqual(['10.0.0.1', '172.16.0.1', '192.168.1.2', '192.168.1.10']); }); }); + +describe('ipv6ToBigInt', () => { + test('parses full and compressed forms to the same value', () => { + const full = ipv6ToBigInt('2001:0db8:0000:0000:0000:0000:0000:0001'); + expect(ipv6ToBigInt('2001:db8::1')).toBe(full); + expect(ipv6ToBigInt('2001:db8::1/64')).toBe(full); // CIDR stripped + }); + + test('handles :: at the start and end', () => { + expect(ipv6ToBigInt('::1')).toBe(1n); + expect(ipv6ToBigInt('::')).toBe(0n); + expect(ipv6ToBigInt('fe80::')).toBe(ipv6ToBigInt('fe80:0:0:0:0:0:0:0')); + }); + + test('handles an IPv4 tail (IPv4-mapped)', () => { + expect(ipv6ToBigInt('::ffff:192.168.0.1')).toBe(ipv6ToBigInt('::ffff:c0a8:1')); + }); + + test('orders IPv6 numerically, not lexically', () => { + // The #1453 case: the last hextet differs; string sort mis-orders 0x9 vs 0x10. + expect(ipv6ToBigInt('2001:db8::9')! < ipv6ToBigInt('2001:db8::10')!).toBe(true); + expect(ipv6ToBigInt('2001:db8::2')! < ipv6ToBigInt('2001:db8::a')!).toBe(true); + }); + + test('rejects invalid input', () => { + expect(ipv6ToBigInt('192.168.0.1')).toBeNull(); // no colon + expect(ipv6ToBigInt('2001::db8::1')).toBeNull(); // two '::' + expect(ipv6ToBigInt('gggg::1')).toBeNull(); // non-hex + expect(ipv6ToBigInt('1:2:3:4:5:6:7:8:9')).toBeNull(); // too many groups + expect(ipv6ToBigInt('')).toBeNull(); + }); +}); + +describe('compareIps (#1453 mixed v4/v6 ordering)', () => { + test('orders IPv6 subnets numerically (the reported bug)', () => { + const subnets = ['2001:db8::10/64', '2001:db8::2/64', '2001:db8::a/64', '2001:db8::1/64']; + const sorted = [...subnets].sort(compareIps); + expect(sorted).toEqual([ + '2001:db8::1/64', + '2001:db8::2/64', + '2001:db8::a/64', + '2001:db8::10/64' + ]); + }); + + test('IPv4 sorts before IPv6, each family numerically', () => { + const mixed = ['fd00::1', '10.0.0.5', 'fd00::2', '10.0.0.1', '2001:db8::1']; + const sorted = [...mixed].sort(compareIps); + expect(sorted).toEqual(['10.0.0.1', '10.0.0.5', '2001:db8::1', 'fd00::1', 'fd00::2']); + }); + + test('empty / invalid values sort last', () => { + const list = ['fd00::1', '-', '10.0.0.1', undefined, null, 'garbage']; + const sorted = [...list].sort(compareIps); + expect(sorted.slice(0, 2)).toEqual(['10.0.0.1', 'fd00::1']); + // the three empty/invalid entries trail, order among them not significant + expect(new Set(sorted.slice(2))).toEqual(new Set(['-', undefined, null, 'garbage'])); + }); + + test('is a stable transitive comparator (returns -1/0/1 semantics)', () => { + expect(compareIps('10.0.0.1', '10.0.0.1')).toBe(0); + expect(compareIps('10.0.0.1', '10.0.0.2')).toBeLessThan(0); + expect(compareIps('2001:db8::2', '2001:db8::1')).toBeGreaterThan(0); + }); +});