feat: declare workspace repository in installation config
This commit is contained in:
@@ -9,6 +9,7 @@ const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface GitStatus {
|
||||
branch: string;
|
||||
repository?: WorkspaceRepositoryIdentity;
|
||||
head?: string;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
@@ -16,6 +17,12 @@ export interface GitStatus {
|
||||
lastError?: WorkspaceErrorCode;
|
||||
}
|
||||
|
||||
export interface WorkspaceRepositoryIdentity {
|
||||
host: string;
|
||||
repository: string;
|
||||
transport: "https" | "ssh" | "local";
|
||||
}
|
||||
|
||||
export interface EvidenceTreeObject {
|
||||
mode: "100644" | "100755";
|
||||
oid: string;
|
||||
@@ -29,6 +36,62 @@ export class WorkspaceRegistryError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function invalidRemote(): never {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace Git remote is invalid");
|
||||
}
|
||||
|
||||
function safeRepositoryPath(raw: string): string {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(raw).replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/, "");
|
||||
} catch {
|
||||
return invalidRemote();
|
||||
}
|
||||
if (
|
||||
decoded.length === 0
|
||||
|| decoded.includes("\\")
|
||||
|| decoded.split("/").some((part) => part === "" || part === "." || part === "..")
|
||||
|| /[\p{Cc}\s?#]/u.test(decoded)
|
||||
) return invalidRemote();
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/** Convert a configured remote to the only repository identity safe for API/UI responses. */
|
||||
export function normalizeRepositoryIdentity(remote: string): WorkspaceRepositoryIdentity {
|
||||
if (remote.length === 0 || remote.trim() !== remote || remote.includes("\0")) return invalidRemote();
|
||||
if (isAbsolute(remote) || remote.startsWith("file://")) {
|
||||
return { host: "local", repository: "configured-repository", transport: "local" };
|
||||
}
|
||||
const scp = /^git@([^:/\s]+):(.+)$/.exec(remote);
|
||||
if (scp) {
|
||||
return { host: scp[1].toLowerCase(), repository: safeRepositoryPath(scp[2]), transport: "ssh" };
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(remote);
|
||||
} catch {
|
||||
return invalidRemote();
|
||||
}
|
||||
if (parsed.search || parsed.hash || !parsed.hostname || parsed.port) return invalidRemote();
|
||||
if (parsed.protocol === "https:") {
|
||||
if (parsed.username || parsed.password) return invalidRemote();
|
||||
return {
|
||||
host: parsed.hostname.toLowerCase(),
|
||||
repository: safeRepositoryPath(parsed.pathname),
|
||||
transport: "https",
|
||||
};
|
||||
}
|
||||
if (parsed.protocol === "ssh:") {
|
||||
if (parsed.password || (parsed.username !== "" && parsed.username !== "git")) return invalidRemote();
|
||||
return {
|
||||
host: parsed.hostname.toLowerCase(),
|
||||
repository: safeRepositoryPath(parsed.pathname),
|
||||
transport: "ssh",
|
||||
};
|
||||
}
|
||||
return invalidRemote();
|
||||
}
|
||||
|
||||
function isMissing(path: string): boolean {
|
||||
try {
|
||||
lstatSync(path);
|
||||
@@ -81,6 +144,7 @@ export class GitWorkspaceRepository {
|
||||
readonly statePath: string;
|
||||
readonly locksPath: string;
|
||||
private readonly hooksPath: string;
|
||||
private readonly identity?: WorkspaceRepositoryIdentity;
|
||||
|
||||
constructor(private readonly config: WorkspaceRegistryConfig) {
|
||||
if (!isAbsolute(config.root)) {
|
||||
@@ -92,6 +156,9 @@ export class GitWorkspaceRepository {
|
||||
this.statePath = join(this.root, "state");
|
||||
this.locksPath = join(this.root, "locks");
|
||||
this.hooksPath = join(this.locksPath, "empty-hooks");
|
||||
this.identity = config.remoteUrl === undefined
|
||||
? undefined
|
||||
: normalizeRepositoryIdentity(config.remoteUrl);
|
||||
}
|
||||
|
||||
async ensureLayout(): Promise<void> {
|
||||
@@ -134,6 +201,7 @@ export class GitWorkspaceRepository {
|
||||
const [ahead = "0", behind = "0"] = tracking ? tracking.trim().split(/\s+/) : [];
|
||||
return {
|
||||
branch: this.config.branch,
|
||||
...(this.identity ? { repository: this.identity } : {}),
|
||||
head,
|
||||
ahead: Number(ahead),
|
||||
behind: Number(behind),
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
GitWorkspaceRepository,
|
||||
WorkspaceRegistryError,
|
||||
WorkspaceRepositoryLock,
|
||||
normalizeRepositoryIdentity,
|
||||
type GitStatus,
|
||||
} from "./git-repository.js";
|
||||
import {
|
||||
@@ -492,6 +493,9 @@ export class WorkspaceRegistry {
|
||||
if (!active) throw safeError;
|
||||
return {
|
||||
branch: this.config.branch,
|
||||
...(this.config.remoteUrl
|
||||
? { repository: normalizeRepositoryIdentity(this.config.remoteUrl) }
|
||||
: {}),
|
||||
head: active.head,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
|
||||
@@ -632,7 +632,11 @@ test("normalizes historical operational state during offline fallback after rest
|
||||
rmSync(remote.remote, { recursive: true, force: true });
|
||||
|
||||
const restored = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await expect(restored.pull()).resolves.toMatchObject({ degraded: true, head: remote.initialCommit });
|
||||
await expect(restored.pull()).resolves.toMatchObject({
|
||||
degraded: true,
|
||||
head: remote.initialCommit,
|
||||
repository: { host: "local", repository: "configured-repository", transport: "local" },
|
||||
});
|
||||
const listed = await restored.list();
|
||||
const read = await restored.read("psd-clinical");
|
||||
expect(listed[0]).not.toHaveProperty("state");
|
||||
|
||||
@@ -4,7 +4,11 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { GitWorkspaceRepository, WorkspaceRepositoryLock } from "../src/workspaces/git-repository.js";
|
||||
import {
|
||||
GitWorkspaceRepository,
|
||||
WorkspaceRepositoryLock,
|
||||
normalizeRepositoryIdentity,
|
||||
} from "../src/workspaces/git-repository.js";
|
||||
import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js";
|
||||
|
||||
const validYaml = `workspace:
|
||||
@@ -102,6 +106,28 @@ test("does not expose repository mutation or publication operations", async () =
|
||||
expect(repository).not.toHaveProperty("commitAndPush");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["https://github.com/aritmolab/workspaces.git", {
|
||||
host: "github.com", repository: "aritmolab/workspaces", transport: "https",
|
||||
}],
|
||||
["ssh://git@gitlab.example.org/clinical/workspaces.git", {
|
||||
host: "gitlab.example.org", repository: "clinical/workspaces", transport: "ssh",
|
||||
}],
|
||||
["git@gitea.example.org:clinical/workspaces.git", {
|
||||
host: "gitea.example.org", repository: "clinical/workspaces", transport: "ssh",
|
||||
}],
|
||||
] as const)("normalizes the safe repository identity for %s", (remote, expected) => {
|
||||
expect(normalizeRepositoryIdentity(remote)).toEqual(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
"https://user:secret@github.com/aritmolab/workspaces.git",
|
||||
"https://github.com/aritmolab/workspaces.git?token=secret",
|
||||
"ssh://git:secret@gitlab.example.org/clinical/workspaces.git",
|
||||
])("rejects a remote that could expose embedded credentials: %s", (remote) => {
|
||||
expect(() => normalizeRepositoryIdentity(remote)).toThrow("Workspace Git remote is invalid");
|
||||
});
|
||||
|
||||
test("bootstraps a persistent checkout from a local bare repository", async () => {
|
||||
const fixture = await temporaryRemote();
|
||||
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
|
||||
|
||||
Reference in New Issue
Block a user