Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/client/Session.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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;
}

Expand Down
52 changes: 40 additions & 12 deletions src/utils/Config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions src/utils/Errors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
31 changes: 29 additions & 2 deletions tests/unit/Config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/Errors.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
}
});
});
55 changes: 55 additions & 0 deletions tests/unit/SessionRedirect.test.ts
Original file line numberDiff line numberDiff line change
@@ -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();
}
});
});
Loading