Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ describe("AssetAccess", () => {
workspaceRoot: root,
}).pipe(Effect.flip);
expect(error.message).toBe("Workspace file path must be relative to the project root.");
expect(error).toMatchObject({
operation: "validate-workspace-path",
resource: {
_tag: "workspace-file",
threadId: "thread-1",
path: htmlPath,
},
});
expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError);
}).pipe(Effect.provide(testLayer)),
);
Expand Down Expand Up @@ -121,6 +129,14 @@ describe("AssetAccess", () => {
}).pipe(Effect.provideService(FileSystem.FileSystem, failingFileSystem), Effect.flip);

expect(error.message).toBe("Failed to inspect the workspace asset.");
expect(error).toMatchObject({
operation: "inspect-workspace-asset",
resource: {
_tag: "workspace-file",
threadId: "thread-1",
path: htmlPath,
},
});
expect(error.cause).toBe(cause);
}).pipe(Effect.provide(testLayer)),
);
Expand Down Expand Up @@ -222,4 +238,37 @@ describe("AssetAccess", () => {
).toEqual({ kind: "project-favicon-fallback" });
}).pipe(Effect.provide(testLayer)),
);

it.effect("preserves structured project favicon resolution causes", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-error-",
});
const platformCause = PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "stat",
});
const resolutionCause = new ProjectFaviconResolver.ProjectFaviconResolutionError({
operation: "stat-candidate",
workspaceRoot: root,
relativePath: "favicon.svg",
cause: platformCause,
});
const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({
resolvePath: () => Effect.fail(resolutionCause),
});

const error = yield* issueAssetUrl({
resource: { _tag: "project-favicon", cwd: root },
}).pipe(
Effect.provideService(ProjectFaviconResolver.ProjectFaviconResolver, resolver),
Effect.flip,
);

expect(error.message).toBe("Failed to resolve project favicon.");
expect(error.cause).toBe(resolutionCause);
}).pipe(Effect.provide(testLayer)),
);
});
54 changes: 49 additions & 5 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,18 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
switch (input.resource._tag) {
case "workspace-file": {
if (!input.workspaceRoot) {
return yield* new AssetAccessError({ message: "Workspace context was not found." });
return yield* new AssetAccessError({
operation: "resolve-workspace-context",
resource: input.resource,
message: "Workspace context was not found.",
});
}
const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe(
Effect.mapError(
(cause) =>
new AssetAccessError({
operation: "normalize-workspace-root",
resource: input.resource,
message: "Failed to normalize the workspace root.",
cause,
}),
Expand All @@ -185,13 +191,17 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
Effect.mapError(
(cause) =>
new AssetAccessError({
operation: "validate-workspace-path",
resource: input.resource,
message: "Workspace file path must be relative to the project root.",
cause,
}),
),
);
if (!isWorkspacePreviewEntryPath(resolved.relativePath)) {
return yield* new AssetAccessError({
operation: "validate-preview-type",
resource: input.resource,
message: "Only browser documents and images can be previewed.",
});
}
Expand All @@ -202,18 +212,26 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
Effect.mapError(
(cause) =>
new AssetAccessError({
operation: "inspect-workspace-asset",
resource: input.resource,
message: "Failed to inspect the workspace asset.",
cause,
}),
),
);
if (!canonicalFile) {
return yield* new AssetAccessError({ message: "Workspace asset was not found." });
return yield* new AssetAccessError({
operation: "locate-workspace-asset",
resource: input.resource,
message: "Workspace asset was not found.",
});
}
const canonicalWorkspaceRoot = yield* fileSystem.realPath(workspaceRoot).pipe(
Effect.mapError(
(cause) =>
new AssetAccessError({
operation: "resolve-workspace",
resource: input.resource,
message: "Failed to resolve workspace.",
cause,
}),
Expand Down Expand Up @@ -244,7 +262,11 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
attachmentId: input.resource.attachmentId,
});
if (!attachmentPath) {
return yield* new AssetAccessError({ message: "Attachment was not found." });
return yield* new AssetAccessError({
operation: "locate-attachment",
resource: input.resource,
message: "Attachment was not found.",
});
}
claims = {
version: 1,
Expand All @@ -260,27 +282,45 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
Effect.mapError(
(cause) =>
new AssetAccessError({
operation: "normalize-workspace-root",
resource: input.resource,
message: "Failed to normalize the workspace root.",
cause,
}),
),
);
const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot);
const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe(
Effect.mapError(
(cause) =>
new AssetAccessError({
operation: "resolve-project-favicon",
resource: input.resource,
message: "Failed to resolve project favicon.",
cause,
}),
),
);
const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;
if (
relativePath &&
!(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe(
Effect.mapError(
(cause) =>
new AssetAccessError({
operation: "inspect-project-favicon",
resource: input.resource,
message: "Failed to inspect the project favicon.",
cause,
}),
),
))
) {
return yield* new AssetAccessError({ message: "Project favicon was not found." });
return yield* new AssetAccessError({
operation: "locate-project-favicon",
resource: input.resource,
message: "Project favicon was not found.",
});
}
claims = {
version: 1,
Expand All @@ -289,6 +329,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
Effect.mapError(
(cause) =>
new AssetAccessError({
operation: "resolve-workspace",
resource: input.resource,
message: "Failed to resolve workspace.",
cause,
}),
Expand All @@ -307,6 +349,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
Effect.mapError(
(cause) =>
new AssetAccessError({
operation: "load-signing-key",
resource: input.resource,
message: "Failed to load the asset signing key.",
cause,
}),
Expand Down
111 changes: 111 additions & 0 deletions apps/server/src/project/ProjectFaviconResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";

import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
Expand Down Expand Up @@ -34,6 +35,12 @@ const writeTextFile = Effect.fn("writeTextFile")(function* (
yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie);
});

const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) =>
ProjectFaviconResolver.make.pipe(
Effect.provide(WorkspacePaths.layer),
Effect.provideService(FileSystem.FileSystem, fileSystem),
);

it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
describe("resolvePath", () => {
it.effect("prefers well-known favicon files", () =>
Expand Down Expand Up @@ -73,5 +80,109 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
expect(resolved).toBeNull();
}),
);

it.effect("preserves workspace normalization context", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
const missingCwd = `${cwd}/missing`;

const error = yield* resolver.resolvePath(missingCwd).pipe(Effect.flip);

expect(error).toMatchObject({
_tag: "ProjectFaviconResolutionError",
operation: "normalize-workspace",
workspaceRoot: missingCwd,
});
expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspaceRootNotExistsError);
}),
);

