Skip to content

Commit a85c190

Browse files
authored
Merge pull request #257 from konard/renovate/all
fix(deps): adapt to TypeScript 6.0 and sonarjs 4.x for PR #180
2 parents d003e25 + 94c7843 commit a85c190

20 files changed

Lines changed: 551 additions & 128 deletions
35.5 KB
Loading

packages/api/src/services/terminal-image-fetch-core.ts

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { fileURLToPath } from "node:url"
2+
13
export type TerminalImageFetchPlan =
24
| {
35
readonly _tag: "InvalidTerminalImageFetch"
@@ -23,6 +25,21 @@ const controlCharRange = `${String.fromCodePoint(0)}-${String.fromCodePoint(0x1F
2325
const deleteChar = String.fromCodePoint(0x7F)
2426
const invalidCharacterPattern = new RegExp(String.raw`[\s${controlCharRange}${deleteChar}]`, "u")
2527
const traversalPattern = /(?:^|\/)(?:\.|\.\.)(?=\/|$)/u
28+
const urlSchemePattern = /^[A-Za-z][A-Za-z0-9+.-]*:/u
29+
const fileUrlPattern = /^file:\/\//iu
30+
const encodedPathSeparatorPattern = /%(?:2f|5c)/iu
31+
const fileUrlBackslashPattern = /\\/u
32+
const fileUrlTraversalPattern = /(?:^|[\\/])(?:\.|%2e)(?:(?:\.|%2e))?(?=[\\/]|$)/iu
33+
34+
type TerminalImagePathNormalization =
35+
| {
36+
readonly _tag: "InvalidTerminalImagePath"
37+
readonly message: string
38+
}
39+
| {
40+
readonly _tag: "ValidTerminalImagePath"
41+
readonly path: string
42+
}
2643

2744
const lowercaseExtension = (path: string): string | null => {
2845
const lastDot = path.lastIndexOf(".")
@@ -32,26 +49,85 @@ const lowercaseExtension = (path: string): string | null => {
3249
return path.slice(lastDot + 1).toLowerCase()
3350
}
3451

52+
const rawFileUrlPathname = (path: string): string => {
53+
const withoutScheme = path.slice("file://".length)
54+
const pathStart = withoutScheme.indexOf("/")
55+
if (pathStart < 0) {
56+
return ""
57+
}
58+
const pathAndSuffix = withoutScheme.slice(pathStart)
59+
const queryStart = pathAndSuffix.indexOf("?")
60+
const hashStart = pathAndSuffix.indexOf("#")
61+
if (queryStart < 0 && hashStart < 0) {
62+
return pathAndSuffix
63+
}
64+
if (queryStart < 0) {
65+
return pathAndSuffix.slice(0, hashStart)
66+
}
67+
if (hashStart < 0) {
68+
return pathAndSuffix.slice(0, queryStart)
69+
}
70+
return pathAndSuffix.slice(0, Math.min(queryStart, hashStart))
71+
}
72+
73+
const normalizeTerminalImagePath = (path: string): TerminalImagePathNormalization => {
74+
if (!urlSchemePattern.test(path)) {
75+
return { _tag: "ValidTerminalImagePath", path }
76+
}
77+
if (!fileUrlPattern.test(path)) {
78+
return { _tag: "InvalidTerminalImagePath", message: "Only file:// image URLs are supported." }
79+
}
80+
81+
const rawPathname = rawFileUrlPathname(path)
82+
if (fileUrlTraversalPattern.test(rawPathname)) {
83+
return { _tag: "InvalidTerminalImagePath", message: "Image path must not contain '.' or '..' segments." }
84+
}
85+
if (encodedPathSeparatorPattern.test(rawPathname) || fileUrlBackslashPattern.test(rawPathname)) {
86+
return {
87+
_tag: "InvalidTerminalImagePath",
88+
message: "Image file URL must not contain encoded or backslash path separators."
89+
}
90+
}
91+
92+
try {
93+
const url = new URL(path)
94+
if (url.protocol !== "file:" || (url.hostname !== "" && url.hostname !== "localhost")) {
95+
return { _tag: "InvalidTerminalImagePath", message: "Image file URL must point to a local path." }
96+
}
97+
if (url.search.length > 0 || url.hash.length > 0) {
98+
return { _tag: "InvalidTerminalImagePath", message: "Image file URL must not include query or fragment." }
99+
}
100+
return { _tag: "ValidTerminalImagePath", path: fileURLToPath(url, { windows: false }) }
101+
} catch {
102+
return { _tag: "InvalidTerminalImagePath", message: "Image file URL is invalid." }
103+
}
104+
}
105+
35106
export const planTerminalImageFetch = (path: string): TerminalImageFetchPlan => {
36107
if (typeof path !== "string" || path.length === 0) {
37108
return { _tag: "InvalidTerminalImageFetch", message: "Image path is required." }
38109
}
39-
if (!path.startsWith("/")) {
110+
const normalized = normalizeTerminalImagePath(path)
111+
if (normalized._tag === "InvalidTerminalImagePath") {
112+
return { _tag: "InvalidTerminalImageFetch", message: normalized.message }
113+
}
114+
const containerPath = normalized.path
115+
if (!containerPath.startsWith("/")) {
40116
return { _tag: "InvalidTerminalImageFetch", message: "Image path must be absolute." }
41117
}
42-
if (invalidCharacterPattern.test(path)) {
118+
if (invalidCharacterPattern.test(containerPath)) {
43119
return { _tag: "InvalidTerminalImageFetch", message: "Image path contains invalid characters." }
44120
}
45-
if (traversalPattern.test(path)) {
121+
if (traversalPattern.test(containerPath)) {
46122
return { _tag: "InvalidTerminalImageFetch", message: "Image path must not contain '.' or '..' segments." }
47123
}
48-
const extension = lowercaseExtension(path)
124+
const extension = lowercaseExtension(containerPath)
49125
if (extension === null) {
50126
return { _tag: "InvalidTerminalImageFetch", message: "Image path must include a file extension." }
51127
}
52128
const mediaType = supportedExtensionMediaTypes.get(extension)
53129
if (mediaType === undefined) {
54130
return { _tag: "InvalidTerminalImageFetch", message: `Unsupported image extension: .${extension}` }
55131
}
56-
return { _tag: "ValidTerminalImageFetch", containerPath: path, mediaType }
132+
return { _tag: "ValidTerminalImageFetch", containerPath, mediaType }
57133
}

packages/api/tests/terminal-image-fetch-core.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,22 @@ import { describe, expect, it } from "@effect/vitest"
33
import { planTerminalImageFetch } from "../src/services/terminal-image-fetch-core.js"
44

55
describe("terminal image fetch core", () => {
6-
it("accepts an absolute path with a supported image extension", () => {
6+
it("continues to accept an absolute path with a supported image extension", () => {
77
expect(planTerminalImageFetch("/tmp/issue232-main.png")).toEqual({
88
_tag: "ValidTerminalImageFetch",
99
containerPath: "/tmp/issue232-main.png",
1010
mediaType: "image/png"
1111
})
1212
})
1313

14+
it("accepts a file URL and normalizes it to an absolute container path", () => {
15+
expect(planTerminalImageFetch("file:///tmp/phantom-e2e.tuhl98/wallet-step-after-password.png")).toEqual({
16+
_tag: "ValidTerminalImageFetch",
17+
containerPath: "/tmp/phantom-e2e.tuhl98/wallet-step-after-password.png",
18+
mediaType: "image/png"
19+
})
20+
})
21+
1422
it("maps each supported extension to its media type", () => {
1523
expect(planTerminalImageFetch("/a.jpg")).toMatchObject({ mediaType: "image/jpeg" })
1624
expect(planTerminalImageFetch("/a.jpeg")).toMatchObject({ mediaType: "image/jpeg" })
@@ -33,6 +41,13 @@ describe("terminal image fetch core", () => {
3341
})
3442
})
3543

44+
it("rejects non-file URLs", () => {
45+
expect(planTerminalImageFetch("https://example.com/tmp/photo.png")).toEqual({
46+
_tag: "InvalidTerminalImageFetch",
47+
message: "Only file:// image URLs are supported."
48+
})
49+
})
50+
3651
it("rejects whitespace and control characters", () => {
3752
expect(planTerminalImageFetch("/tmp/has space.png")).toMatchObject({
3853
_tag: "InvalidTerminalImageFetch"
@@ -51,6 +66,32 @@ describe("terminal image fetch core", () => {
5166
})
5267
})
5368

69+
it("rejects traversal segments in file URLs before URL normalization", () => {
70+
expect(planTerminalImageFetch("file:///tmp/../etc/photo.png")).toMatchObject({
71+
_tag: "InvalidTerminalImageFetch",
72+
message: "Image path must not contain '.' or '..' segments."
73+
})
74+
expect(planTerminalImageFetch("file:///tmp/%2E%2E/etc/photo.png")).toMatchObject({
75+
_tag: "InvalidTerminalImageFetch",
76+
message: "Image path must not contain '.' or '..' segments."
77+
})
78+
})
79+
80+
it("rejects unsafe file URL forms", () => {
81+
expect(planTerminalImageFetch("file://example.com/tmp/photo.png")).toMatchObject({
82+
_tag: "InvalidTerminalImageFetch",
83+
message: "Image file URL must point to a local path."
84+
})
85+
expect(planTerminalImageFetch("file:///tmp/photo.png?download=1")).toMatchObject({
86+
_tag: "InvalidTerminalImageFetch",
87+
message: "Image file URL must not include query or fragment."
88+
})
89+
expect(planTerminalImageFetch("file:///tmp/%2Fetc/photo.png")).toMatchObject({
90+
_tag: "InvalidTerminalImageFetch",
91+
message: "Image file URL must not contain encoded or backslash path separators."
92+
})
93+
})
94+
5495
it("rejects unsupported extensions", () => {
5596
expect(planTerminalImageFetch("/tmp/file.bmp")).toMatchObject({
5697
_tag: "InvalidTerminalImageFetch"

packages/app/src/lib/core/templates-zsh.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,9 @@ docker_git_prompt_apply() {
8484
docker_git_terminal_sanitize
8585
local b
8686
b="$(docker_git_branch)"
87-
local short_pwd
88-
short_pwd="$(docker_git_short_pwd)"
89-
local base="[%*] $short_pwd"
87+
local short_path
88+
short_path="$(docker_git_short_pwd)"
89+
local base="[%*] $short_path"
9090
if [[ -n "$b" ]]; then
9191
PROMPT="$base ($b)> "
9292
else
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { ApiEvent } from "./api.js"
2+
3+
export const readEventPayloadString = (
4+
event: ApiEvent,
5+
key: string
6+
): string | null => {
7+
const payload = event.payload
8+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
9+
return null
10+
}
11+
const value = Object.entries(payload).find(([name]) => name === key)?.[1]
12+
return typeof value === "string" ? value : null
13+
}

packages/app/src/web/actions-project-create.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,14 @@ import { Either } from "effect"
33

44
import { createProjectDraftFromInputs } from "../docker-git/menu-create-shared.js"
55
import type { CreateInputs } from "../docker-git/menu-types.js"
6+
import { readEventPayloadString } from "./actions-event-payload.js"
67
import { appendOutputLine, appendOutputLineHandler, notifyProjectEventRateLimit } from "./actions-output.js"
78
import { type BrowserActionContext, withBusy } from "./actions-shared.js"
89
import { ProjectDetailsSchema } from "./api-schema.js"
910
import { type ApiEvent, loadProjectDetails, type ProjectDetails, startCreateProject } from "./api.js"
1011
import { openProjectEventStream } from "./project-events.js"
1112
import { outputScreen, projectPickerScreen } from "./screen.js"
1213

13-
const readEventPayloadString = (
14-
event: ApiEvent,
15-
key: string
16-
): string | null => {
17-
const payload = event.payload
18-
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
19-
return null
20-
}
21-
const value = Object.entries(payload).find(([name]) => name === key)?.[1]
22-
return typeof value === "string" ? value : null
23-
}
24-
2514
const readCreatedProjectId = (event: ApiEvent): string | null =>
2615
event.type === "project.created" ? readEventPayloadString(event, "projectId") : null
2716

0 commit comments

Comments
 (0)