-
Notifications
You must be signed in to change notification settings - Fork 1.1k
fix(flue-review): hydrate from the GitHub tarball instead of JS git #2386
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
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,162 @@ | ||||||||||||||||||||||||
| // Streaming untar for GitHub source tarballs (post-gunzip). The parser is the | ||||||||||||||||||||||||
| // trust boundary between an externally-supplied archive and the review | ||||||||||||||||||||||||
| // workspace: entry paths and symlink targets are validated so no write can | ||||||||||||||||||||||||
| // land outside the destination directory. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** The subset of the Workspace surface the untar needs; narrow for testing. */ | ||||||||||||||||||||||||
| export interface UntarTarget { | ||||||||||||||||||||||||
| mkdir(path: string, options?: { recursive?: boolean }): Promise<void>; | ||||||||||||||||||||||||
| symlink(target: string, linkPath: string): Promise<void>; | ||||||||||||||||||||||||
| writeFileBytes(path: string, content: Uint8Array): Promise<void>; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| function stripRoot(name: string): string | undefined { | ||||||||||||||||||||||||
| const slash = name.indexOf("/"); | ||||||||||||||||||||||||
| if (slash === -1) return undefined; | ||||||||||||||||||||||||
| const rest = name.slice(slash + 1); | ||||||||||||||||||||||||
| return rest.length > 0 ? rest : undefined; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| function parentOf(path: string): string { | ||||||||||||||||||||||||
| return path.slice(0, path.lastIndexOf("/")); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| function normalizeSegments(path: string): string[] | undefined { | ||||||||||||||||||||||||
| const out: string[] = []; | ||||||||||||||||||||||||
| for (const part of path.split("/")) { | ||||||||||||||||||||||||
| if (part === "" || part === ".") continue; | ||||||||||||||||||||||||
| if (part === "..") { | ||||||||||||||||||||||||
| if (out.length === 0) return undefined; | ||||||||||||||||||||||||
| out.pop(); | ||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| out.push(part); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| return out; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Untar a ustar stream into `destDir`, stripping the archive's single | ||||||||||||||||||||||||
| * top-level directory. Handles regular files, directories, symlinks, GNU | ||||||||||||||||||||||||
| * longname ('L') and pax ('x') path overrides. One entry's content is | ||||||||||||||||||||||||
| * buffered at a time. Rejects any entry path or symlink target that would | ||||||||||||||||||||||||
| * resolve outside `destDir`. | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| export async function untarInto( | ||||||||||||||||||||||||
| target: UntarTarget, | ||||||||||||||||||||||||
| stream: ReadableStream<Uint8Array>, | ||||||||||||||||||||||||
| destDir: string, | ||||||||||||||||||||||||
| ): Promise<{ files: number; bytes: number }> { | ||||||||||||||||||||||||
| const decoder = new TextDecoder(); | ||||||||||||||||||||||||
| let buffer = new Uint8Array(0); | ||||||||||||||||||||||||
| let files = 0; | ||||||||||||||||||||||||
| let bytes = 0; | ||||||||||||||||||||||||
| let pendingLongName: string | undefined; | ||||||||||||||||||||||||
| let pendingPaxPath: string | undefined; | ||||||||||||||||||||||||
| const dirsMade = new Set<string>(); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const append = (chunk: Uint8Array) => { | ||||||||||||||||||||||||
| const next = new Uint8Array(buffer.length + chunk.length); | ||||||||||||||||||||||||
| next.set(buffer, 0); | ||||||||||||||||||||||||
| next.set(chunk, buffer.length); | ||||||||||||||||||||||||
| buffer = next; | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
| const readCString = (view: Uint8Array): string => { | ||||||||||||||||||||||||
| const end = view.indexOf(0); | ||||||||||||||||||||||||
| return decoder.decode(end === -1 ? view : view.subarray(0, end)); | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
| const ensureDir = async (path: string) => { | ||||||||||||||||||||||||
| if (dirsMade.has(path)) return; | ||||||||||||||||||||||||
| await target.mkdir(path, { recursive: true }); | ||||||||||||||||||||||||
| dirsMade.add(path); | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const reader = stream.getReader(); | ||||||||||||||||||||||||
| let done = false; | ||||||||||||||||||||||||
| const need = async (n: number): Promise<boolean> => { | ||||||||||||||||||||||||
| while (buffer.length < n && !done) { | ||||||||||||||||||||||||
| const r = await reader.read(); | ||||||||||||||||||||||||
| if (r.done) done = true; | ||||||||||||||||||||||||
| else append(r.value); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| return buffer.length >= n; | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| while (await need(512)) { | ||||||||||||||||||||||||
| const header = buffer.subarray(0, 512); | ||||||||||||||||||||||||
| buffer = buffer.subarray(512); | ||||||||||||||||||||||||
| // Two consecutive zero blocks terminate the archive. | ||||||||||||||||||||||||
| if (header.every((b) => b === 0)) break; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const rawName = readCString(header.subarray(0, 100)); | ||||||||||||||||||||||||
| const prefix = readCString(header.subarray(345, 500)); | ||||||||||||||||||||||||
| const size = parseInt(readCString(header.subarray(124, 136)).trim() || "0", 8); | ||||||||||||||||||||||||
| // Mode bytes (100-108) are ignored: the workspace has no chmod and the | ||||||||||||||||||||||||
| // reviewer never executes files. | ||||||||||||||||||||||||
| const type = String.fromCharCode(header[156] ?? 48); | ||||||||||||||||||||||||
| const linkTarget = readCString(header.subarray(157, 257)); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const padded = Math.ceil(size / 512) * 512; | ||||||||||||||||||||||||
| if (!(await need(padded)) && size > 0) { | ||||||||||||||||||||||||
| throw new Error(`tar truncated: needed ${padded} bytes for entry ${rawName}`); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| const content = buffer.subarray(0, size); | ||||||||||||||||||||||||
| buffer = buffer.subarray(Math.min(padded, buffer.length)); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (type === "L") { | ||||||||||||||||||||||||
| pendingLongName = readCString(content); | ||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| if (type === "x" || type === "g") { | ||||||||||||||||||||||||
| // pax records: "<len> key=value\n" | ||||||||||||||||||||||||
| const text = decoder.decode(content); | ||||||||||||||||||||||||
| for (const line of text.split("\n")) { | ||||||||||||||||||||||||
| const eq = line.indexOf("="); | ||||||||||||||||||||||||
| if (eq > 0 && line.slice(line.indexOf(" ") + 1, eq) === "path") { | ||||||||||||||||||||||||
| pendingPaxPath = line.slice(eq + 1); | ||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] The pax parser only honors the
Suggested change
|
||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const fullName = | ||||||||||||||||||||||||
| pendingPaxPath ?? pendingLongName ?? (prefix ? `${prefix}/${rawName}` : rawName); | ||||||||||||||||||||||||
| pendingLongName = undefined; | ||||||||||||||||||||||||
| pendingPaxPath = undefined; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (fullName.startsWith("/")) { | ||||||||||||||||||||||||
| throw new Error(`tar entry path is absolute: ${fullName}`); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| const relative = stripRoot(fullName); | ||||||||||||||||||||||||
| if (!relative) continue; | ||||||||||||||||||||||||
| const segments = normalizeSegments(relative); | ||||||||||||||||||||||||
| if (segments === undefined || segments.length === 0) { | ||||||||||||||||||||||||
| throw new Error(`tar entry path escapes the destination: ${fullName}`); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| const dest = `${destDir}/${segments.join("/")}`; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (type === "5") { | ||||||||||||||||||||||||
| await ensureDir(dest); | ||||||||||||||||||||||||
| } else if (type === "2") { | ||||||||||||||||||||||||
| if (linkTarget.startsWith("/")) { | ||||||||||||||||||||||||
| throw new Error(`tar symlink target is absolute: ${fullName} -> ${linkTarget}`); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| // Resolve the target against the link's directory; it must stay | ||||||||||||||||||||||||
| // inside destDir even before the workspace resolves anything. | ||||||||||||||||||||||||
| const resolved = normalizeSegments(`${segments.slice(0, -1).join("/")}/${linkTarget}`); | ||||||||||||||||||||||||
| if (resolved === undefined) { | ||||||||||||||||||||||||
| throw new Error(`tar symlink target escapes the destination: ${fullName} -> ${linkTarget}`); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| await ensureDir(parentOf(dest)); | ||||||||||||||||||||||||
| await target.symlink(linkTarget, dest); | ||||||||||||||||||||||||
| } else if (type === "0" || type === "\0" || type === "7") { | ||||||||||||||||||||||||
| await ensureDir(parentOf(dest)); | ||||||||||||||||||||||||
| // Copy out of the rolling buffer: content is a subarray view. | ||||||||||||||||||||||||
| await target.writeFileBytes(dest, new Uint8Array(content)); | ||||||||||||||||||||||||
| files += 1; | ||||||||||||||||||||||||
| bytes += size; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| // Hardlinks and other exotic types don't occur in GitHub tarballs; skip. | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| return { files, bytes }; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[needs fixing] The octal
sizeis parsed but never bounded, andneed(padded)below buffers the entire entry beforewriteFileBytes. A malformed header—or a legitimate large binary—can declare a multi-gigabyte size and exhaust the DO's memory before the write happens. Add an explicit max-entry-size guard after parsingsize.