Skip to content

Commit f715693

Browse files
author
Raul Melo
authored
Add escapeBreakLine utility and tests for special character handling (#165)
1 parent 78e3aa8 commit f715693

File tree

5 files changed

+132
-6
lines changed

5 files changed

+132
-6
lines changed

.changeset/tasty-baboons-run.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@layerfig/config": patch
3+
---
4+
5+
Fix: escape line breaks and special characters in slot values
6+
7+
- Safely escape \, ", \n, \r, and \t before insertion to avoid misparse/misrender issues.

packages/config/src/config-builder.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,31 @@ describe.each(builders)(
199199
});
200200

201201
describe("slots", () => {
202+
it("should handle multiline values", () => {
203+
const pemKeyMock = `-----BEGIN EC PRIVATE KEY-----
204+
MHcCAQEEINofexMSQApYFYPK1/oISaOG4BgHm9SEkiHRUQOcmloToAoGCCqGSM49
205+
AwEHoUQDQgAEoRFl5IWEgK9PTCvI8lzT1kBdvFvVw/EZzKT8XHQczrBnVSc+S8qw
206+
tQrWvRJknz7jP0GHpvUm2GXHx6aOcbdBag==
207+
-----END EC PRIVATE KEY-----`;
208+
209+
const config = new ConfigBuilder({
210+
validate: (finalConfig) => finalConfig,
211+
runtimeEnv: {
212+
PEM_KEY: pemKeyMock,
213+
},
214+
})
215+
.addSource(
216+
new ObjectSource({
217+
multiline: "${PEM_KEY}",
218+
}),
219+
)
220+
.build();
221+
222+
expect(config).toEqual({
223+
multiline: pemKeyMock,
224+
});
225+
});
226+
202227
it("should replace single slots", () => {
203228
const schema = z.object({
204229
port: z.coerce.number().int().positive(),

packages/config/src/sources/source.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
UnknownArray,
88
UnknownRecord,
99
} from "../types";
10+
import { escapeBreakLine } from "../utils/escape-break-line";
1011
import { extractSlotsFromExpression, hasSlot, type Slot } from "../utils/slot";
1112

1213
const UNDEFINED_MARKER = "___UNDEFINED_MARKER___" as const;
@@ -34,7 +35,11 @@ export abstract class Source<T = Record<string, unknown>> {
3435
options.slotPrefix,
3536
);
3637

37-
let updatedContentString = options.contentString;
38+
/**
39+
* At this moment it does not matter what parser the user had defined,
40+
* we're in the JS/JSON land.
41+
*/
42+
let updatedContentString = JSON.stringify(initialObject);
3843

3944
for (const slot of slots) {
4045
let envVarValue: RuntimeEnvValue;
@@ -45,7 +50,7 @@ export abstract class Source<T = Record<string, unknown>> {
4550
}
4651

4752
if (reference.type === "self_reference") {
48-
const partialObj = options.transform(updatedContentString);
53+
const partialObj = JSON.parse(updatedContentString);
4954

5055
envVarValue = get(
5156
partialObj,
@@ -63,16 +68,19 @@ export abstract class Source<T = Record<string, unknown>> {
6368
envVarValue = slot.fallbackValue;
6469
}
6570

66-
updatedContentString = updatedContentString.replaceAll(
67-
slot.slotMatch,
71+
const valueToInsert =
6872
envVarValue !== null && envVarValue !== undefined
6973
? String(envVarValue)
70-
: UNDEFINED_MARKER,
74+
: UNDEFINED_MARKER;
75+
76+
updatedContentString = updatedContentString.replaceAll(
77+
slot.slotMatch,
78+
escapeBreakLine(valueToInsert),
7179
);
7280
}
7381

7482
const partialConfig = this.#cleanUndefinedMarkers(
75-
options.transform(updatedContentString),
83+
JSON.parse(updatedContentString),
7684
);
7785

7886
return partialConfig;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, expect, it } from "vitest";
2+
import { escapeBreakLine } from "./escape-break-line";
3+
4+
describe("escapeBreakLine", () => {
5+
it("should escape newlines", () => {
6+
const input = "line1\nline2\nline3";
7+
const expected = "line1\\nline2\\nline3";
8+
expect(escapeBreakLine(input)).toBe(expected);
9+
});
10+
11+
it("should escape double quotes", () => {
12+
const input = 'He said "Hello World"';
13+
const expected = 'He said \\"Hello World\\"';
14+
expect(escapeBreakLine(input)).toBe(expected);
15+
});
16+
17+
it("should escape backslashes", () => {
18+
const input = "path\\to\\file";
19+
const expected = "path\\\\to\\\\file";
20+
expect(escapeBreakLine(input)).toBe(expected);
21+
});
22+
23+
it("should escape carriage returns", () => {
24+
const input = "line1\rline2";
25+
const expected = "line1\\rline2";
26+
expect(escapeBreakLine(input)).toBe(expected);
27+
});
28+
29+
it("should escape tabs", () => {
30+
const input = "col1\tcol2\tcol3";
31+
const expected = "col1\\tcol2\\tcol3";
32+
expect(escapeBreakLine(input)).toBe(expected);
33+
});
34+
35+
it("should handle empty string", () => {
36+
expect(escapeBreakLine("")).toBe("");
37+
});
38+
39+
it("should handle string with no special characters", () => {
40+
const input = "simple string";
41+
expect(escapeBreakLine(input)).toBe(input);
42+
});
43+
44+
it("should escape multiple special characters in correct order", () => {
45+
const input = 'test\\with"quotes\nand\ttabs';
46+
const expected = 'test\\\\with\\"quotes\\nand\\ttabs';
47+
expect(escapeBreakLine(input)).toBe(expected);
48+
});
49+
50+
it("should handle PEM key format", () => {
51+
const pemKey = `-----BEGIN EC PRIVATE KEY-----
52+
MHcCAQEEINofexMSQApYFYPK1/oISaOG4BgHm9SEkiHRUQOcmloToAoGCCqGSM49
53+
AwEHoUQDQgAEoRFl5IWEgK9PTCvI8lzT1kBdvFvVw/EZzKT8XHQczrBnVSc+S8qw
54+
tQrWvRJknz7jP0GHpvUm2GXHx6aOcbdBag==
55+
-----END EC PRIVATE KEY-----`;
56+
57+
const escaped = escapeBreakLine(pemKey);
58+
59+
// Should not contain actual newlines
60+
expect(escaped).not.toContain("\n");
61+
// Should contain escaped newlines
62+
expect(escaped).toContain("\\n");
63+
// Should start and end correctly
64+
expect(escaped).toMatch(/^-----BEGIN EC PRIVATE KEY-----\\n/);
65+
expect(escaped).toMatch(/\\n-----END EC PRIVATE KEY-----$/);
66+
});
67+
68+
it("should handle complex strings with all escape characters", () => {
69+
const input = 'backslash: \\ quote: " newline: \n tab: \t carriage: \r';
70+
const expected =
71+
'backslash: \\\\ quote: \\" newline: \\n tab: \\t carriage: \\r';
72+
expect(escapeBreakLine(input)).toBe(expected);
73+
});
74+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export function escapeBreakLine<T = unknown>(value: T): T {
2+
if (typeof value !== "string") {
3+
return value;
4+
}
5+
6+
return value
7+
.replace(/\\/g, "\\\\")
8+
.replace(/"/g, '\\"')
9+
.replace(/\n/g, "\\n")
10+
.replace(/\r/g, "\\r")
11+
.replace(/\t/g, "\\t") as T;
12+
}

0 commit comments

Comments
 (0)