it.effect("preserves non-missing candidate stat failures", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* makeTempDir;
const faviconPath = path.join(cwd, "favicon.svg");
const cause = PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "stat",
pathOrDescriptor: faviconPath,
});
const resolver = yield* makeResolverWithFileSystem(
FileSystem.FileSystem.of({
...fileSystem,
stat: (filePath) =>
filePath === faviconPath ? Effect.fail(cause) : fileSystem.stat(filePath),
}),
);

const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip);

expect(error).toMatchObject({
_tag: "ProjectFaviconResolutionError",
operation: "stat-candidate",
workspaceRoot: cwd,
relativePath: "favicon.svg",
absolutePath: faviconPath,
});
expect(error.cause).toBe(cause);
}),
);

it.effect("preserves icon source read failures", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* makeTempDir;
const sourcePath = path.join(cwd, "index.html");
yield* writeTextFile(cwd, "index.html", '<link rel="icon" href="/favicon.svg">');
const cause = PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "readFileString",
pathOrDescriptor: sourcePath,
});
const resolver = yield* makeResolverWithFileSystem(
FileSystem.FileSystem.of({
...fileSystem,
readFileString: (filePath, options) =>
filePath === sourcePath
? Effect.fail(cause)
: fileSystem.readFileString(filePath, options),
}),
);

const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip);

expect(error).toMatchObject({
_tag: "ProjectFaviconResolutionError",
operation: "read-source",
workspaceRoot: cwd,
relativePath: "index.html",
absolutePath: sourcePath,
});
expect(error.cause).toBe(cause);
}),
);

it.effect("rejects icon metadata paths outside the workspace", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "index.html", '<link rel="icon" href="../../secret.svg">');

const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip);

expect(error).toMatchObject({
_tag: "ProjectFaviconResolutionError",
operation: "resolve-path",
workspaceRoot: cwd,
relativePath: "../secret.svg",
});
expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError);
}),
);
});
});
Loading
Loading