import { badRequest, forbidden } from "../errors"; import type { GitStore } from "../git/store"; import { MAX_POINTER_BYTES, parseLfsPointer } from "../lfs/pointer"; import { readBounded } from "./blob-read"; import { attributeGlob } from "~/lib/gitattributes"; const MAX_ATTRIBUTES_BYTES = 64 * 1024; const MAX_ATTRIBUTE_RULES = 500; const decoder = new TextDecoder(); type Rule = { regex: RegExp; anchored: boolean; lfs: boolean }; /** Root `.gitattributes` rules that set or unset `filter=lfs`; later rules win, as in git. */ async function lfsRules(store: GitStore, rootTree: string): Promise { const entry = await store.entryAtPath(rootTree, ".gitattributes"); if (!entry || entry.type === "tree" || entry.type === "gitlink") return []; const bytes = (await readBounded(store, entry.sha, MAX_ATTRIBUTES_BYTES))?.bytes; if (!bytes) return []; const rules: Rule[] = []; for (const line of decoder.decode(bytes).split("\n").slice(0, MAX_ATTRIBUTE_RULES)) { const [pattern, ...attributes] = line.trim().split(/\s+/); if (!pattern || pattern.startsWith("#")) continue; const lfs = attributes.includes("filter=lfs"); if (!lfs && !attributes.some((attribute) => attribute === "-filter" || attribute === "!filter")) continue; const anchored = pattern.includes("/"); rules.push({ regex: attributeGlob(pattern.replace(/^\//, "")), anchored, lfs }); } return rules; } function isLfsTracked(rules: Rule[], path: string): boolean { let tracked = false; const basename = path.slice(path.lastIndexOf("/") + 1); for (const rule of rules) if (rule.regex.test(rule.anchored ? path : basename)) tracked = rule.lfs; return tracked; } function assertDistinct(paths: string[]): void { const seen = new Set(); for (const path of paths) { if (seen.has(path)) throw badRequest(`${path} appears more than once in this commit`); seen.add(path); } for (const path of paths) { const parts = path.split("/"); for (let length = 1; length < parts.length; length++) { const prefix = parts.slice(0, length).join("/"); if (seen.has(prefix)) throw badRequest(`${prefix} can't be both a file and a directory`); } } } /** * The browser editor only writes plain text files: LFS pointers (or paths `.gitattributes` routes through * LFS), symlinks and submodules are refused so text never replaces something git treats specially. */ export async function assertEditableChanges( store: GitStore, rootTree: string, changes: Array<{ path: string; content: Uint8Array | null }>, ): Promise { assertDistinct(changes.map((change) => change.path)); const rules = await lfsRules(store, rootTree); for (const change of changes) { const entry = await store.entryAtPath(rootTree, change.path); if (entry?.type === "symlink" || entry?.type === "gitlink") { throw forbidden(`${change.path} is a ${entry.type === "symlink" ? "symbolic link" : "submodule"}`); } if (change.content === null) continue; if (isLfsTracked(rules, change.path)) { throw forbidden(`${change.path} is tracked by Git LFS and can't be written from the browser`); } if (!entry || entry.type === "tree") continue; const bytes = (await readBounded(store, entry.sha, MAX_POINTER_BYTES))?.bytes; if (bytes && parseLfsPointer(bytes)) { throw forbidden(`${change.path} is stored in Git LFS and can't be edited in the browser`); } } }