Skip to content

Commit a6c56eb

Browse files
committed
fix: ensure connection encodes utf8 correctly
Signed-off-by: Gordon Smith <GordonJSmith@gmail.com>
1 parent 5e5fc8d commit a6c56eb

File tree

9 files changed

+111
-12
lines changed

9 files changed

+111
-12
lines changed

packages/comms/.vscode/launch.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"name": "index.html",
3434
"request": "launch",
3535
"type": "msedge",
36-
"url": "http://localhost:5521/index.html",
36+
"url": "http://localhost:5173/index.html",
3737
"runtimeArgs": [
3838
"--disable-web-security"
3939
],

packages/comms/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,19 @@ <h1>ESM Quick Test</h1>
3535

3636
Workunit.submit({ baseUrl: "http://localhost:8010" }, "hthor", "'Hello and Welcome!';")
3737
.then((wu) => {
38+
console.log("Submitted WU: " + wu.Wuid);
3839
return wu.watchUntilComplete();
3940
}).then((wu) => {
41+
console.log("WU: " + wu.Wuid, wu.StateID, wu.State, wu.isComplete());
4042
return wu.fetchResults().then((results) => {
43+
console.log("Results: " + results.length);
4144
return results[0].fetchRows();
4245
}).then((rows) => {
46+
console.log("Rows: " + rows.length);
4347
return wu;
4448
});
4549
}).then((wu) => {
50+
console.log("Deleting WU: " + wu.Wuid);
4651
return wu.delete().then(() => wu);
4752
}).then(wu => {
4853
console.log("Deleted WU: " + wu.Wuid, wu.isDeleted());

packages/comms/src/connection.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { join, promiseTimeout, scopedLogger } from "@hpcc-js/util";
1+
import { join, promiseTimeout, scopedLogger, utf8ToBase64 } from "@hpcc-js/util";
22

33
const logger = scopedLogger("comms/connection.ts");
44

@@ -137,7 +137,7 @@ export function jsonp(opts: IOptions, action: string, request: any = {}, respons
137137
}
138138

139139
function authHeader(opts: IOptions): object {
140-
return opts.userID ? { Authorization: `Basic ${btoa(`${opts.userID}:${opts.password}`)}` } : {};
140+
return opts.userID ? { Authorization: `Basic ${utf8ToBase64(`${opts.userID}:${opts.password}`)}` } : {};
141141
}
142142

143143
// _omitMap is a workaround for older HPCC-Platform instances without credentials ---

packages/comms/src/index.common.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
export * from "./__package__.ts";
2-
32
export * from "./services/fileSpray.ts";
43
export * from "./services/wsAccess.ts";
54
export * from "./services/wsAccount.ts";

packages/comms/src/index.node.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ root.DOMParser = DOMParser;
77
import fetch, { Headers, Request, Response, } from "node-fetch";
88

99
import * as https from "node:https";
10+
import { Buffer } from "node:buffer";
1011
import { Agent, setGlobalDispatcher } from "undici";
1112

1213
if (typeof root.fetch === "undefined") {
@@ -57,14 +58,6 @@ root.fetch.__trustwaveAgent = new https.Agent({
5758
ca: globalCA + trustwave
5859
});
5960

60-
// btoa polyfill ---
61-
import { Buffer } from "node:buffer";
62-
if (typeof root.btoa === "undefined") {
63-
root.btoa = function (str: string) {
64-
return Buffer.from(str || "", "utf8").toString("base64");
65-
};
66-
}
67-
6861
export * from "./index.common.ts";
6962

7063
// Client Tools ---

packages/util/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ export * from "./stack.ts";
1919
export * from "./stateful.ts";
2020
export * from "./string.ts";
2121
export * from "./url.ts";
22+
export * from "./utf8ToBase64.ts";

packages/util/src/utf8ToBase64.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2+
3+
function toUTF8Bytes(value: string): Uint8Array {
4+
if (typeof TextEncoder !== "undefined") {
5+
return new TextEncoder().encode(value);
6+
}
7+
8+
const encoded = encodeURIComponent(value);
9+
const bytes: number[] = [];
10+
for (let i = 0; i < encoded.length; ++i) {
11+
if (encoded[i] === "%") {
12+
bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16));
13+
i += 2;
14+
} else {
15+
bytes.push(encoded.charCodeAt(i));
16+
}
17+
}
18+
return Uint8Array.from(bytes);
19+
}
20+
21+
function bytesToBase64(bytes: Uint8Array): string {
22+
let output = "";
23+
for (let i = 0; i < bytes.length; i += 3) {
24+
const byte1 = bytes[i];
25+
const hasByte2 = i + 1 < bytes.length;
26+
const hasByte3 = i + 2 < bytes.length;
27+
const byte2 = hasByte2 ? bytes[i + 1] : 0;
28+
const byte3 = hasByte3 ? bytes[i + 2] : 0;
29+
30+
output += BASE64_ALPHABET[byte1 >> 2];
31+
output += BASE64_ALPHABET[((byte1 & 0x03) << 4) | (byte2 >> 4)];
32+
output += hasByte2 ? BASE64_ALPHABET[((byte2 & 0x0f) << 2) | (byte3 >> 6)] : "=";
33+
output += hasByte3 ? BASE64_ALPHABET[byte3 & 0x3f] : "=";
34+
}
35+
return output;
36+
}
37+
38+
export function utf8ToBase64(value: string = ""): string {
39+
const normalized = value == null ? "" : String(value);
40+
41+
const maybeBuffer = (globalThis as any)?.Buffer;
42+
if (maybeBuffer?.from) {
43+
return maybeBuffer.from(normalized, "utf8").toString("base64");
44+
}
45+
46+
return bytesToBase64(toUTF8Bytes(normalized));
47+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, it, expect } from "vitest";
2+
import { utf8ToBase64 } from "@hpcc-js/util";
3+
4+
describe("utf8ToBase64", () => {
5+
it("encodes multi-byte characters", () => {
6+
const sample = "Привет, 世界 👋";
7+
const expected = Buffer.from(sample, "utf8").toString("base64");
8+
expect(utf8ToBase64(sample)).toEqual(expected);
9+
});
10+
11+
it("falls back when Buffer is unavailable", () => {
12+
const sample = "mañana ☀️";
13+
const expected = Buffer.from(sample, "utf8").toString("base64");
14+
const original = (globalThis as any).Buffer;
15+
(globalThis as any).Buffer = undefined;
16+
try {
17+
expect(utf8ToBase64(sample)).toEqual(expected);
18+
} finally {
19+
(globalThis as any).Buffer = original;
20+
}
21+
});
22+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, it, expect } from "vitest";
2+
import { utf8ToBase64 } from "@hpcc-js/util";
3+
4+
describe("utf8ToBase64", () => {
5+
it("encodes ASCII input", () => {
6+
expect(utf8ToBase64("hello world"))
7+
.toEqual("aGVsbG8gd29ybGQ=");
8+
});
9+
10+
it("treats nullish values as empty string", () => {
11+
expect(utf8ToBase64(undefined as unknown as string)).toEqual("");
12+
expect(utf8ToBase64(null as unknown as string)).toEqual("");
13+
});
14+
15+
it("check frequest characters", () => {
16+
expect(utf8ToBase64("hello world")).toEqual(btoa("hello world"));
17+
expect(utf8ToBase64("g@s*!")).toEqual(btoa("g@s*!"));
18+
expect(utf8ToBase64("~@:!$%^&*()_-;'#***")).toEqual(btoa("~@:!$%^&*()_-;'#***"));
19+
expect(utf8ToBase64("!$%^&*()_-;'#:@~")).toEqual(btoa("!$%^&*()_-;'#:@~"));
20+
// expect(utf8ToBase64("¬!£$%^&*()_-;'#:@~")).toEqual(btoa("¬!£$%^&*()_-;'#:@~"));
21+
});
22+
23+
describe("incremental ASCII coverage", () => {
24+
const printableAscii = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)).join("");
25+
const checkpoints = [1, 5, 10, 20, 32, 48, 64, 80, 95];
26+
const cases = checkpoints.map((len) => [len, printableAscii.slice(0, len)] as const);
27+
28+
it.each(cases)("encodes first %i printable ASCII chars", (_length, sample) => {
29+
expect(utf8ToBase64(sample)).toEqual(btoa(sample));
30+
});
31+
});
32+
});

0 commit comments

Comments
 (0)