From e428c003c9bce7ea29ec132f9dbd5e2b2f4624f8 Mon Sep 17 00:00:00 2001 From: Raul Melo Date: Thu, 28 Aug 2025 07:09:23 +0200 Subject: [PATCH 1/2] Add escapeBreakLine utility and tests for special character handling --- .changeset/tasty-baboons-run.md | 7 ++ packages/config/src/config-builder.test.ts | 25 +++++++ packages/config/src/sources/source.ts | 14 ++-- .../src/utils/escape-break-line.test.ts | 74 +++++++++++++++++++ .../config/src/utils/escape-break-line.ts | 12 +++ 5 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 .changeset/tasty-baboons-run.md create mode 100644 packages/config/src/utils/escape-break-line.test.ts create mode 100644 packages/config/src/utils/escape-break-line.ts diff --git a/.changeset/tasty-baboons-run.md b/.changeset/tasty-baboons-run.md new file mode 100644 index 0000000..f94c17c --- /dev/null +++ b/.changeset/tasty-baboons-run.md @@ -0,0 +1,7 @@ +--- +"@layerfig/config": patch +--- + +Fix: escape line breaks and special characters in slot values + +- Safely escape \, ", \n, \r, and \t before insertion to avoid misparse/misrender issues. \ No newline at end of file diff --git a/packages/config/src/config-builder.test.ts b/packages/config/src/config-builder.test.ts index 8552e87..be31cb2 100644 --- a/packages/config/src/config-builder.test.ts +++ b/packages/config/src/config-builder.test.ts @@ -199,6 +199,31 @@ describe.each(builders)( }); describe("slots", () => { + it("should handle multiline values", () => { + const pemKeyMock = `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEINofexMSQApYFYPK1/oISaOG4BgHm9SEkiHRUQOcmloToAoGCCqGSM49 +AwEHoUQDQgAEoRFl5IWEgK9PTCvI8lzT1kBdvFvVw/EZzKT8XHQczrBnVSc+S8qw +tQrWvRJknz7jP0GHpvUm2GXHx6aOcbdBag== +-----END EC PRIVATE KEY-----`; + + const config = new ConfigBuilder({ + validate: (finalConfig) => finalConfig, + runtimeEnv: { + PEM_KEY: pemKeyMock, + }, + }) + .addSource( + new ObjectSource({ + multiline: "${PEM_KEY}", + }), + ) + .build(); + + expect(config).toEqual({ + multiline: pemKeyMock, + }); + }); + it("should replace single slots", () => { const schema = z.object({ port: z.coerce.number().int().positive(), diff --git a/packages/config/src/sources/source.ts b/packages/config/src/sources/source.ts index 7dcb7de..3b63a2c 100644 --- a/packages/config/src/sources/source.ts +++ b/packages/config/src/sources/source.ts @@ -7,6 +7,7 @@ import type { UnknownArray, UnknownRecord, } from "../types"; +import { escapeBreakLine } from "../utils/escape-break-line"; import { extractSlotsFromExpression, hasSlot, type Slot } from "../utils/slot"; const UNDEFINED_MARKER = "___UNDEFINED_MARKER___" as const; @@ -45,7 +46,7 @@ export abstract class Source> { } if (reference.type === "self_reference") { - const partialObj = options.transform(updatedContentString); + const partialObj = JSON.parse(updatedContentString); envVarValue = get( partialObj, @@ -63,16 +64,19 @@ export abstract class Source> { envVarValue = slot.fallbackValue; } - updatedContentString = updatedContentString.replaceAll( - slot.slotMatch, + const valueToInsert = envVarValue !== null && envVarValue !== undefined ? String(envVarValue) - : UNDEFINED_MARKER, + : UNDEFINED_MARKER; + + updatedContentString = updatedContentString.replaceAll( + slot.slotMatch, + escapeBreakLine(valueToInsert), ); } const partialConfig = this.#cleanUndefinedMarkers( - options.transform(updatedContentString), + JSON.parse(updatedContentString), ); return partialConfig; diff --git a/packages/config/src/utils/escape-break-line.test.ts b/packages/config/src/utils/escape-break-line.test.ts new file mode 100644 index 0000000..7485d54 --- /dev/null +++ b/packages/config/src/utils/escape-break-line.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { escapeBreakLine } from "./escape-break-line"; + +describe("escapeBreakLine", () => { + it("should escape newlines", () => { + const input = "line1\nline2\nline3"; + const expected = "line1\\nline2\\nline3"; + expect(escapeBreakLine(input)).toBe(expected); + }); + + it("should escape double quotes", () => { + const input = 'He said "Hello World"'; + const expected = 'He said \\"Hello World\\"'; + expect(escapeBreakLine(input)).toBe(expected); + }); + + it("should escape backslashes", () => { + const input = "path\\to\\file"; + const expected = "path\\\\to\\\\file"; + expect(escapeBreakLine(input)).toBe(expected); + }); + + it("should escape carriage returns", () => { + const input = "line1\rline2"; + const expected = "line1\\rline2"; + expect(escapeBreakLine(input)).toBe(expected); + }); + + it("should escape tabs", () => { + const input = "col1\tcol2\tcol3"; + const expected = "col1\\tcol2\\tcol3"; + expect(escapeBreakLine(input)).toBe(expected); + }); + + it("should handle empty string", () => { + expect(escapeBreakLine("")).toBe(""); + }); + + it("should handle string with no special characters", () => { + const input = "simple string"; + expect(escapeBreakLine(input)).toBe(input); + }); + + it("should escape multiple special characters in correct order", () => { + const input = 'test\\with"quotes\nand\ttabs'; + const expected = 'test\\\\with\\"quotes\\nand\\ttabs'; + expect(escapeBreakLine(input)).toBe(expected); + }); + + it("should handle PEM key format", () => { + const pemKey = `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEINofexMSQApYFYPK1/oISaOG4BgHm9SEkiHRUQOcmloToAoGCCqGSM49 +AwEHoUQDQgAEoRFl5IWEgK9PTCvI8lzT1kBdvFvVw/EZzKT8XHQczrBnVSc+S8qw +tQrWvRJknz7jP0GHpvUm2GXHx6aOcbdBag== +-----END EC PRIVATE KEY-----`; + + const escaped = escapeBreakLine(pemKey); + + // Should not contain actual newlines + expect(escaped).not.toContain("\n"); + // Should contain escaped newlines + expect(escaped).toContain("\\n"); + // Should start and end correctly + expect(escaped).toMatch(/^-----BEGIN EC PRIVATE KEY-----\\n/); + expect(escaped).toMatch(/\\n-----END EC PRIVATE KEY-----$/); + }); + + it("should handle complex strings with all escape characters", () => { + const input = 'backslash: \\ quote: " newline: \n tab: \t carriage: \r'; + const expected = + 'backslash: \\\\ quote: \\" newline: \\n tab: \\t carriage: \\r'; + expect(escapeBreakLine(input)).toBe(expected); + }); +}); diff --git a/packages/config/src/utils/escape-break-line.ts b/packages/config/src/utils/escape-break-line.ts new file mode 100644 index 0000000..3090438 --- /dev/null +++ b/packages/config/src/utils/escape-break-line.ts @@ -0,0 +1,12 @@ +export function escapeBreakLine(value: T): T { + if (typeof value !== "string") { + return value; + } + + return value + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t") as T; +} From 57a88c3812dd28e3d226589adf313eff15393dfe Mon Sep 17 00:00:00 2001 From: Raul Melo Date: Thu, 28 Aug 2025 07:13:46 +0200 Subject: [PATCH 2/2] fix updatedContentString --- packages/config/src/sources/source.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/config/src/sources/source.ts b/packages/config/src/sources/source.ts index 3b63a2c..ff9fad3 100644 --- a/packages/config/src/sources/source.ts +++ b/packages/config/src/sources/source.ts @@ -35,7 +35,11 @@ export abstract class Source> { options.slotPrefix, ); - let updatedContentString = options.contentString; + /** + * At this moment it does not matter what parser the user had defined, + * we're in the JS/JSON land. + */ + let updatedContentString = JSON.stringify(initialObject); for (const slot of slots) { let envVarValue: RuntimeEnvValue;