Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(gitrest): Handle FileSystem Errors in HTTP Responses #22986

Merged
merged 3 commits into from
Nov 5, 2024
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
12 changes: 5 additions & 7 deletions server/gitrest/packages/gitrest-base/src/routes/summaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ import {
logAndThrowApiError,
persistLatestFullSummaryInStorage,
retrieveLatestFullSummaryFromStorage,
SystemErrors,
isFilesystemError,
throwFileSystemErrorAsNetworkError,
} from "../utils";

function getFullSummaryDirectory(repoManager: IRepositoryManager, documentId: string): string {
Expand Down Expand Up @@ -79,14 +80,11 @@ async function getSummary(
error,
);
if (enforceStrictPersistedFullSummaryReads) {
if (isNetworkError(error) && error.code === 413) {
if (isNetworkError(error)) {
throw error;
}
if (
typeof (error as any).code === "string" &&
(error as any).code === SystemErrors.EFBIG.code
) {
throw new NetworkError(413, "Full summary too large.");
if (isFilesystemError(error)) {
throwFileSystemErrorAsNetworkError(error);
}
}
}
Expand Down
10 changes: 7 additions & 3 deletions server/gitrest/packages/gitrest-base/src/utils/fileSystemBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { Stream } from "node:stream";
import type { Abortable } from "node:events";
import sizeof from "object-sizeof";
import { type IFileSystemPromises } from "./definitions";
import { FilesystemError, SystemErrors } from "./fileSystemHelper";
import { filepathToString, FilesystemError, SystemErrors } from "./fileSystemHelper";

export abstract class FsPromisesBase implements IFileSystemPromises {
public readonly promises: IFileSystemPromises;
Expand Down Expand Up @@ -88,8 +88,12 @@ export abstract class FsPromisesBase implements IFileSystemPromises {
this.maxFileSizeBytes > 0 &&
sizeof(data) > this.maxFileSizeBytes
) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
throw new FilesystemError(SystemErrors.EFBIG, filepath.toString());
throw new FilesystemError(
SystemErrors.EFBIG,
`Attempted write size (${sizeof(data)} bytes) to ${filepathToString(
filepath,
)} exceeds limit (${this.maxFileSizeBytes} bytes).`,
);
}
return this.writeFileCore(filepath, data, options);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
* Licensed under the MIT License.
*/

import fs from "fs";
import { NetworkError } from "@fluidframework/server-services-client";
import fs, { type PathLike } from "fs";
import type { FileHandle } from "fs/promises";

export const packedRefsFileName = "packed-refs";

Expand All @@ -12,43 +14,54 @@ export type FsEntityType = "file" | "directory" | "symlink";
export interface ISystemError {
code: string;
description: string;
httpStatusCode: number;
}

export const SystemErrors: Record<string, ISystemError> = {
EEXIST: {
code: "EEXIST",
description: "File already exists",
httpStatusCode: 409,
},
EINVAL: {
code: "EINVAL",
description: "Invalid argument",
httpStatusCode: 400,
},
EISDIR: {
code: "EISDIR",
description: "Illegal operation on a directory",
httpStatusCode: 405,
},
ENOENT: {
code: "ENOENT",
description: "No such file or directory",
httpStatusCode: 404,
},
ENOTDIR: {
code: "ENOTDIR",
description: "Not a directory",
httpStatusCode: 406,
},
ENOTEMPTY: {
code: "ENOTEMPTY",
description: "Directory not empty",
httpStatusCode: 409,
},
EFBIG: {
code: "EFBIG",
description: "File too large",
httpStatusCode: 413,
},
UNKNOWN: {
code: "UNKNOWN",
description: "Unknown error",
httpStatusCode: 500,
},
};

const KnownSystemErrorCodes = new Set(Object.keys(SystemErrors));

export class FilesystemError extends Error {
public get code() {
return this.err.code;
Expand All @@ -63,6 +76,63 @@ export class FilesystemError extends Error {
}
}

/**
* Check if an error is a recognized FilesystemError (or RedisFsError).
*
* @param err - An unknown error object
* @returns Whether the error object is a FilesystemError (or RedisFsError)
*/
export function isFilesystemError(err: unknown): err is FilesystemError {
// This also works for RedisFsError which exposes a compatible code property.
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
typeof err.code === "string" &&
KnownSystemErrorCodes.has(err.code)
);
}

/**
* If the error is a FilesystemError, throw it as a NetworkError with the appropriate status code.
* Otherwise, rethrow the error as-is.
*
* @param err - An unknown error object
*/
export function throwFileSystemErrorAsNetworkError(err: FilesystemError): never {
const systemError = SystemErrors[err.code] ?? SystemErrors.UNKNOWN;
const error = new NetworkError(
systemError.httpStatusCode,
// Only use SystemError.description, not the message, to protect against leaking sensitive information.
systemError.description,
systemError.httpStatusCode === 500,
undefined /* isFatal */,
undefined /* retryAfterMs */,
"Gitrest filesystem error",
);
throw error;
}

function isFileHandle(filepath: PathLike | FileHandle): filepath is FileHandle {
return typeof filepath !== "string" && !Buffer.isBuffer(filepath) && "fd" in filepath;
}

/**
* Convert a PathLike or FileHandle to a string path.
* @remarks
* This is useful for logging and debugging.
* If the input is a FileHandle, the path is unknown and a generic message is returned, rather than using readLink.
*
* @param filepath - A PathLike or FileHandle
* @returns The string representation of the path
*/
export function filepathToString(filepath: PathLike | FileHandle): string {
if (isFileHandle(filepath)) {
return "Unknown file handle path";
}
return filepath.toString();
}

/**
* Creates an `fs.Stats` object using the information retrieved through the filesystem APIs.
* GitRest and isomorphic-git expect `fs.Stats` objects, but we don't use `fs` to obtain them.
Expand Down
9 changes: 8 additions & 1 deletion server/gitrest/packages/gitrest-base/src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
BaseGitRestTelemetryProperties,
GitRestLumberEventName,
} from "./gitrestTelemetryDefinitions";
import { isFilesystemError, throwFileSystemErrorAsNetworkError } from "./fileSystemHelper";

/**
* Validates that the input encoding is valid
Expand Down Expand Up @@ -139,11 +140,14 @@ export async function persistLatestFullSummaryInStorage(
persistLatestFullSummaryInStorageMetric.success(
"Successfully persisted latest full summary in storage",
);
} catch (error: any) {
} catch (error: unknown) {
persistLatestFullSummaryInStorageMetric.error(
"Failed to persist latest full summary in storage",
error,
);
if (isFilesystemError(error)) {
throwFileSystemErrorAsNetworkError(error);
}
throw error;
}
}
Expand Down Expand Up @@ -261,6 +265,9 @@ export function logAndThrowApiError(
if (isNetworkError(error)) {
throw error;
}
if (isFilesystemError(error)) {
throwFileSystemErrorAsNetworkError(error);
}
// TODO: some APIs might expect 400 responses by default, like GetRef in GitManager. Since `handleResponse` uses
// 400 by default, using something different here would override the expected behavior and cause issues. Because
// of that, for now, we use 400 here. But ideally, we would revisit every RepoManager API and make sure that API
Expand Down
7 changes: 6 additions & 1 deletion server/gitrest/packages/gitrest-base/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ export {
IStorageRoutingId,
} from "./definitions";
export { FsPromisesBase } from "./fileSystemBase";
export { SystemErrors } from "./fileSystemHelper";
export {
SystemErrors,
isFilesystemError,
throwFileSystemErrorAsNetworkError,
filepathToString,
} from "./fileSystemHelper";
export { MemFsManagerFactory, NodeFsManagerFactory, RedisFsManagerFactory } from "./filesystems";
export {
BaseGitRestTelemetryProperties,
Expand Down
Loading