diff --git a/src/client/Session.ts b/src/client/Session.ts index 73d180d..ef6424a 100644 --- a/src/client/Session.ts +++ b/src/client/Session.ts @@ -29,7 +29,7 @@ import { registerClosable, unregisterClosable } from "../utils/ProcessCleanup"; import { SessionDataSet } from "./SessionDataSet"; import { RowRecord } from "./RowRecord"; import { BaseColumnDecoder, ColumnEncoding, Column } from "./ColumnDecoder"; -import { RedirectException } from "../utils/Errors"; +import { RedirectException, isWildcardAddress } from "../utils/Errors"; import { serializeTabletValuesFast, serializeTimestamps @@ -243,6 +243,11 @@ export class Session { getAndClearLastRedirect(): EndPoint | null { const redirect = this.lastRedirectEndpoint; this.lastRedirectEndpoint = null; + // Ignore a redirect to a wildcard/listen-all address (0.0.0.0 / ::), + // which is not a connectable remote endpoint (mirrors apache/iotdb#18162). + if (redirect && isWildcardAddress(redirect.host)) { + return null; + } return redirect; } diff --git a/src/utils/Config.ts b/src/utils/Config.ts index 05016f9..3c574b6 100644 --- a/src/utils/Config.ts +++ b/src/utils/Config.ts @@ -32,23 +32,51 @@ export interface InternalConfig extends Config { sqlDialect?: string; } +/** + * Parse a single "host:port" node URL into an EndPoint. Accepts the bracketed + * IPv6 form "[::1]:6667" (consistent with the [ipv6]:port endpoint format + * standardized in apache/iotdb#18162) as well as IPv4 and hostname URLs. A bare + * (unbracketed) IPv6 address with a port is ambiguous and rejected; the + * "[ipv6]:port" form must be used. + */ +function parseNodeUrl(url: string): EndPoint { + const trimmed = url.trim(); + let host: string; + let portStr: string; + if (trimmed.startsWith('[')) { + // Bracketed IPv6: [host]:port + const close = trimmed.indexOf(']'); + if (close === -1 || trimmed[close + 1] !== ':') { + throw new Error(`Invalid nodeUrl format: ${url}. Expected format: "[ipv6]:port"`); + } + host = trimmed.slice(1, close).trim(); + portStr = trimmed.slice(close + 2).trim(); + } else { + const idx = trimmed.indexOf(':'); + // A non-bracketed URL with more than one colon is a bare IPv6 address, + // which is ambiguous with a trailing port; it must be written as [ipv6]:port. + if (idx === -1 || idx !== trimmed.lastIndexOf(':')) { + throw new Error(`Invalid nodeUrl format: ${url}. Expected format: "host:port" (use "[ipv6]:port" for IPv6 addresses)`); + } + host = trimmed.slice(0, idx).trim(); + portStr = trimmed.slice(idx + 1).trim(); + } + // Require the port to be all digits: parseInt would otherwise accept a numeric prefix, so + // "[::1]:6667junk" or "[::1]:6667:9999" would be silently read as port 6667 and mask a malformed + // endpoint (IoTDB's Java and Python parsers reject these because the whole value must convert). + const port = /^\d+$/.test(portStr) ? parseInt(portStr, 10) : NaN; + if (!host || isNaN(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid nodeUrl format: ${url}. Host must be non-empty and port must be a valid number (1-65535)`); + } + return { host, port }; +} + /** * Parse nodeUrls from string array format (e.g., ["host1:6667", "host2:6668"]) * to EndPoint array format */ export function parseNodeUrls(nodeUrls: string[]): EndPoint[] { - return nodeUrls.map((url) => { - const parts = url.split(':'); - if (parts.length !== 2) { - throw new Error(`Invalid nodeUrl format: ${url}. Expected format: "host:port"`); - } - const host = parts[0].trim(); - const port = parseInt(parts[1].trim(), 10); - if (!host || isNaN(port) || port <= 0 || port > 65535) { - throw new Error(`Invalid nodeUrl format: ${url}. Host must be non-empty and port must be a valid number (1-65535)`); - } - return { host, port }; - }); + return nodeUrls.map(parseNodeUrl); } export interface Config { diff --git a/src/utils/Errors.ts b/src/utils/Errors.ts index 0d3581c..ce89307 100644 --- a/src/utils/Errors.ts +++ b/src/utils/Errors.ts @@ -19,6 +19,17 @@ import { EndPoint } from "./Config"; +/** + * A wildcard / listen-all address (IPv4 "0.0.0.0" or IPv6 "::") is not a + * connectable remote endpoint. When a server advertises one in a redirect + * recommendation, the client must ignore it and keep its current endpoint, + * consistent with apache/iotdb#18162. + */ +export function isWildcardAddress(host: string): boolean { + const h = host.trim().replace(/^\[/, "").replace(/\]$/, ""); + return h === "0.0.0.0" || h === "::" || h === "0:0:0:0:0:0:0:0"; +} + /** * Represents a redirect recommendation from the server. * Thrown when the server suggests a better endpoint for a device. diff --git a/tests/unit/Config.test.ts b/tests/unit/Config.test.ts index 1606f47..254aedd 100644 --- a/tests/unit/Config.test.ts +++ b/tests/unit/Config.test.ts @@ -182,11 +182,38 @@ describe('parseNodeUrls', () => { test('Should handle whitespace in nodeUrls', () => { const nodeUrls = [' localhost : 6667 ']; - + const parsed = parseNodeUrls(nodeUrls); - + expect(parsed[0]).toEqual({ host: 'localhost', port: 6667 }); }); + + test('Should parse bracketed IPv6 nodeUrls', () => { + const parsed = parseNodeUrls(['[::1]:6667', '[2001:db8::1]:6668']); + + expect(parsed).toHaveLength(2); + expect(parsed[0]).toEqual({ host: '::1', port: 6667 }); + expect(parsed[1]).toEqual({ host: '2001:db8::1', port: 6668 }); + }); + + test('Should throw for a bare (unbracketed) IPv6 address', () => { + expect(() => parseNodeUrls(['::1:6667'])).toThrow('Invalid nodeUrl format'); + }); + + test('Should throw for malformed bracketed IPv6', () => { + expect(() => parseNodeUrls(['[::1:6667'])).toThrow('Invalid nodeUrl format'); // unbalanced bracket + expect(() => parseNodeUrls(['[::1]6667'])).toThrow('Invalid nodeUrl format'); // missing colon before port + expect(() => parseNodeUrls(['[::1]:'])).toThrow('Invalid nodeUrl format'); // empty port + }); + + test('Should throw for a port with trailing non-digits', () => { + // parseInt accepts a numeric prefix; the whole port string must be digits so a malformed + // endpoint is rejected instead of silently parsed as the leading number. + expect(() => parseNodeUrls(['[::1]:6667junk'])).toThrow('Invalid nodeUrl format'); // trailing text + expect(() => parseNodeUrls(['[::1]:6667:9999'])).toThrow('Invalid nodeUrl format'); // extra :port + expect(() => parseNodeUrls(['localhost:6667junk'])).toThrow('Invalid nodeUrl format'); // hostname + expect(() => parseNodeUrls(['127.0.0.1:80x'])).toThrow('Invalid nodeUrl format'); // IPv4 + }); }); describe('ConfigBuilder', () => { diff --git a/tests/unit/Errors.test.ts b/tests/unit/Errors.test.ts new file mode 100644 index 0000000..bdc36fc --- /dev/null +++ b/tests/unit/Errors.test.ts @@ -0,0 +1,34 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isWildcardAddress } from "../../src/utils/Errors"; + +describe("isWildcardAddress", () => { + test("returns true for IPv4/IPv6 wildcard (listen-all) addresses", () => { + for (const h of ["0.0.0.0", "::", "0:0:0:0:0:0:0:0", "[::]", "[0.0.0.0]"]) { + expect(isWildcardAddress(h)).toBe(true); + } + }); + + test("returns false for real addresses", () => { + for (const h of ["127.0.0.1", "::1", "10.0.0.1", "example.com", "[::1]"]) { + expect(isWildcardAddress(h)).toBe(false); + } + }); +}); diff --git a/tests/unit/SessionRedirect.test.ts b/tests/unit/SessionRedirect.test.ts new file mode 100644 index 0000000..a4e8152 --- /dev/null +++ b/tests/unit/SessionRedirect.test.ts @@ -0,0 +1,55 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Session } from "../../src/client/Session"; + +function newSession(): Session { + return new Session({ + host: "localhost", + port: 6667, + username: "root", + password: "root", + }); +} + +// getAndClearLastRedirect is the single consumption point for the redirect +// endpoint the server advertises (Session.lastRedirectEndpoint), before the +// pool caches and connects to it — so the wildcard guard is exercised here. +describe("Session.getAndClearLastRedirect", () => { + test("returns a normal redirect endpoint and clears it", () => { + const session = newSession(); + (session as any).lastRedirectEndpoint = { host: "10.0.0.9", port: 6667 }; + + expect(session.getAndClearLastRedirect()).toEqual({ + host: "10.0.0.9", + port: 6667, + }); + // Cleared after reading. + expect(session.getAndClearLastRedirect()).toBeNull(); + }); + + test("ignores a redirect to a wildcard/listen-all address", () => { + for (const host of ["0.0.0.0", "::"]) { + const session = newSession(); + (session as any).lastRedirectEndpoint = { host, port: 6667 }; + + expect(session.getAndClearLastRedirect()).toBeNull(); + } + }); +});