diff --git a/.github/labeler.yml b/.github/labeler.yml index ce6bcf05..aaa86481 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -10,6 +10,10 @@ dotnet: - changed-files: - any-glob-to-any-file: dotnet/** +deno: +- changed-files: + - any-glob-to-any-file: deno/** + dub: - changed-files: - any-glob-to-any-file: dub/** diff --git a/.github/workflows/deno.yml b/.github/workflows/deno.yml new file mode 100644 index 00000000..611dd662 --- /dev/null +++ b/.github/workflows/deno.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + paths: + - deno/** + branches: + - master + pull_request: + paths: + - deno/** + branches: + - master + +defaults: + run: + working-directory: deno + +permissions: + contents: read + +jobs: + deno: + runs-on: ubuntu-latest + + steps: + - name: Setup repo + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + + - name: Verify formatting + run: deno fmt --check + + - name: Run linter + run: deno lint + + - name: Run type check + run: deno check . + + - name: Run tests + run: deno test -A diff --git a/.github/workflows/publish-jsr.yml b/.github/workflows/publish-jsr.yml new file mode 100644 index 00000000..7225f9b0 --- /dev/null +++ b/.github/workflows/publish-jsr.yml @@ -0,0 +1,28 @@ +name: Publish +on: + push: + paths: + - deno/** + branches: + - master + +defaults: + run: + working-directory: deno + +jobs: + publish: + runs-on: ubuntu-latest + + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + + - name: Publish package + run: deno publish diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..82023e22 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +deno/coverage diff --git a/CODEOWNERS b/CODEOWNERS index 65c8caf9..32322fc5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,2 +1,3 @@ /spm/ @david-swift /gradle/ @hadess +/deno/ @sigmasd diff --git a/deno/README.md b/deno/README.md new file mode 100644 index 00000000..339cd441 --- /dev/null +++ b/deno/README.md @@ -0,0 +1,72 @@ +# Flatpak Deno Generator + +Run from jsr + +``` +deno -RN -W=. jsr:@flatpak-contrib/flatpak-deno-generator deno.lock +``` + +or locally from this repo + +``` +deno -RN -W=. src/main.ts deno.lock --output sources.json +``` + +This will create a `deno-sources.json` (or the name specified with --output) +that can be used in flatpak build files. The sources files provides these 2 +directories: + +- it creates and populates `./deno_dir` with npm dependencies +- it creates and populates `./vendor` with jsr + http dependencies + +## Usage: + +- Use the sources file as a source, example: + +```yml +sources: + - deno-sources.json +``` + +- To use `deno_dir` (when your project have npm dependencies) point `DENO_DIR` + env variable to it, like so: + +```yml +- name: someModule + buildsystem: simple + build-options: + env: + # sources provides deno_dir directory + DENO_DIR: deno_dir +``` + +- To use `vendor` (when your project have http or jsr dependencies) move it next + to your `deno.json` file and make sure to compile or run with `--vendor` flag, + exmaple: + +```yml +- # sources provides vendor directory +- # src is where my deno project at as in deno.json is under src directory, so I'm moving vendor next to it +- mv ./vendor src/ +- DENORT_BIN=$PWD/denort ./deno compile --vendor --no-check --output virtaudio-bin --cached-only + --allow-all --include ./src/gui.slint --include ./src/client.html ./src/gui.ts +``` + +## Notes + +Currently this only supports lockfile V5 (available since deno version 2.3) + +## License + +MIT + +## Example + +- checkout https://github.com/flathub/io.github.sigmasd.VirtAudio/ + +## Technical Info + +Theoretically it would've been better to put all the dependencies in `DENO_DIR` +but currently thats not possible because jsr and https dependencies have some +special metadata checks made by deno, more info here +https://github.com/denoland/deno/issues/29212 diff --git a/deno/deno.json b/deno/deno.json new file mode 100644 index 00000000..f7263cfa --- /dev/null +++ b/deno/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@flatpak-contrib/flatpak-deno-generator", + "version": "1.3.0", + "exports": "./src/main.ts", + "license": "MIT" +} diff --git a/deno/deno.lock b/deno/deno.lock new file mode 100644 index 00000000..77ca4d79 --- /dev/null +++ b/deno/deno.lock @@ -0,0 +1,39 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@0.221": "0.221.0", + "jsr:@std/assert@0.221.0": "0.221.0", + "jsr:@std/encoding@1": "1.0.10", + "jsr:@std/fmt@0.221": "0.221.0", + "jsr:@std/fs@0.221.0": "0.221.0", + "jsr:@std/path@0.221": "0.221.0", + "jsr:@std/path@0.221.0": "0.221.0" + }, + "jsr": { + "@std/assert@0.221.0": { + "integrity": "a5f1aa6e7909dbea271754fd4ab3f4e687aeff4873b4cef9a320af813adb489a", + "dependencies": [ + "jsr:@std/fmt" + ] + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/fmt@0.221.0": { + "integrity": "379fed69bdd9731110f26b9085aeb740606b20428ce6af31ef6bd45ef8efa62a" + }, + "@std/fs@0.221.0": { + "integrity": "028044450299de8ed5a716ade4e6d524399f035513b85913794f4e81f07da286", + "dependencies": [ + "jsr:@std/assert@0.221", + "jsr:@std/path@0.221" + ] + }, + "@std/path@0.221.0": { + "integrity": "0a36f6b17314ef653a3a1649740cc8db51b25a133ecfe838f20b79a56ebe0095", + "dependencies": [ + "jsr:@std/assert@0.221" + ] + } + } +} diff --git a/deno/src/main.ts b/deno/src/main.ts new file mode 100644 index 00000000..6bec4996 --- /dev/null +++ b/deno/src/main.ts @@ -0,0 +1,251 @@ +// LICENSE = MIT +import { + base64ToHex, + sha256, + shortHash, + shouldHash, + splitOnce, + urlSegments, +} from "./utils.ts"; + +export interface Pkg { + module: string; + version: string; + name: string; + cpu?: "x86_64" | "aarch64"; +} + +export interface FlatpakData { + type: string; + url: string; + dest: string; + "dest-filename"?: string; + "only-arches"?: ("x86_64" | "aarch64")[]; + "archive-type"?: + | "tar-gzip" + | "rpm" + | "tar" + | "tar-gzip" + | "tar-compress" + | "tar-bzip2" + | "tar-lzip" + | "tar-lzma" + | "tar-lzop" + | "tar-xz" + | "tar-zst" + | "zip" + | "7z"; + sha256?: string; + sha512?: string; +} + +export async function jsrPkgToFlatpakData(pkg: Pkg): Promise { + const flatpkData: FlatpakData[] = []; + const metaUrl = `https://jsr.io/${pkg.module}/meta.json`; + const metaText = await fetch( + metaUrl, + ).then((r) => r.text()); + + flatpkData.push({ + type: "file", + url: metaUrl, + sha256: await sha256(metaText), + dest: `vendor/jsr.io/${pkg.module}`, + "dest-filename": "meta.json", + }); + + const metaVerUrl = `https://jsr.io/${pkg.module}/${pkg.version}_meta.json`; + const metaVerText = await fetch( + metaVerUrl, + ).then((r) => r.text()); + + flatpkData.push({ + type: "file", + url: metaVerUrl, + sha256: await sha256(metaVerText), + dest: `vendor/jsr.io/${pkg.module}`, + "dest-filename": `${pkg.version}_meta.json`, + }); + + const metaVer = JSON.parse(metaVerText); + + for ( + const fileUrl of Object.keys(metaVer.moduleGraph2 || metaVer.moduleGraph1) + ) { + const fileMeta = metaVer.manifest[fileUrl]; + // this mean the url exists in the module graph but not in the manifest -> this url is not needed + if (!fileMeta) continue; + const [checksumType, checksumValue] = splitOnce(fileMeta.checksum, "-"); + + const url = `https://jsr.io/${pkg.module}/${pkg.version}${fileUrl}`; + let [fileDir, fileName] = splitOnce(fileUrl, "/", "right"); + const dest = `vendor/jsr.io/${pkg.module}/${pkg.version}${fileDir}`; + + if (shouldHash(fileName)) { + fileName = await shortHash(fileName); + } + + flatpkData.push({ + type: "file", + url, + [checksumType]: checksumValue, + dest, + "dest-filename": fileName, + }); + } + + // If a moule imports deno.json (import ... from "deno.json" with {type:"json"}), it won't appear in the module graph + // Worarkound: if there is a deno.json file in the manifest just add it + // Note this can be made better, by looking in the moduleGraph if deno.json is specified in the dependencies + for (const [fileUrl, fileMeta] of Object.entries(metaVer.manifest)) { + if (fileUrl.includes("deno.json")) { + const [checksumType, checksumValue] = splitOnce( + // deno-lint-ignore no-explicit-any + (fileMeta as any).checksum, + "-", + ); + const url = `https://jsr.io/${pkg.module}/${pkg.version}${fileUrl}`; + const [fileDir, fileName] = splitOnce(fileUrl, "/", "right"); + const dest = `vendor/jsr.io/${pkg.module}/${pkg.version}${fileDir}`; + flatpkData.push({ + type: "file", + url, + [checksumType]: checksumValue, + dest, + "dest-filename": fileName, + }); + } + } + + return flatpkData; +} + +export async function npmPkgToFlatpakData(pkg: Pkg): Promise { + //url: https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz + //npmPkgs; + const metaUrl = `https://registry.npmjs.org/${pkg.module}`; + const metaText = await fetch(metaUrl).then( + (r) => r.text(), + ); + const meta = JSON.parse(metaText); + + const metaData = { + type: "file", + url: metaUrl, + sha256: await sha256(metaText), + dest: `deno_dir/npm/registry.npmjs.org/${pkg.module}`, + "dest-filename": "registry.json", + }; + + const [checksumType, checksumValue] = splitOnce( + meta.versions[pkg.version].dist.integrity, + "-", + ); + const pkgData: FlatpakData = { + type: "archive", + "archive-type": "tar-gzip", + url: + `https://registry.npmjs.org/${pkg.module}/-/${pkg.name}-${pkg.version}.tgz`, + [checksumType]: base64ToHex(checksumValue), + dest: `deno_dir/npm/registry.npmjs.org/${pkg.module}/${pkg.version}`, + }; + + if (pkg.cpu) { + pkgData["only-arches"] = [pkg.cpu]; + } + + return [metaData, pkgData]; +} + +export async function main( + lockPath: string, + outputPath: string = "deno-sources.json", +) { + const lock = JSON.parse(Deno.readTextFileSync(lockPath)); + if (lock.version !== "5") { + throw new Error(`Unsupported deno lock version: ${lock.version}`); + } + + const jsrPkgs: Pkg[] = !lock.jsr ? [] : Object.keys(lock.jsr).map((pkg) => { + const r = splitOnce(pkg, "@", "right"); + const name = r[0].split("/")[1]; + return { module: r[0], version: r[1], name }; + }); + jsrPkgs; + const npmPkgs: Pkg[] = !lock.npm ? [] : Object.entries(lock.npm) + .filter(( + // deno-lint-ignore no-explicit-any + [_key, val]: any, + ) => (val.os === undefined || val.os?.at(0) === "linux")) + // deno-lint-ignore no-explicit-any + .map(([key, val]: [string, any]) => { + let r = splitOnce(key, "@", "right"); + // hande peer deps + if (/_.+$/.test(r[0])) { + const actualModule = splitOnce(r[0], "_", "right")[0]; + r = splitOnce(actualModule, "@", "right"); + } + const name = r[0].includes("/") ? r[0].split("/")[1] : r[0]; + const cpu = val.cpu?.at(0); + return { + module: r[0], + version: r[1], + name, + cpu: cpu === "x64" ? "x86_64" : cpu === "arm64" ? "aarch64" : cpu, + }; + }); + //url: https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz + npmPkgs; + const httpPkgsData = !lock.remote + ? [] + : Object.entries(lock.remote).map(async ([urlStr, checksum]) => { + const url = new URL(urlStr); + const segments = await Promise.all( + urlSegments(url) + .map(async (part) => shouldHash(part) ? await shortHash(part) : part), + ); + const filename = segments.pop(); + return { + type: "file", + url: urlStr, + sha256: checksum, + dest: `vendor/${url.hostname}/${segments.join("/")}`, + "dest-filename": filename, + }; + }); + + const flatpakData = [ + await Promise.all( + jsrPkgs.map((pkg) => jsrPkgToFlatpakData(pkg)), + ).then((r) => r.flat()), + await Promise.all(npmPkgs.map((pkg) => npmPkgToFlatpakData(pkg))).then( + (r) => r.flat(), + ), + await Promise.all(httpPkgsData), + ].flat(); + // console.log(flatpakData); + Deno.writeTextFileSync( + outputPath, + JSON.stringify(flatpakData, null, 2), + ); +} + +if (import.meta.main) { + const args = Deno.args; + const lockPath = args[0]; + if (!lockPath) { + console.error( + "Usage: deno run -RN -W=. [--output ]", + ); + console.error( + `Examples: + - deno run -RN -W=. main.ts deno.lock + - deno run -RN -W=. jsr:@flatpak-contrib/flatpak-deno-generator deno.lock --output sources.json`, + ); + Deno.exit(1); + } + const outputFile = args.includes("--output") + ? args[args.indexOf("--output") + 1] + : "deno-sources.json"; + await main(lockPath, outputFile); +} diff --git a/deno/src/utils.ts b/deno/src/utils.ts new file mode 100644 index 00000000..6ce91c90 --- /dev/null +++ b/deno/src/utils.ts @@ -0,0 +1,107 @@ +// LICENSE = MIT + +import { encodeHex } from "jsr:@std/encoding@1/hex"; +import { decodeBase64 } from "jsr:@std/encoding@1/base64"; + +export async function sha256(text: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(text); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + // Convert buffer to hex string + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Converts a Base64 encoded string to its hexadecimal representation + * + * @param base64String The Base64 encoded string. + * @returns The hexadecimal representation of the decoded string. + */ +export function base64ToHex(base64String: string): string { + // Step 1: Base64 decode the string into a Uint8Array. + const binaryData: Uint8Array = decodeBase64(base64String); + // Step 2: Convert the Uint8Array (raw binary data) to a hexadecimal string. + const hexString: string = encodeHex(binaryData); + return hexString; +} + +export function splitOnce( + str: string, + separator: string, + dir: "left" | "right" = "left", +) { + const idx = dir === "left" + ? str.indexOf(separator) + : str.lastIndexOf(separator); + if (idx === -1) return [str]; + return [str.slice(0, idx), str.slice(idx + separator.length)]; +} + +const FORBIDDEN_CHARS = new Set([ + "?", + "<", + ">", + ":", + "*", + "|", + "\\", + ":", + '"', + "'", + "/", +]); + +// https://github.com/denoland/deno_cache_dir/blob/0b2dbb2553019dd829d71665bed7f48f610b64f0/rs_lib/src/local.rs#L594 +export function hasForbiddenChars(segment: string): boolean { + for (const c of segment) { + const isUppercase = /[A-Z]/.test(c); + if (FORBIDDEN_CHARS.has(c) || isUppercase) { + // do not allow uppercase letters in order to make this work + // the same on case insensitive file systems + return true; + } + } + return false; +} + +// https://github.com/denoland/deno_cache_dir/blob/0b2dbb2553019dd829d71665bed7f48f610b64f0/rs_lib/src/local.rs#L651 +export function shouldHash(fileName: string): boolean { + return fileName.length === 0 || + fileName.length > 30 || + hasForbiddenChars(fileName); +} + +// https://github.com/denoland/deno_cache_dir/blob/0b2dbb2553019dd829d71665bed7f48f610b64f0/rs_lib/src/local.rs#L621 +export async function shortHash(fileName: string): Promise { + const hash = await sha256(fileName); + const MAX_LENGTH = 20; + let sub = ""; + let count = 0; + for (const c of fileName) { + if (count >= MAX_LENGTH) break; + if (c === "?") break; + if (FORBIDDEN_CHARS.has(c)) { + sub += "_"; + } else { + sub += c.toLowerCase(); + } + count++; + } + + const parts = splitOnce(sub, ".", "right"); + sub = parts[0]; + let ext = parts.at(1); + ext = ext ? `.${ext}` : ""; + + if (sub.length === 0) { + return `#${hash.slice(0, 7)}${ext}`; + } else { + return `#${sub}_${hash.slice(0, 5)}${ext}`; + } +} + +export function urlSegments(url: string | URL) { + return new URL(url).pathname.replace(/^\//, "").split("/"); +} diff --git a/deno/tests/main.test.ts b/deno/tests/main.test.ts new file mode 100644 index 00000000..ce920ce1 --- /dev/null +++ b/deno/tests/main.test.ts @@ -0,0 +1,167 @@ +// LICENSE = MIT +// deno-lint-ignore-file require-await +import { assertEquals, assertMatch } from "jsr:@std/assert@0.221.0"; +import { jsrPkgToFlatpakData, npmPkgToFlatpakData } from "../src/main.ts"; + +Deno.test("jsrPkgToFlatpakData returns correct flatpak data", async () => { + // Mock fetch for meta.json and versioned meta + const metaJson = JSON.stringify({ + dummy: true, + }); + const metaVerJson = JSON.stringify({ + moduleGraph2: { + "/mod.ts": {}, + "/deno.json": {}, + }, + moduleGraph1: {}, + manifest: { + "/mod.ts": { + checksum: "sha256-abcdef1234567890", + }, + "/deno.json": { + checksum: "sha256-ffeeddccbbaa9988", + }, + }, + }); + + let fetchCallCount = 0; + const origFetch = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + writable: true, + value: async (input: URL | RequestInfo, _init?: RequestInit) => { + const url = typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + fetchCallCount++; + if (url.endsWith("_meta.json")) { + return { + text: async () => metaVerJson, + } as Response; + } + if (url.endsWith("meta.json")) { + return { + text: async () => metaJson, + } as Response; + } + throw new Error("Unexpected fetch url: " + url); + }, + }); + + const pkg = { + module: "@std/encoding", + version: "1.0.10", + name: "encoding", + }; + + const data = await jsrPkgToFlatpakData(pkg); + // Restore fetch after test + Object.defineProperty(globalThis, "fetch", { + configurable: true, + writable: true, + value: origFetch, + }); + + // Should have meta.json, versioned meta, /mod.ts, deno.json, and duplicate deno.json + assertEquals(data.length, 5); + + // meta.json + assertEquals(data[0].url, "https://jsr.io/@std/encoding/meta.json"); + assertEquals(data[0]["dest-filename"], "meta.json"); + assertEquals(data[1].url, "https://jsr.io/@std/encoding/1.0.10_meta.json"); + assertEquals(data[1]["dest-filename"], "1.0.10_meta.json"); + + // /mod.ts + assertEquals(data[2].url, "https://jsr.io/@std/encoding/1.0.10/mod.ts"); + assertEquals(data[2].sha256, "abcdef1234567890"); + // Accept either "mod.ts" or a hashed filename + assertMatch( + data[2]["dest-filename"] as string, + /^mod\.ts$|^#mod_[a-f0-9]{5}\.ts$/, + ); + + // /deno.json + assertEquals(data[3].url, "https://jsr.io/@std/encoding/1.0.10/deno.json"); + assertEquals(data[3].sha256, "ffeeddccbbaa9988"); + assertEquals(data[3]["dest-filename"], "deno.json"); +}); + +Deno.test("npmPkgToFlatpakData returns correct flatpak data", async () => { + // Mock fetch for npm meta + const metaJson = JSON.stringify({ + versions: { + "2.18.4": { + dist: { + // "abcdefg" in base64 is "YWJjZGVmZw==" + integrity: "sha512-YWJjZGVmZw==", + }, + }, + }, + }); + + const origFetch = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + writable: true, + value: async (input: URL | RequestInfo, _init?: RequestInit) => { + const url = typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + if (url === "https://registry.npmjs.org/@napi-rs/cli") { + return { + text: async () => metaJson, + } as Response; + } + throw new Error("Unexpected fetch url: " + url); + }, + }); + + const pkg = { + module: "@napi-rs/cli", + version: "2.18.4", + name: "cli", + cpu: "x86_64" as const, + }; + + const data = await npmPkgToFlatpakData(pkg); + // Restore fetch after test + Object.defineProperty(globalThis, "fetch", { + configurable: true, + writable: true, + value: origFetch, + }); + + // Should have registry.json and archive + assertEquals(data.length, 2); + + // registry.json + assertEquals(data[0].url, "https://registry.npmjs.org/@napi-rs/cli"); + assertEquals(data[0]["dest-filename"], "registry.json"); + + // archive + assertEquals( + data[1].url, + "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", + ); + assertEquals( + data[1]["archive-type"], + "tar-gzip", + ); + assertEquals( + data[1].dest, + "deno_dir/npm/registry.npmjs.org/@napi-rs/cli/2.18.4", + ); + assertEquals( + (data[1]["only-arches"] as string[])[0], + "x86_64", + ); + // sha512 should be present and hex + assertMatch( + String(data[1].sha512), + /^[a-f0-9]+$/, + ); +}); diff --git a/deno/tests/main_function.test.ts b/deno/tests/main_function.test.ts new file mode 100644 index 00000000..a1e386e9 --- /dev/null +++ b/deno/tests/main_function.test.ts @@ -0,0 +1,76 @@ +// LICENSE = MIT +// deno-lint-ignore-file no-explicit-any +import { main } from "../src/main.ts"; +import { assert } from "jsr:@std/assert@0.221.0"; +import { join } from "jsr:@std/path@0.221.0"; +import { existsSync } from "jsr:@std/fs@0.221.0"; + +Deno.test("main function: generates deno-sources.json from lockfile", async () => { + const tmpDir = "./tests/tmp_main"; + await Deno.mkdir(tmpDir, { recursive: true }); + const lockPath = join(tmpDir, "deno.lock"); + const sourcesPath = join(tmpDir, "deno-sources.json"); + + // Lockfile with jsr, npm, and https deps + await Deno.writeTextFile( + lockPath, + JSON.stringify( + { + version: "5", + jsr: { + "@std/encoding@1.0.10": { + integrity: + "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1", + }, + }, + npm: { + "left-pad@1.3.0": { + integrity: + "sha512-1r9Z1tcHTul3e8DqRLVQjaxAg/P6nxsVXni4eWh05rq6ArlTc95xJMu38xpv8uKXuX4nHCqB6f+GO6zkRgLr1w==", + engines: { node: ">=0.10.0" }, + }, + // peer dep + "update-browserslist-db@1.1.3_browserslist@4.24.4": { + "integrity": + "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dependencies": [ + "browserslist", + "escalade", + "picocolors", + ], + "bin": true, + }, + }, + remote: { + "https://deno.land/std@0.203.0/uuid/v1.ts": + "b6e2e2c1e2c1e2c1e2c1e2c1e2c1e2c1e2c1e2c1e2c1e2c1e2c1e2c1e2c1e2c1", + }, + }, + null, + 2, + ), + ); + + try { + await main(lockPath, sourcesPath); + assert(existsSync(sourcesPath), "deno-sources.json should be created"); + const sources = JSON.parse(await Deno.readTextFile(sourcesPath)); + assert(Array.isArray(sources)); + // jsr checks + assert(sources.some((s: any) => s["dest-filename"] === "meta.json")); + assert(sources.some((s: any) => s["dest-filename"] === "1.0.10_meta.json")); + // npm checks + assert(sources.some((s: any) => s["dest-filename"] === "registry.json")); + assert(sources.some((s: any) => + typeof s.dest === "string" && + s.dest.includes("left-pad/1.3.0") + )); + // https checks + assert(sources.some((s: any) => + typeof s.url === "string" && + s.url.startsWith("https://deno.land/std@0.203.0/uuid/v1.ts") + )); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); diff --git a/deno/tests/utils.test.ts b/deno/tests/utils.test.ts new file mode 100644 index 00000000..be4a424c --- /dev/null +++ b/deno/tests/utils.test.ts @@ -0,0 +1,77 @@ +// LICENSE = MIT +import { assert, assertEquals, assertMatch } from "jsr:@std/assert@0.221.0"; +import { + base64ToHex, + sha256, + shortHash, + shouldHash, + splitOnce, + urlSegments, +} from "../src/utils.ts"; + +Deno.test("sha256 produces correct hash", async () => { + const hash = await sha256("hello world"); + assertEquals( + hash, + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + ); +}); + +Deno.test("base64ToHex converts base64 to hex", () => { + // "hello" in base64 is "aGVsbG8=" + // "hello" in hex is "68656c6c6f" + assertEquals(base64ToHex("aGVsbG8="), "68656c6c6f"); +}); + +Deno.test("splitOnce splits string correctly (left)", () => { + assertEquals(splitOnce("foo:bar:baz", ":"), ["foo", "bar:baz"]); + assertEquals(splitOnce("foo", ":"), ["foo"]); +}); + +Deno.test("splitOnce splits string correctly (right)", () => { + assertEquals(splitOnce("foo:bar:baz", ":", "right"), ["foo:bar", "baz"]); + assertEquals(splitOnce("foo", ":", "right"), ["foo"]); +}); + +Deno.test("shouldHash returns true for forbidden or long file names", () => { + assert(shouldHash("ThisIsUppercase.txt")); + assert(shouldHash("file?name.txt")); + assert(shouldHash("a".repeat(31))); + assert(shouldHash("")); +}); + +Deno.test("shouldHash returns false for safe short lowercase names", () => { + assert(!shouldHash("file.txt")); + assert(!shouldHash("abc")); +}); + +Deno.test("shortHash returns hashed filename for forbidden/long names", async () => { + const result = await shortHash("ThisIsUppercase.txt"); + assertMatch(result, /^#thisisuppercase_[a-f0-9]{5}\.txt$/); + const result2 = await shortHash("file?name.txt"); + assertMatch(result2, /^#file_[a-f0-9]{5}$/); + const result3 = await shortHash("a".repeat(40)); + assertMatch(result3, /^#aaaaaaaaaaaaaaaaaaaa_[a-f0-9]{5}$/); + const result4 = await shortHash("file { + const result = await shortHash(""); + assertMatch(result, /^#[a-f0-9]{7}$/); +}); + +Deno.test("urlSegments splits URL path into segments", () => { + assertEquals( + urlSegments("https://example.com/foo/bar/baz.txt"), + ["foo", "bar", "baz.txt"], + ); + assertEquals( + urlSegments(new URL("https://example.com/a/b/c")), + ["a", "b", "c"], + ); + assertEquals( + urlSegments("https://example.com/"), + [""], + ); +});