diff --git a/README.md b/README.md index 8ff26ba..25c9b3d 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Here is the list of all utilities: - [Random String Generator](https://jam.dev/utilities/random-string-generator) - [CSV file viewer](https://jam.dev/utilities/csv-file-viewer) - [JSONL Validator](https://jam.dev/utilities/jsonl-validator) +- [File Integrity Checker](https://jam.dev/utilities/file-integrity-checker) ### Built With diff --git a/__tests__/pages/utilities/file-integrity-checker.test.tsx b/__tests__/pages/utilities/file-integrity-checker.test.tsx new file mode 100644 index 0000000..f94cc52 --- /dev/null +++ b/__tests__/pages/utilities/file-integrity-checker.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import FileIntegrityChecker from "../../../pages/utilities/file-integrity-checker"; + +describe("FileIntegrityChecker", () => { + test("computes the sha256 hash automatically as soon as a file is uploaded", async () => { + render(); + + const user = userEvent.setup(); + const file = new File(["hello world"], "hello.txt", { + type: "text/plain", + }); + + await user.upload(screen.getByTestId("input"), file); + + expect(await screen.findByText(/sha-256 hash/i)).toBeInTheDocument(); + expect(screen.getByDisplayValue(/^[a-f0-9]{64}$/i)).toBeInTheDocument(); + }); + + test("switching algorithms clears the stale hash and recomputes for the new one", async () => { + render(); + + const user = userEvent.setup(); + const file = new File(["hello world"], "hello.txt", { + type: "text/plain", + }); + + await user.upload(screen.getByTestId("input"), file); + await screen.findByDisplayValue(/^[a-f0-9]{64}$/i); // sha256 by default + + await user.selectOptions(screen.getByLabelText(/hash algorithm/i), "md5"); + + expect(await screen.findByText(/md5 hash/i)).toBeInTheDocument(); + expect( + await screen.findByDisplayValue(/^[a-f0-9]{32}$/i) + ).toBeInTheDocument(); + }); +}); diff --git a/components/seo/FileIntegrityCheckerSEO.tsx b/components/seo/FileIntegrityCheckerSEO.tsx new file mode 100644 index 0000000..16ae5da --- /dev/null +++ b/components/seo/FileIntegrityCheckerSEO.tsx @@ -0,0 +1,101 @@ +export default function FileIntegrityCheckerSEO() { + return ( +
+
+

+ Generate a checksum (hash) of any file to confirm it wasn't + corrupted during download or transfer, or tampered with along the way. + If the hash you calculate matches the hash published by the + file's source, the file is identical, byte for byte, to the + original. +

+
+ +
+

+ Drop a file into the tool above or click to browse for one. Large + files are streamed in chunks, so there's no file size limit and + nothing is ever uploaded anywhere. +

+
+ +
+

How to Check File Integrity

+
    +
  • + Drop in or browse for your file:
    Drag and drop the + file directly or click to select it from your machine. +
  • +
  • + Pick the algorithm:
    Choose SHA-256, SHA-512, or MD5 to + match the hash you were given. +
  • +
  • + Wait for the hash to generate:
    Large files are + streamed in chunks, so even multi-gigabyte files can be checked + without running out of memory. +
  • +
  • + Verify against a known hash:
    Paste the published hash + into the verify field to confirm it matches. +
  • +
+
+ +
+

SHA-256 vs SHA-512 vs MD5

+

+ SHA-256 is the most common choice today for verifying downloads and + software packages. SHA-512 offers a longer digest for the same family. + MD5 is faster and still widely used for detecting accidental + corruption, though it's not considered cryptographically secure + against deliberate tampering. +

+
+ +
+

Why Use a File Integrity Checker?

+
    +
  • + Catch corrupted downloads:
    A hash mismatch tells you + immediately if a download was interrupted or damaged in transit. +
  • +
  • + Detect tampering:
    Comparing against a + publisher-provided hash confirms a file wasn't altered before + it reached you. +
  • +
  • + Works on any file size:
    Chunked streaming means + multi-gigabyte files are checked without loading the whole file into + memory. +
  • +
+
+ +
+

FAQs

+
    +
  • + Is this tool safe for large or sensitive files?
    Yes. + Everything runs locally in your browser — files are never uploaded + anywhere. +
  • +
  • + Which algorithm should I use?
    Use whichever algorithm + matches the hash published by the file's source — SHA-256, + SHA-512, or MD5. +
  • +
  • + Is there a file size limit?
    No. Files are read and + hashed in chunks, so even very large files can be checked. +
  • +
  • + Is my file sent to a server?
    No. All hashing happens + locally in your browser. Your file never leaves your machine. +
  • +
+
+
+ ); +} diff --git a/components/utils/tools-list.ts b/components/utils/tools-list.ts index d2e8027..10dd62b 100644 --- a/components/utils/tools-list.ts +++ b/components/utils/tools-list.ts @@ -209,4 +209,10 @@ export const tools = [ "View, search, and filter CSV, TSV or LOG files with color-coded severity levels. Quickly scan through logs with Datadog-inspired faceted filtering.", link: "/utilities/csv-file-viewer", }, + { + title: "File integrity checker", + description: + "Generate SHA-256, SHA-512, and MD5 checksums for files of any size, then verify them against a known hash to confirm the file wasn't corrupted or altered in transit.", + link: "/utilities/file-integrity-checker", + }, ]; diff --git a/jest.setup.ts b/jest.setup.ts index 148b93a..693aeb6 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1,5 +1,18 @@ import "@testing-library/jest-dom"; +// jsdom's Blob (and File, which extends it) doesn't implement arrayBuffer(). +// so polyfill via FileReader, which jsdom does support. +if (!Blob.prototype.arrayBuffer) { + Blob.prototype.arrayBuffer = function (this: Blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as ArrayBuffer); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(this); + }); + }; +} + // There's no official way to mock next/navigation. // We just make it no-op for all tests. jest.mock("next/navigation", () => ({ diff --git a/pages/utilities/file-integrity-checker.tsx b/pages/utilities/file-integrity-checker.tsx new file mode 100644 index 0000000..4fe8b0e --- /dev/null +++ b/pages/utilities/file-integrity-checker.tsx @@ -0,0 +1,360 @@ +import React, { useCallback, useRef, useState } from "react"; +import PageHeader from "@/components/PageHeader"; +import { Card } from "@/components/ds/CardComponent"; +import { Button } from "@/components/ds/ButtonComponent"; +import { Label } from "@/components/ds/LabelComponent"; +import { Textarea } from "@/components/ds/TextareaComponent"; +import Header from "@/components/Header"; +import UploadIcon from "@/components/icons/UploadIcon"; +import { useCopyToClipboard } from "@/components/hooks/useCopyToClipboard"; +import CallToActionGrid from "@/components/CallToActionGrid"; +import Meta from "@/components/Meta"; +import { CMDK } from "@/components/CMDK"; +import crypto from "crypto"; +import GitHubContribution from "@/components/GitHubContribution"; +import FileIntegrityCheckerSEO from "@/components/seo/FileIntegrityCheckerSEO"; +import { Flame } from "lucide-react"; + +const ALGORITHMS = ["sha256", "sha512", "md5"] as const; +type Algorithm = (typeof ALGORITHMS)[number]; + +const ALGORITHM_LABELS: Record = { + sha256: "SHA-256", + sha512: "SHA-512", + md5: "MD5", +}; + +// 8MB chunks: file is read and hashed incrementally so memory use stays constant +const CHUNK_SIZE = 8 * 1024 * 1024; + +interface AlgorithmOption { + value: Algorithm; + label: string; +} + +const algorithmOptions: AlgorithmOption[] = ALGORITHMS.map((algo) => ({ + value: algo, + label: ALGORITHM_LABELS[algo], +})); + +type HashResults = Record; + +function throwIfAborted(signal: AbortSignal) { + if (signal.aborted) throw new DOMException("Aborted", "AbortError"); +} + +async function hashFileChunked( + file: File, + algorithms: readonly Algorithm[], + onProgress: (percent: number) => void, + signal: AbortSignal +): Promise { + const hashes = algorithms.reduce( + (acc, algo) => { + acc[algo] = crypto.createHash(algo); + return acc; + }, + {} as Record + ); + + let offset = 0; + while (offset < file.size) { + throwIfAborted(signal); + const chunk = file.slice(offset, offset + CHUNK_SIZE); + const buffer = Buffer.from(await chunk.arrayBuffer()); + throwIfAborted(signal); + for (const algo of algorithms) { + hashes[algo].update(buffer); + } + offset += CHUNK_SIZE; + onProgress(Math.min(100, Math.round((offset / file.size) * 100))); + } + + // Empty file: loop above never runs, still needs a digest. + return algorithms.reduce((acc, algo) => { + acc[algo] = hashes[algo].digest("hex"); + return acc; + }, {} as HashResults); +} + +export default function FileIntegrityChecker() { + const [dropStatus, setDropStatus] = useState< + "idle" | "unsupported" | "hover" + >("idle"); + const [fileName, setFileName] = useState(""); + const [isHashing, setIsHashing] = useState(false); + const [progress, setProgress] = useState(0); + const [results, setResults] = useState(null); + const [error, setError] = useState(""); + const [selectedFile, setSelectedFile] = useState(null); + const [verifyInput, setVerifyInput] = useState(""); + const [selectedAlgorithm, setSelectedAlgorithm] = + useState("sha256"); + const { buttonText, handleCopy } = useCopyToClipboard(); + const abortControllerRef = useRef(null); + + const computeHash = useCallback(async (file: File, algorithm: Algorithm) => { + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + setError(""); + setVerifyInput(""); + setProgress(0); + setResults(null); + setIsHashing(true); + + try { + const hashes = await hashFileChunked( + file, + [algorithm], + (percent) => setProgress(percent), + controller.signal + ); + setResults(hashes); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; + setError( + err instanceof Error + ? `Failed to hash file: ${err.message}` + : "An unexpected error occurred while hashing the file." + ); + } finally { + if (!controller.signal.aborted) setIsHashing(false); + } + }, []); + + const handleAlgorithmSelect = useCallback( + (value: string) => { + if (!ALGORITHMS.includes(value as Algorithm)) return; + const algorithm = value as Algorithm; + setSelectedAlgorithm(algorithm); + if (selectedFile) { + computeHash(selectedFile, algorithm); + } + }, + [selectedFile, computeHash] + ); + + const processFile = useCallback( + (file: File) => { + setFileName(file.name); + setSelectedFile(file); + setError(""); + setVerifyInput(""); + setProgress(0); + computeHash(file, selectedAlgorithm); + }, + [selectedAlgorithm, computeHash] + ); + + const handleFileChange = useCallback( + (event: React.ChangeEvent) => { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ""; + if (!file) return; + setDropStatus("idle"); + processFile(file); + }, + [processFile] + ); + + const handleDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + const file = event.dataTransfer.files[0]; + if (!file) { + setDropStatus("unsupported"); + return; + } + setDropStatus("idle"); + processFile(file); + }, + [processFile] + ); + + const handleDragOver = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + setDropStatus("hover"); + }, + [] + ); + + const handleVerifyChange = useCallback( + (event: React.ChangeEvent) => { + setVerifyInput(event.currentTarget.value); + }, + [] + ); + + const isMatch = + !!results && + !!verifyInput.trim() && + verifyInput.trim().toLowerCase() === results[selectedAlgorithm]; + + return ( +
+ +
+ + +
+ +
+ +
+ +
setDropStatus("idle")} + className="relative flex flex-col border border-dashed border-border p-6 text-center text-muted-foreground rounded-lg min-h-40 items-center justify-center bg-muted" + > + + +
+ {dropStatus === "idle" && !fileName && ( +

Drop any file here, or click to browse

+ )} + {dropStatus === "hover" && ( +

+ Drop it like it's hot +

+ )} + {dropStatus === "unsupported" && ( +

Couldn't read that file

+ )} + {dropStatus === "idle" && fileName && ( +

{fileName}

+ )} +
+
+ + {fileName && ( +
+
+
+
+

+ Hashing… {progress}% +

+
+ )} + + {error &&

{error}

} + + {fileName && ( +
+ + + + + +
+ +