feat: add safe manual workspace uploads

This commit is contained in:
Federico Jaramillo Martinez
2026-06-25 15:16:44 +02:00
parent f0b779b062
commit e46d9ecbf8
34 changed files with 2314 additions and 131 deletions
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadEffectiveProjectPathAccess, loadProjectPiWebConfig, mergePathAccessConfigs, PROJECT_PI_WEB_CONFIG_PATH } from "./projectPiWebConfig.js";
import { loadEffectiveProjectPathAccess, loadEffectiveProjectUploadsConfig, loadProjectPiWebConfig, mergePathAccessConfigs, PROJECT_PI_WEB_CONFIG_PATH } from "./projectPiWebConfig.js";
let tempDir: string;
let projectPath: string;
@@ -26,13 +26,13 @@ describe("project PI WEB config", () => {
});
});
it("loads project-local path access config", async () => {
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } });
it("loads project-local path access and upload config", async () => {
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual\\incoming" } });
await expect(loadProjectPiWebConfig(projectPath)).resolves.toEqual({
path: join(projectPath, PROJECT_PI_WEB_CONFIG_PATH),
exists: true,
config: { version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } },
config: { version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual/incoming" } },
});
});
@@ -48,6 +48,12 @@ describe("project PI WEB config", () => {
await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings");
});
it("reuses PI WEB upload schema validation", async () => {
await writeProjectConfig({ version: 1, uploads: { defaultFolder: "../outside" } });
await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB config uploads.defaultFolder must not contain path traversal");
});
it("merges global and project path access in order", async () => {
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/project-sdk", "/shared"] } });
@@ -55,6 +61,14 @@ describe("project PI WEB config", () => {
allowedPaths: ["/global-sdk", "/shared", "/project-sdk"],
});
});
it("lets project upload defaults override global upload defaults", async () => {
await writeProjectConfig({ version: 1, uploads: { defaultFolder: "project-uploads" } });
await expect(loadEffectiveProjectUploadsConfig(projectPath, { uploads: { defaultFolder: "global-uploads" } })).resolves.toEqual({
defaultFolder: "project-uploads",
});
});
});
describe("mergePathAccessConfigs", () => {
+9 -2
View File
@@ -1,13 +1,14 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { parsePathAccessConfig, type PiWebConfig } from "../../config.js";
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
import { effectiveUploadsConfig, parsePathAccessConfig, parseUploadsConfig, type PiWebConfig } from "../../config.js";
import type { PiWebPathAccessConfig, PiWebUploadsConfig } from "../../shared/apiTypes.js";
export const PROJECT_PI_WEB_CONFIG_PATH = ".pi-web/config.json";
export interface ProjectPiWebConfig {
version?: 1;
pathAccess?: PiWebPathAccessConfig;
uploads?: PiWebUploadsConfig;
}
export interface LoadedProjectPiWebConfig {
@@ -33,6 +34,11 @@ export async function loadEffectiveProjectPathAccess(projectPath: string, global
return mergePathAccessConfigs(globalConfig.pathAccess, projectConfig.config.pathAccess);
}
export async function loadEffectiveProjectUploadsConfig(projectPath: string, globalConfig: PiWebConfig): Promise<PiWebUploadsConfig> {
const projectConfig = await loadProjectPiWebConfig(projectPath);
return effectiveUploadsConfig({ uploads: { ...(globalConfig.uploads ?? {}), ...(projectConfig.config.uploads ?? {}) } });
}
export function mergePathAccessConfigs(...configs: (PiWebPathAccessConfig | undefined)[]): PiWebPathAccessConfig | undefined {
const allowedPaths = dedupe(configs.flatMap((config) => config?.allowedPaths ?? []));
return allowedPaths.length === 0 ? undefined : { allowedPaths };
@@ -43,6 +49,7 @@ function parseProjectPiWebConfig(value: Record<string, unknown>, path: string):
return {
...(version !== undefined ? { version: parseProjectConfigVersion(version, path) } : {}),
...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}),
...(value["uploads"] !== undefined ? { uploads: parseUploadsConfig(value["uploads"], path) } : {}),
};
}