fix(opencode): enforce storage path invariants (#29666)
This commit is contained in:
@@ -4,9 +4,15 @@ import { fileURLToPath } from "url"
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { Effect } from "effect"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { eq, inArray, sql } from "drizzle-orm"
|
||||
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
||||
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
|
||||
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
|
||||
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
|
||||
|
||||
@@ -37,7 +43,7 @@ describe("DatabaseMigration", () => {
|
||||
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
|
||||
name: "session",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 21 })
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 22 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -71,6 +77,151 @@ describe("DatabaseMigration", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, sandboxes text NOT NULL)`)
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, directory text NOT NULL, path text)`)
|
||||
// Windows-shaped rows (drive + backslash) must be normalized.
|
||||
yield* db.run(
|
||||
sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"win"}, ${"C:\\Repo\\Thing"}, ${JSON.stringify([
|
||||
"C:\\Repo\\Thing\\sandbox",
|
||||
])})`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session (id, directory, path) VALUES (${"win"}, ${"C:\\Repo\\Thing\\packages\\api"}, ${"packages\\api"})`,
|
||||
)
|
||||
// UNC worktrees and their sandboxes must normalize too (not just drive paths).
|
||||
yield* db.run(
|
||||
sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"unc"}, ${"\\\\server\\share"}, ${JSON.stringify([
|
||||
"\\\\server\\share\\sandbox",
|
||||
])})`,
|
||||
)
|
||||
// The "/" worktree sentinel and POSIX paths (including a pathological
|
||||
// backslash in a POSIX filename) must survive byte-for-byte.
|
||||
yield* db.run(sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"global"}, ${"/"}, ${"[]"})`)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session (id, directory, path) VALUES (${"posix"}, ${"/home/me/we\\ird"}, ${"src\\weird"})`,
|
||||
)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [normalizeStoragePathsMigration])
|
||||
|
||||
expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'win'`)).toEqual({
|
||||
worktree: "C:/Repo/Thing",
|
||||
sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'win'`)).toEqual({
|
||||
directory: "C:/Repo/Thing/packages/api",
|
||||
path: "packages/api",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'unc'`)).toEqual({
|
||||
worktree: "//server/share",
|
||||
sandboxes: JSON.stringify(["//server/share/sandbox"]),
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({ worktree: "/" })
|
||||
expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'posix'`)).toEqual({
|
||||
directory: "/home/me/we\\ird",
|
||||
path: "src\\weird",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("maps native Windows paths through database columns", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* DatabaseMigration.apply(db)
|
||||
const projectID = ProjectV2.ID.make("codec_project")
|
||||
const worktree = AbsolutePath.make("C:\\Repo\\Thing")
|
||||
const sandbox = AbsolutePath.make("C:\\Repo\\Thing\\sandbox")
|
||||
const directory = "C:\\Repo\\Thing\\packages\\api"
|
||||
const sessionID = SessionSchema.ID.make("ses_codec")
|
||||
|
||||
expect(() =>
|
||||
Effect.runSync(
|
||||
db
|
||||
.insert(ProjectTable)
|
||||
.values({
|
||||
id: ProjectV2.ID.make("invalid_path"),
|
||||
worktree: AbsolutePath.make("not-absolute"),
|
||||
sandboxes: [],
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
})
|
||||
.run(),
|
||||
),
|
||||
).toThrow()
|
||||
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({
|
||||
id: projectID,
|
||||
worktree,
|
||||
sandboxes: [sandbox],
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
})
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: projectID,
|
||||
slug: "codec",
|
||||
directory,
|
||||
path: "packages\\api",
|
||||
title: "Codec",
|
||||
version: "test",
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
})
|
||||
.run()
|
||||
|
||||
expect(yield* db.get<{ worktree: string; sandboxes: string }>(sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`)).toEqual({
|
||||
worktree: "C:/Repo/Thing",
|
||||
sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
|
||||
})
|
||||
expect(yield* db.get<{ directory: string; path: string }>(sql`SELECT directory, path FROM session WHERE id = ${sessionID}`)).toEqual({
|
||||
directory: "C:/Repo/Thing/packages/api",
|
||||
path: "packages/api",
|
||||
})
|
||||
|
||||
const project = yield* db.select().from(ProjectTable).where(eq(ProjectTable.worktree, worktree)).get()
|
||||
const session = yield* db.select().from(SessionTable).where(eq(SessionTable.directory, directory)).get()
|
||||
expect(project?.worktree).toBe(worktree)
|
||||
expect(project?.sandboxes).toEqual([sandbox])
|
||||
expect(session?.directory).toBe(directory)
|
||||
expect(session?.path).toBe("packages/api")
|
||||
|
||||
expect((yield* db.select().from(SessionTable).where(eq(SessionTable.path, "packages\\api")).get())?.id).toBe(
|
||||
sessionID,
|
||||
)
|
||||
|
||||
const moved = AbsolutePath.make("D:\\Moved\\Thing")
|
||||
const updated = yield* db
|
||||
.update(ProjectTable)
|
||||
.set({ worktree: moved, sandboxes: [moved] })
|
||||
.where(eq(ProjectTable.id, projectID))
|
||||
.returning()
|
||||
.get()
|
||||
expect(updated?.worktree).toBe(moved)
|
||||
expect(updated?.sandboxes).toEqual([moved])
|
||||
expect(
|
||||
yield* db.get<{ worktree: string; sandboxes: string }>(sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`),
|
||||
).toEqual({ worktree: "D:/Moved/Thing", sandboxes: JSON.stringify(["D:/Moved/Thing"]) })
|
||||
expect((yield* db.select().from(ProjectTable).where(inArray(ProjectTable.worktree, [moved])).get())?.id).toBe(
|
||||
projectID,
|
||||
)
|
||||
|
||||
yield* db.run(sql`UPDATE project SET worktree = ${"not-absolute"} WHERE id = ${projectID}`)
|
||||
expect(() => Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get())).toThrow()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("imports existing drizzle migration state", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
Reference in New Issue
Block a user