Files
MyIP/common/ip-math.js
T
jason5ng32andGPT-6 0c6e5f2c7a Fix(ip): validate subnet split limits before conversion
Return null for invalid options or limits instead of throwing during
BigInt conversion. Preserve zero, default, and safe integer limits.

Co-Authored-By: GPT-6 <noreply@openai.com>
2026-09-17 10:49:17 +08:00

379 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// IP address arithmetic shared by the IP Calculator (front-end) and rdap.js's
// CIDR containment (back-end). Both families are BigInt: 32-bit `<<` goes
// negative on 240.0.0.0, and IPv6 counts exceed Number anyway.
//
// Parsed = { family: 4 | 6, value: bigint }
// Cidr = { family, prefix, address, network, aligned }
//
// Every export is pure, never throws, and returns null (false for predicates)
// on unusable input. Parsers are strict — `01.2.3.4` is rejected because
// inet_aton would read the octet as octal; ip-calc.js labels such forms
// instead. No imports, so valid-ip.js could adopt this file without a cycle.
const BITS = { 4: 32, 6: 128 };
const MAX = { 4: (1n << 32n) - 1n, 6: (1n << 128n) - 1n };
const V4_OCTET = /^(0|[1-9]\d{0,2})$/;
const HEX_GROUP = /^[0-9a-fA-F]{1,4}$/;
const V6_CHARS = /^[0-9a-fA-F:.]+$/;
const isFamily = (family) => family === 4 || family === 6;
const isBig = (v) => typeof v === 'bigint';
const inFamily = (value, family) => isBig(value) && value >= 0n && value <= MAX[family];
const isPrefix = (prefix, family) =>
Number.isInteger(prefix) && prefix >= 0 && prefix <= BITS[family];
/* ------------------------------------------------------------------ */
/* Parsing */
/* ------------------------------------------------------------------ */
export const parseIPv4 = (str) => {
if (typeof str !== 'string') return null;
const parts = str.split('.');
if (parts.length !== 4) return null;
let value = 0n;
for (const part of parts) {
if (!V4_OCTET.test(part)) return null;
const n = Number(part);
if (n > 255) return null;
value = (value << 8n) | BigInt(n);
}
return { family: 4, value };
};
// One `::` at most, 1–4 hex per group, and a dotted-quad tail only as the
// final token (`::ffff:192.0.2.1`), which then stands in for two groups.
export const parseIPv6 = (str) => {
if (typeof str !== 'string' || str === '' || !V6_CHARS.test(str)) return null;
let text = str;
let embeddedV4 = false;
if (str.includes('.')) {
const lastColon = str.lastIndexOf(':');
const tail = str.slice(lastColon + 1);
if (lastColon < 0 || !tail.includes('.')) return null;
const quad = parseIPv4(tail);
if (!quad) return null;
const hi = (quad.value >> 16n).toString(16);
const lo = (quad.value & 0xffffn).toString(16);
text = `${str.slice(0, lastColon + 1)}${hi}:${lo}`;
embeddedV4 = true;
}
const halves = text.split('::');
if (halves.length > 2) return null;
const shorthand = halves.length === 2;
const left = halves[0] === '' ? [] : halves[0].split(':');
const right = shorthand && halves[1] !== '' ? halves[1].split(':') : [];
const explicit = left.length + right.length;
if (shorthand ? explicit > 7 : explicit !== 8) return null;
const groups = [...left, ...Array(shorthand ? 8 - explicit : 0).fill('0'), ...right];
let value = 0n;
for (const group of groups) {
if (!HEX_GROUP.test(group)) return null;
value = (value << 16n) | BigInt(parseInt(group, 16));
}
return { family: 6, value, embeddedV4 };
};
export const parseIp = (str) => parseIPv4(str) || parseIPv6(str);
export const ipToBigInt = (str) => parseIp(str)?.value ?? null;
// `a.b.c.d/n`, `x::/n`, or (IPv4 only) `a.b.c.d/m.m.m.m` with a contiguous
// dotted mask. Host bits are kept on `address`; `network` has them cleared.
export const parseCidr = (str) => {
if (typeof str !== 'string') return null;
const slash = str.indexOf('/');
if (slash <= 0 || slash !== str.lastIndexOf('/')) return null;
const parsed = parseIp(str.slice(0, slash));
if (!parsed) return null;
const { family, value } = parsed;
const rhs = str.slice(slash + 1);
let prefix = null;
if (/^\d{1,3}$/.test(rhs)) {
prefix = Number(rhs);
} else if (family === 4) {
const mask = parseIPv4(rhs);
prefix = mask ? maskToPrefix(mask.value, 4) : null;
}
if (prefix === null || !isPrefix(prefix, family)) return null;
const network = value & prefixToMask(prefix, family);
return { family, prefix, address: value, network, aligned: network === value };
};
/* ------------------------------------------------------------------ */
/* Formatting */
/* ------------------------------------------------------------------ */
export const toOctets = (value) => {
if (!inFamily(value, 4)) return null;
return [24n, 16n, 8n, 0n].map((shift) => Number((value >> shift) & 0xffn));
};
export const toHextets = (value) => {
if (!inFamily(value, 6)) return null;
return Array.from({ length: 8 }, (_, i) => Number((value >> BigInt((7 - i) * 16)) & 0xffffn));
};
export const formatIPv4 = (value) => toOctets(value)?.join('.') ?? null;
// RFC 5952: lowercase, no leading zeros, the longest run of two or more zero
// groups becomes `::` (first run wins a tie), a lone zero group stays.
export const formatIPv6 = (value, { expanded = false } = {}) => {
const hextets = toHextets(value);
if (!hextets) return null;
if (expanded) return hextets.map((h) => h.toString(16).padStart(4, '0')).join(':');
let bestStart = -1;
let bestLen = 0;
for (let i = 0; i < 8; i += 1) {
if (hextets[i] !== 0) continue;
let j = i;
while (j < 8 && hextets[j] === 0) j += 1;
if (j - i > bestLen) {
bestStart = i;
bestLen = j - i;
}
i = j;
}
const hex = hextets.map((h) => h.toString(16));
if (bestLen < 2) return hex.join(':');
const head = hex.slice(0, bestStart).join(':');
const tail = hex.slice(bestStart + bestLen).join(':');
return `${head}::${tail}`;
};
export const formatIp = (parsed) => {
if (!parsed || !isFamily(parsed.family)) return null;
return parsed.family === 4 ? formatIPv4(parsed.value) : formatIPv6(parsed.value);
};
export const formatCidr = (cidr, { network = true } = {}) => {
if (!cidr || !isFamily(cidr.family)) return null;
const ip = formatIp({ family: cidr.family, value: network ? cidr.network : cidr.address });
return ip === null ? null : `${ip}/${cidr.prefix}`;
};
/* ------------------------------------------------------------------ */
/* Masks & counts */
/* ------------------------------------------------------------------ */
export const prefixToMask = (prefix, family) => {
if (!isFamily(family) || !isPrefix(prefix, family)) return null;
const bits = BITS[family];
return ((1n << BigInt(prefix)) - 1n) << BigInt(bits - prefix);
};
// Null unless the mask is ones followed by zeros.
export const maskToPrefix = (mask, family) => {
if (!isFamily(family) || !inFamily(mask, family)) return null;
for (let prefix = 0; prefix <= BITS[family]; prefix += 1) {
if (prefixToMask(prefix, family) === mask) return prefix;
}
return null;
};
export const wildcardMask = (prefix, family) => {
const mask = prefixToMask(prefix, family);
return mask === null ? null : MAX[family] ^ mask;
};
export const addressCount = (prefix, family) => {
if (!isFamily(family) || !isPrefix(prefix, family)) return null;
return 1n << BigInt(BITS[family] - prefix);
};
// IPv4 loses network + broadcast except on /31 (RFC 3021 point-to-point)
// and /32; IPv6 has no broadcast, every address is assignable.
export const usableCount = (prefix, family) => {
const count = addressCount(prefix, family);
if (count === null) return null;
if (family === 6 || prefix >= 31) return count;
return count - 2n;
};
// Smallest block that holds `count` addresses (number or bigint).
export const smallestPrefixFor = (count, family) => {
if (!isFamily(family)) return null;
let need;
try {
need = BigInt(count);
} catch {
return null;
}
if (need < 1n) return null;
for (let prefix = BITS[family]; prefix >= 0; prefix -= 1) {
if (addressCount(prefix, family) >= need) return prefix;
}
return null;
};
/* ------------------------------------------------------------------ */
/* Block info */
/* ------------------------------------------------------------------ */
export const cidrInfo = (str) => {
const cidr = parseCidr(str);
if (!cidr) return null;
const { family, prefix, address, network, aligned } = cidr;
const mask = prefixToMask(prefix, family);
const wildcard = wildcardMask(prefix, family);
const lastAddress = network | wildcard;
const hostRange = family === 4 && prefix < 31;
return {
family,
prefix,
cidr: formatCidr(cidr),
address,
network,
broadcast: family === 4 ? lastAddress : null,
first: hostRange ? network + 1n : network,
last: hostRange ? lastAddress - 1n : lastAddress,
lastAddress,
mask,
wildcard,
count: addressCount(prefix, family),
usable: usableCount(prefix, family),
aligned,
};
};
/* ------------------------------------------------------------------ */
/* Containment & ordering */
/* ------------------------------------------------------------------ */
export const prefixContains = (network, prefix, family, value) => {
if (!isFamily(family) || !isPrefix(prefix, family)
|| !inFamily(network, family) || !inFamily(value, family)) return false;
const shift = BigInt(BITS[family] - prefix);
return (value >> shift) === (network >> shift);
};
export const cidrContains = (cidrStr, ipStr) => {
const cidr = parseCidr(cidrStr);
const ip = parseIp(ipStr);
if (!cidr || !ip || cidr.family !== ip.family) return null;
return prefixContains(cidr.network, cidr.prefix, cidr.family, ip.value);
};
export const cidrOverlaps = (aStr, bStr) => {
const a = parseCidr(aStr);
const b = parseCidr(bStr);
if (!a || !b || a.family !== b.family) return null;
return prefixContains(a.network, a.prefix, a.family, b.network)
|| prefixContains(b.network, b.prefix, b.family, a.network);
};
// IPv4 sorts before IPv6; within a family, numerically.
export const compareIps = (a, b) => {
if (a.family !== b.family) return a.family < b.family ? -1 : 1;
if (a.value === b.value) return 0;
return a.value < b.value ? -1 : 1;
};
/* ------------------------------------------------------------------ */
/* Set operations */
/* ------------------------------------------------------------------ */
// Children of `cidrStr` at `newPrefix`. `total` is exact; `subnets` stops at
// `limit` so a /8 → /32 request never materialises 16M strings.
export const splitCidr = (cidrStr, newPrefix, options = {}) => {
if (!options || typeof options !== 'object' || Array.isArray(options)) return null;
const { limit = 1024 } = options;
if (!Number.isSafeInteger(limit) || limit < 0) return null;
const cidr = parseCidr(cidrStr);
if (!cidr || !isPrefix(newPrefix, cidr.family) || newPrefix < cidr.prefix) return null;
const { family, network } = cidr;
const total = 1n << BigInt(newPrefix - cidr.prefix);
const size = addressCount(newPrefix, family);
const emit = total < BigInt(limit) ? Number(total) : limit;
const subnets = [];
for (let i = 0n; i < BigInt(emit); i += 1n) {
subnets.push(`${formatIp({ family, value: network + i * size })}/${newPrefix}`);
}
return { family, prefix: newPrefix, total, subnets, truncated: BigInt(emit) < total };
};
const isAligned = (network, prefix, family) =>
(network & (addressCount(prefix, family) - 1n)) === 0n;
// Minimal covering set per family: bare IPs become host routes, contained
// blocks are absorbed, aligned siblings merge upward (4 × /26 → one /24).
export const aggregateCidrs = (list) => {
const out = { v4: [], v6: [], invalid: [] };
if (!Array.isArray(list)) return out;
const blocks = { 4: [], 6: [] };
for (const token of list) {
const text = typeof token === 'string' ? token.trim() : '';
const cidr = parseCidr(text);
if (cidr) {
blocks[cidr.family].push({ network: cidr.network, prefix: cidr.prefix });
continue;
}
const ip = parseIp(text);
if (ip) {
blocks[ip.family].push({ network: ip.value, prefix: BITS[ip.family] });
continue;
}
out.invalid.push(token);
}
for (const family of [4, 6]) {
const sorted = blocks[family].sort((a, b) => {
if (a.network !== b.network) return a.network < b.network ? -1 : 1;
return a.prefix - b.prefix;
});
const stack = [];
for (const block of sorted) {
const top = stack[stack.length - 1];
if (top && top.prefix <= block.prefix
&& prefixContains(top.network, top.prefix, family, block.network)) continue;
stack.push(block);
while (stack.length >= 2) {
const a = stack[stack.length - 2];
const b = stack[stack.length - 1];
const size = addressCount(a.prefix, family);
if (a.prefix === 0 || a.prefix !== b.prefix || b.network !== a.network + size
|| !isAligned(a.network, a.prefix - 1, family)) break;
stack.splice(-2, 2, { network: a.network, prefix: a.prefix - 1 });
}
}
out[family === 4 ? 'v4' : 'v6'] = stack.map(
(b) => `${formatIp({ family, value: b.network })}/${b.prefix}`,
);
}
return out;
};
// Greedy cover of an inclusive range with the fewest CIDRs: at each step
// take the largest block aligned at `cur` that still fits before `end`.
export const rangeToCidrs = (startStr, endStr) => {
const start = parseIp(startStr);
const end = parseIp(endStr);
if (!start || !end || start.family !== end.family || start.value > end.value) return null;
const { family } = start;
const bits = BITS[family];
const cidrs = [];
let cur = start.value;
while (cur <= end.value) {
let prefix = 0;
for (; prefix <= bits; prefix += 1) {
const size = addressCount(prefix, family);
if (isAligned(cur, prefix, family) && cur + size - 1n <= end.value) break;
}
cidrs.push(`${formatIp({ family, value: cur })}/${prefix}`);
cur += addressCount(prefix, family);
}
return { family, cidrs };
};
export const cidrToRange = (cidrStr) => {
const info = cidrInfo(cidrStr);
if (!info) return null;
return { family: info.family, start: info.network, end: info.lastAddress };
};