From 1aec86e5cee9118129e0f13c71604ae4e8bf3224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C6=A1n=20Tr=E1=BA=A7n-Nguy=E1=BB=85n?= Date: Mon, 19 Oct 2026 21:33:23 +0000 Subject: [PATCH] feat: the azureblob and doi backends, and the name encoder of rclone azureblob reads and writes the blobs of Azure Blob Storage and of Azurite, its emulator, as the azureblob backend of rclone: - Shared Key with the account and its key, a SAS URL, a connection string, or the emulator. The options of Azure AD give 501. - A file of chunk_size or less goes in one Put Blob. A larger file, or a file of unknown size, goes in blocks with the IDs of rclone. - Folders exist when they have a file. With directory_markers, a PUT puts the markers of rclone (hdi_isfolder=true). - The time of a file is in the metadata mtime, as rclone. COPY is a Copy Blob, and PATCH is a Set Blob Metadata. - Ranges as RFC 9110, also a suffix range and a start at the end of the file: Azurite gives 206 for them. - The names have the encoding of the option encoding, with the default of rclone: Slash, BackSlash, Del, Ctl, RightPeriod and InvalidUtf8. - The key, the SAS and the connection string are not in the links of a list. doi reads the files of a dataset with a DOI, as the doi backend of rclone: Zenodo, InvenioRDM and Dataverse. The provider comes from the host, from the landing page or from the option provider. The backend is read only. lib/encoder.js is the encoder of the names of rclone 1.75.1: the masks, the option encoding, and the standard names and paths. The full table of the tests of rclone (10457 cases) passes. The test file keeps the first case of each mask, and a fast-check test of the round trip. lib/parts.js has Parts of s3, for the uploads in parts of s3 and azureblob. lib/xml.js gives the attributes of an element. The shared suite runs on Azurite with three configurations, and the tests compare rfetch with rclone on the same Azurite: the files, the times and the encoded names in the two directions. CI installs Azurite 3.37.0. --- .github/workflows/ci.yml | 22 +- CONTRIBUTING.md | 26 +- README.md | 4 + backend/azureblob/main.js | 1243 +++++++++++++ backend/azureblob/main_test.js | 230 +++ backend/azureblob/package.json | 25 + backend/azureblob/sharedkey.js | 74 + backend/azureblob/testing.js | 60 + backend/doi/main.js | 588 ++++++ backend/doi/main_test.js | 462 +++++ backend/doi/package.json | 25 + backend/main.js | 2 + backend/s3/main.js | 54 +- lib/encoder.js | 486 +++++ lib/encoder_test.js | 3158 ++++++++++++++++++++++++++++++++ lib/package.json | 2 + lib/parts.js | 57 + lib/parts_test.js | 50 + lib/xml.js | 25 +- lib/xml_test.js | 10 + package-lock.json | 32 + package.json | 2 + 22 files changed, 6573 insertions(+), 64 deletions(-) create mode 100644 backend/azureblob/main.js create mode 100644 backend/azureblob/main_test.js create mode 100644 backend/azureblob/package.json create mode 100644 backend/azureblob/sharedkey.js create mode 100644 backend/azureblob/testing.js create mode 100644 backend/doi/main.js create mode 100644 backend/doi/main_test.js create mode 100644 backend/doi/package.json create mode 100644 lib/encoder.js create mode 100644 lib/encoder_test.js create mode 100644 lib/parts.js create mode 100644 lib/parts_test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0b110b..5a12f4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,9 @@ # The checks of each push and of each pull request: # # - npm test: the tests, with the oldest Node.js of package.json and the -# newer versions. The job installs rclone, 7-Zip, Go, go-pmtiles and -# versatiles, thus the tests that compare rfetch with these tools run too. +# newer versions. The job installs rclone, 7-Zip, Go, go-pmtiles, +# versatiles and Azurite, thus the tests that compare rfetch with these +# tools, or that run on them, run too. # - deno fmt --check: the format of the files. # - wasm: the tests of the crate of backend/crypt/secretbox.wasm, and its # build from its source gives the same bytes as the file in the repository. @@ -107,16 +108,27 @@ jobs: "$HOME/bin/versatiles" --version echo "VERSATILES=$HOME/bin/versatiles" >> "$GITHUB_ENV" + # Azurite is the emulator of Azure Storage: the tests of the azureblob + # backend run on it. npm checks the package with its integrity hash. + - name: Install Azurite + env: + VERSION: 3.37.0 + run: | + npm install --prefix "$RUNNER_TEMP/azurite" --no-save --no-audit \ + --no-fund "azurite@$VERSION" + "$RUNNER_TEMP/azurite/node_modules/.bin/azurite-blob" --version + echo "AZURITE=$RUNNER_TEMP/azurite/node_modules/.bin/azurite-blob" >> "$GITHUB_ENV" + - run: npm ci - # A test against rclone, 7-Zip, Go, go-pmtiles or versatiles skips when - # the tool is not installed. + # A test against rclone, 7-Zip, Go, go-pmtiles, versatiles or Azurite + # skips when the tool is not installed. # Here the tools are installed, thus such a skip is an error. - name: npm test shell: bash run: | npm test 2>&1 | tee "$RUNNER_TEMP/test.log" - if grep -E "((rclone|7-Zip|Go|go-pmtiles|versatiles) is not installed|node:sqlite is not in)" "$RUNNER_TEMP/test.log"; then + if grep -E "((rclone|7-Zip|Go|go-pmtiles|versatiles|Azurite) is not installed|node:sqlite is not in)" "$RUNNER_TEMP/test.log"; then echo "::error::A test skipped because a tool is not installed." exit 1 fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3236f5..a7d08c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -267,6 +267,18 @@ a remote in its path, as `:archive:s3:bucket/a.zip`, uses `decodeRemotePath`: the connection string of that remote can have empty names, for example in `url='https://example.com'`. +A service can refuse some names, or change them, for example a name with a +control character or with a dot at the end. As rclone, a backend for such a +service changes these names with the option `encoding`: read its value with +`parseEncoding` of `@rfetch/lib/encoder`, give each name of a request to +`encodeName`, and each name of a list to `decodeName`. The default value is the +default of rclone for that backend: then rclone and rfetch keep the same names +in the service. + +A backend that uploads a large file in parts, for example s3 and azureblob, +reads the body with `Parts` of `@rfetch/lib/parts`: each part has the size of +the option `chunk_size`, and only one part is in memory. + A backend on another remote, for example alias, gives `fetch` the location `encodeLocation(remote) + encodePath(path)`. The option `remote` is a path of rclone: `encodeLocation` of `@rfetch/lib/fspath` keeps its `%`, `?` and `#` in @@ -454,6 +466,13 @@ default). Put the credentials in `RFETCH_S3_ACCESS_KEY_ID` and `RCLONE_`. Use a bucket only for the tests: the tests make folders in it and delete them. +The tests of `azureblob` use Azurite, the emulator of Azure Storage, with its +data in memory: they run when `azurite-blob` is in the `PATH`, or when the +variable `AZURITE` is its path. Get it with `npm install azurite@3.37.0`. The +tests with rclone use the same Azurite. The tests of `doi` use a local server +that gives the answers of Zenodo, InvenioRDM and Dataverse: they need no +network. + ## WebAssembly of crypt The crypt backend encrypts the blocks of the data with @@ -505,6 +524,7 @@ message of the commit. ## Continuous integration The workflow `.github/workflows/ci.yml` runs for each push and each pull -request. It installs rclone and Go, runs `npm test` with Node.js 22, 24 and 26, -and runs `deno fmt --check`. Before a push, run `npm test` and -`deno fmt --check`. +request. It installs rclone, 7-Zip, Go, go-pmtiles, versatiles and Azurite, runs +`npm test` with Node.js 22, 24 and 26, and runs `deno fmt --check`. A test that +needs one of these tools skips when the tool is not installed: in the workflow, +such a skip is an error. Before a push, run `npm test` and `deno fmt --check`. diff --git a/README.md b/README.md index 971a35f..993a00f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ A [Rclone wrapper for WebAssembly build](./rclone.js) is also available. ## Storage providers +- Azure Blob Storage, and Azurite, its emulator + [:page_facing_up:](https://rclone.org/azureblob/) +- DOI: read the files of a dataset with a DOI, on Zenodo, InvenioRDM and + Dataverse [:page_facing_up:](https://rclone.org/doi/) - Google Drive [:page_facing_up:](https://rclone.org/drive/) - HTTP [:page_facing_up:](https://rclone.org/http/) - The local filesystem [:page_facing_up:](https://rclone.org/local/) diff --git a/backend/azureblob/main.js b/backend/azureblob/main.js new file mode 100644 index 0000000..3456cfb --- /dev/null +++ b/backend/azureblob/main.js @@ -0,0 +1,1243 @@ +/** + * Azure Blob Storage backend + * + * The azureblob backend reads and writes the blobs of the Blob service of + * Microsoft Azure Storage, and of Azurite, its emulator, as the azureblob + * backend of rclone. + * + * A path is `/container/path/to/file`. The path `/` is the list of the + * containers, and `/container/` is the root of a container. + * + * The options, with the names of rclone: + * + * - `account` and `key`: the name of the storage account, and its key in + * base64, for the Shared Key authorization. With an account and no key, + * the requests have no authorization: a public container can answer them. + * - `sas_url`: a URL with a shared access signature (SAS), for an account or + * for one container. With a SAS of a container, the remote has only that + * container. + * - `connection_string`: a connection string of Azure, with + * `AccountName`, `AccountKey`, `BlobEndpoint`, `SharedAccessSignature`, + * `DefaultEndpointsProtocol` and `EndpointSuffix`. + * - `endpoint`: the URL of the service, by default + * `https://ACCOUNT.blob.core.windows.net`. + * - `use_emulator`: `true` for Azurite on `http://127.0.0.1:10000`, with the + * account `devstoreaccount1` and its key, for the options with no value. + * - `chunk_size`: 4 MiB by default. A file of a known size up to this size + * goes in one Put Blob. A larger file, or a file of unknown size, goes in + * blocks of this size. + * - `list_chunk`: 5000 by default, the number of blobs of each list. + * - `access_tier`: `hot`, `cool`, `cold` or `archive`, for each new blob. + * - `directory_markers`: `false` by default, thus a folder exists only when + * it has a file, as in rclone. With `true`, the PUT of a folder, or of a + * file, puts a marker for the folder and for each parent folder that has + * none: an empty blob with the name of the folder and a `/`, and the + * metadata `hdi_isfolder=true`. + * - `no_check_container`: with `true`, a PUT does not make its container. + * - `public_access`: `blob` or `container`, the public access of a new + * container. + * - `encoding`: the names that change in the service, as rclone, by default + * `Slash,BackSlash,Del,Ctl,RightPeriod,InvalidUtf8`. For example, the + * blob of the file `a.` is `a.`, and the blob of a control character is + * a character of the block "Control Pictures", as `␋`. The lists give the + * names of the files. + * + * rfetch accepts `upload_concurrency`, `copy_cutoff`, `use_copy_blob`, + * `upload_cutoff`, `disable_checksum`, `no_head_object` and + * `delete_snapshots` of rclone, but they do nothing. The options of Azure + * AD, for example `env_auth`, `tenant` and `use_msi`, give 501. + * + * Each option can also come from a header or from `env`, with the name of + * the variable of rfetch, for example `RFETCH_AZUREBLOB_KEY`, or of rclone, + * `RCLONE_AZUREBLOB_KEY`. Put the key in a header or in `env`, not in a URL. + * The key, the SAS and the connection string are not in the links of a + * list. + * + * As rclone, the backend keeps the time of a file in the metadata `mtime` + * of the blob, from the header `X-OC-Mtime` of a `PUT` or a `COPY`. `GET`, + * `HEAD` and the list give that time. A `COPY` of a file is a Copy Blob, and + * a `PATCH` with `X-OC-Mtime` is a Set Blob Metadata. A PUT of a file makes + * its container first, unless `no_check_container`. + */ +import { renderListing } from "@rfetch/lib/listing"; +import { mimeType } from "@rfetch/lib/mime"; +import { optionsOf } from "@rfetch/lib/options"; +import { decodeName, encodeName, parseEncoding } from "@rfetch/lib/encoder"; +import { Parts } from "@rfetch/lib/parts"; +import { decodePath, encodePath } from "@rfetch/lib/path"; +import { + notSatisfiable, + parseRange, + partialHeaders, + sliceStream, +} from "@rfetch/lib/range"; +import { parseSize } from "@rfetch/lib/size"; +import { sized } from "@rfetch/lib/sized"; +import { isoStringOf, modTimeOf } from "@rfetch/lib/time"; +import { childrenOf, escapeXML, parseXML, textOf } from "@rfetch/lib/xml"; +import { sign } from "./sharedkey.js"; + +/** + * The options of the backend, with their types, for example for the flag + * `--azureblob-account` of the command line. + */ +export const options = { + string: [ + "account", + "key", + "sas_url", + "connection_string", + "endpoint", + "chunk_size", + "list_chunk", + "access_tier", + "public_access", + "upload_concurrency", + "copy_cutoff", + "upload_cutoff", + "encoding", + "delete_snapshots", + ], + boolean: [ + "use_emulator", + "directory_markers", + "no_check_container", + "no_head_object", + "disable_checksum", + "use_copy_blob", + ], + default: { + chunk_size: "4Mi", + list_chunk: "5000", + encoding: "Slash,BackSlash,Del,Ctl,RightPeriod,InvalidUtf8", + }, +}; + +/** + * The options that are secret. They are not in the links of a list, which a + * browser can keep in its history and send to other sites. + */ +const SECRETS = ["key", "sas_url", "connection_string"]; + +/** The options of Azure AD, which rfetch does not know. */ +const AZURE_AD = [ + "env_auth", + "tenant", + "client_id", + "client_secret", + "client_certificate_path", + "client_certificate_password", + "username", + "password", + "service_principal_file", + "use_msi", + "msi_object_id", + "msi_client_id", + "msi_mi_res_id", + "use_az", +]; + +/** The version of the API of the Blob service, as rclone. */ +const VERSION = "2026-06-06"; + +/** The account of Azurite, as rclone: its name and its key are public. */ +const EMULATOR = { + account: "devstoreaccount1", + key: + "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==", + endpoint: "http://127.0.0.1:10000/devstoreaccount1", +}; + +/** The name of the metadata of the time of a file, as rclone. */ +const MTIME = "mtime"; + +/** The largest number of blocks of a blob. */ +const MAX_BLOCKS = 50000; + +/** The containers that this process made, by their URLs. */ +const made = new Set(); + +/** + * The configuration of the requests. + * @typedef {Object} Config + * @property {URL} endpoint The URL of the service, with no `/` at the end. + * @property {{ account: string, key: string } | null} credentials + * @property {URLSearchParams | null} sas The signature of a SAS. + * @property {string} container The container of a SAS of a container, or + * "". + * @property {number} chunkSize + * @property {number} listChunk + * @property {string} tier + * @property {boolean} markers + * @property {boolean} checkContainer + * @property {string} publicAccess + * @property {number} encoding The mask of the encoding of the names, as + * the option `encoding` of rclone. + */ + +/** An error with the status of the answer. */ +class AzureError extends Error { + /** @param {string} message @param {number} status */ + constructor(message, status) { + super(message); + this.status = status; + } +} + +/** + * Serves an Azure Blob remote. + * @param {Request} request + * @param {Record} [env] The options in the variables + * `RFETCH_AZUREBLOB_*` or `RCLONE_AZUREBLOB_*`. + * @returns {Promise} + */ +async function azureblob(request, env = {}) { + const { method } = request; + const { pathname, searchParams } = new URL(request.url); + + let config; + try { + config = configOf(optionsOf(request, "azureblob", env)); + } catch (error) { + await request.body?.cancel(); + if (error instanceof AzureError) { + return new Response(error.message, { status: error.status }); + } + throw error; + } + const path = decodePath(pathname); + if (path === null) { + await request.body?.cancel(); + return new Response(null, { status: 400 }); + } + const slash = path.indexOf("/", 1); + // The names in the service have the encoding of the option `encoding`. + const container = encodeName( + slash === -1 ? path.slice(1) : path.slice(1, slash), + config.encoding, + ); + const blob = slash === -1 + ? "" + : encodeNames(path.slice(slash + 1), config.encoding); + const isFolder = path.endsWith("/"); + if (!isFolder && blob === "") { + // A file is in a container. + await request.body?.cancel(); + return new Response(null, { status: method === "PUT" ? 400 : 404 }); + } + const modTime = modTimeOf(request); + if (modTime === undefined) { + await request.body?.cancel(); + return new Response("X-OC-Mtime is not a time", { status: 400 }); + } + /** The headers of an answer that sets the time of a file. */ + const accepted = modTime === null ? {} : { "X-OC-Mtime": "accepted" }; + + try { + if (method === "GET" || method === "HEAD") { + if (!isFolder) return await getFile(config, container, blob, request); + const query = new URLSearchParams(searchParams); + for (const name of SECRETS) query.delete(name); + return await getFolder(config, container, blob, method, `${query}`); + } + if (container === "") { + await request.body?.cancel(); + return new Response(null, { status: 405, headers: { Allow: "GET" } }); + } + if (method === "PUT") { + if (isFolder) { + await request.body?.cancel(); + await putFolder(config, container, blob); + } else { + await putFile(config, container, blob, request, modTime); + } + return new Response(null, { + status: 201, + headers: { + "Content-Location": pathname, + ...(isFolder ? {} : accepted), + }, + }); + } + if (method === "DELETE") { + await (isFolder + ? deleteFolder(config, container, blob) + : deleteFile(config, container, blob)); + return new Response(null, { status: 204 }); + } + if (method === "COPY" && !isFolder) { + const target = decodePath(request.headers.get("Destination") ?? ""); + const end = target?.indexOf("/", 1) ?? -1; + if (target === null || end === -1 || end === target.length - 1) { + return new Response("Destination is not a file", { status: 400 }); + } + if (target.endsWith("/")) { + return new Response("Destination is a folder", { status: 409 }); + } + await copyFile(config, { container, blob }, { + container: encodeName(target.slice(1, end), config.encoding), + blob: encodeNames(target.slice(end + 1), config.encoding), + }, modTime); + return new Response(null, { status: 201, headers: accepted }); + } + if (method === "PATCH" && !isFolder) { + if (modTime === null) { + return new Response("X-OC-Mtime is missing", { status: 400 }); + } + await setModTime(config, container, blob, modTime); + return new Response(null, { status: 204, headers: accepted }); + } + } catch (error) { + await request.body?.cancel().catch(() => {}); + if (error instanceof AzureError) { + return new Response(error.message, { status: error.status }); + } + throw error; + } + await request.body?.cancel(); + return new Response(null, { + status: 405, + headers: { Allow: "GET, HEAD, PUT, DELETE, COPY, PATCH" }, + }); +} + +/** + * Encodes each name of a path, as `FromStandardPath` of rclone. + * @param {string} path For example `a/b.txt`, or `a/` for a folder. + * @param {number} mask + */ +function encodeNames(path, mask) { + return path.split("/").map((name) => encodeName(name, mask)).join("/"); +} + +/** + * Reads the options of a request, as `NewFs` of the azureblob backend of + * rclone. + * @param {URLSearchParams | Map} values + * @returns {Config} + * @throws {AzureError} When an option is not correct. + */ +function configOf(values) { + /** @param {string} name */ + const get = (name) => values.get(name) ?? options.default[name] ?? ""; + for (const name of AZURE_AD) { + const value = get(name); + if (value && value !== "false") { + throw new AzureError(`rfetch does not know the option ${name}`, 501); + } + } + let account = get("account"); + let key = get("key"); + let endpoint = get("endpoint"); + /** @type {URLSearchParams | null} */ + let sas = null; + let container = ""; + + if (get("use_emulator") === "true") { + account ||= EMULATOR.account; + key ||= EMULATOR.key; + endpoint ||= EMULATOR.endpoint; + } + const connection = get("connection_string"); + if (connection) { + /** @type {Record} */ + const parts = {}; + for (const part of connection.split(";")) { + const equal = part.indexOf("="); + if (equal > 0) parts[part.slice(0, equal).trim()] = part.slice(equal + 1); + } + account ||= parts.AccountName ?? ""; + key ||= parts.AccountKey ?? ""; + endpoint ||= parts.BlobEndpoint ?? + (account + ? `${parts.DefaultEndpointsProtocol || "https"}://${account}.blob.${ + parts.EndpointSuffix || "core.windows.net" + }` + : ""); + if (parts.SharedAccessSignature) { + sas = new URLSearchParams(parts.SharedAccessSignature); + } + } + const sasURL = get("sas_url"); + if (sasURL) { + let url; + try { + url = new URL(sasURL); + } catch { + throw new AzureError("The sas_url option is not a URL", 400); + } + const names = url.pathname.split("/").filter(Boolean); + // A URL of the emulator, or of an IP address, has the account in its + // path. + const pathStyle = /^[\d.]+$|^\[|^localhost$/.test(url.hostname); + const prefix = pathStyle ? names.slice(0, 1) : []; + container = decodeURIComponent(names[prefix.length] ?? ""); + endpoint = `${url.origin}${prefix.map((name) => `/${name}`).join("")}`; + sas = url.searchParams; + } + if (!endpoint) { + if (!account) { + throw new AzureError("The account option or a SAS is missing", 400); + } + endpoint = `https://${account}.blob.core.windows.net`; + } + let url; + try { + url = new URL(endpoint); + } catch { + throw new AzureError("The endpoint is not a URL", 400); + } + url.pathname = url.pathname.replace(/\/+$/, ""); + url.search = ""; + + const chunkSize = parseSize(get("chunk_size")); + if (!(chunkSize >= 1)) { + throw new AzureError("chunk_size must be at least 1 byte", 400); + } + const listChunk = Number(get("list_chunk")); + if (!Number.isInteger(listChunk) || listChunk < 1 || listChunk > 5000) { + throw new AzureError("list_chunk must be from 1 to 5000", 400); + } + const tier = get("access_tier"); + if (tier && !/^(hot|cool|cold|archive)$/i.test(tier)) { + throw new AzureError(`azure: tier ${tier} not recognized`, 400); + } + let encoding; + try { + encoding = parseEncoding(get("encoding")); + } catch (error) { + throw new AzureError(error.message, 400); + } + const publicAccess = get("public_access"); + if (publicAccess && !/^(blob|container)$/.test(publicAccess)) { + throw new AzureError( + `public_access must be blob or container, not ${publicAccess}`, + 400, + ); + } + return { + endpoint: url, + credentials: key && !sas ? { account, key } : null, + sas, + container, + chunkSize, + listChunk, + tier, + markers: get("directory_markers") === "true", + checkContainer: get("no_check_container") !== "true" && !container, + publicAccess, + encoding, + }; +} + +/** + * The URL of a container or of a blob. + * @param {Config} config + * @param {string} container "" for the service. + * @param {string} blob "" for the container. + * @param {Record} [query] + * @returns {URL} + */ +function urlOf(config, container, blob, query = {}) { + const url = new URL(config.endpoint); + let path = url.pathname; + if (container) path += `/${encodeURIComponent(container)}`; + if (blob) path += `/${encodePath(blob)}`; + url.pathname = path || "/"; + const params = Object.entries(query); + if (config.sas) params.push(...config.sas); + url.search = params + .map(([name, value]) => + `${encodeURIComponent(name)}=${encodeURIComponent(value)}` + ) + .join("&"); + return url; +} + +/** + * Sends a request to the Blob service, with the authorization of the + * configuration. + * @param {Config} config + * @param {string} method + * @param {string} container "" for the service. + * @param {string} blob "" for the container. + * @param {Object} [init] + * @param {Record} [init.query] + * @param {Record} [init.headers] + * @param {BodyInit | null} [init.body] + * @returns {Promise} + * @throws {AzureError} When the service does not answer, or when the + * container is not the container of the SAS. + */ +async function send( + config, + method, + container, + blob, + { query = {}, headers = {}, body = null } = {}, +) { + if (config.container && container && container !== config.container) { + throw new AzureError( + `The SAS is only for the container ${config.container}`, + 403, + ); + } + const url = urlOf(config, container, blob, query); + let all = new Headers(headers); + all.set("x-ms-version", VERSION); + // The size of a file is the size of its body. + all.set("Accept-Encoding", "identity"); + if (config.credentials) { + all = sign({ method, url, headers: all }, config.credentials); + } + /** @type {RequestInit} */ + const init = { method, headers: all }; + if (body !== null) { + init.body = body; + if (body instanceof ReadableStream) init.duplex = "half"; + } + try { + return await fetch(url, init); + } catch { + throw new AzureError("The Blob service did not answer", 502); + } +} + +/** + * Makes sure that an answer is a success, else throws its error, with the + * code of the service. + * @param {Response} response + * @returns {Promise} + * @throws {AzureError} + */ +async function check(response) { + if (response.ok) return response; + const text = await response.text().catch(() => ""); + let code = response.headers.get("x-ms-error-code") ?? ""; + if (!code) { + try { + code = textOf(parseXML(text), "Code"); + } catch { + // An answer with no XML, for example of HEAD. + } + } + throw new AzureError(code || response.statusText, response.status); +} + +/** + * The metadata of a blob, from the headers `x-ms-meta-*`, by their names in + * lower case. + * @param {Headers} headers + * @returns {Record} + */ +function metadataOf(headers) { + /** @type {Record} */ + const metadata = {}; + for (const [name, value] of headers) { + if (name.startsWith("x-ms-meta-")) metadata[name.slice(10)] = value; + } + return metadata; +} + +/** + * The headers `x-ms-meta-*` of metadata. + * @param {Record} metadata + * @returns {Record} + */ +function metaHeaders(metadata) { + return Object.fromEntries( + Object.entries(metadata).map(( + [name, value], + ) => [`x-ms-meta-${name}`, value]), + ); +} + +/** + * Tells if a blob is the marker of a folder, as rclone: an empty blob with a + * name that ends with `/`, or with the metadata `hdi_isfolder=true`. + * @param {string} name + * @param {number} size + * @param {Record} metadata + */ +function isMarker(name, size, metadata) { + return size === 0 && + (name === "" || name.endsWith("/") || metadata.hdi_isfolder === "true"); +} + +/** + * The time of a blob in ISO 8601: its metadata `mtime`, else its + * `Last-Modified`, as rclone. + * @param {Record} metadata + * @param {string | null} lastModified + * @returns {string} + */ +function timeOf(metadata, lastModified) { + const mtime = metadata[MTIME]; + if (mtime !== undefined) { + return Number.isNaN(Date.parse(mtime)) ? "0001-01-01T00:00:00Z" : mtime; + } + const time = Date.parse(lastModified ?? ""); + return Number.isNaN(time) + ? "" + : new Date(time).toISOString().replace(/\.000Z$/, "Z"); +} + +/** + * Gets a file, or only its headers for HEAD, with the range of the request. + * + * A range `bytes=START-END` or `bytes=START-` goes to the service, and the + * client gets its answer 206 when it starts at START. Else, and for a range + * of the last bytes, which the Blob service does not read, the backend gets + * the size with HEAD first, and makes the answer of RFC 9110. + * @param {Config} config + * @param {string} container + * @param {string} blob + * @param {Request} request + * @returns {Promise} + */ +async function getFile(config, container, blob, request) { + const { method } = request; + const header = method === "GET" ? request.headers.get("Range") : null; + let range = null; + try { + // Only the form of the range: the size is not known yet. + range = parseRange(header, Number.MAX_SAFE_INTEGER); + } catch { + // For example bytes=-0: no byte of a file is in the range. + return await getRange(config, container, blob, header); + } + if (range && /^bytes=\d/.test(header ?? "")) { + const response = await send(config, "GET", container, blob, { + headers: { Range: header }, + }); + const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec( + response.headers.get("Content-Range") ?? "", + ); + // Azurite gives 206 for a start at the end of the file, for example + // `bytes 10-9/10`: then the range has no byte of the file. + if ( + response.status === 206 && match && + Number(match[1]) === range.start && + Number(match[1]) <= Number(match[2]) && + Number(match[2]) < Number(match[3]) + ) { + return fileResponse(response, blob); + } + await response.body?.cancel(); + if (response.ok || response.status === 416) { + return await getRange(config, container, blob, header); + } + return new Response(null, { status: response.status }); + } + if (range) return await getRange(config, container, blob, header); + const response = await send(config, method, container, blob); + if (!response.ok) { + await response.body?.cancel(); + return new Response(null, { status: response.status }); + } + return fileResponse(response, blob, method === "HEAD"); +} + +/** + * Gets a range of a file with the size of a HEAD request, as RFC 9110. + * @param {Config} config + * @param {string} container + * @param {string} blob + * @param {string | null} header The `Range` of the request. + * @returns {Promise} + */ +async function getRange(config, container, blob, header) { + const head = await send(config, "HEAD", container, blob); + await head.body?.cancel(); + if (!head.ok) return new Response(null, { status: head.status }); + if ( + isMarker( + blob, + Number(head.headers.get("Content-Length")), + metadataOf(head.headers), + ) + ) { + return new Response(null, { status: 404 }); + } + const size = Number(head.headers.get("Content-Length")); + let range; + try { + range = parseRange(header, size); + } catch { + return notSatisfiable(size); + } + const response = await send(config, "GET", container, blob, { + headers: range ? { Range: `bytes=${range.start}-${range.end}` } : {}, + }); + if (!response.ok) { + await response.body?.cancel(); + return new Response(null, { status: response.status }); + } + if (!range) return fileResponse(response, blob); + const length = range.end - range.start + 1; + let body = response.body; + // A service that sends all the file sends the bytes before the range too. + if (response.status !== 206) body = sliceStream(body, range.start, length); + const headers = fileHeaders(response.headers, blob); + for (const [name, value] of Object.entries(partialHeaders(range, size))) { + headers.set(name, value); + } + return new Response(sized(body, length), { status: 206, headers }); +} + +/** + * The answer for a blob, with the headers of the answer of the service. A + * marker is a folder, thus not a file. + * @param {Response} response + * @param {string} blob + * @param {boolean} [head] True for no body. + * @returns {Response} + */ +function fileResponse(response, blob, head = false) { + const length = response.headers.get("Content-Length"); + if ( + !response.headers.has("Content-Range") && + isMarker(blob, Number(length), metadataOf(response.headers)) + ) { + response.body?.cancel(); + return new Response(null, { status: 404 }); + } + const headers = fileHeaders(response.headers, blob); + if (length !== null) headers.set("Content-Length", length); + const contentRange = response.headers.get("Content-Range"); + if (contentRange) headers.set("Content-Range", contentRange); + if (head) { + response.body?.cancel(); + return new Response(null, { headers }); + } + return new Response(response.body, { status: response.status, headers }); +} + +/** + * The headers of a file for its answer, from the headers of the service: + * its type and its time. + * @param {Headers} service + * @param {string} blob + * @returns {Headers} + */ +function fileHeaders(service, blob) { + const headers = new Headers({ + "Content-Type": service.get("Content-Type") || mimeType(blob), + "Accept-Ranges": "bytes", + }); + const time = Date.parse( + timeOf(metadataOf(service), service.get("Last-Modified")), + ); + if (!Number.isNaN(time)) { + headers.set("Last-Modified", new Date(time).toUTCString()); + } + return headers; +} + +/** + * Gets the list of a folder, or only its headers for HEAD. + * @param {Config} config + * @param {string} container "" for the list of the containers. + * @param {string} prefix The path of the folder in the container, with a + * `/` at the end, or "" for the root of the container. + * @param {string} method + * @param {string} query The query of each link. + * @returns {Promise} + */ +async function getFolder(config, container, prefix, method, query) { + const entries = container === "" + ? await listContainers(config) + : await listFolder(config, container, prefix); + if (entries === null) return new Response(null, { status: 404 }); + const headers = { "Content-Type": "text/html;charset=utf-8" }; + if (method === "HEAD") return new Response(null, { headers }); + return new Response(renderListing(entries, { query }), { headers }); +} + +/** + * The containers, as folders. With a SAS of a container, only that + * container. + * @param {Config} config + * @returns {Promise} + */ +async function listContainers(config) { + if (config.container) { + return [{ + Name: decodeName(config.container, config.encoding), + IsDir: true, + Size: -1, + MimeType: "", + ModTime: "", + }]; + } + /** @type {import("@rfetch/lib/listing").Entry[]} */ + const entries = []; + let marker = ""; + do { + /** @type {Record} */ + const query = { comp: "list", maxresults: `${config.listChunk}` }; + if (marker) query.marker = marker; + const response = await check(await send(config, "GET", "", "", { query })); + const root = parseXML(await response.text()); + for ( + const item of childrenOf(childrenOf(root, "Containers")[0], "Container") + ) { + const properties = childrenOf(item, "Properties")[0]; + entries.push({ + Name: decodeName(textOf(item, "Name"), config.encoding), + IsDir: true, + Size: -1, + MimeType: "", + ModTime: timeOf({}, textOf(properties, "Last-Modified")), + }); + } + marker = textOf(root, "NextMarker"); + } while (marker); + return entries; +} + +/** + * The name of a blob of a list: with `Encoded="true"`, the name has the + * percent-encoding of a query, as `url.QueryUnescape` of Go. + * @param {import("@rfetch/lib/xml").XMLElement} element + * @returns {string | null} null for an encoding that is not correct. + */ +function nameOf(element) { + const [name] = childrenOf(element, "Name"); + if (!name) return null; + if (name.attributes?.Encoded !== "true") return name.text; + try { + return decodeURIComponent(name.text.replaceAll("+", " ")); + } catch { + return null; + } +} + +/** + * Reads the metadata of a blob of a list, by their names in lower case. + * @param {import("@rfetch/lib/xml").XMLElement} element + * @returns {Record} + */ +function listMetadata(element) { + /** @type {Record} */ + const metadata = {}; + for (const item of childrenOf(element, "Metadata")[0]?.children ?? []) { + metadata[item.name.toLowerCase()] = item.text; + } + return metadata; +} + +/** + * The files and the folders in a folder, as `list` of the azureblob backend + * of rclone with a delimiter. + * @param {Config} config + * @param {string} container + * @param {string} prefix "" for the root of the container. + * @returns {Promise} null when + * the folder does not exist. + */ +async function listFolder(config, container, prefix) { + /** @type {import("@rfetch/lib/listing").Entry[]} */ + const entries = []; + const folders = new Set(); + let found = false; + let marker = ""; + do { + /** @type {Record} */ + const query = { + restype: "container", + comp: "list", + delimiter: "/", + include: "metadata", + maxresults: `${config.listChunk}`, + prefix, + }; + if (marker) query.marker = marker; + const response = await send(config, "GET", container, "", { query }); + if (response.status === 404) { + await response.body?.cancel(); + return null; + } + const root = parseXML(await (await check(response)).text()); + const blobs = childrenOf(root, "Blobs")[0]; + for (const item of childrenOf(blobs, "BlobPrefix")) { + const name = nameOf(item); + if (name === null || !name.startsWith(prefix)) continue; + found = true; + const folder = name.slice(prefix.length).replace(/\/$/, ""); + if (folder === "" || folder.includes("/")) continue; + folders.add(folder); + } + for (const item of childrenOf(blobs, "Blob")) { + const name = nameOf(item); + if (name === null || !name.startsWith(prefix)) continue; + found = true; + const file = name.slice(prefix.length); + // The marker of the folder is not a file in it. + if (file === "") continue; + const properties = childrenOf(item, "Properties")[0]; + const size = Number(textOf(properties, "Content-Length")); + const metadata = listMetadata(item); + if (isMarker(file, size, metadata)) { + const folder = file.replace(/\/$/, ""); + if (folder && !folder.includes("/")) folders.add(folder); + continue; + } + if (file.includes("/")) continue; + const Name = decodeName(file, config.encoding); + entries.push({ + Name, + IsDir: false, + Size: size, + MimeType: textOf(properties, "Content-Type") || mimeType(Name), + ModTime: timeOf(metadata, textOf(properties, "Last-Modified")), + }); + } + marker = textOf(root, "NextMarker"); + } while (marker); + + // The root of a container exists with no file. An empty folder exists + // when it has a marker, as rclone checks it. + if (!found && prefix !== "") { + if (!config.markers) return null; + const response = await send(config, "HEAD", container, prefix); + await response.body?.cancel(); + if (!response.ok) return null; + } + for (const folder of folders) { + entries.push({ + Name: decodeName(folder, config.encoding), + IsDir: true, + Size: -1, + MimeType: "", + ModTime: "", + }); + } + return entries; +} + +/** + * Makes a container, as rclone: a container that exists is not an error. + * @param {Config} config + * @param {string} container + */ +async function makeContainer(config, container) { + const key = urlOf(config, container, "").href; + if (made.has(key)) return; + /** @type {Record} */ + const headers = {}; + if (config.publicAccess) { + headers["x-ms-blob-public-access"] = config.publicAccess; + } + const response = await send(config, "PUT", container, "", { + query: { restype: "container" }, + headers, + }); + if (response.status !== 409) await check(response); + await response.body?.cancel(); + made.add(key); +} + +/** + * Makes a container, or a marker of a folder with the option + * `directory_markers`. With no marker, a folder exists when it has a file. + * @param {Config} config + * @param {string} container + * @param {string} prefix + */ +async function putFolder(config, container, prefix) { + if (config.checkContainer || prefix === "") { + await makeContainer(config, container); + } + if (prefix !== "" && config.markers) { + await putMarkers(config, container, prefix); + } +} + +/** + * Puts the markers of a folder and of its parent folders, as rclone: from + * the folder to the root of the container, until a marker exists. + * @param {Config} config + * @param {string} container + * @param {string} prefix The folder, with a `/` at the end. + */ +async function putMarkers(config, container, prefix) { + while (prefix !== "") { + const head = await send(config, "HEAD", container, prefix); + await head.body?.cancel(); + if (head.ok) return; + const response = await send(config, "PUT", container, prefix, { + headers: { + "x-ms-blob-type": "BlockBlob", + "Content-Length": "0", + "x-ms-blob-content-type": "application/octet-stream", + "x-ms-meta-hdi_isfolder": "true", + [`x-ms-meta-${MTIME}`]: isoStringOf(BigInt(Date.now()) * 1000000n), + }, + body: new Uint8Array(0), + }); + await check(response); + await response.body?.cancel(); + prefix = prefix.slice(0, prefix.lastIndexOf("/", prefix.length - 2) + 1); + } +} + +/** + * Puts a file: in one Put Blob up to `chunk_size`, else in blocks, as + * rclone. The container, and the markers with `directory_markers`, come + * first. + * @param {Config} config + * @param {string} container + * @param {string} blob + * @param {Request} request + * @param {bigint | null} modTime The time of the file, in the metadata + * `mtime`, or null. + */ +async function putFile(config, container, blob, request, modTime) { + if (config.checkContainer) await makeContainer(config, container); + if (config.markers) { + await putMarkers( + config, + container, + blob.slice(0, blob.lastIndexOf("/") + 1), + ); + } + const header = request.headers.get("Content-Length"); + const length = header !== null && /^\d+$/.test(header) + ? Number(header) + : null; + /** @type {Record} */ + const headers = { + "x-ms-blob-content-type": request.headers.get("Content-Type") || + mimeType(blob), + }; + if (modTime !== null) headers[`x-ms-meta-${MTIME}`] = isoStringOf(modTime); + if (config.tier) headers["x-ms-access-tier"] = config.tier; + + const body = request.body ?? new Blob([]).stream(); + if (length !== null && length <= config.chunkSize) { + const response = await send(config, "PUT", container, blob, { + headers: { + ...headers, + "x-ms-blob-type": "BlockBlob", + "Content-Length": `${length}`, + }, + body: sized(body, length), + }); + await check(response); + await response.body?.cancel(); + return; + } + + // A block has the size of chunk_size, or more for a large file, in MiB. + let blockSize = config.chunkSize; + if (length !== null && length / blockSize > MAX_BLOCKS) { + const mebibyte = 1024 * 1024; + blockSize = Math.ceil(length / MAX_BLOCKS / mebibyte) * mebibyte; + } + const parts = new Parts(body, blockSize); + const random = crypto.getRandomValues(new Uint8Array(8)); + /** @type {string[]} */ + const ids = []; + for (let part = await parts.next(); part; part = await parts.next()) { + if (ids.length === MAX_BLOCKS) throw new AzureError("too many blocks", 413); + const id = blockId(ids.length, random); + const response = await send(config, "PUT", container, blob, { + query: { comp: "block", blockid: id }, + headers: { "Content-Length": `${part.length}` }, + body: part, + }); + await check(response); + await response.body?.cancel(); + ids.push(id); + if (parts.done) break; + } + const xml = new TextEncoder().encode( + `${ + ids.map((id) => `${escapeXML(id)}`).join("") + }`, + ); + const response = await send(config, "PUT", container, blob, { + query: { comp: "blocklist" }, + headers: { ...headers, "Content-Length": `${xml.length}` }, + body: xml, + }); + await check(response); + await response.body?.cancel(); +} + +/** + * The ID of a block, as rclone: the number of the block in 8 bytes, big + * endian, then 8 random bytes of the upload, in base64. + * @param {number | bigint} index + * @param {Uint8Array} random + * @returns {string} + */ +export function blockId(index, random) { + const bytes = new Uint8Array(16); + new DataView(bytes.buffer).setBigUint64(0, BigInt(index)); + bytes.set(random, 8); + return btoa(String.fromCharCode(...bytes)); +} + +/** + * Copies a blob with Copy Blob, as `Copy` of rclone. With a time, the copy + * gets the metadata of the source, with that time. With no time, the copy + * keeps the metadata of the source. The backend waits for the end of the + * copy. + * @param {Config} config + * @param {{ container: string, blob: string }} source + * @param {{ container: string, blob: string }} target + * @param {bigint | null} modTime + * @throws {AzureError} With 404 for a source that does not exist. + */ +async function copyFile(config, source, target, modTime) { + const head = await send(config, "HEAD", source.container, source.blob); + await head.body?.cancel(); + if (head.status === 404) throw new AzureError("BlobNotFound", 404); + await check(head); + const metadata = metadataOf(head.headers); + if ( + isMarker(source.blob, Number(head.headers.get("Content-Length")), metadata) + ) { + throw new AzureError("BlobNotFound", 404); + } + if (config.checkContainer) await makeContainer(config, target.container); + if (config.markers) { + await putMarkers( + config, + target.container, + target.blob.slice(0, target.blob.lastIndexOf("/") + 1), + ); + } + /** @type {Record} */ + const headers = { + "x-ms-copy-source": urlOf(config, source.container, source.blob).href, + }; + if (modTime !== null) { + Object.assign( + headers, + metaHeaders({ ...metadata, [MTIME]: isoStringOf(modTime) }), + ); + } + if (config.tier) headers["x-ms-access-tier"] = config.tier; + const response = await send(config, "PUT", target.container, target.blob, { + headers, + }); + await check(response); + await response.body?.cancel(); + // A copy can end later: the backend waits for it. + let status = response.headers.get("x-ms-copy-status"); + for (let wait = 100; status === "pending"; wait = Math.min(wait * 2, 1000)) { + await new Promise((resolve) => setTimeout(resolve, wait)); + const state = await send(config, "HEAD", target.container, target.blob); + await state.body?.cancel(); + await check(state); + status = state.headers.get("x-ms-copy-status"); + } + if (status && status !== "success") { + throw new AzureError(`The copy of the blob ended with ${status}`, 502); + } +} + +/** + * Sets the time of a blob with Set Blob Metadata, as `SetModTime` of + * rclone: the call takes the place of all the metadata. + * @param {Config} config + * @param {string} container + * @param {string} blob + * @param {bigint} modTime + */ +async function setModTime(config, container, blob, modTime) { + const head = await send(config, "HEAD", container, blob); + await head.body?.cancel(); + if (head.status === 404) throw new AzureError("BlobNotFound", 404); + await check(head); + const metadata = metadataOf(head.headers); + if (isMarker(blob, Number(head.headers.get("Content-Length")), metadata)) { + throw new AzureError("BlobNotFound", 404); + } + const response = await send(config, "PUT", container, blob, { + query: { comp: "metadata" }, + headers: metaHeaders({ ...metadata, [MTIME]: isoStringOf(modTime) }), + }); + await check(response); + await response.body?.cancel(); +} + +/** + * Deletes a blob. A marker is not a file. + * @param {Config} config + * @param {string} container + * @param {string} blob + */ +async function deleteFile(config, container, blob) { + const response = await send(config, "DELETE", container, blob); + if (response.status === 404) { + await response.body?.cancel(); + throw new AzureError("BlobNotFound", 404); + } + await check(response); + await response.body?.cancel(); +} + +/** + * Deletes a folder and all that is in it, with lists with no delimiter. The + * root of a container deletes the container, as `rclone purge`. + * @param {Config} config + * @param {string} container + * @param {string} prefix + */ +async function deleteFolder(config, container, prefix) { + if (prefix === "") { + const response = await send(config, "DELETE", container, "", { + query: { restype: "container" }, + }); + if (response.status === 404) { + await response.body?.cancel(); + throw new AzureError("ContainerNotFound", 404); + } + await check(response); + await response.body?.cancel(); + made.delete(urlOf(config, container, "").href); + return; + } + let found = false; + let marker = ""; + do { + /** @type {Record} */ + const query = { + restype: "container", + comp: "list", + maxresults: `${config.listChunk}`, + prefix, + }; + if (marker) query.marker = marker; + const response = await send(config, "GET", container, "", { query }); + if (response.status === 404) { + await response.body?.cancel(); + throw new AzureError("ContainerNotFound", 404); + } + const root = parseXML(await (await check(response)).text()); + const names = childrenOf(childrenOf(root, "Blobs")[0], "Blob") + .map(nameOf) + .filter((name) => name !== null && name.startsWith(prefix)); + if (names.length) found = true; + // Eight deletes at the same time. + for (let i = 0; i < names.length; i += 8) { + await Promise.all( + names.slice(i, i + 8).map(async (name) => { + const deleted = await send(config, "DELETE", container, name); + if (deleted.status !== 404) await check(deleted); + await deleted.body?.cancel(); + }), + ); + } + marker = textOf(root, "NextMarker"); + } while (marker); + if (!found) throw new AzureError("directory not found", 404); +} + +export default { + fetch: azureblob, +}; diff --git a/backend/azureblob/main_test.js b/backend/azureblob/main_test.js new file mode 100644 index 0000000..9139f1b --- /dev/null +++ b/backend/azureblob/main_test.js @@ -0,0 +1,230 @@ +/** + * The tests of the azureblob backend: the shared tests on Azurite, when + * `azurite-blob` is in the PATH or when AZURITE is its path, and the + * comparison with rclone on the same Azurite, when rclone is in the PATH or + * when RCLONE is its path. + */ +import { after, describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { execFile, spawnSync } from "node:child_process"; +import process from "node:process"; + +import { backendSuite } from "@rfetch/lib/testing/suite"; +import { remote } from "@rfetch/lib/testing/remote"; +// Sets the global `fetch` that reads the paths of remotes. +import { lsjson } from "../../mod.js"; +import azureblob, { blockId } from "./main.js"; +import { EMULATOR, hasAzurite, serveAzurite } from "./testing.js"; +import { sign } from "./sharedkey.js"; + +const RCLONE = process.env.RCLONE || "rclone"; +const hasRclone = spawnSync(RCLONE, ["version"]).status === 0; + +test("blockId writes the IDs of the blocks of rclone", () => { + assert.equal( + blockId(0xFEDCBA9876543210n, new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])), + "/ty6mHZUMhABAgMEBQYHCA==", + ); +}); + +test("sign writes the Shared Key of Azure", () => { + // The example of "Authorize with Shared Key", with the key of Azurite. + const headers = sign( + { + method: "GET", + url: new URL( + "http://127.0.0.1:10000/devstoreaccount1/c?restype=container&comp=list&prefix=a%20b", + ), + headers: new Headers({ "x-ms-version": "2026-06-06" }), + }, + EMULATOR, + new Date("2026-01-02T03:04:05Z"), + ); + assert.equal(headers.get("x-ms-date"), "Fri, 02 Jan 2026 03:04:05 GMT"); + assert.match( + headers.get("Authorization"), + /^SharedKey devstoreaccount1:[A-Za-z0-9+/]{43}=$/, + ); +}); + +if (hasAzurite) { + const server = await serveAzurite(); + after(() => server.stop()); + const params = { + account: EMULATOR.account, + key: EMULATOR.key, + endpoint: server.endpoint, + }; + + const features = { + copy: true, + modTime: true, + setModTime: true, + }; + // A container has no empty folders with no markers, as rclone. + backendSuite("azureblob", azureblob, { + type: "azureblob", + params, + parent: "/rfetch/", + emptyFolders: false, + features, + }); + // Blocks of 100 KiB, thus the large file of the tests has 3 blocks. + backendSuite("azureblob in blocks", azureblob, { + type: "azureblob", + params: { ...params, chunk_size: "100k" }, + parent: "/rfetch/", + emptyFolders: false, + features, + }); + backendSuite("azureblob with folder markers", azureblob, { + type: "azureblob", + params: { ...params, directory_markers: "true" }, + parent: "/rfetch/", + features, + }); + + describe("azureblob", () => { + test("lists the containers", async () => { + const fs = remote(azureblob, { params }); + await fs.put("listed/", new Uint8Array(0)).then((r) => r.body?.cancel()); + const names = (await fs.list("")).map(({ Name, IsDir }) => [Name, IsDir]); + assert.ok(names.some(([name, isDir]) => name === "listed" && isDir)); + const response = await fs.delete("listed/"); + assert.equal(response.status, 204); + }); + + test("gives 501 for the options of Azure AD", async () => { + const fs = remote(azureblob, { params: { ...params, use_msi: "true" } }); + const response = await fs.get(""); + assert.equal(response.status, 501); + }); + + test("puts no secret in the links of a list", async () => { + const fs = remote(azureblob, { params, root: "/secret/" }); + await fs.put("a b.txt", new TextEncoder().encode("a")).then((r) => + r.body?.cancel() + ); + const html = await fs.get("").then((response) => response.text()); + assert.ok(html.includes(' href="a%20b.txt?account=')); + assert.ok(!html.includes(encodeURIComponent(EMULATOR.key))); + assert.ok(!html.includes("key=")); + await fs.delete("").then((r) => r.body?.cancel()); + }); + + describe("and rclone", { + skip: !hasRclone && "rclone is not installed. Set RCLONE to its path.", + }, () => { + /** + * Runs rclone, with no spawnSync: Azurite answers in another process, + * but the tests wait for rclone with no block. + * @param {...string} args + */ + const rclone = (...args) => + new Promise((resolve) => { + execFile(RCLONE, args, { + encoding: "utf8", + env: { PATH: process.env.PATH, HOME: "/nonexistent", TZ: "UTC" }, + }, (error, stdout, stderr) => + resolve({ status: error ? error.code ?? 1 : 0, stdout, stderr })); + }); + /** The remote of rclone on Azurite. */ + const theirs = () => + `:azureblob,use_emulator=true,endpoint='${server.endpoint}':both/`; + /** The same remote for rfetch. */ + const ours = () => + `:azureblob,use_emulator=true,endpoint='${server.endpoint}':both/`; + + test("rclone reads the files and the times of rfetch", async () => { + const time = "1354040105.123456789"; + for ( + const [path, text] of [["a.txt", "hello"], ["d/b.txt", "world!"]] + ) { + const response = await fetch(`${ours()}${path}`, { + method: "PUT", + headers: { "X-OC-Mtime": time }, + body: text, + }); + assert.equal(response.status, 201); + assert.equal(response.headers.get("X-OC-Mtime"), "accepted"); + } + const { status, stdout, stderr } = await rclone( + "lsjson", + "-R", + "--files-only", + theirs(), + ); + assert.equal(status, 0, stderr); + assert.deepEqual( + JSON.parse(stdout).map(({ Path, Size, ModTime }) => [ + Path, + Size, + ModTime, + ]).sort(), + [ + ["a.txt", 5, "2012-11-27T18:15:05.123456789Z"], + ["d/b.txt", 6, "2012-11-27T18:15:05.123456789Z"], + ], + ); + assert.equal( + (await rclone("cat", `${theirs()}d/b.txt`)).stdout, + "world!", + ); + }); + + test("rfetch and rclone encode the names in the same way", async () => { + // The default encoding changes a dot at the end of a name and a + // backslash: rclone reads the names of rfetch, and rfetch reads the + // names of rclone. + // A backslash in a location is %5C: else the URL has a "/". + const response = await fetch(`${ours()}names/a./b%5Cc.`, { + method: "PUT", + body: "x", + }); + assert.equal(response.status, 201); + const { status, stdout, stderr } = await rclone( + "copyto", + "--ignore-checksum", + `${theirs()}names/a./b\\c.`, + `${theirs()}names/d./e\\f.`, + ); + assert.equal(status, 0, stderr + stdout); + const theirList = JSON.parse( + (await rclone("lsjson", "-R", "--files-only", `${theirs()}names`)) + .stdout, + ).map(({ Path }) => Path).sort(); + const ourList = (await (await lsjson(`${ours()}names`, { + recursive: true, + })).json()).filter(({ IsDir }) => !IsDir).map(({ Path }) => Path) + .sort(); + assert.deepEqual(theirList, ["a./b\\c.", "d./e\\f."]); + assert.deepEqual(ourList, theirList); + }); + + test("rfetch reads the files and the times of rclone", async () => { + const { status, stderr } = await rclone( + "touch", + "--timestamp", + "2020-01-02T03:04:05.5", + `${theirs()}c/new.txt`, + ); + assert.equal(status, 0, stderr); + const listed = await (await lsjson(ours(), { recursive: true })).json(); + const item = listed.find((item) => item.Path === "c/new.txt"); + assert.equal(item?.ModTime, "2020-01-02T03:04:05.500000000Z"); + assert.equal(item?.Size, 0); + // The same list as rclone. + const theirList = JSON.parse( + (await rclone("lsjson", "-R", theirs())).stdout, + ); + const shape = (list) => + list.map(({ Path, Size, IsDir }) => [Path, Size, IsDir]).sort(); + assert.deepEqual(shape(listed), shape(theirList)); + }); + }); + }); +} else { + test("azureblob", { + skip: "Azurite is not installed. Set AZURITE to the path of azurite-blob.", + }); +} diff --git a/backend/azureblob/package.json b/backend/azureblob/package.json new file mode 100644 index 0000000..c835bc5 --- /dev/null +++ b/backend/azureblob/package.json @@ -0,0 +1,25 @@ +{ + "name": "@rfetch/azureblob", + "version": "0.1.0", + "description": "The azureblob backend of rfetch: the Blob service of Microsoft Azure Storage, and Azurite.", + "type": "module", + "exports": { + ".": "./main.js" + }, + "scripts": { + "test": "node --test" + }, + "engines": { + "node": ">=22.2.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/sntran/rfetch.git", + "directory": "backend/azureblob" + }, + "author": "Trần Nguyễn Sơn", + "license": "ISC", + "dependencies": { + "@rfetch/lib": "^0.1.0" + } +} diff --git a/backend/azureblob/sharedkey.js b/backend/azureblob/sharedkey.js new file mode 100644 index 0000000..dceeb8b --- /dev/null +++ b/backend/azureblob/sharedkey.js @@ -0,0 +1,74 @@ +/** + * The Shared Key authorization of Azure Storage for the Blob service: the + * header `Authorization` of a request, from the method, the headers, the + * path and the query, as "Authorize with Shared Key" of Azure. + */ +import { Buffer } from "node:buffer"; +import { createHmac } from "node:crypto"; + +/** The standard headers in the text to sign, in their order. */ +const STANDARD = [ + "Content-Encoding", + "Content-Language", + "Content-Length", + "Content-MD5", + "Content-Type", + "Date", + "If-Modified-Since", + "If-Match", + "If-None-Match", + "If-Unmodified-Since", + "Range", +]; + +/** + * Signs a request with the key of a storage account. + * @param {Object} request + * @param {string} request.method + * @param {URL} request.url The path has the percent-encoding of the request. + * @param {Headers} request.headers The headers of the request. They must + * have `x-ms-version`. + * @param {Object} credentials + * @param {string} credentials.account The name of the storage account. + * @param {string} credentials.key The key of the account, in base64. + * @param {Date} [now] + * @returns {Headers} The headers of the request, with `x-ms-date` and + * `Authorization`. + */ +export function sign( + { method, url, headers }, + { account, key }, + now = new Date(), +) { + headers = new Headers(headers); + headers.set("x-ms-date", now.toUTCString()); + + const standard = STANDARD.map((name) => { + const value = headers.get(name) ?? ""; + // From version 2015-02-21, a length of 0 is an empty string. + return name === "Content-Length" && value === "0" ? "" : value; + }); + const canonicalHeaders = [...headers.keys()] + .filter((name) => name.startsWith("x-ms-")) + .sort() + .map((name) => `${name}:${headers.get(name).replace(/\s+/g, " ").trim()}\n`) + .join(""); + // The query, with no percent-encoding, by the names in lower case. + /** @type {Map} */ + const params = new Map(); + for (const [name, value] of url.searchParams) { + const lower = name.toLowerCase(); + params.set(lower, [...(params.get(lower) ?? []), value]); + } + const canonicalQuery = [...params.keys()].sort() + .map((name) => `\n${name}:${params.get(name).sort().join(",")}`) + .join(""); + const toSign = [method, ...standard].join("\n") + "\n" + canonicalHeaders + + `/${account}${url.pathname}${canonicalQuery}`; + + const signature = createHmac("sha256", Buffer.from(key, "base64")) + .update(toSign, "utf8") + .digest("base64"); + headers.set("Authorization", `SharedKey ${account}:${signature}`); + return headers; +} diff --git a/backend/azureblob/testing.js b/backend/azureblob/testing.js new file mode 100644 index 0000000..ca53213 --- /dev/null +++ b/backend/azureblob/testing.js @@ -0,0 +1,60 @@ +/** + * A test server of the Blob service of Azure: Azurite, the emulator of + * Azure Storage, with its data in memory. The tests run when + * `azurite-blob` is in the PATH, or when AZURITE is the path of + * `azurite-blob`. + */ +import { spawn, spawnSync } from "node:child_process"; +import { createServer } from "node:net"; +import process from "node:process"; + +const AZURITE = process.env.AZURITE || "azurite-blob"; + +/** Tells if Azurite is installed. */ +export const hasAzurite = + spawnSync(AZURITE, ["--version"], { stdio: "ignore" }).status === 0; + +/** + * The account of the emulator: its name and its key are public, in the + * documents of Azure. + */ +export const EMULATOR = { + account: "devstoreaccount1", + key: + "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==", +}; + +/** + * Starts Azurite on a free port, with its data in memory. + * @returns {Promise<{ endpoint: string, stop: () => void }>} The endpoint + * has the account in its path, for example + * `http://127.0.0.1:10000/devstoreaccount1`. + */ +export async function serveAzurite() { + const port = await new Promise((resolve) => { + const server = createServer().listen(0, "127.0.0.1", () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + }); + const child = spawn(AZURITE, [ + "--silent", + "--inMemoryPersistence", + "--skipApiVersionCheck", + "--blobHost", + "127.0.0.1", + "--blobPort", + `${port}`, + ], { env: { PATH: process.env.PATH }, stdio: "ignore" }); + const endpoint = `http://127.0.0.1:${port}/${EMULATOR.account}`; + // Waits until the server answers. + for (let i = 0; i < 100; i++) { + try { + await fetch(endpoint); + break; + } catch { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + return { endpoint, stop: () => child.kill() }; +} diff --git a/backend/doi/main.js b/backend/doi/main.js new file mode 100644 index 0000000..1a44f7f --- /dev/null +++ b/backend/doi/main.js @@ -0,0 +1,588 @@ +/** + * DOI backend + * + * The doi backend reads the files of a dataset with a DOI (Digital Object + * Identifier), as the doi backend of rclone: a dataset of Zenodo, of + * another InvenioRDM server, or of Dataverse. It only reads. + * + * The options, as rclone: + * + * - `doi`: the DOI, or its URL, for example `10.5281/zenodo.2600782`, + * `doi:10.5281/zenodo.2600782` or `https://doi.org/10.5281/zenodo.2600782`. + * - `provider`: `zenodo`, `invenio` or `dataverse`. With another value, or + * with no value, the backend finds the provider from the URL of the DOI. + * - `doi_resolver_api_url`: the API of the resolver of the DOI, + * `https://doi.org/api` by default. + * + * The backend asks the resolver for the URL of the DOI, then the API of the + * provider for the list of the files of the dataset: + * + * - Zenodo: the URL `/api/records/ID` of the server of the DOI, with the ID + * after `zenodo.` in the DOI. + * - InvenioRDM: the link `linkset` in the header `Link` of the page of the + * DOI, else `/api/records/ID` for a page `/records/ID`. + * - Dataverse: the server `dataverse.harvard.edu`, or a URL with the query + * `persistentId`. All the files have the time of the last change of the + * dataset. + * + * A name with `/` is in folders. A file has the time of the provider, to the + * second. The list stays in memory for 5 minutes after its last use, as in + * rclone. A `Range` of a file goes to the server of the file. + */ +import { renderListing } from "@rfetch/lib/listing"; +import { mimeType } from "@rfetch/lib/mime"; +import { optionsOf } from "@rfetch/lib/options"; +import { decodePath } from "@rfetch/lib/path"; +import { + notSatisfiable, + parseRange, + partialHeaders, + sliceStream, +} from "@rfetch/lib/range"; +import { sized } from "@rfetch/lib/sized"; + +/** + * The options of the backend, with their types, for example for the flag + * `--doi-provider` of the command line. + */ +export const options = { + string: ["doi", "provider", "doi_resolver_api_url"], +}; + +/** The API of the resolver of a DOI, by default. */ +const RESOLVER = "https://doi.org/api"; + +/** The time of a file with no time that is correct, as rclone. */ +const EPOCH = "1970-01-01T00:00:00Z"; + +/** The milliseconds that a list stays in memory after its last use. */ +const CACHE_TIME = 5 * 60 * 1000; + +/** The methods that change files. */ +const WRITES = ["PUT", "DELETE", "COPY", "MOVE", "PATCH"]; + +/** + * A file of a dataset. + * @typedef {Object} DataFile + * @property {string} path The path in the dataset, for example `a/b.txt`. + * @property {number} size + * @property {string} type The media type, or "". + * @property {string} modTime The time in ISO 8601, to the second. + * @property {string} url The URL of the bytes of the file. + */ + +/** + * The lists of the datasets, by the options, with the time of their end. + * @type {Map, expires: number }>} + */ +const cache = new Map(); + +/** An error with the status of the answer to the client. */ +class DOIError extends Error { + /** @param {string} message @param {number} [status] */ + constructor(message, status = 502) { + super(message); + this.status = status; + } +} + +/** + * Serves the files of a dataset with a DOI. + * @param {Request} request + * @param {Record} [env] The options in the variables + * `RFETCH_DOI_*` or `RCLONE_DOI_*`. + * @returns {Promise} + */ +async function doi(request, env = {}) { + const { method } = request; + const { pathname, searchParams } = new URL(request.url); + if (WRITES.includes(method)) { + await request.body?.cancel(); + return new Response("doi remotes are read only", { status: 403 }); + } + if (method !== "GET" && method !== "HEAD") { + await request.body?.cancel(); + return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } }); + } + const path = decodePath(pathname); + if (path === null) return new Response(null, { status: 400 }); + + const values = optionsOf(request, "doi", env); + const name = parseDoi(values.get("doi") ?? ""); + if (!name) return new Response("Missing doi", { status: 400 }); + const provider = values.get("provider") ?? ""; + const resolver = values.get("doi_resolver_api_url") || RESOLVER; + + let files; + try { + files = await filesOf(name, provider, resolver); + } catch (error) { + if (error instanceof DOIError) { + return new Response(error.message, { status: error.status }); + } + throw error; + } + + if (path.endsWith("/")) { + const entries = list(files, path.slice(1)); + if (entries === null) return new Response(null, { status: 404 }); + const headers = { "Content-Type": "text/html;charset=utf-8" }; + if (method === "HEAD") return new Response(null, { headers }); + return new Response( + renderListing(entries, { query: `${searchParams}` }), + { headers }, + ); + } + const file = files.find((file) => file.path === path.slice(1)); + if (!file) return new Response(null, { status: 404 }); + return await getFile(file, request); +} + +/** + * Reads a DOI as rclone: the text after `doi:`, or the path of a URL of + * `doi.org`. Another text is the DOI. + * @param {string} text + * @returns {string} + */ +export function parseDoi(text) { + let url; + try { + url = new URL(text); + } catch { + return text; + } + if (url.protocol === "doi:") { + return (text.startsWith("doi:") ? text.slice(4) : text).replace(/^\/+/, ""); + } + if (url.hostname.endsWith("doi.org")) { + let path = url.pathname; + try { + path = decodeURIComponent(path); + } catch { + // The path stays with its percent-encoding. + } + return path.replace(/^\/+/, ""); + } + return text; +} + +/** + * Gives the files of a dataset, from the cache or from the provider. + * @param {string} name The DOI. + * @param {string} provider + * @param {string} resolver + * @returns {Promise} + */ +async function filesOf(name, provider, resolver) { + const key = JSON.stringify([name, provider, resolver]); + const now = Date.now(); + for (const [other, { expires }] of cache) { + if (expires < now) cache.delete(other); + } + let entry = cache.get(key); + if (!entry) { + const files = readDataset(name, provider, resolver); + entry = { files, expires: 0 }; + cache.set(key, entry); + // An error is not in the cache. + files.catch(() => cache.delete(key)); + } + entry.expires = now + CACHE_TIME; + return await entry.files; +} + +/** + * Finds the provider of a dataset, and reads the list of its files. + * @param {string} name The DOI. + * @param {string} provider + * @param {string} resolver + * @returns {Promise} + */ +async function readDataset(name, provider, resolver) { + const landing = await resolve(name, resolver); + const forced = ["dataverse", "invenio", "zenodo"].includes(provider) + ? provider + : ""; + if ( + forced === "dataverse" || (!forced && ( + landing.hostname.toLowerCase() === "dataverse.harvard.edu" || + landing.searchParams.get("persistentId") + )) + ) { + return await dataverseFiles(landing); + } + if ( + forced === "zenodo" || (!forced && ( + landing.hostname === "zenodo.org" || + landing.hostname.endsWith(".zenodo.org") + )) + ) { + return await invenioFiles(await zenodoEndpoint(name, landing)); + } + const endpoint = await invenioEndpoint(landing).catch((error) => { + if (forced) throw error; + return null; + }); + if (!endpoint) { + throw new DOIError(`provider '${landing.hostname}' is not supported`, 501); + } + return await invenioFiles(endpoint); +} + +/** + * Sends a GET request, and reads its JSON. + * @param {string | URL} url + * @returns {Promise} + * @throws {DOIError} When the server gives an error, or no JSON. + */ +async function getJSON(url) { + const response = await send(url); + try { + return await response.json(); + } catch { + throw new DOIError(`could not parse API response from '${url}'`); + } +} + +/** + * Sends a GET request, with no header of the client. + * @param {string | URL} url + * @returns {Promise} An answer 2xx. + * @throws {DOIError} When the server does not answer, or gives an error. + */ +async function send(url) { + let response; + try { + response = await fetch(url); + } catch { + throw new DOIError(`The server of ${new URL(url).host} did not answer`); + } + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new DOIError( + `HTTP error ${response.status} (${response.statusText}) returned body: ` + + JSON.stringify(text.slice(0, 200)), + response.status === 404 ? 404 : 502, + ); + } + return response; +} + +/** + * Gives the URL of a DOI, from the API of the resolver: the last value of + * the type `URL`, as rclone. + * @param {string} name The DOI. + * @param {string} resolver + * @returns {Promise} + */ +async function resolve(name, resolver) { + const answer = await getJSON( + `${resolver.replace(/\/$/, "")}/handles/${name}?index=1`, + ); + if (answer?.responseCode !== 1) { + // The code 100 is a DOI that does not exist. + throw new DOIError( + `could not resolve DOI (error code ${answer?.responseCode})`, + answer?.responseCode === 100 ? 404 : 502, + ); + } + let value; + for (const item of answer.values ?? []) { + if (item?.type !== "URL" || item.data?.format !== "string") continue; + if (typeof item.data.value !== "string") { + throw new DOIError("could not resolve DOI (incorrect response format)"); + } + value = item.data.value; + } + try { + return new URL(value ?? ""); + } catch { + throw new DOIError("could not resolve DOI (incorrect response format)"); + } +} + +/** + * The API of a record of Zenodo: the ID is the text after `zenodo.` in the + * DOI, as rclone. + * @param {string} name The DOI. + * @param {URL} landing The URL of the DOI. + * @returns {Promise} + */ +async function zenodoEndpoint(name, landing) { + const match = /zenodo[.](.+)/.exec(name); + if (!match) { + throw new DOIError(`could not derive API endpoint URL from '${landing}'`); + } + const record = await getJSON(`${landing.origin}/api/records/${match[1]}`); + return endpointOf(record, landing); +} + +/** + * The API of a record of an InvenioRDM server: the link `linkset` in the + * header `Link` of the page of the DOI, else `/api/records/ID` for a page + * `/records/ID`, as rclone. + * @param {URL} landing The URL of the DOI. + * @returns {Promise} + */ +async function invenioEndpoint(landing) { + const page = await send(landing); + await page.body?.cancel(); + const link = linksOf(page.headers.get("Link") ?? "").find((link) => + link.rel === "linkset" && link.type === "application/linkset+json" + ); + if (link) { + try { + return endpointOf(await getJSON(link.href), landing); + } catch { + // The other way below. + } + } + const final = new URL(page.url || landing); + const match = /\/records?\/(.+)/.exec(final.pathname); + if (match) { + try { + return endpointOf( + await getJSON(`${final.origin}/api/records/${match[1]}`), + landing, + ); + } catch { + // The error below. + } + } + throw new DOIError( + `could not resolve the Invenio API endpoint for '${landing}'`, + ); +} + +/** + * The URL `links.self` of a record of InvenioRDM. + * @param {any} record + * @param {URL} landing + * @returns {URL} + */ +function endpointOf(record, landing) { + try { + return new URL(record?.links?.self); + } catch { + throw new DOIError(`could not parse API response from '${landing}'`); + } +} + +/** + * Reads the links of a header `Link`, as rclone: the first part of each + * link is ``, and the others are `name=value`. + * @param {string} header + * @returns {{ href: string, rel: string, type: string }[]} + */ +export function linksOf(header) { + const links = []; + for (const part of header.split(",")) { + const [first, ...pieces] = part.trim().split(";"); + const match = /^<(.+)>$/.exec(first.trim()); + if (!match) continue; + const link = { href: match[1], rel: "", type: "" }; + for (const piece of pieces) { + const equal = piece.indexOf("="); + if (equal === -1) continue; + const key = piece.slice(0, equal).trim().toLowerCase(); + const value = piece.slice(equal + 1).trim().replace(/^"(.*)"$/, "$1"); + if (key === "rel" || key === "type") link[key] = value; + } + links.push(link); + } + return links; +} + +/** + * Reads the files of a record of InvenioRDM. + * @param {URL} endpoint The API of the record. + * @returns {Promise} + */ +async function invenioFiles(endpoint) { + const answer = await getJSON( + `${endpoint.origin}${endpoint.pathname.replace(/\/+$/, "")}/files`, + ); + return (answer?.entries ?? []).map((entry) => ({ + path: `${entry?.key ?? ""}`, + size: Number(entry?.size ?? -1), + type: `${entry?.mimetype ?? ""}`, + modTime: secondOf(entry?.updated), + url: `${entry?.links?.content ?? ""}`, + })).filter(isSafe); +} + +/** + * Reads the files of a dataset of Dataverse. A file in its original format + * has its original name, size and type, as rclone. + * @param {URL} landing The URL of the DOI. + * @returns {Promise} + */ +async function dataverseFiles(landing) { + const query = new URLSearchParams({ + persistentId: landing.searchParams.get("persistentId") ?? "", + }); + const answer = await getJSON( + `${landing.origin}/api/datasets/:persistentId/?${query}`, + ); + const version = answer?.data?.latestVersion ?? {}; + const modTime = secondOf(version.lastUpdateTime); + return (version.files ?? []).map((item) => { + const file = item?.dataFile ?? {}; + const original = Boolean(file.originalFileName); + const name = original ? file.originalFileName : file.filename; + return { + path: [item?.directoryLabel, name].filter(Boolean).join("/"), + size: Number((original ? file.originalFileSize : file.filesize) ?? -1), + type: `${(original ? file.originalFileFormat : file.contentType) ?? ""}`, + modTime, + url: `${landing.origin}/api/access/datafile/${file.id}?format=original`, + }; + }).filter(isSafe); +} + +/** + * Tells if a path of a file of a provider can be a path of rfetch: no empty + * name, and no `.` or `..`. + * @param {DataFile} file + */ +function isSafe(file) { + return file.path.split("/").every((name) => + name !== "" && name !== "." && name !== ".." + ); +} + +/** + * Writes a time of RFC 3339 to the second, as the precision of the backend + * in rclone. + * @param {unknown} value + * @returns {string} + */ +function secondOf(value) { + const time = Date.parse(`${value ?? ""}`); + if (Number.isNaN(time)) return EPOCH; + return new Date(Math.floor(time / 1000) * 1000).toISOString() + .replace(/\.\d+Z$/, "Z"); +} + +/** + * The entries of a folder: its files, then its folders, as rclone. + * @param {DataFile[]} files + * @param {string} folder The path of the folder, with a `/` at the end, or + * "" for the root. + * @returns {import("@rfetch/lib/listing").Entry[] | null} null for a folder + * that does not exist. + */ +function list(files, folder) { + /** @type {import("@rfetch/lib/listing").Entry[]} */ + const entries = []; + const folders = new Set(); + let found = folder === ""; + for (const file of files) { + if (!file.path.startsWith(folder)) continue; + found = true; + const rest = file.path.slice(folder.length); + const slash = rest.indexOf("/"); + if (slash === -1) { + entries.push({ + Name: rest, + IsDir: false, + Size: file.size, + MimeType: file.type || mimeType(rest), + ModTime: file.modTime, + }); + } else { + folders.add(rest.slice(0, slash)); + } + } + if (!found) return null; + for (const Name of folders) { + entries.push({ Name, IsDir: true, Size: -1, MimeType: "", ModTime: "" }); + } + return entries; +} + +/** + * Gets a file, or only its headers for HEAD, with the range of the request. + * The headers come from the list of the provider: HEAD sends no request. + * @param {DataFile} file + * @param {Request} request + * @returns {Promise} + */ +async function getFile(file, request) { + const name = file.path.slice(file.path.lastIndexOf("/") + 1); + const headers = new Headers({ + "Content-Type": file.type || mimeType(name), + "Last-Modified": new Date(file.modTime).toUTCString(), + "Accept-Ranges": "bytes", + }); + if (file.size >= 0) headers.set("Content-Length", `${file.size}`); + if (request.method === "HEAD") return new Response(null, { headers }); + + let range = null; + if (file.size >= 0) { + try { + range = parseRange(request.headers.get("Range"), file.size); + } catch { + return notSatisfiable(file.size); + } + } + let response; + try { + response = await send(file.url); + // As rclone: a server that gives a Location with a 2xx answer. + } catch (error) { + if (error instanceof DOIError) { + return new Response(error.message, { status: error.status }); + } + throw error; + } + if (!range) { + return new Response( + file.size >= 0 ? sized(response.body, file.size) : response.body, + { headers }, + ); + } + return await rangeOf(file, range, headers, response); +} + +/** + * The answer 206 for a range of a file. The server of the file gets the + * range: a server that does not read it gives all the file, and the backend + * cuts it. + * @param {DataFile} file + * @param {{ start: number, end: number }} range + * @param {Headers} headers + * @param {Response} first The answer with no range, which the backend + * cancels. + * @returns {Promise} + */ +async function rangeOf(file, range, headers, first) { + await first.body?.cancel(); + const length = range.end - range.start + 1; + let response; + try { + response = await fetch(file.url, { + headers: { Range: `bytes=${range.start}-${range.end}` }, + }); + } catch { + return new Response("The server of the file did not answer", { + status: 502, + }); + } + if (!response.ok) { + await response.body?.cancel(); + return new Response(null, { status: 502 }); + } + let body = response.body; + if (response.status !== 206) body = sliceStream(body, range.start, length); + for ( + const [name, value] of Object.entries(partialHeaders(range, file.size)) + ) { + headers.set(name, value); + } + return new Response(sized(body, length), { status: 206, headers }); +} + +export default { + fetch: doi, +}; diff --git a/backend/doi/main_test.js b/backend/doi/main_test.js new file mode 100644 index 0000000..dfa4c63 --- /dev/null +++ b/backend/doi/main_test.js @@ -0,0 +1,462 @@ +/** + * The tests of the doi backend, on a test server of a resolver of DOIs, of + * Zenodo, of an InvenioRDM server and of Dataverse. When rclone is in the + * PATH, or when RCLONE is the path of rclone, the tests also compare the + * lists and the files of rfetch with the lists and the files of rclone. + */ +import { after, before, describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { execFile, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import process from "node:process"; + +import { listen } from "@rfetch/lib/testing/server"; +// Sets the global `fetch` that reads the paths of remotes. +import { lsjson } from "../../mod.js"; +import backend, { linksOf, parseDoi } from "./main.js"; + +const RCLONE = process.env.RCLONE || "rclone"; +const hasRclone = spawnSync(RCLONE, ["version"]).status === 0; + +/** The files of the datasets, by their paths on the test server. */ +const FILES = { + "/api/files/README.md": "This is a dataset.", + "/api/files/data.txt": "Some data", + "/api/files/sub/deep.csv": "a,b\n1,2\n", + "/inv/files/table.tsv": "x\ty\n", + "/api/access/datafile/42": "original,csv\n", + "/api/access/datafile/43": "hello", +}; + +const md5 = (text) => createHash("md5").update(text).digest("hex"); + +/** The DOIs of the tests, and the URLs that the resolver gives. */ +const DOIS = { + "10.5281/zenodo.2600782": (origin) => `${origin}/record/2600782`, + "10.1234/inv.1": (origin) => `${origin}/records/abc`, + "10.7910/DVN/ABC": (origin) => + `${origin}/citation?persistentId=doi:10.7910/DVN/ABC`, +}; + +/** The requests of the test server, for the tests of the cache. */ +const requests = []; + +const server = await listen((request) => { + const url = new URL(request.url); + const { origin, pathname } = url; + requests.push(`${pathname}${url.search}`); + const json = (value, headers = {}) => + Response.json(value, { headers: { ...headers } }); + if (pathname.startsWith("/api/handles/")) { + const name = pathname.slice("/api/handles/".length); + const landing = DOIS[name]; + if (!landing) { + return json({ responseCode: 100, handle: name }); + } + return json({ + responseCode: 1, + handle: name, + values: [ + { index: 100, type: "HS_ADMIN", data: { format: "admin" } }, + { + index: 1, + type: "URL", + data: { format: "string", value: landing(origin) }, + }, + ], + }); + } + // The page of a record of Zenodo, with no header Link: on another host + // than zenodo.org, the backend finds its API from its path. + if (pathname === "/record/2600782") { + return new Response("", { + headers: { "Content-Type": "text/html" }, + }); + } + if (pathname === "/api/records/2600782") { + return json({ links: { self: `${origin}/api/records/2600782` } }); + } + if (pathname === "/api/records/2600782/files") { + return json({ + entries: [ + ["README.md", "text/markdown"], + ["data.txt", "text/plain"], + ["sub/deep.csv", "text/csv"], + ].map(([key, mimetype]) => { + const text = FILES[`/api/files/${key}`]; + return { + key, + checksum: `md5:${md5(text)}`, + size: new TextEncoder().encode(text).length, + updated: "2025-01-02T03:04:05.678901+00:00", + mimetype, + links: { content: `${origin}/api/files/${key}` }, + }; + }), + }); + } + // An InvenioRDM server: the page of the record has the link to its API. + if (pathname === "/records/abc") { + return new Response("", { + headers: { + "Content-Type": "text/html", + Link: + `<${origin}/inv/api/records/abc> ; rel="linkset" ; type="application/linkset+json"`, + }, + }); + } + if (pathname === "/inv/api/records/abc") { + return json({ links: { self: `${origin}/inv/api/records/abc` } }); + } + if (pathname === "/inv/api/records/abc/files") { + return json({ + entries: [{ + key: "table.tsv", + checksum: `md5:${md5(FILES["/inv/files/table.tsv"])}`, + size: 4, + updated: "2024-06-07T08:09:10Z", + mimetype: "text/tab-separated-values", + links: { content: `${origin}/inv/files/table.tsv` }, + }], + }); + } + if (pathname === "/api/datasets/:persistentId/") { + assert.equal(url.searchParams.get("persistentId"), "doi:10.7910/DVN/ABC"); + return json({ + status: "OK", + data: { + latestVersion: { + lastUpdateTime: "2024-05-01T10:00:00Z", + files: [ + { + directoryLabel: "sub", + dataFile: { + id: 42, + filename: "a.tab", + contentType: "text/tab-separated-values", + filesize: 10, + originalFileFormat: "text/csv", + originalFileSize: 13, + originalFileName: "a.csv", + md5: md5(FILES["/api/access/datafile/42"]), + }, + }, + { + dataFile: { + id: 43, + filename: "b.txt", + contentType: "text/plain", + filesize: 5, + md5: md5("hello"), + }, + }, + ], + }, + }, + }); + } + const text = FILES[pathname]; + if (text === undefined) return new Response(null, { status: 404 }); + // The server of the files reads one range, as most servers. + const bytes = new TextEncoder().encode(text); + const match = /^bytes=(\d+)-(\d+)$/.exec(request.headers.get("Range") ?? ""); + if (match) { + const [start, end] = [Number(match[1]), Number(match[2])]; + return new Response(bytes.subarray(start, end + 1), { + status: 206, + headers: { "Content-Range": `bytes ${start}-${end}/${bytes.length}` }, + }); + } + return new Response(bytes); +}); +after(() => server.close()); + +const resolver = new URL("/api", server.url).href; + +/** + * The location of a path in a dataset, for the fetch of rfetch. + * @param {string} doi + * @param {string} path + */ +const locationOf = (doi, path = "", provider = "") => + `:doi,doi='${doi}',doi_resolver_api_url='${resolver}'${ + provider ? `,provider=${provider}` : "" + }:${path}`; + +/** + * The names, the sizes, the types and the times of a list of lsjson. + * @param {string} location + */ +async function items(location, flags = {}) { + const response = await lsjson(location, flags); + assert.equal(response.status, 200, await response.clone().text()); + return (await response.json()) + .map(({ Path, Size, MimeType, ModTime, IsDir }) => ({ + Path, + Size, + MimeType, + ModTime, + IsDir, + })) + .sort((a, b) => a.Path < b.Path ? -1 : 1); +} + +test("parseDoi reads a DOI as rclone", () => { + for ( + const [text, doi] of [ + ["10.1000/182", "10.1000/182"], + ["doi:10.1000/182", "10.1000/182"], + ["doi://10.1000/182", "10.1000/182"], + ["https://doi.org/10.1000/182", "10.1000/182"], + ["https://dx.doi.org/10.1000/182?x=1", "10.1000/182"], + ["https://doi.org/10.1000/a%20b", "10.1000/a b"], + // As rclone: the prefix is "doi:" in lower case. + ["DOI:10.1000/182", "DOI:10.1000/182"], + ] + ) { + assert.equal(parseDoi(text), doi, text); + } +}); + +test("linksOf reads the header Link as rclone", () => { + assert.deepEqual( + linksOf( + ' ; rel="linkset" ; type="application/linkset+json"', + ), + [{ + href: "https://zenodo.org/api/records/15063252", + rel: "linkset", + type: "application/linkset+json", + }], + ); + assert.deepEqual( + linksOf('; rel="next", ; REL=last'), + [ + { href: "https://a/?page=2", rel: "next", type: "" }, + { href: "https://a/?page=5", rel: "last", type: "" }, + ], + ); +}); + +describe("doi", () => { + test("lists a dataset of Zenodo, with its folders", async () => { + assert.deepEqual( + await items(locationOf("10.5281/zenodo.2600782"), { recursive: true }), + [ + { + Path: "README.md", + Size: 18, + MimeType: "text/markdown", + ModTime: "2025-01-02T03:04:05Z", + IsDir: false, + }, + { + Path: "data.txt", + Size: 9, + MimeType: "text/plain", + ModTime: "2025-01-02T03:04:05Z", + IsDir: false, + }, + { + Path: "sub", + Size: -1, + MimeType: "inode/directory", + ModTime: (await items(locationOf("10.5281/zenodo.2600782"))) + .find((item) => item.Path === "sub").ModTime, + IsDir: true, + }, + { + Path: "sub/deep.csv", + Size: 8, + MimeType: "text/csv", + ModTime: "2025-01-02T03:04:05Z", + IsDir: false, + }, + ], + ); + }); + + test("reads a file, its headers and its ranges", async () => { + const location = locationOf("10.5281/zenodo.2600782", "sub/deep.csv"); + const response = await fetch(location); + assert.equal(response.status, 200); + assert.equal(await response.text(), "a,b\n1,2\n"); + assert.equal(response.headers.get("Content-Length"), "8"); + assert.equal( + response.headers.get("Last-Modified"), + "Thu, 02 Jan 2025 03:04:05 GMT", + ); + const range = await fetch(location, { headers: { Range: "bytes=2-4" } }); + assert.equal(range.status, 206); + assert.equal(range.headers.get("Content-Range"), "bytes 2-4/8"); + assert.equal(await range.text(), "b\n1"); + const outside = await fetch(location, { headers: { Range: "bytes=8-" } }); + await outside.body?.cancel(); + assert.equal(outside.status, 416); + // HEAD reads only the list. + const before = requests.length; + const head = await fetch(location, { method: "HEAD" }); + assert.equal(head.status, 200); + assert.equal(head.headers.get("Content-Type"), "text/csv"); + assert.equal(requests.length, before, "HEAD sent a request"); + }); + + test("reads Zenodo with the option provider", async () => { + const list = await items( + locationOf("10.5281/zenodo.2600782", "", "zenodo"), + ); + assert.deepEqual(list.map(({ Path }) => Path), [ + "README.md", + "data.txt", + "sub", + ]); + const response = await fetch( + locationOf("10.5281/zenodo.2600782", "README.md", "zenodo"), + ); + assert.equal(await response.text(), "This is a dataset."); + }); + + test("keeps the list in memory", async () => { + const location = locationOf("10.5281/zenodo.2600782"); + await items(location); + const before = requests.length; + await items(location); + assert.equal(requests.length, before); + }); + + test("finds the API of an InvenioRDM server in the header Link", async () => { + assert.deepEqual(await items(locationOf("10.1234/inv.1")), [{ + Path: "table.tsv", + Size: 4, + MimeType: "text/tab-separated-values", + ModTime: "2024-06-07T08:09:10Z", + IsDir: false, + }]); + const response = await fetch(locationOf("10.1234/inv.1", "table.tsv")); + assert.equal(await response.text(), "x\ty\n"); + }); + + test("lists a dataset of Dataverse, in the original formats", async () => { + const listed = await items(locationOf("10.7910/DVN/ABC"), { + recursive: true, + }); + assert.deepEqual(listed.filter((item) => !item.IsDir), [ + { + Path: "b.txt", + Size: 5, + MimeType: "text/plain", + ModTime: "2024-05-01T10:00:00Z", + IsDir: false, + }, + { + Path: "sub/a.csv", + Size: 13, + MimeType: "text/csv", + ModTime: "2024-05-01T10:00:00Z", + IsDir: false, + }, + ]); + const response = await fetch(locationOf("10.7910/DVN/ABC", "sub/a.csv")); + assert.equal(await response.text(), "original,csv\n"); + }); + + test("gives 404 for a DOI, a file or a folder that does not exist", async () => { + for ( + const location of [ + locationOf("10.9999/none"), + locationOf("10.5281/zenodo.2600782", "missing.txt"), + locationOf("10.5281/zenodo.2600782", "missing/"), + ] + ) { + const response = await fetch(location); + await response.body?.cancel(); + assert.equal(response.status, 404, location); + } + }); + + test("is read only", async () => { + for (const method of ["PUT", "DELETE", "COPY", "MOVE", "PATCH"]) { + const response = await backend.fetch( + new Request( + `http://doi.test/a.txt?doi=10.5281/zenodo.2600782&doi_resolver_api_url=${ + encodeURIComponent(resolver) + }`, + { method }, + ), + ); + assert.equal(response.status, 403, method); + assert.equal(await response.text(), "doi remotes are read only"); + } + }); +}); + +describe("doi and rclone", { + skip: !hasRclone && "rclone is not installed. Set RCLONE to its path.", +}, () => { + /** + * Runs rclone. It does not wait with spawnSync: the test server is in this + * process, and must answer rclone. + * @param {...string} args + * @returns {Promise<{ status: number, stdout: string, stderr: string }>} + */ + const rclone = (...args) => + new Promise((resolve) => { + execFile(RCLONE, args, { + encoding: "utf8", + env: { PATH: process.env.PATH, HOME: "/nonexistent", TZ: "UTC" }, + }, (error, stdout, stderr) => + resolve({ status: error ? error.code ?? 1 : 0, stdout, stderr })); + }); + + /** The lsjson of rclone for a DOI, with the fields of the tests. */ + const rcloneItems = async (doi, provider = "") => { + const remote = `:doi,doi='${doi}',doi_resolver_api_url='${resolver}'${ + provider ? `,provider=${provider}` : "" + }:`; + const { status, stdout, stderr } = await rclone("lsjson", "-R", remote); + assert.equal(status, 0, stderr); + return JSON.parse(stdout) + .map(({ Path, Size, MimeType, ModTime, IsDir }) => ({ + Path, + Size, + MimeType, + ModTime, + IsDir, + })) + .sort((a, b) => a.Path < b.Path ? -1 : 1); + }; + + before(() => { + process.env.TZ = "UTC"; + }); + + for ( + const [name, doi, provider] of [ + ["Zenodo", "10.5281/zenodo.2600782", "zenodo"], + ["a record", "10.5281/zenodo.2600782", ""], + ["InvenioRDM", "10.1234/inv.1", ""], + ["Dataverse", "10.7910/DVN/ABC", ""], + ] + ) { + test(`gives the files of rclone for a dataset of ${name}`, async () => { + const ours = await items(locationOf(doi, "", provider), { + recursive: true, + }); + const theirs = await rcloneItems(doi, provider); + // The folders have no time in the lists of the providers. + const files = (list) => list.filter((item) => !item.IsDir); + const folders = (list) => + list.filter((item) => item.IsDir).map(({ Path }) => Path); + assert.deepEqual(files(ours), files(theirs)); + assert.deepEqual(folders(ours), folders(theirs)); + for (const { Path } of files(theirs)) { + const remote = `:doi,doi='${doi}',doi_resolver_api_url='${resolver}'${ + provider ? `,provider=${provider}` : "" + }:${Path}`; + const { stdout } = await rclone("cat", remote); + const response = await fetch(locationOf(doi, Path, provider)); + assert.equal(await response.text(), stdout, Path); + } + }); + } +}); diff --git a/backend/doi/package.json b/backend/doi/package.json new file mode 100644 index 0000000..d0e2e37 --- /dev/null +++ b/backend/doi/package.json @@ -0,0 +1,25 @@ +{ + "name": "@rfetch/doi", + "version": "0.1.0", + "description": "The doi backend of rfetch: the files of a dataset with a DOI, on Zenodo, on an InvenioRDM server or on Dataverse. It only reads.", + "type": "module", + "exports": { + ".": "./main.js" + }, + "scripts": { + "test": "node --test" + }, + "engines": { + "node": ">=22.2.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/sntran/rfetch.git", + "directory": "backend/doi" + }, + "author": "Trần Nguyễn Sơn", + "license": "ISC", + "dependencies": { + "@rfetch/lib": "^0.1.0" + } +} diff --git a/backend/main.js b/backend/main.js index 61bf004..a1d4ae0 100644 --- a/backend/main.js +++ b/backend/main.js @@ -24,10 +24,12 @@ export const backends = { alias: () => import("@rfetch/alias"), archive: () => import("@rfetch/archive"), + azureblob: () => import("@rfetch/azureblob"), chunker: () => import("@rfetch/chunker"), combine: () => import("@rfetch/combine"), compress: () => import("@rfetch/compress"), crypt: () => import("@rfetch/crypt"), + doi: () => import("@rfetch/doi"), drive: () => import("@rfetch/drive"), http: () => import("@rfetch/http"), local: () => import("@rfetch/local"), diff --git a/backend/s3/main.js b/backend/s3/main.js index b7c6890..ada00bb 100644 --- a/backend/s3/main.js +++ b/backend/s3/main.js @@ -61,6 +61,7 @@ import { partialHeaders, sliceStream, } from "@rfetch/lib/range"; +import { Parts } from "@rfetch/lib/parts"; import { parseSize } from "@rfetch/lib/size"; import { sized } from "@rfetch/lib/sized"; import { @@ -962,59 +963,6 @@ function sha256(bytes) { return createHash("sha256").update(bytes).digest("hex"); } -/** - * Cuts a stream into parts of a size: each part has the size, but the last - * part can be smaller. - */ -class Parts { - /** @param {ReadableStream} stream @param {number} size */ - constructor(stream, size) { - this.reader = stream.getReader(); - this.size = size; - /** @type {Uint8Array} */ - this.pending = new Uint8Array(0); - /** True when the stream has no more data. */ - this.done = false; - } - - /** - * The next part, or null at the end. - * @returns {Promise} - */ - async next() { - const chunks = this.pending.length ? [this.pending] : []; - let length = this.pending.length; - this.pending = new Uint8Array(0); - while (length < this.size && !this.done) { - const { done, value } = await this.reader.read(); - if (done) { - this.done = true; - break; - } - chunks.push(value); - length += value.length; - } - if (length === 0 && this.done) return null; - const bytes = new Uint8Array(length); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.length; - } - if (length > this.size) { - this.pending = bytes.slice(this.size); - return bytes.subarray(0, this.size); - } - // A part of the size can be the last part: the next read tells it. - if (!this.done && this.pending.length === 0) { - const { done, value } = await this.reader.read(); - if (done) this.done = true; - else this.pending = value; - } - return bytes; - } -} - export default { fetch: s3, }; diff --git a/lib/encoder.js b/lib/encoder.js new file mode 100644 index 0000000..0b2ef82 --- /dev/null +++ b/lib/encoder.js @@ -0,0 +1,486 @@ +/** + * The encoding of the names of rclone, as the package lib/encoder of rclone + * 1.75.1. + * + * Some remotes cannot keep some characters in a name, for example `/` or + * `\`. The encoder replaces such a character with a character that looks + * almost the same. For most characters, this is the FULLWIDTH form, for + * example `/` for `/`. When the name already has the replacement character, + * the encoder puts the quote `‛` before it. Thus the decoder can find the + * original name. + * + * A mask is a number. Each bit of the mask is a flag that encodes some + * characters. The option `encoding` of a backend sets the mask, for example + * `Slash,BackSlash,Del,Ctl`. + */ + +/** The quote that the encoder puts before a character to keep it. */ +export const QuoteRune = "‛"; + +/** No flag. All the masks encode NUL (0x00), but `EncodeRaw` does not. */ +export const EncodeZero = 0; +/** No encoding. This flag works only alone: the names do not change. */ +export const EncodeRaw = 1; +/** Encodes `/`. */ +export const EncodeSlash = 1 << 1; +/** Encodes `<` and `>`. */ +export const EncodeLtGt = 1 << 2; +/** Encodes `"`. */ +export const EncodeDoubleQuote = 1 << 3; +/** Encodes `'`. */ +export const EncodeSingleQuote = 1 << 4; +/** Encodes `` ` ``. */ +export const EncodeBackQuote = 1 << 5; +/** Encodes `$`. */ +export const EncodeDollar = 1 << 6; +/** Encodes `:`. */ +export const EncodeColon = 1 << 7; +/** Encodes `?`. */ +export const EncodeQuestion = 1 << 8; +/** Encodes `*`. */ +export const EncodeAsterisk = 1 << 9; +/** Encodes `|`. */ +export const EncodePipe = 1 << 10; +/** Encodes `#`. */ +export const EncodeHash = 1 << 11; +/** Encodes `%`. */ +export const EncodePercent = 1 << 12; +/** Encodes `\`. */ +export const EncodeBackSlash = 1 << 13; +/** Encodes CR (0x0D) and LF (0x0A). */ +export const EncodeCrLf = 1 << 14; +/** Encodes DEL (0x7F). */ +export const EncodeDel = 1 << 15; +/** Encodes the control characters, from 0x01 to 0x1F. */ +export const EncodeCtl = 1 << 16; +/** Encodes a space at the start of a name. */ +export const EncodeLeftSpace = 1 << 17; +/** Encodes a `.` at the start of a name. */ +export const EncodeLeftPeriod = 1 << 18; +/** Encodes a `~` at the start of a name. */ +export const EncodeLeftTilde = 1 << 19; +/** Encodes a CR, LF, HT or VT at the start of a name. */ +export const EncodeLeftCrLfHtVt = 1 << 20; +/** Encodes a space at the end of a name. */ +export const EncodeRightSpace = 1 << 21; +/** Encodes a `.` at the end of a name. */ +export const EncodeRightPeriod = 1 << 22; +/** Encodes a CR, LF, HT or VT at the end of a name. */ +export const EncodeRightCrLfHtVt = 1 << 23; +/** + * Encodes the bytes that are not correct UTF-8. In JavaScript, this flag + * changes nothing: a string cannot keep such bytes. Thus the masks and + * `parseEncoding` keep this flag, but the encoder and the decoder do not + * use it. With this flag, rclone also decodes `‛` and two hexadecimal + * digits to one byte, for example `‛41` to `A`. This module does not do + * that. + */ +export const EncodeInvalidUtf8 = 1 << 24; +/** Encodes the names `.` and `..`. */ +export const EncodeDot = 1 << 25; +/** Encodes `[` and `]`. */ +export const EncodeSquareBracket = 1 << 26; +/** Encodes `;`. */ +export const EncodeSemicolon = 1 << 27; +/** Encodes `!`. */ +export const EncodeExclamation = 1 << 28; + +/** Encodes `:?"*<>|`, the characters that Windows cannot keep in a name. */ +export const EncodeWin = EncodeColon | EncodeQuestion | EncodeDoubleQuote | + EncodeAsterisk | EncodeLtGt | EncodePipe; +/** Encodes `#` and `%`. */ +export const EncodeHashPercent = EncodeHash | EncodePercent; + +/** + * The encoding of the paths that rclone reads and writes. It encodes NUL, + * `/`, the control characters, DEL, and the names `.` and `..`. + */ +export const Standard = EncodeZero | EncodeSlash | EncodeCtl | EncodeDel | + EncodeDot; + +/** The encoding of NUL, `/`, and the names `.` and `..` only. */ +export const Base = EncodeZero | EncodeSlash | EncodeDot; + +/** The names of the flags for the option `encoding`, as rclone. */ +const NAMES = new Map([ + ["Raw", EncodeRaw], + ["None", EncodeZero], + ["Slash", EncodeSlash], + ["LtGt", EncodeLtGt], + ["SquareBracket", EncodeSquareBracket], + ["Semicolon", EncodeSemicolon], + ["Exclamation", EncodeExclamation], + ["DoubleQuote", EncodeDoubleQuote], + ["SingleQuote", EncodeSingleQuote], + ["BackQuote", EncodeBackQuote], + ["Dollar", EncodeDollar], + ["Colon", EncodeColon], + ["Question", EncodeQuestion], + ["Asterisk", EncodeAsterisk], + ["Pipe", EncodePipe], + ["Hash", EncodeHash], + ["Percent", EncodePercent], + ["BackSlash", EncodeBackSlash], + ["CrLf", EncodeCrLf], + ["Del", EncodeDel], + ["Ctl", EncodeCtl], + ["LeftSpace", EncodeLeftSpace], + ["LeftPeriod", EncodeLeftPeriod], + ["LeftTilde", EncodeLeftTilde], + ["LeftCrLfHtVt", EncodeLeftCrLfHtVt], + ["RightSpace", EncodeRightSpace], + ["RightPeriod", EncodeRightPeriod], + ["RightCrLfHtVt", EncodeRightCrLfHtVt], + ["InvalidUtf8", EncodeInvalidUtf8], + ["Dot", EncodeDot], +]); + +/** The white space that `strings.TrimSpace` of Go removes. */ +const SPACE = "[\\t\\n\\v\\f\\r \\u0085\\u00a0\\u1680\\u2000-\\u200a" + + "\\u2028\\u2029\\u202f\\u205f\\u3000]"; +const TRIM = new RegExp(`^${SPACE}+|${SPACE}+$`, "g"); + +/** + * The syntax of `strconv.ParseUint` of Go with the base 0: a decimal number, + * or a number with the prefix `0x`, `0o`, `0b` or `0`. One `_` can be + * between two digits, or after the prefix. + */ +const UINT = new RegExp( + "^(?:0[xX](?:_?[0-9a-fA-F])+|0[oO](?:_?[0-7])+|0[bB](?:_?[01])+" + + "|0(?:_?[0-7])*|[1-9](?:_?[0-9])*)$", +); + +/** + * Reads the value of the option `encoding` of rclone, as + * `MultiEncoder.Set` of rclone. The value is a list of the names of the + * flags, with `,` between them. A name can also be `None` for no flag, or a + * number, for example `0x80`. + * @param {string} text For example `Slash,BackSlash,Del,Ctl,RightPeriod`. + * @returns {number} The mask. It keeps only the 32 low bits of a number: + * rclone has no flag in the other bits. + * @throws {Error} For an unknown name, with the message of rclone. + */ +export function parseEncoding(text) { + let mask = 0; + for (let part of text.split(",")) { + part = part.replace(TRIM, ""); + const flag = NAMES.get(part) ?? parseUint(part); + if (flag === undefined) { + const names = [...NAMES.keys()].sort().join(", "); + // For most texts, the quotes of JSON are the same as `%q` of Go. + throw new Error( + `bad encoding ${JSON.stringify(part)}: possible values are: ${names}`, + ); + } + mask |= flag; + } + return mask >>> 0; +} + +/** + * Reads a number as `strconv.ParseUint` of Go, with the base 0 and 64 bits. + * @param {string} text + * @returns {number | undefined} The 32 low bits of the number, or no + * number for a text that Go does not accept. + */ +function parseUint(text) { + if (!UINT.test(text)) return undefined; + let digits = text.replaceAll("_", ""); + // BigInt does not read an octal number with only a `0` before it. + if (/^0[0-7]/.test(digits)) digits = `0o${digits.slice(1)}`; + const value = BigInt(digits); + if (value >= 2n ** 64n) return undefined; + return Number(BigInt.asUintN(32, value)); +} + +/** + * Adds a number to the code of each character of a text. + * @param {string} text + * @param {number} offset + * @returns {string} + */ +function shift(text, offset) { + return String.fromCharCode( + ...[...text].map((character) => character.charCodeAt(0) + offset), + ); +} + +/** The number to add to an ASCII character to get its FULLWIDTH form. */ +const FULL_OFFSET = 0xFEE0; +/** The number to add to a control character for its symbol, from `␀`. */ +const SYMBOL_OFFSET = 0x2400; +/** The control characters, from 0x01 to 0x1F. */ +const CONTROLS = String.fromCharCode( + ...Array.from({ length: 0x1F }, (_, i) => i + 1), +); +/** HT, LF, VT and CR. */ +const CR_LF_HT_VT = "\t\n\v\r"; + +/** + * The characters that each flag encodes, and their replacements, in the + * same order. + * @type {[number, string, string][]} + */ +const REPLACEMENTS = [ + ...[ + [EncodeAsterisk, "*"], + [EncodeLtGt, "<>"], + [EncodeSquareBracket, "[]"], + [EncodeSemicolon, ";"], + [EncodeExclamation, "!"], + [EncodeQuestion, "?"], + [EncodeColon, ":"], + [EncodePipe, "|"], + [EncodeDoubleQuote, '"'], + [EncodeSingleQuote, "'"], + [EncodeBackQuote, "`"], + [EncodeDollar, "$"], + [EncodeSlash, "/"], + [EncodeBackSlash, "\\"], + [EncodeHash, "#"], + [EncodePercent, "%"], + ].map(([flag, text]) => [flag, text, shift(text, FULL_OFFSET)]), + [EncodeCrLf, "\r\n", shift("\r\n", SYMBOL_OFFSET)], + [EncodeDel, "\x7f", "␡"], + [EncodeCtl, CONTROLS, shift(CONTROLS, SYMBOL_OFFSET)], +]; + +/** + * For each character that a flag encodes: the flags, and the text that + * the encoder puts in its place. A replacement is also in this map, with + * the quote before it. + * @type {Map} + */ +const ENCODE = new Map(); +/** + * For each replacement: the flags that decode it, and the original + * character. + * @type {Map} + */ +const DECODE = new Map(); +for (const [flag, characters, replacements] of REPLACEMENTS) { + for (let i = 0; i < characters.length; i++) { + // CR and LF are in two flags, with the same replacements. + const flags = (ENCODE.get(characters[i])?.[0] ?? 0) | flag; + ENCODE.set(characters[i], [flags, replacements[i]]); + ENCODE.set(replacements[i], [flags, QuoteRune + replacements[i]]); + DECODE.set(replacements[i], [flags, characters[i]]); + } +} + +/** + * The flags for the first character of a name, with the characters that + * they encode and their replacements. + * @type {[number, string, string][]} + */ +const LEFT = [ + [EncodeLeftSpace, " ", "␠"], + [EncodeLeftPeriod, ".", "."], + [EncodeLeftTilde, "~", "~"], + [EncodeLeftCrLfHtVt, CR_LF_HT_VT, shift(CR_LF_HT_VT, SYMBOL_OFFSET)], +]; + +/** + * The flags for the last character of a name, with the characters that + * they encode and their replacements. + * @type {[number, string, string][]} + */ +const RIGHT = [ + [EncodeRightSpace, " ", "␠"], + [EncodeRightPeriod, ".", "."], + [EncodeRightCrLfHtVt, CR_LF_HT_VT, shift(CR_LF_HT_VT, SYMBOL_OFFSET)], +]; + +/** The names that `EncodeDot` encodes, and their encoded names. */ +const DOTS = new Map([ + [".", "."], + ["..", ".."], + [".", `${QuoteRune}.`], + ["..", `${QuoteRune}.${QuoteRune}.`], +]); + +/** The encoded names of `EncodeDot`, and their original names. */ +const UNDOTS = new Map([...DOTS].map(([name, encoded]) => [encoded, name])); + +/** + * Encodes the first or the last character of a name. + * @param {[number, string, string][]} edges `LEFT` or `RIGHT`. + * @param {number} mask + * @param {string} character + * @returns {string} The text to put in the place of the character, or "" + * when the mask does not encode it. + */ +function encodeEdge(edges, mask, character) { + for (const [flag, characters, replacements] of edges) { + if ((mask & flag) === 0) continue; + const index = characters.indexOf(character); + if (index !== -1) return replacements[index]; + // A replacement keeps its form with the quote before it. + if (replacements.includes(character)) return QuoteRune + character; + } + return ""; +} + +/** + * Decodes the first or the last character of a name. + * @param {[number, string, string][]} edges `LEFT` or `RIGHT`. + * @param {number} mask + * @param {string | undefined} character + * @returns {string | undefined} The original character, or no character + * when the mask does not decode it. + */ +function decodeEdge(edges, mask, character) { + if (character === undefined) return undefined; + for (const [flag, characters, replacements] of edges) { + if ((mask & flag) === 0) continue; + const index = replacements.indexOf(character); + if (index !== -1) return characters[index]; + } + return undefined; +} + +/** + * Encodes a name, as `MultiEncoder.Encode` of rclone. The encoder replaces + * the characters that the mask encodes, and puts the quote `‛` before the + * characters that are already a replacement. + * @param {string} name A name with no encoding, for example `a/b:c`. + * @param {number} mask For example `EncodeSlash | EncodeColon`. + * @returns {string} For example `a/b:c`. + */ +export function encodeName(name, mask) { + if (mask === EncodeRaw || name === "") return name; + if (mask & EncodeDot && DOTS.has(name)) return DOTS.get(name); + + const prefix = encodeEdge(LEFT, mask, name[0]); + if (prefix) name = name.slice(1); + const suffix = name && encodeEdge(RIGHT, mask, name.at(-1)); + if (suffix) name = name.slice(0, -1); + + let encoded = prefix; + for (const character of name) { + if (character === "\0") { + encoded += "␀"; + } else if (character === "␀" || character === QuoteRune) { + encoded += QuoteRune + character; + } else { + const entry = ENCODE.get(character); + encoded += entry && mask & entry[0] ? entry[1] : character; + } + } + return encoded + suffix; +} + +/** + * Decodes a name, as `MultiEncoder.Decode` of rclone: the reverse of + * `encodeName`. A quote stays when no replacement is after it. + * @param {string} name An encoded name, for example `a/b:c`. + * @param {number} mask For example `EncodeSlash | EncodeColon`. + * @returns {string} For example `a/b:c`. + */ +export function decodeName(name, mask) { + if (mask === EncodeRaw) return name; + if (mask & EncodeDot && UNDOTS.has(name)) return UNDOTS.get(name); + + let prefix = decodeEdge(LEFT, mask, name[0]) ?? ""; + if (prefix) { + name = name.slice(1); + } else if ( + name[0] === QuoteRune && decodeEdge(LEFT, mask, name[1]) !== undefined + ) { + prefix = name[1]; + name = name.slice(2); + } + + let suffix = ""; + const last = decodeEdge(RIGHT, mask, name.at(-1)); + if (last !== undefined) { + suffix = name.at(-1); + name = name.slice(0, -1); + // As rclone, a quote before the last character is the quote of that + // character. Thus the name `a‛.` does not come back after the encoding. + if (name.at(-1) === QuoteRune) { + name = name.slice(0, -1); + } else { + suffix = last; + } + } + + let decoded = prefix; + let quoted = false; + for (const character of name) { + const entry = DECODE.get(character); + const replacement = character === "␀" || + entry !== undefined && (mask & entry[0]) !== 0; + if (quoted) { + quoted = false; + if (replacement || character === QuoteRune) { + decoded += character; + } else { + // The quote stays before a character that is not a replacement. + decoded += QuoteRune + character; + } + } else if (character === QuoteRune) { + quoted = true; + } else if (character === "␀") { + decoded += "\0"; + } else { + decoded += replacement ? entry[1] : character; + } + } + if (quoted) decoded += QuoteRune; + return decoded + suffix; +} + +/** + * Converts a name in the `Standard` encoding of rclone to the encoding of a + * mask, as `FromStandardName` of rclone. + * @param {string} name A name in the `Standard` encoding, for example + * `a/b`. + * @param {number} mask + * @returns {string} The name in the encoding of the mask. + */ +export function fromStandardName(name, mask) { + if (mask === Standard) return name; + return encodeName(decodeName(name, Standard), mask); +} + +/** + * Converts a name in the encoding of a mask to the `Standard` encoding of + * rclone, as `ToStandardName` of rclone. + * @param {string} name A name in the encoding of the mask. + * @param {number} mask + * @returns {string} The name in the `Standard` encoding. + */ +export function toStandardName(name, mask) { + if (mask === Standard) return name; + return encodeName(decodeName(name, mask), Standard); +} + +/** + * Converts a path in the `Standard` encoding of rclone to the encoding of a + * mask, as `FromStandardPath` of rclone. The function converts each name + * between the `/` of the path. + * @param {string} path A path in the `Standard` encoding, for example + * `dir/a:b`. + * @param {number} mask + * @returns {string} The path in the encoding of the mask. + */ +export function fromStandardPath(path, mask) { + if (mask === Standard) return path; + return path.split("/").map((name) => fromStandardName(name, mask)).join( + "/", + ); +} + +/** + * Converts a path in the encoding of a mask to the `Standard` encoding of + * rclone, as `ToStandardPath` of rclone. The function converts each name + * between the `/` of the path. + * @param {string} path A path in the encoding of the mask. + * @param {number} mask + * @returns {string} The path in the `Standard` encoding. + */ +export function toStandardPath(path, mask) { + if (mask === Standard) return path; + return path.split("/").map((name) => toStandardName(name, mask)).join("/"); +} diff --git a/lib/encoder_test.js b/lib/encoder_test.js new file mode 100644 index 0000000..5bd5a4f --- /dev/null +++ b/lib/encoder_test.js @@ -0,0 +1,3158 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fc from "fast-check"; + +import { + Base, + decodeName, + EncodeAsterisk, + EncodeBackQuote, + EncodeBackSlash, + EncodeColon, + EncodeCrLf, + EncodeCtl, + EncodeDel, + EncodeDollar, + EncodeDot, + EncodeDoubleQuote, + EncodeExclamation, + EncodeHash, + EncodeHashPercent, + EncodeInvalidUtf8, + EncodeLeftCrLfHtVt, + EncodeLeftPeriod, + EncodeLeftSpace, + EncodeLeftTilde, + EncodeLtGt, + encodeName, + EncodePercent, + EncodePipe, + EncodeQuestion, + EncodeRaw, + EncodeRightCrLfHtVt, + EncodeRightPeriod, + EncodeRightSpace, + EncodeSemicolon, + EncodeSingleQuote, + EncodeSlash, + EncodeSquareBracket, + EncodeWin, + EncodeZero, + fromStandardName, + fromStandardPath, + parseEncoding, + QuoteRune, + Standard, + toStandardName, + toStandardPath, +} from "./encoder.js"; + +/** The mask of OneDrive in the benchmarks of rclone. */ +const ONEDRIVE = Standard | EncodeWin | EncodeBackSlash | EncodeHashPercent | + EncodeDel | EncodeCtl | EncodeLeftTilde | EncodeRightSpace | + EncodeRightPeriod; + +/** The default mask of the S3 backend of rclone. */ +const S3 = EncodeInvalidUtf8 | EncodeSlash | EncodeDot; + +/** The default mask of the OpenDrive backend of rclone. */ +const OPENDRIVE = Base | EncodeWin | EncodeLeftCrLfHtVt | EncodeRightCrLfHtVt | + EncodeBackSlash | EncodeLeftSpace | EncodeRightSpace | EncodeInvalidUtf8; + +/** The default mask of the ShareFile backend of rclone. */ +const SHAREFILE = Base | EncodeWin | EncodeBackSlash | EncodeCtl | + EncodeRightSpace | EncodeRightPeriod | EncodeLeftSpace | EncodeLeftPeriod | + EncodeInvalidUtf8; + +/** The message of rclone for an unknown name, after the name. */ +const POSSIBLE_VALUES = "possible values are: Asterisk, BackQuote, " + + "BackSlash, Colon, CrLf, Ctl, Del, Dollar, Dot, DoubleQuote, Exclamation, " + + "Hash, InvalidUtf8, LeftCrLfHtVt, LeftPeriod, LeftSpace, LeftTilde, LtGt, " + + "None, Percent, Pipe, Question, Raw, RightCrLfHtVt, RightPeriod, " + + "RightSpace, Semicolon, SingleQuote, Slash, SquareBracket"; + +// rclone also tests `MultiEncoder.String`. This module has no such function. + +test("parseEncoding reads the names of the flags, as rclone", () => { + for ( + const [text, mask] of [ + ["Raw", EncodeRaw], + ["None", EncodeZero], + ["DoubleQuote", EncodeDoubleQuote], + ["Dot", EncodeDot], + ["LtGt,DoubleQuote,Colon,Question,Asterisk,Pipe", EncodeWin], + ["Hash,Percent", EncodeHashPercent], + ["Slash,Dollar,Colon", EncodeSlash | EncodeDollar | EncodeColon], + ["Slash,0x80000000", EncodeSlash + 2 ** 31], + [ + "Slash,BackSlash,Del,Ctl,RightPeriod,InvalidUtf8", + EncodeSlash | EncodeBackSlash | EncodeDel | EncodeCtl | + EncodeRightPeriod | EncodeInvalidUtf8, + ], + ["Raw,Slash", EncodeRaw | EncodeSlash], + [" Slash , Dot ", EncodeSlash | EncodeDot], + ["\tSlash\n", EncodeSlash], + ["Slash\u00a0,\u3000Dot", EncodeSlash | EncodeDot], + [ + "Exclamation,Semicolon,SquareBracket", + EncodeExclamation | EncodeSemicolon | EncodeSquareBracket, + ], + ] + ) { + assert.equal(parseEncoding(text), mask, JSON.stringify(text)); + } +}); + +test("parseEncoding reads numbers as Go", () => { + for ( + const [text, mask] of [ + ["0", 0], + ["010", 8], + ["0x10", 16], + ["0X10", 16], + ["0b10", 2], + ["0o17", 15], + ["0x_1", 1], + ["0_7", 7], + ["1_000", 1000], + // The mask keeps only the 32 low bits. + ["4294967298", 2], + ["18446744073709551615", 0xFFFFFFFF], + ] + ) { + assert.equal(parseEncoding(text), mask, text); + } +}); + +test("parseEncoding throws the error of rclone for an unknown name", () => { + for ( + const [text, part] of [ + ["", ""], + ["Blerp", "Blerp"], + ["0xFGFFF", "0xFGFFF"], + ["slash", "slash"], + ["Slash,", ""], + ["Slash, Blerp ,Dot", "Blerp"], + ["08", "08"], + ["0x", "0x"], + ["1__0", "1__0"], + ["_1", "_1"], + ["1_", "1_"], + ["+1", "+1"], + ["-1", "-1"], + ["1e3", "1e3"], + ["18446744073709551616", "18446744073709551616"], + ] + ) { + assert.throws(() => parseEncoding(text), { + name: "Error", + message: `bad encoding ${JSON.stringify(part)}: ${POSSIBLE_VALUES}`, + }, JSON.stringify(text)); + } + // As Go, the parser does not remove U+FEFF. Go writes it as `\ufeff` in + // the message. + assert.throws(() => parseEncoding("\ufeffSlash"), Error); +}); + +// rclone also tests names with bytes that are not correct UTF-8, in +// TestEncodeInvalidUnicode. A JavaScript string cannot keep such bytes. Thus +// this file skips these 10 cases. + +test("encodeName and decodeName change the names . and .. with EncodeDot", () => { + for ( + const [mask, name, encoded] of [ + [EncodeZero, ".", "."], + [EncodeDot, ".", "."], + [EncodeZero, "..", ".."], + [EncodeDot, "..", ".."], + [EncodeDot, "...", "..."], + [EncodeDot, ". .", ". ."], + [EncodeDot, ".", "‛."], + [EncodeDot, "..", "‛.‛."], + ] + ) { + assert.equal(encodeName(name, mask), encoded, name); + assert.equal(decodeName(encoded, mask), name, encoded); + } +}); + +test("decodeName keeps a quote with no replacement after it", () => { + for ( + const [mask, encoded, name] of [ + [EncodeZero, "‛", "‛"], + [EncodeZero, "‛‛", "‛"], + [EncodeZero, "‛a‛", "‛a‛"], + [EncodeInvalidUtf8, "a‛B‛Eg", "a‛B‛Eg"], + [EncodeInvalidUtf8, "a‛B\‛Eg", "a‛B\‛Eg"], + [EncodeInvalidUtf8 | EncodeBackSlash, "a‛B\‛Eg", "a‛B\\‛Eg"], + ] + ) { + assert.equal(decodeName(encoded, mask), name, encoded); + } +}); + +test("encodeName and decodeName pass the OneDrive cases of rclone", () => { + for ( + const [name, encoded] of [ + ["", ""], + ["abc 123", "abc 123"], + [`\\*<>?:|#%".~`, `\*<>?:|#%".~`], + [ + `\\*<>?:|#%".~/\\*<>?:|#%".~`, + `\*<>?:|#%".~/\*<>?:|#%".~`, + ], + [" leading space", " leading space"], + ["~leading tilde", "~leading tilde"], + ["trailing dot.", "trailing dot."], + [ + " leading space/ leading space/ leading space", + " leading space/ leading space/ leading space", + ], + [ + "~leading tilde/~leading tilde/~leading tilde", + "~leading tilde/~leading tilde/~leading tilde", + ], + ["leading tilde/~leading tilde", "leading tilde/~leading tilde"], + [ + "trailing dot./trailing dot./trailing dot.", + "trailing dot./trailing dot./trailing dot.", + ], + ] + ) { + assert.equal(encodeName(name, ONEDRIVE), encoded, name); + assert.equal(decodeName(encoded, ONEDRIVE), name, encoded); + } +}); + +test("encodeName and decodeName do nothing with EncodeRaw", () => { + for (const name of ["", "a/b", "\0", "‛", ".", ".", " a ", "a␀b"]) { + assert.equal(encodeName(name, EncodeRaw), name, name); + assert.equal(decodeName(name, EncodeRaw), name, name); + } +}); + +test("fromStandardPath and toStandardPath convert each name of a path", () => { + for ( + const [mask, standard, encoded] of [ + [Standard, "a/b./c␀/d‛/", "a/b./c␀/d‛/"], + [ONEDRIVE, "~dir/file: name?.txt/end. ", "~dir/file: name?.txt/end.␠"], + [S3, "a/b/./../c␀d/x␊y", "a/b/./../c␀d/x\ny"], + [Base, "a␁b/‛/", "a\x01b/‛/"], + [EncodeRaw, "a/b/.", "a/b/."], + [ONEDRIVE, "", ""], + [ONEDRIVE, "/", "/"], + ] + ) { + assert.equal(fromStandardPath(standard, mask), encoded, standard); + assert.equal(toStandardPath(encoded, mask), standard, encoded); + } + // A standard name with `/` becomes two names with no encoding of `/`. + assert.equal(fromStandardPath("a/b/../c", EncodeZero), "a/b/../c"); + assert.equal(toStandardPath("a/../c", EncodeZero), "a/../c"); + // A replacement in a standard name gets a quote. + assert.equal( + fromStandardPath("~dir/file: name?.txt/end.␠", ONEDRIVE), + "‛~dir/file‛: name‛?.txt/end.‛␠", + ); +}); + +test("fromStandardName and toStandardName also convert the `/`", () => { + assert.equal( + fromStandardName("~dir/file: name?.txt/end. ", ONEDRIVE), + "~dir/file: name?.txt/end.␠", + ); + assert.equal( + toStandardName("~dir/file: name?.txt/end.␠", ONEDRIVE), + "~dir/file: name?.txt/end. ", + ); + assert.equal(fromStandardName("x␊y", Standard), "x␊y"); + assert.equal(fromStandardName("x␊y", S3), "x\ny"); + assert.equal(toStandardName("x\ny", S3), "x␊y"); +}); + +/** The characters that a flag encodes, their replacements, and others. */ +const CHARACTERS = [ + ..."\0\x01\x07\t\n\v\f\r\x1b\x1f\x7f /\\<>[];!?:|\"'`$#%*.~a", + ..."␀␁␉␊␋␍␟␠␡/\<>[];!?:|"'`$#%*.~", + QuoteRune, +]; + +/** The flags for the last character of a name, and the characters. */ +const RIGHT = [ + [EncodeRightSpace, " "], + [EncodeRightPeriod, "."], + [EncodeRightCrLfHtVt, "\t\n\v\r"], +]; + +/** + * Tells if rclone gives back a name after the encoding and the decoding. + * rclone does not in two cases: + * + * - The name ends with the quote and a character that a flag for the last + * character encodes. For example, `a‛.` with `EncodeRightPeriod`. + * - The encoded name is an encoded name of `EncodeDot`, but the name is not + * `.` or `..`. For example, `..` with `EncodeRightPeriod` gives `..`. + * @param {string} name + * @param {number} mask + * @returns {boolean} + */ +function rcloneGivesBack(name, mask) { + if ( + name.at(-2) === QuoteRune && + RIGHT.some(([flag, characters]) => + mask & flag && characters.includes(name.at(-1)) + ) + ) { + return false; + } + const dots = [".", "..", ".", ".."]; + return !(mask & EncodeDot && !dots.includes(name) && + dots.map((dot) => encodeName(dot, mask)).includes(encodeName(name, mask))); +} + +test("rclone does not give back some names, and this module does the same", () => { + assert.equal(encodeName("a‛.", EncodeRightPeriod), "a‛‛."); + assert.equal(decodeName("a‛‛.", EncodeRightPeriod), "a‛."); + assert.equal(encodeName("..", EncodeDot | EncodeRightPeriod), ".."); + assert.equal(decodeName("..", EncodeDot | EncodeRightPeriod), ".."); + assert.equal(rcloneGivesBack("a‛.", EncodeRightPeriod), false); + assert.equal(rcloneGivesBack("..", EncodeDot | EncodeRightPeriod), false); +}); + +test("decodeName gives back the name of encodeName, for some masks", () => { + fc.assert( + fc.property( + fc.constantFrom( + EncodeZero, + EncodeRaw, + Base, + Standard, + ONEDRIVE, + S3, + OPENDRIVE, + SHAREFILE, + ), + fc.oneof( + fc.stringOf( + fc.oneof( + { arbitrary: fc.constantFrom(...CHARACTERS), weight: 4 }, + { arbitrary: fc.fullUnicode(), weight: 1 }, + ), + { maxLength: 12 }, + ), + fc.constantFrom(".", "..", ".", ".."), + ), + (mask, name) => { + fc.pre(rcloneGivesBack(name, mask)); + assert.equal(decodeName(encodeName(name, mask), mask), name); + }, + ), + { numRuns: 2000 }, + ); +}); + +/** + * Asserts that `encodeName` gives the encoded name of each case, and that + * `decodeName` gives back the name. + * @param {string} table + * @param {[number, string, string][]} cases + */ +function assertCases(table, cases) { + for (const [index, [mask, name, encoded]] of cases.entries()) { + const message = `${table} ${index}: ${JSON.stringify(name)}`; + assert.equal(encodeName(name, mask), encoded, message); + assert.equal(decodeName(encoded, mask), name, message); + } +} + +// The cases of rclone 1.75.1, from lib/encoder/encoder_cases_test.go. A +// program of rclone makes them. Each case is a mask, a name, and its encoded +// name. That file has 10457 cases. This file keeps a part of them, in the +// order of that file: +// +// - SINGLE: all the cases of testCasesSingle. +// - SINGLE_EDGE: the first case of each mask of testCasesSingleEdge. +// - DOUBLE_EDGE: the first case of each mask of testCasesDoubleEdge. +// - EDGE_EXAMPLES: the cases at the end of testCasesSingleEdge that the +// program writes by hand, but not the cases that SINGLE_EDGE has. +// +// No case of that file has bytes that are not correct UTF-8. + +const SINGLE = [ + [ + EncodeZero, + "*2π%j8|aw␄k%\x15β\x00σ\\τ7␟'R\f␀τ;β77␇", + "*2π%j8|aw␄k%\x15β␀σ\\τ7␟'R\f‛␀τ;β77␇", + ], + [ + EncodeSlash, + "␄␅VJg:K|\x10+\x10/φ`/π\n\bρRα\x03ζ?<VLν␗q", + "␄␅VJg:K|\x10+\x10‛/φ`/π\n\bρRα\x03ζ?<VLν␗q", + ], + [ + EncodeSingleQuote, + "γ␅'Pn\fχ\nε␍[υ\r␉]ζM2ω␘'$\nAN␄^'\\\x7f", + "γ␅‛'Pn\fχ\nε␍[υ\r␉]ζM2ω␘‛'$\nAN␄^'\\\x7f", + ], + [ + EncodeBackQuote, + "\x17\θWfcR␟([`\vV6τη\x1d['mυ>:#|βw{`ρ", + "\x17\θWfcR␟([‛`\vV6τη\x1d['mυ>:#|βw{`ρ", + ], + [ + EncodeLtGt, + "4>>o\n\x1bPι,␅Pυ\x0e<\n"!9tπ><␎\b(ης:oε", + "4‛>>o\n\x1bPι,␅Pυ\x0e‛<\n"!9tπ‛><␎\b(ης:oε", + ], + [ + EncodeSquareBracket, + "␛␡\fενm\x1bρ[M+τ\x19\\\x04νw^]#␚q.ψI0X#[]", + "␛␡\fενm\x1bρ[M+τ\x19\\\x04νw^‛]#␚q.ψI0X#‛[]", + ], + [ + EncodeSemicolon, + "Rφ#'\x06\x13ςY|ψ>l%;yso$;;ς␆ξ␗␚\x1b[\ι\", + "Rφ#'\x06\x13ςY|ψ>l%‛;yso$;;ς␆ξ␗␚\x1b[\ι\", + ], + [ + EncodeExclamation, + '\x17!;Xτ\x02?^εE!π\x01&#␇␐&ψ*ι~␀:~/"θ␎R', + '\x17!;Xτ\x02?^εE‛!π\x01&#␇␐&ψ*ι~‛␀:~/"θ␎R', + ], + [ + EncodeDollar, + "ωQ=λ␕γ␗$"ε/#R$!$␚\x1e:Y\x1e8\x7f$π␇ν\x19␁=", + "ωQ=λ␕γ␗‛$"ε/#R$!$␚\x1e:Y\x1e8\x7f$π␇ν\x19␁=", + ], + [ + EncodeDoubleQuote, + 'χ␙ρ\x13\x15E\t\x0fC␈\x153sος␙7␔νk!π"␆o␎"MW\x05', + "χ␙ρ\x13\x15E\t\x0fC␈\x153sος␙7␔νk!π‛"␆o␎"MW\x05", + ], + [ + EncodeColon, + "\x1f\x18%~:/\x18Q+δω␆R|:OζIs␇\x7f/\x17?␊μτC;φ", + "\x1f\x18%~:/\x18Q+δω␆R|‛:OζIs␇\x7f/\x17?␊μτC;φ", + ], + [ + EncodeQuestion, + "Q␝Jal?Vz\v␏␁\x16\x10?␛χκ?[␑ι\n<␝ν\x15#βλU", + "Q␝Jal‛?Vz\v␏␁\x16\x10?␛χκ?[␑ι\n<␝ν\x15#βλU", + ], + [ + EncodeAsterisk, + 'W=\x05xξβ$\x1bmaε␡*␝!*␡␊f\\"␑Yhzτ\x1fδτk', + 'W=\x05xξβ$\x1bmaε␡*␝!‛*␡␊f\\"␑Yhzτ\x1fδτk', + ], + [ + EncodePipe, + "#\x18␍␊X\x18␍kp*λ\f:\x07θtg]|kρ;|υο␎Oξ␍`", + "#\x18␍␊X\x18␍kp*λ\f:\x07θtg]‛|kρ;|υο␎Oξ␍`", + ], + [ + EncodeHash, + "*[␄%μ$φ#$$iδ%|␝ο\χ\x1fxI)\x11yG#!ςt9", + "*[␄%μ$φ#$$iδ%|␝ο\χ\x1fxI)\x11yG‛#!ςt9", + ], + [ + EncodePercent, + "DδM`ξ␍\x06ρ␓ζ:$T\x04␒φ!\x01O$%d%/γWJ\r%:", + "DδM`ξ␍\x06ρ␓ζ:$T\x04␒φ!\x01O$%d%/γWJ\r‛%:", + ], + [ + EncodeBackSlash, + "\x1al^.\\xP␇ββσ\x10*\x01␓\α>\x16*\":'/␄λ/eχV", + "\x1al^.\xP␇ββσ\x10*\x01␓‛\α>\x16*\":'/␄λ/eχV", + ], + [ + EncodeCrLf, + "Fg![␂π|\x0eY\nμ$*0␊ν\x01VχI3␍>\x00␐?δ\r␊ω", + "Fg![␂π|\x0eY␊μ$*0‛␊ν\x01VχI3‛␍>␀␐?δ␍‛␊ω", + ], + [ + EncodeDel, + "ο␡:πς␓\x14o␏*P:␙\x18d␊λδZ<;&/\x7f\x006[\x03Vδ", + "ο‛␡:πς␓\x14o␏*P:␙\x18d␊λδZ<;&/␡␀6[\x03Vδ", + ], + [ + EncodeCtl, + "␊\n␅␚ρ␉␄␈\x1f\\\x1bζ\x19␏t␘\x07φ\f\x11\x05␃-␒␁␙␟␔ξ#␄XN␜\b\r\x14\x04␋\x10<␑2!\x18\x1c\t␆\x06␞{␍␐␎5␝\x12c\x1a\x03␗\x0e␛\b\x15\x1eX␛␐␌\x01\x1d\x17\x02\x1d8?\x13␖␂\x18\v␕^␙\x0fα␕␇;μ␓\x16", + "‛␊␊‛␅‛␚ρ‛␉‛␄‛␈␟\\␛ζ␙‛␏t‛␘␇φ␌␑␅‛␃-‛␒‛␁‛␙‛␟‛␔ξ#‛␄XN‛␜␈␍␔␄‛␋␐<‛␑2!␘␜␉‛␆␆‛␞{‛␍‛␐‛␎5‛␝␒c␚␃‛␗␎‛␛␈␕␞X‛␛‛␐‛␌␁␝␗␂␝8?␓‛␖‛␂␘␋‛␕^‛␙␏α‛␕‛␇;μ‛␓␖", + ], +]; + +const SINGLE_EDGE = [ + [EncodeLeftSpace, " ", "␠"], + [ + EncodeSlash | EncodeLeftSpace, + '["α//s]␎d\x19 9~ζO\n␍ντU*T\x0eπm␟|uε;]\x16␌χ", + "]␟,χ%ι$?]D 1εw\x14\x15Xη$␉>␟|uε;]\x16␌χ", + ], + [ + EncodeDollar | EncodeLeftSpace, + '3~?ε␞\x14μO?ψ {ς\81\np<"f␒\x1b␔C\x15␃|ρF', + '3~?ε␞\x14μO?ψ {ς\81\np<"f␒\x1b␔C\x15␃|ρF', + ], + [ + EncodeDoubleQuote | EncodeLeftSpace, + "J␛:o\x02%␍Oε␈ τa\uff00χK\x0f␛\x12|>9ς]ξ␃J\x07*`", + "J␛:o\x02%␍Oε␈ τa\uff00χK\x0f␛\x12|>9ς]ξ␃J\x07*`", + ], + [ + EncodeColon | EncodeLeftSpace, + "␖\x1fλ]␙νο\tμ1 2\x03\x1fy\x03XPδ␅V%ν\x7fWq* S␒", + "␖\x1fλ]␙νο\tμ1 2\x03\x1fy\x03XPδ␅V%ν\x7fWq* S␒", + ], + [ + EncodeQuestion | EncodeLeftSpace, + "*νμ␜V\x18\x07jθψ θSψU82j\x01?␂`P\x17%!␕tv#", + "*νμ␜V\x18\x07jθψ θSψU82j\x01?␂`P\x17%!␕tv#", + ], + [ + EncodeAsterisk | EncodeLeftSpace, + "5"^␅\x16␡ςJM' '%AB␡;\x7fχπ7Dχ5!θ\r\x00'Z", + "5"^␅\x16␡ςJM' '%AB␡;\x7fχπ7Dχ5!θ\r␀'Z", + ], + [ + EncodePipe | EncodeLeftSpace, + "Y!\x06␟o!\x12=!I λ'α&g\x16\x1bJν*|Rφ`ψ/␞␌σ", + "Y!\x06␟o!\x12=!I λ'α&g\x16\x1bJν*‛|Rφ`ψ/␞␌σ", + ], + [ + EncodeHash | EncodeLeftSpace, + "\v␊φ\x11\x0e}ζ0N7 \x10(+ηυ/C\x1b?γS"}␆"␈\r\ρ", + "\v␊φ\x11\x0e}ζ0N7 \x10(+ηυ/C\x1b?γS"}␆"␈\r\ρ", + ], + [ + EncodePercent | EncodeLeftSpace, + "φg␕+{<␚sJ5 γν␔θ␟\x00.φ\x12[αundC␞␅]>", + "φg␕+{<␚sJ5 γν␔θ␟␀.φ\x12[αundC␞␅]>", + ], + [ + EncodeBackSlash | EncodeLeftSpace, + ".\x13␡MUψ1J␆W "ςο\x18x*'<χcυ?\x0e!50N;ς", + ".\x13␡MUψ1J␆W "ςο\x18x*'<χcυ?\x0e!50N;ς", + ], + [ + EncodeCrLf | EncodeLeftSpace, + "ε]N)␍Eτ␙=/ \\\x19$\x15|␙ωEξ1s%3\n#␖\rκg", + "ε]N)‛␍Eτ␙=/ \\\x19$\x15|␙ωEξ1s%3␊#␖␍κg", + ], + [ + EncodeDel | EncodeLeftSpace, + "\x14\bθ/^βδ␀a; H#>\x00\x12␐/Mγ\x05␛ς$U&N␐eη", + "\x14\bθ/^βδ‛␀a; H#>␀\x12␐/Mγ\x05␛ς$U&N␐eη", + ], + [ + EncodeCtl | EncodeLeftSpace, + "␉B␑s5}φub␉ \x0f\rξ@\x17βhι/>\x18\␈<Mυ{i]", + "‛␉B‛␑s5}φub‛␉ ␏␍ξ@␗βhι/>␘\‛␈<Mυ{i]", + ], + [EncodeLeftPeriod, ".", "."], + [ + EncodeSlash | EncodeLeftPeriod, + "ε-π␍ιNX,Eα.\*|ι\x14G␒␚;*θV%K+]␡BG", + "ε-π␍ιNX,Eα.\*|ι\x14G␒␚;*θV%K+]␡BG", + ], + [ + EncodeSingleQuote | EncodeLeftPeriod, + "\x0e:zπ97␖ξ\U.e#l\rξ%\nι\x1b//1κf#␔ςs$", + "\x0e:zπ97␖ξ\U.e#l\rξ%\nι\x1b//1κf#␔ςs$", + ], + [ + EncodeBackQuote | EncodeLeftPeriod, + "/?#␅ ;␞␖\rO.lητ`dl\x01]]ιψ*\x12γ(ρ0~:", + "/?#␅ ;␞␖\rO.lητ`dl\x01]]ιψ*\x12γ(ρ0~:", + ], + [ + EncodeLtGt | EncodeLeftPeriod, + "U(!υ'#\x04sLρ.>'{x\x05]␜␌Lτ>\x07υυ!{#σ'", + "U(!υ'#\x04sLρ.>'{x\x05]␜␌Lτ‛>\x07υυ!{#σ'", + ], + [ + EncodeSquareBracket | EncodeLeftPeriod, + "V!\x0f$␃\x1fλβf1.I;;/o?Wψm\rςζb!2␕␊!{", + "V!\x0f$␃\x1fλβf1.I;;/o?Wψm\rςζb!2␕␊!{", + ], + [ + EncodeSemicolon | EncodeLeftPeriod, + "\x1f␔`4`ωVζυ$.ν␛TeAqw\x11␈θ\x17@ψw␙␗S1\x02", + "\x1f␔`4`ωVζυ$.ν␛TeAqw\x11␈θ\x17@ψw␙␗S1\x02", + ], + [ + EncodeExclamation | EncodeLeftPeriod, + "␍)\x1c+␃lυ$␙\r.δ/'.*:\b␏δ?W␔$1τ?`οφ", + "␍)\x1c+␃lυ$␙\r.δ/'.*:\b␏δ?W␔$1τ?`οφ", + ], + [ + EncodeDollar | EncodeLeftPeriod, + "\x06)μk{␊*9␊A.\x04π\x1e\x0e␖m<\x1as\x04Hσ-"@χ$λ%", + "\x06)μk{␊*9␊A.\x04π\x1e\x0e␖m<\x1as\x04Hσ-"@χ‛$λ%", + ], + [ + EncodeDoubleQuote | EncodeLeftPeriod, + "\x1d\x07sG#ο\v␋$7.␡λ"`␌ν[5ω]"\x00"[~οsη1", + "\x1d\x07sG#ο\v␋$7.␡λ‛"`␌ν[5ω]‛"␀‛"[~οsη1", + ], + [ + EncodeColon | EncodeLeftPeriod, + "ςε]cV|␟5S␎.\rCLo␍\\π{\n\x1aι:\x01␟π}!\x07␌", + "ςε]cV|␟5S␎.\rCLo␍\\π{\n\x1aι‛:\x01␟π}!\x07␌", + ], + [ + EncodeQuestion | EncodeLeftPeriod, + "␒:W%&ξ(\x16␊r.π\x06''Vξη^␆h'D[τPθ␔2␛", + "␒:W%&ξ(\x16␊r.π\x06''Vξη^␆h'D[τPθ␔2␛", + ], + [ + EncodeAsterisk | EncodeLeftPeriod, + "+υXAξ\x04\\[␑\x05.␟&Wζυ;/c\x02:n␈8␖x!XρW", + "+υXAξ\x04\\[␑\x05.␟&Wζυ;/c\x02:n␈8␖x!XρW", + ], + [ + EncodePipe | EncodeLeftPeriod, + "κ"␄␖␁a␕:V[.ι!μχ/!ς*\x06ω\"\x00%J\td'\uff00!", + "κ"␄␖␁a␕:V[.ι!μχ/!ς*\x06ω\"␀%J\td'\uff00!", + ], + [ + EncodeHash | EncodeLeftPeriod, + '!3"j]Asσ␛␂.πPbξ@<␐\x12θι"S*ο\x14H!\x1e␑', + '!3"j]Asσ␛␂.πPbξ@<␐\x12θι"S*ο\x14H!\x1e␑', + ], + [ + EncodePercent | EncodeLeftPeriod, + "τAτkB|␀γ$␎._\x7fμψ␀U\nτb\n4␖2?d␇␅]z", + "τAτkB|‛␀γ$␎._\x7fμψ‛␀U\nτb\n4␖2?d␇␅]z", + ], + [ + EncodeBackSlash | EncodeLeftPeriod, + "\\(\x025␃I␜q%N.\nZpφ<␃ζ\rχξτt'p␊Cφ:|", + "\(\x025␃I␜q%N.\nZpφ<␃ζ\rχξτt'p␊Cφ:|", + ], + [ + EncodeCrLf | EncodeLeftPeriod, + "␎:'6\x03&ξω\tf.θ␗6ρ\x1a␔+\\u%tIφ␏dKω+\x0e", + "␎:'6\x03&ξω\tf.θ␗6ρ\x1a␔+\\u%tIφ␏dKω+\x0e", + ], + [ + EncodeDel | EncodeLeftPeriod, + "␓HW\tG\x1b$\x1a_B.␓ζφψω<%\x01$i_>'λ?g_UG", + "␓HW\tG\x1b$\x1a_B.␓ζφψω<%\x01$i_>'λ?g_UG", + ], + [ + EncodeCtl | EncodeLeftPeriod, + 'U␔\x7f*φwζ\x12\x12␝.B\x18Uυ␀|0ψ$\x06$"s]<βο\x15D', + 'U‛␔\x7f*φwζ␒␒‛␝.B␘Uυ‛␀|0ψ$␆$"s]<βο␕D', + ], + [EncodeLeftTilde, "~", "~"], + [ + EncodeSlash | EncodeLeftTilde, + "%s␀\x0eψv&ω+#~␍ι*Pe␊␚\x19\x1e#Uρ\υνE\x0e??", + "%s‛␀\x0eψv&ω+#~␍ι*Pe␊␚\x19\x1e#Uρ\υνE\x0e??", + ], + [ + EncodeSingleQuote | EncodeLeftTilde, + '5␓%φφ%L*"\x1e~θv<τ1\\ηβ␋\x03d␉x7␁\f;K\r', + '5␓%φφ%L*"\x1e~θv<τ1\\ηβ␋\x03d␉x7␁\f;K\r', + ], + [ + EncodeBackQuote | EncodeLeftTilde, + 'ν"␈6U<uξh|~gmπ\b␊οσ23z␄`\x13\t-␍\x16ε)', + 'ν"␈6U<uξh|~gmπ\b␊οσ23z␄‛`\x13\t-␍\x16ε)', + ], + [ + EncodeLtGt | EncodeLeftTilde, + "Yε\x10m\x1f'vsCφ~|wgδ\r7ξ:\n\\*␉Lμkf␊?u", + "Yε\x10m\x1f'vsCφ~|wgδ\r7ξ:\n\\*␉Lμkf␊?u", + ], + [ + EncodeSquareBracket | EncodeLeftTilde, + ">\x1cκ␐␜Eω\x03dλ~<␎z\x1cX[V/N␇ψ|%\rμ>μ^␉", + ">\x1cκ␐␜Eω\x03dλ~<␎z\x1cX[V/N␇ψ|%\rμ>μ^␉", + ], + [ + EncodeSemicolon | EncodeLeftTilde, + "␝␊m<;\uff001δj␏~J$γzRρ:%6\x04:\x07␡ιψ␏5χ)", + "␝␊m<‛;\uff001δj␏~J$γzRρ:%6\x04:\x07␡ιψ␏5χ)", + ], + [ + EncodeExclamation | EncodeLeftTilde, + "'$ζ>υ#W\r]σ~f␉+/)\x10␄η<[r;ς\\x188β␗a", + "'$ζ>υ#W\r]σ~f␉+/)\x10␄η<[r;ς\\x188β␗a", + ], + [ + EncodeDollar | EncodeLeftTilde, + "σ<χ*ω␒x){␂~\x03αUh|σg39␆y\x181\x01β␖\x0ec␇", + "σ<χ*ω␒x){␂~\x03αUh|σg39␆y\x181\x01β␖\x0ec␇", + ], + [ + EncodeDoubleQuote | EncodeLeftTilde, + "|␒rLφνu[:\x1c~ρiPιd#␎`pb␅σ\x19Mβ\x15␟/+", + "|␒rLφνu[:\x1c~ρiPιd#␎`pb␅σ\x19Mβ\x15␟/+", + ], + [ + EncodeColon | EncodeLeftTilde, + "␜;:υφ\x11χζ{c~␔-6"\x04\bS]@0%\x00υ\x0f;Dc␉κ", + "␜;:υφ\x11χζ{c~␔-6"\x04\bS]@0%␀υ\x0f;Dc␉κ", + ], + [ + EncodeQuestion | EncodeLeftTilde, + "␖F'/P/Kπ/n~cδ␜`ε$>,-\x07λ\x05CW^␄ςε␚", + "␖F'/P/Kπ/n~cδ␜`ε$>,-\x07λ\x05CW^␄ςε␚", + ], + [ + EncodeAsterisk | EncodeLeftTilde, + '!␙W␈7φ`lεζ~␀(za`\x1bo|eUν␄\r;rσ"/m', + '!␙W␈7φ`lεζ~‛␀(za`\x1bo|eUν␄\r;rσ"/m', + ], + [ + EncodePipe | EncodeLeftTilde, + "\x16Lρ\x1eρT␐%\x00␓~QL␆\vM␘;\x16αθYι.γ,a*\x02␛", + "\x16Lρ\x1eρT␐%␀␓~QL␆\vM␘;\x16αθYι.γ,a*\x02␛", + ], + [ + EncodeHash | EncodeLeftTilde, + "]%␛e*b<U#␌~\x1b\x17δ;I␕␁_\x12\t#σςBξω2ρ\x00", + "]%␛e*b<U#␌~\x1b\x17δ;I␕␁_\x12\t‛#σςBξω2ρ␀", + ], + [ + EncodePercent | EncodeLeftTilde, + 'Zν\x05␂"␂O!ρj~ε\x18ωνr\x1bn_ψE3w␞F␍s:y␓', + 'Zν\x05␂"␂O!ρj~ε\x18ωνr\x1bn_ψE3w␞F␍s:y␓', + ], + [ + EncodeBackSlash | EncodeLeftTilde, + "η+(?γz|tav~λ␗\x19\x05␘␒\x11$ω8"'!ε*)ψ␄*", + "η+(?γz|tav~λ␗\x19\x05␘␒\x11$ω8"'!ε*)ψ␄*", + ], + [ + EncodeCrLf | EncodeLeftTilde, + "#υt␑[␝\x0fo␇λ~Wφ,%]>!␓␀\x00\x1fHιψ\x06φ&u8", + "#υt␑[␝\x0fo␇λ~Wφ,%]>!␓‛␀␀\x1fHιψ\x06φ&u8", + ], + [ + EncodeDel | EncodeLeftTilde, + "|νJ=?␑>γ/σ~#σwl\x1bfπl$n2T␍␟<B\x1f\n␍", + "|νJ=?␑>γ/σ~#σwl\x1bfπl$n2T␍␟<B\x1f\n␍", + ], + [ + EncodeCtl | EncodeLeftTilde, + "\x14S*1Q␍[_ζ|~τ\rδU␗␆jξ\x1cνYHV>%␂ζ<\", + "␔S*1Q‛␍[_ζ|~τ␍δU‛␗‛␆jξ␜νYHV>%‛␂ζ<\", + ], + [EncodeLeftCrLfHtVt, "\t", "␉"], + [ + EncodeSlash | EncodeLeftCrLfHtVt, + 'χQ␙"ξ\n=SP%\tκβ\x07φzW\x1d␆i"␍␛5)τ6`*\x15', + 'χQ␙"ξ\n=SP%\tκβ\x07φzW\x1d␆i"␍␛5)τ6`*\x15', + ], + [ + EncodeSingleQuote | EncodeLeftCrLfHtVt, + "β␙&J_];iτθ\t\x0eN␗I\x16aβ␈\x16>>$\x0e\x07_αUpη", + "β␙&J_];iτθ\t\x0eN␗I\x16aβ␈\x16>>$\x0e\x07_αUpη", + ], + [ + EncodeBackQuote | EncodeLeftCrLfHtVt, + "GD[\v#T#σ>%\tmθ␝I~?\x00znλ\x06\x0f␉␞<υτdφ", + "GD[\v#T#σ>%\tmθ␝I~?␀znλ\x06\x0f␉␞<υτdφ", + ], + [ + EncodeLtGt | EncodeLeftCrLfHtVt, + "?`δCπ\x00;γ␗3\t␜%]/qιlN␃\x06#ρ4P9␖;xπ", + "?`δCπ␀;γ␗3\t␜%]/qιlN␃\x06#ρ4P9␖;xπ", + ], + [ + EncodeSquareBracket | EncodeLeftCrLfHtVt, + "δfB/?ηyξ\x1aυ\t+␍<␉_\x14␗5%!θ␋eG>X\bκ\n", + "δfB/?ηyξ\x1aυ\t+␍<␉_\x14␗5%!θ␋eG>X\bκ\n", + ], + [ + EncodeSemicolon | EncodeLeftCrLfHtVt, + "[*9?Fφ␃fVη\t␈>\t\x16δk*#ψ3\x11O␁\x05`τMρ␀", + "[*9?Fφ␃fVη\t␈>\t\x16δk*#ψ3\x11O␁\x05`τMρ‛␀", + ], + [ + EncodeExclamation | EncodeLeftCrLfHtVt, + '␎C6*Ta!πζκ\to]ζ$ε^Y/T"\x1fS\τ\nAU\x02\x0f', + '␎C6*Ta‛!πζκ\to]ζ$ε^Y/T"\x1fS\τ\nAU\x02\x0f', + ], + [ + EncodeDollar | EncodeLeftCrLfHtVt, + "␊ρW\tF-/τ!5\t␑\x12)ψ<n\x7f&nA>!␁ωπ?δ/4", + "‛␊ρW\tF-/τ!5\t␑\x12)ψ<n\x7f&nA>!␁ωπ?δ/4", + ], + [ + EncodeDoubleQuote | EncodeLeftCrLfHtVt, + "σ#\n#4GV%ζχ\tP\b␍␌@ω␚[\n␟␊&[2κ␜␘\x05@δ\tυE#\x07j*|γξG\x00vB"␡θ^%p', + '‛␊;a>κ␜␘\x05@δ\tυE#\x07j*|γξG␀vB"␡θ^%p', + ], + [ + EncodeBackSlash | EncodeLeftCrLfHtVt, + "␉\x12νλ#α6*S5\t/␁:ul\bφ␗␘␎?&ω^\x10Q\x1eφd", + "‛␉\x12νλ#α6*S5\t/␁:ul\bφ␗␘␎?&ω^\x10Q\x1eφd", + ], + [ + EncodeDel | EncodeLeftCrLfHtVt, + "Vj\x05B[^&␗α\x1b\tζc!␒λ␍\x16ιq$␞#i%ω@␂ρ␊", + "Vj\x05B[^&␗α\x1b\tζc!␒λ␍\x16ιq$␞#i%ω@␂ρ␊", + ], + [EncodeRightSpace, " ", "␠"], + [ + EncodeSlash | EncodeRightSpace, + '␑>␒z␌osσ\x0fζ X"#)η8ρ␉#λ1\v]^ο9␍>z', + '␑>␒z␌osσ\x0fζ X"#)η8ρ␉#λ1\v]^ο9␍>z', + ], + [ + EncodeSingleQuote | EncodeRightSpace, + "\rKi2\rI^ξ␊% \x19ιeγγ␆3ηh\x0f␍␀7ψcK␋{␎", + "\rKi2\rI^ξ␊% \x19ιeγγ␆3ηh\x0f␍‛␀7ψcK␋{␎", + ], + [ + EncodeBackQuote | EncodeRightSpace, + 'nr"@β/cOρ~ \x16␐κκ␍gr␒\x19\r\x1d%6Q5ζυ\x1c␈', + 'nr"@β/cOρ~ \x16␐κκ␍gr␒\x19\r\x1d%6Q5ζυ\x1c␈', + ], + [ + EncodeLtGt | EncodeRightSpace, + '@bφη;␈fLlC φ[γZ$␚>*:α,:"ι{,\v|\x01', + '@bφη;␈fLlC φ[γZ$␚‛>*:α,:"ι{,\v|\x01', + ], + [ + EncodeSquareBracket | EncodeRightSpace, + "*t\n1\r|χ␞[\x16 ;9pgδωυ`Ffc%χ#|\x16lS<", + "*t\n1\r|χ␞‛[\x16 ;9pgδωυ`Ffc%χ#|\x16lS<", + ], + [ + EncodeSemicolon | EncodeRightSpace, + "␆\x05?␞w<\fσ␇5 sκω/;H␉␐-ξUφλ_\p|9>", + "␆\x05?␞w<\fσ␇5 sκω/‛;H␉␐-ξUφλ_\p|9>", + ], + [ + EncodeExclamation | EncodeRightSpace, + "\t0|\x11o\vςδjP πξ;DE␐9\x18η9=[\x1cU\x1e␏ν[h", + "\t0|\x11o\vςδjP πξ;DE␐9\x18η9=[\x1cU\x1e␏ν[h", + ], + [ + EncodeDollar | EncodeRightSpace, + "-ς\tIN␎?\ζ` 6@?:nW␅τTι\x19?␑␇!!κθ\x01", + "-ς\tIN␎?\ζ` 6@?:nW␅τTι\x19?␑␇!!κθ\x01", + ], + [ + EncodeDoubleQuote | EncodeRightSpace, + "␝IVe0%\x7f3κe !\x0fθζυ␟/#:FπUβG␌\x1ee\x12\x01", + "␝IVe0%\x7f3κe !\x0fθζυ␟/#:FπUβG␌\x1ee\x12\x01", + ], + [ + EncodeColon | EncodeRightSpace, + ":]χJnT:η[ε ο\n~\x06␒U*pι␒p␙&\vE`.ι␗", + ":]χJnT:η[ε ο\n~\x06␒U*pι␒p␙&\vE`.ι␗", + ], + [ + EncodeQuestion | EncodeRightSpace, + 'U\x15+&υ␡/\x1bμς # π␘|z"/\x19yE␉\x00␉aσ,{*', + 'U\x15+&υ␡/\x1bμς # π␘|z"/\x19yE␉␀␉aσ,{*', + ], + [ + EncodeAsterisk | EncodeRightSpace, + ">tTm␁\x18Yεσ1 ␎6\x10!<]E␌␊␀2eρ\flγ\rχγ", + ">tTm␁\x18Yεσ1 ␎6\x10!<]E␌␊‛␀2eρ\flγ\rχγ", + ], + [ + EncodePipe | EncodeRightSpace, + "6\x02Lς\x1cλE\x05<\x05 :␎γν^␕&q\r␅*χ4ψo␉0␖\x15", + "6\x02Lς\x1cλE\x05<\x05 :␎γν^␕&q\r␅*χ4ψo␉0␖\x15", + ], + [ + EncodeHash | EncodeRightSpace, + "γ␍`θβζ\x7f/␔O Kω\x14?␋\x0e~I\x16;>ψ$poB␏)K", + "γ␍`θβζ\x7f/␔O Kω\x14?␋\x0e~I\x16;>ψ$poB␏)K", + ], + [ + EncodePercent | EncodeRightSpace, + "-μ␁Eζi`/U? ␊U\x18␚␇gZππ1β\x1f*;Vθ␅PF", + "-μ␁Eζi`/U? ␊U\x18␚␇gZππ1β\x1f*;Vθ␅PF", + ], + [ + EncodeBackSlash | EncodeRightSpace, + '␛\\Rς␟\x14%θTω r\f;b"yε%:␔\x06␘5cδ/␓au', + '␛\Rς␟\x14%θTω r\f;b"yε%:␔\x06␘5cδ/␓au', + ], + [ + EncodeCrLf | EncodeRightSpace, + "␊3␜h\\\vZσθ] \x13'"<h[Yσ␂\v`lθυτ>|aL", + "‛␊3␜h\\\vZσθ] \x13'"<h[Yσ␂\v`lθυτ>|aL", + ], + [ + EncodeDel | EncodeRightSpace, + "4β%␝h.␘;!C `π␊␐υ\\x18:\\ςp\x07q`θ%|\r>", + "4β%␝h.␘;!C `π␊␐υ\\x18:\\ςp\x07q`θ%|\r>", + ], + [ + EncodeCtl | EncodeRightSpace, + "q\rC!#-?rβv ς%␘␅δ*θ␇σ␡␌%π4nS!f+", + "q␍C!#-?rβv ς%‛␘‛␅δ*θ‛␇σ␡‛␌%π4nS!f+", + ], + [EncodeRightPeriod, ".", "."], + [ + EncodeSlash | EncodeRightPeriod, + ":[\x1e44νS\\!y.dκ[η\x0e;Z{o␊α␊ς␇λf]y[", + ":[\x1e44νS\\!y.dκ[η\x0e;Z{o␊α␊ς␇λf]y[", + ], + [ + EncodeSingleQuote | EncodeRightPeriod, + "(;ηFυ>ν␘+$.o\x12y%\x05D?2ψ\x15H[ζι␚\x18␍<\x1e", + "(;ηFυ>ν␘+$.o\x12y%\x05D?2ψ\x15H[ζι␚\x18␍<\x1e", + ], + [ + EncodeBackQuote | EncodeRightPeriod, + "β$bUτD|/}\x1e.p␅;]χD␖<4␄\x07m\x12dLνε\x1cφ", + "β$bUτD|/}\x1e.p␅;]χD␖<4␄\x07m\x12dLνε\x1cφ", + ], + [ + EncodeLtGt | EncodeRightPeriod, + "@#"␊\x1chθbGχ.␑Vςα␐L\x13m\f1;μ␇2_\x1e|k␜", + "@#"␊\x1chθbGχ.␑Vςα␐L\x13m\f1;μ␇2_\x1e|k␜", + ], + [ + EncodeSquareBracket | EncodeRightPeriod, + "#\x1cα'\x13θ!> κ.␅[uε\x171,w0\'7θJ\x7f}\x18}%", + "#\x1cα'\x13θ!> κ.␅‛[uε\x171,w0\'7θJ\x7f}\x18}%", + ], + [ + EncodeSemicolon | EncodeRightPeriod, + "␆λu!ξSf\x02:␆.θχS=}$η␏x[:r[\x1f\nοfRJ", + "␆λu!ξSf\x02:␆.θχS=}$η␏x[:r[\x1f\nοfRJ", + ], + [ + EncodeExclamation | EncodeRightPeriod, + "␀␆βο\\%\x180ς:.3οk&prDg\x1dU␎\tI'␊␀κHe", + "‛␀␆βο\\%\x180ς:.3οk&prDg\x1dU␎\tI'␊‛␀κHe", + ], + [ + EncodeDollar | EncodeRightPeriod, + "␖[ηυz\v␒/{␛.βn!L␅MK\x17\nNARλ5"δ␏σU", + "␖[ηυz\v␒/{␛.βn!L␅MK\x17\nNARλ5"δ␏σU", + ], + [ + EncodeDoubleQuote | EncodeRightPeriod, + "βiB\␙\x15␙;η].~λc>σ␓\x05ωcyvθ␌%_k#h\v", + "βiB\␙\x15␙;η].~λc>σ␓\x05ωcyvθ␌%_k#h\v", + ], + [ + EncodeColon | EncodeRightPeriod, + "z\x1ap?%[iu\vx.␆␍α)␉[^γC;κ\x11␜β]ηζ'\x12", + "z\x1ap?%[iu\vx.␆␍α)␉[^γC;κ\x11␜β]ηζ'\x12", + ], + [ + EncodeQuestion | EncodeRightPeriod, + "\x1cD%␎|βκθυS.\x1emτ␆\x1f\bK\b\f*P@=␊Bη>_<", + "\x1cD%␎|βκθυS.\x1emτ␆\x1f\bK\b\f*P@=␊Bη>_<", + ], + [ + EncodeAsterisk | EncodeRightPeriod, + "y'`\x7fθ\x1ce␔Q\x13.tb\x02\x19ξ,+%ξ␍␏;␅ζρ=νST", + "y'`\x7fθ\x1ce␔Q\x13.tb\x02\x19ξ,+%ξ␍␏;␅ζρ=νST", + ], + [ + EncodePipe | EncodeRightPeriod, + "xφ:[!\x00J;λ␉.6ζ7;qZ&?ω␔␏\nςv\r!(σ;", + "xφ:[!␀J;λ␉.6ζ7;qZ&?ω␔␏\nςv\r!(σ;", + ], + [ + EncodeHash | EncodeRightPeriod, + "/␁}\x13␂)y\x04D".U&f\x16␞\nω␔\x05φs2ν␋<χψγ5", + "/␁}\x13␂)y\x04D".U&f\x16␞\nω␔\x05φs2ν␋<χψγ5", + ], + [ + EncodePercent | EncodeRightPeriod, + "ααyς␘\x13␟Rι<.Gχgi\r␊κ6\x18Y>RD␒b␔C\x1b\x1a", + "ααyς␘\x13␟Rι<.Gχgi\r␊κ6\x18Y>RD␒b␔C\x1b\x1a", + ], + [ + EncodeBackSlash | EncodeRightPeriod, + "␗%\x00*η\x0e:ισ*.:P:f[kzC%3]<βGξςN`␃", + "␗%␀*η\x0e:ισ*.:P:f[kzC%3]<βGξςN`␃", + ], + [ + EncodeCrLf | EncodeRightPeriod, + "ρMR␑DπwS$\x03.B␝␁$␇λβ␜␀λ$ιG\n$\rds\b", + "ρMR␑DπwS$\x03.B␝␁$␇λβ␜‛␀λ$ιG␊$␍ds\b", + ], + [ + EncodeDel | EncodeRightPeriod, + "[y`ο␛υ^\n\x1d:.!υ<\x16=\nμ\x02βIj!]'~;5\τ", + "[y`ο␛υ^\n\x1d:.!υ<\x16=\nμ\x02βIj!]'~;5\τ", + ], + [ + EncodeCtl | EncodeRightPeriod, + "\x11␌>β␔#'wσ0.a@jaφ␔τvFMκ␡ τ;l\x0fm\x16", + "␑‛␌>β‛␔#'wσ0.a@jaφ‛␔τvFMκ␡ τ;l␏m␖", + ], + [EncodeRightCrLfHtVt, "\t", "␉"], + [ + EncodeSlash | EncodeRightCrLfHtVt, + "H␅χUQ␋A␔ωχ\t\nοε\x0eKm\x02␁|A/|υ.␅go\x1c\v", + "H␅χUQ␋A␔ωχ\t\nοε\x0eKm\x02␁|A‛/|υ.␅go\x1c␋", + ], + [ + EncodeSingleQuote | EncodeRightCrLfHtVt, + "|&␃␄;\x02>ζfχ\t,<υτc`τ␞2[6#%n\rθ\x02`>", + "|&␃␄;\x02>ζfχ\t,<υτc`τ␞2[6#%n\rθ\x02`>", + ], + [ + EncodeBackQuote | EncodeRightCrLfHtVt, + "'β[/\x15␗=␇\x11&\t'ςAβG+\x04␃\x12␝\x03\rwO$τς␆#", + "'β[/\x15␗=␇\x11&\t'ςAβG+\x04␃\x12␝\x03\rwO$τς␆#", + ], + [ + EncodeLtGt | EncodeRightCrLfHtVt, + "\x16 ␟ζ8ksζ[?\t\x1c$X:␅0ω"$φ>\x1eκ\x1f:%{\x04Q", + "\x16 ␟ζ8ksζ[?\t\x1c$X:␅0ω"$φ‛>\x1eκ\x1f:%{\x04Q", + ], + [ + EncodeSquareBracket | EncodeRightCrLfHtVt, + "j::Wχ;χ4\x047\tιc␞ψ␃Aβ␀\x0e!ThX\x03Z<`&\x01", + "j::Wχ;χ4\x047\tιc␞ψ␃Aβ‛␀\x0e!ThX\x03Z<`&\x01", + ], + [ + EncodeSemicolon | EncodeRightCrLfHtVt, + "ifpX.\x03ηa'␍\tPορ␑λ?%\r&␋GρZ␑\x162\bkμ", + "ifpX.\x03ηa'␍\tPορ␑λ?%\r&␋GρZ␑\x162\bkμ", + ], + [ + EncodeExclamation | EncodeRightCrLfHtVt, + "e.'\x00B␗(πu$\t%␆μα[Fc&0β[*\rςξS\x01\b\\", + "e.'␀B␗(πu$\t%␆μα[Fc&0β[*\rςξS\x01\b\\", + ], + [ + EncodeDollar | EncodeRightCrLfHtVt, + '"<`Q/κ#θ"␐\t\x17[%␜N␊B:;?:␁φξν|y5μ', + '"<`Q/κ#θ"␐\t\x17[%␜N␊B:;?:␁φξν|y5μ', + ], + [ + EncodeDoubleQuote | EncodeRightCrLfHtVt, + "$&!\x16%\x06δ%~ω\t`β\x1b/ψe␁v/τ#ν[:)␕Bw␅", + "$&!\x16%\x06δ%~ω\t`β\x1b/ψe␁v/τ#ν[:)␕Bw␅", + ], + [ + EncodeColon | EncodeRightCrLfHtVt, + "φ*Uε\x04,\x05v␘\\t?␇τσ#(␉␊\x0fφ␐I/;no\x18PV", + "φ*Uε\x04,\x05v␘\\t?␇τσ#(␉␊\x0fφ␐I/;no\x18PV", + ], + [ + EncodeQuestion | EncodeRightCrLfHtVt, + "␆B\x11\x0ek\\=␉mψ\tπ\x19␉(␊y`)σ\x1aυZyFπw␂␊'", + "␆B\x11\x0ek\\=␉mψ\tπ\x19␉(␊y`)σ\x1aυZyFπw␂␊'", + ], + [ + EncodeAsterisk | EncodeRightCrLfHtVt, + ";Mα␗/␑υ:\x13;\tοa\x16i#␀@y"δ3!ρ|AT]\x15[", + ";Mα␗/␑υ:\x13;\tοa\x16i#‛␀@y"δ3!ρ|AT]\x15[", + ], + [ + EncodePipe | EncodeRightCrLfHtVt, + "$γιZ2q␡\x04n␋\t\x11\b1ζ\x17?l,ςoκ␆Pρ`\t!)␝", + "$γιZ2q␡\x04n␋\t\x11\b1ζ\x17?l,ςoκ␆Pρ`\t!)␝", + ], + [ + EncodeHash | EncodeRightCrLfHtVt, + "\x16#tψp␎␊o$χ\t␗p%ω␡/μ\tb3S␁T8\x154νG\f", + "\x16#tψp␎␊o$χ\t␗p%ω␡/μ\tb3S␁T8\x154νG\f", + ], + [ + EncodePercent | EncodeRightCrLfHtVt, + '␒+/v␡␄hθ8[\t)*c"χω0␇γZχp\x15g␎χi\x11\x15', + '␒+/v␡␄hθ8[\t)*c"χω0␇γZχp\x15g␎χi\x11\x15', + ], + [ + EncodeBackSlash | EncodeRightCrLfHtVt, + "*\π\"1K␖\Hp\t`τςN\x14ω␏␝'λ\n\x1d2dHF␗Bh", + "*‛\π\"1K␖‛\Hp\t`τςN\x14ω␏␝'λ\n\x1d2dHF␗Bh", + ], + [ + EncodeDel | EncodeRightCrLfHtVt, + "]3ιφ*,?]\x0fυ\t%dX)Sγω/<␂\uff00␋\x072\n\r␅␈δ", + "]3ιφ*,?]\x0fυ\t%dX)Sγω/<␂\uff00␋\x072\n\r␅␈δ", + ], + [EncodeLeftSpace | EncodeRightPeriod, " .", "␠."], + [EncodeLeftSpace | EncodeRightSpace, " ", "␠"], + [EncodeLeftPeriod | EncodeRightPeriod, "...", "..."], + [EncodeRightPeriod | EncodeRightSpace, "a. ", "a.␠"], +]; + +const DOUBLE_EDGE = [ + [ + EncodeZero | EncodeLeftSpace | EncodeLeftPeriod, + ' ./lc6>"␃@οκv\tu␀#Tκ␉\n>E\x0eW#gχ.π', + '␠./lc6>"␃@οκv\tu‛␀#Tκ␉\n>E\x0eW#gχ.π', + ], + [ + EncodeSlash | EncodeLeftSpace | EncodeLeftPeriod, + " .\n\x1eδV2␀t;!␐KOsφ\x7fQ␛@?'\x03␔σξYs.}", + "␠.\n\x1eδV2‛␀t;!␐KOsφ\x7fQ␛@?'\x03␔σξYs.}", + ], + [ + EncodeSingleQuote | EncodeLeftSpace | EncodeLeftPeriod, + " .x:$ξ|␋m"γ!5A\x07\x19l:␀ε\x01Gμ␃υW␈A.o", + "␠.x:$ξ|␋m"γ!5A\x07\x19l:‛␀ε\x01Gμ␃υW␈A.o", + ], + [ + EncodeBackQuote | EncodeLeftSpace | EncodeLeftPeriod, + " .I\x00=}σ#π"#_>?\x17\x18␁␄';οh␃X\x0es/+.9", + "␠.I␀=}σ#π"#_>?\x17\x18␁␄';οh␃X\x0es/+.9", + ], + [ + EncodeLtGt | EncodeLeftSpace | EncodeLeftPeriod, + " .␀ζWγ}a\x18<#χ]ψβ␘>\x13␗=M␘λ\":%␘#.'", + "␠.‛␀ζWγ}a\x18<#χ]ψβ␘‛>\x13␗=M␘λ\":%␘#.'", + ], + [ + EncodeSquareBracket | EncodeLeftSpace | EncodeLeftPeriod, + ' .ασs$␖NXπ\x06|EUm?χYη␓;"␟H\bDι\x16.\t', + '␠.ασs$␖NXπ\x06|EUm?χYη␓;"␟H\bDι\x16.\t', + ], + [ + EncodeSemicolon | EncodeLeftSpace | EncodeLeftPeriod, + " .\vγ.p>\\ρ-η}␄Iσ\x1eD<fο|␃␀ζv␆i/.h", + "␠.\vγ.p>\\ρ-η}␄Iσ\x1eD<fο|␃‛␀ζv␆i/.h", + ], + [ + EncodeExclamation | EncodeLeftSpace | EncodeLeftPeriod, + " .\\|]ψ>6\x185*\vδ\nυ^>1a%\x7fγψ[M\x01κ.u", + "␠.\\|]ψ>6\x185*\vδ\nυ^>1a%\x7fγψ[M\x01κ.u", + ], + [ + EncodeDollar | EncodeLeftSpace | EncodeLeftPeriod, + " .]γ]')ς9φ␎>\x04H\rW␏[s␅␊\x1ccμ\x02,μ8.␓", + "␠.]γ]')ς9φ␎>\x04H\rW␏[s␅␊\x1ccμ\x02,μ8.␓", + ], + [ + EncodeDoubleQuote | EncodeLeftSpace | EncodeLeftPeriod, + " .␕%ι?g\./θ$uγ/αν\x06%␈HιJWU\x0e\x16^.~", + "␠.␕%ι?g\./θ$uγ/αν\x06%␈HιJWU\x0e\x16^.~", + ], + [ + EncodeColon | EncodeLeftSpace | EncodeLeftPeriod, + " .Qμ>5γJ\x00μ␍W␙␁Su␛mJ\x18ςsSβ\x05[λX.g", + "␠.Qμ>5γJ␀μ␍W␙␁Su␛mJ\x18ςsSβ\x05[λX.g", + ], + [ + EncodeQuestion | EncodeLeftSpace | EncodeLeftPeriod, + " .Ih\x078!$ψ$N%τf␘ρ`\x1d\x7f\x1bςς{␋5␖\x11<.[", + "␠.Ih\x078!$ψ$N%τf␘ρ`\x1d\x7f\x1bςς{␋5␖\x11<.[", + ], + [ + EncodeAsterisk | EncodeLeftSpace | EncodeLeftPeriod, + " .b)ω["α\x06$\v\x1cu|!rοV␉ηφ?d|ε␀^?.␝", + "␠.b)ω["α\x06$\v\x1cu|!rοV␉ηφ?d|ε‛␀^?.␝", + ], + [ + EncodePipe | EncodeLeftSpace | EncodeLeftPeriod, + " .␍␚␇\x04\x05%\tςχζ?W␆TL{ρωπI0N>fL\x0e.g", + "␠.␍␚␇\x04\x05%\tςχζ?W␆TL{ρωπI0N>fL\x0e.g", + ], + [ + EncodeHash | EncodeLeftSpace | EncodeLeftPeriod, + " .rδI\t\x7fuδD3ω␙gY\b1σ\x15γ␞e*\x06␘\ω␃.X", + "␠.rδI\t\x7fuδD3ω␙gY\b1σ\x15γ␞e*\x06␘\ω␃.X", + ], + [ + EncodePercent | EncodeLeftSpace | EncodeLeftPeriod, + " .κ<\␈\x15\x0f]\x15ερ:,τXq␒eC␛\x1bz.G!s\v.J", + "␠.κ<\␈\x15\x0f]\x15ερ:,τXq␒eC␛\x1bz.G!s\v.J", + ], + [ + EncodeBackSlash | EncodeLeftSpace | EncodeLeftPeriod, + " .ρ!Xβ␄1␕OfM␍B/\x07\x14ωMηb#ο%\x06,ε:.k", + "␠.ρ!Xβ␄1␕OfM␍B/\x07\x14ωMηb#ο%\x06,ε:.k", + ], + [ + EncodeCrLf | EncodeLeftSpace | EncodeLeftPeriod, + " .\vχ\x13ω;.λ</3pS␚τ\vW␂hρEγ|␗XSq.\x19", + "␠.\vχ\x13ω;.λ</3pS␚τ\vW␂hρEγ|␗XSq.\x19", + ], + [ + EncodeDel | EncodeLeftSpace | EncodeLeftPeriod, + " .#$υ␈dλZ,\vωz\x18K!\v#ςL|*\x0f:␙οτ#.\x1b", + "␠.#$υ␈dλZ,\vωz\x18K!\v#ςL|*\x0f:␙οτ#.\x1b", + ], + [ + EncodeCtl | EncodeLeftSpace | EncodeLeftPeriod, + " .Sτ␉lSOγ␑\x01ψ\x118^␟]γ~>Lκηc\n\x1f`Y.$", + "␠.Sτ‛␉lSOγ‛␑␁ψ␑8^‛␟]γ~>Lκηc␊␟`Y.$", + ], + [ + EncodeZero | EncodeLeftSpace | EncodeLeftTilde, + " ~r7φλQ|Q<␃ρ␊\x1e\x0fu*\uff00*␚~␛ζ!\x1f\x16;ψ~z", + "␠~r7φλQ|Q<␃ρ␊\x1e\x0fu*\uff00*␚~␛ζ!\x1f\x16;ψ~z", + ], + [ + EncodeSlash | EncodeLeftSpace | EncodeLeftTilde, + " ~PY%yC=[_ς\x1c␀8\x1e␊\bbJψ\x13␊\x18ςτ␔/J~ο", + "␠~PY%yC=[_ς\x1c‛␀8\x1e␊\bbJψ\x13␊\x18ςτ␔/J~ο", + ], + [ + EncodeSingleQuote | EncodeLeftSpace | EncodeLeftTilde, + " ~ψκ.␕ω␛\x05␝z#ν\rK|}WM␇\b\x11␄.3/αδ~]", + "␠~ψκ.␕ω␛\x05␝z#ν\rK|}WM␇\b\x11␄.3/αδ~]", + ], + [ + EncodeBackQuote | EncodeLeftSpace | EncodeLeftTilde, + " ~#?οβ*Aα"kgK\λj!␊\x17θ_:\x1c\x04\x1b␝Y␅~\x7f", + "␠~#?οβ*Aα"kgK\λj!␊\x17θ_:\x1c\x04\x1b␝Y␅~\x7f", + ], + [ + EncodeLtGt | EncodeLeftSpace | EncodeLeftTilde, + " ~αξ/\x02\x060␕2[␍v<\x11\x1e␃`ξt`/,κ>}/@~σ", + "␠~αξ/\x02\x060␕2[␍v‛<\x11\x1e␃`ξt`/,κ>}/@~σ", + ], + [ + EncodeSquareBracket | EncodeLeftSpace | EncodeLeftTilde, + " ~qy:X'\x00␉|O\x15>\r'nα!␂␉$("<βγ␜ζ~7", + "␠~qy:X'␀␉|O\x15>\r'nα!␂␉$("<βγ␜ζ~7", + ], + [ + EncodeSemicolon | EncodeLeftSpace | EncodeLeftTilde, + " ~␔$*szφJζ#␁Eη9␌οτ?αHc/,Gk#\r~␐", + "␠~␔$*szφJζ#␁Eη9␌οτ?αHc/,Gk#\r~␐", + ], + [ + EncodeExclamation | EncodeLeftSpace | EncodeLeftTilde, + " ~sωcδσF]/!%9␡[φd\rkςw␔b|\x14G2'~ξ", + "␠~sωcδσF]/‛!%9␡[φd\rkςw␔b|\x14G2'~ξ", + ], + [ + EncodeDollar | EncodeLeftSpace | EncodeLeftTilde, + " ~!l␍\x04]βXγ-␃K␗␑>l3φ␉\x07βq'φ1σ+~\n", + "␠~!l␍\x04]βXγ-␃K␗␑>l3φ␉\x07βq'φ1σ+~\n", + ], + [ + EncodeDoubleQuote | EncodeLeftSpace | EncodeLeftTilde, + " ~:\rm%/\x14␆]mβ␄␌A?θa9ν|>RAkςο␟~\r", + "␠~:\rm%/\x14␆]mβ␄␌A?θa9ν|>RAkςο␟~\r", + ], + [ + EncodeColon | EncodeLeftSpace | EncodeLeftTilde, + " ~l7Uv'H\x15+t*!Sβω\r>␉␗ζυξ\x0eX\x7f\f␄~\x1f", + "␠~l7Uv'H\x15+t*!Sβω\r>␉␗ζυξ\x0eX\x7f\f␄~\x1f", + ], + [ + EncodeQuestion | EncodeLeftSpace | EncodeLeftTilde, + " ~Uuj␓`␕␉=τP␉uο\x14X\x10\v/βγ'!\ξ}L~η", + "␠~Uuj␓`␕␉=τP␉uο\x14X\x10\v/βγ'!\ξ}L~η", + ], + [ + EncodeAsterisk | EncodeLeftSpace | EncodeLeftTilde, + " ~␗ω:'F␋|'>␓]νεewF*\x1e>};%θγ~N~D", + "␠~␗ω:'F␋|'>␓]νεewF*\x1e>};%θγ~N~D", + ], + [ + EncodePipe | EncodeLeftSpace | EncodeLeftTilde, + " ~␁\x18κP~␉QsIτ<k␘pωξ/A"V[v'α␐5~␃", + "␠~␁\x18κP~␉QsIτ<k␘pωξ/A"V[v'α␐5~␃", + ], + [ + EncodeHash | EncodeLeftSpace | EncodeLeftTilde, + " ~Cν?\\\\x03ψ␂Nς"j>/`\x12ψN\nπL␐␂[{]~σ", + "␠~Cν?\\\\x03ψ␂Nς"j>/`\x12ψN\nπL␐␂[{]~σ", + ], + [ + EncodePercent | EncodeLeftSpace | EncodeLeftTilde, + " ~μ␘"zL`\x19θοε8␚␙Wβ$\r/μLom'%Kz~8", + "␠~μ␘"zL`\x19θοε8␚␙Wβ$\r/μLom'‛%Kz~8", + ], + [ + EncodeBackSlash | EncodeLeftSpace | EncodeLeftTilde, + " ~!γVIfκ␜{ELπJ␐2\x7fVζφ'␂|gPι'\r~␍", + "␠~!γVIfκ␜{ELπJ␐2\x7fVζφ'␂|gPι'\r~␍", + ], + [ + EncodeCrLf | EncodeLeftSpace | EncodeLeftTilde, + " ~ζlE$dBι|`␒d'tg␅%*ο?φβψx%]\v~␞", + "␠~ζlE$dBι|`␒d'tg␅%*ο?φβψx%]\v~␞", + ], + [ + EncodeDel | EncodeLeftSpace | EncodeLeftTilde, + " ~8)D/\bmν\\]bε␗'\nξ}T␊:fτ␛␋u?λ~κ", + "␠~8)D/\bmν\\]bε␗'\nξ}T␊:fτ␛␋u?λ~κ", + ], + [ + EncodeCtl | EncodeLeftSpace | EncodeLeftTilde, + " ~`-:/␆μδL]␏τ`%R[n␔γ\rZ\:μ{\x18S~/", + "␠~`-:/‛␆μδL]‛␏τ`%R[n‛␔γ␍Z\:μ{␘S~/", + ], + [ + EncodeZero | EncodeLeftSpace | EncodeLeftCrLfHtVt, + ' \t$x/$`Frη`|]=␇␝λ\x1bδ\x12ζ"␋␊υ;\tW\t*', + '␠\t$x/$`Frη`|]=␇␝λ\x1bδ\x12ζ"␋␊υ;\tW\t*', + ], + [ + EncodeSlash | EncodeLeftSpace | EncodeLeftCrLfHtVt, + " \tc[ι\x01'ιf[}eG}␝Aηψκ\x12␛"\f{*␅//\ty", + "␠\tc[ι\x01'ιf[}eG}␝Aηψκ\x12␛"\f{*␅‛//\ty", + ], + [ + EncodeSingleQuote | EncodeLeftSpace | EncodeLeftCrLfHtVt, + ' \t"\x1a'7>=<sιθ4\x14\x17g␉^␓␂_ιgγ!J␙ο\tG', + '␠\t"\x1a‛'7>=<sιθ4\x14\x17g␉^␓␂_ιgγ!J␙ο\tG', + ], + [ + EncodeBackQuote | EncodeLeftSpace | EncodeLeftCrLfHtVt, + " \t`<S\uff009}\rξυ␛"θλ[/\x19$O9f␑␏τ?P&\t7", + "␠\t‛`<S\uff009}\rξυ␛"θλ[/\x19$O9f␑␏τ?P&\t7", + ], + [ + EncodeLtGt | EncodeLeftSpace | EncodeLeftCrLfHtVt, + " \t\r␁ο\x06/␚L]8H{>C3φVφ␛O'α>0\\\x14\x0e\tψ", + "␠\t\r␁ο\x06/␚L]8H{‛>C3φVφ␛O'α‛>0\\\x14\x0e\tψ", + ], + [ + EncodeSquareBracket | EncodeLeftSpace | EncodeLeftCrLfHtVt, + " \tελ\x07iN␝37#Z]c[^/ε_␔␀␂?␚χFς\x19\t\r", + "␠\tελ\x07iN␝37#Z‛]c[^/ε_␔‛␀␂?␚χFς\x19\t\r", + ], + [ + EncodeSemicolon | EncodeLeftSpace | EncodeLeftCrLfHtVt, + " \ts\tb|␑ν)␗k(π␜,A!D\uff00\x1c2\x7f|\x07\αο\x14\tζ", + "␠\ts\tb|␑ν)␗k(π␜,A!D\uff00\x1c2\x7f|\x07\αο\x14\tζ", + ], + [ + EncodeExclamation | EncodeLeftSpace | EncodeLeftCrLfHtVt, + " \tγ>␁B*3␌␆\v2\\x1dκ<τvMc<&\t'", + "␠\tmC␍μx^\x13dοτ/Kξ\x148/\x16\x1ab ~:Zν`>\t'", + ], + [ + EncodeHash | EncodeLeftSpace | EncodeLeftCrLfHtVt, + " \t␊\t}υ\r8>Z␒δ=\x14%<`:\x13ι`NFφq\x1aGδ\tj", + "␠\t␊\t}υ\r8>Z␒δ=\x14%<`:\x13ι`NFφq\x1aGδ\tj", + ], + [ + EncodePercent | EncodeLeftSpace | EncodeLeftCrLfHtVt, + " \t\x12ZH\x1aς>3.τ`␕8γdγ\\_9K\x1c'Z␘␕β'\t␞", + "␠\t\x12ZH\x1aς>3.τ`␕8γdγ\\_9K\x1c'Z␘␕β'\t␞", + ], + [ + EncodeBackSlash | EncodeLeftSpace | EncodeLeftCrLfHtVt, + " \t\x17\x06?"η~θ\x1d.|D␕mζ1π?␡Oω␄\␞\x16<␓\t\n", + "␠\t\x17\x06?"η~θ\x1d.|D␕mζ1π?␡Oω␄‛\␞\x16<␓\t\n", + ], + [ + EncodeDel | EncodeLeftSpace | EncodeLeftCrLfHtVt, + " \t␟]6λ{j;+/␆<\nξ/(␈σIr/;δ-#Fχ\t:", + "␠\t␟]6λ{j;+/␆<\nξ/(␈σIr/;δ-#Fχ\t:", + ], + [ + EncodeZero | EncodeLeftSpace | EncodeRightSpace, + " iTζ␌\x078\vE;tς␕]vhα␆/\x10[eφ>κn[ ␙", + "␠ iTζ␌\x078\vE;tς␕]vhα␆/\x10[eφ>κn[ ␙", + ], + [ + EncodeSlash | EncodeLeftSpace | EncodeRightSpace, + ' α␕@Zτe"λt\x0e\x02ς␊␜ε␜5␍{\x14Fmσ\x14␙L 1', + '␠ α␕@Zτe"λt\x0e\x02ς␊␜ε␜5␍{\x14Fmσ\x14␙L 1', + ], + [ + EncodeSingleQuote | EncodeLeftSpace | EncodeRightSpace, + " ␈v"␐ψ\x18Qβfq\\U␘|a!6ζ>φ␝Fw␌σx /", + "␠ ␈v"␐ψ\x18Qβfq\\U␘|a!6ζ>φ␝Fw␌σx /", + ], + [ + EncodeBackQuote | EncodeLeftSpace | EncodeRightSpace, + " #κ~␂␄␗a=γ$α7χ\uff00T3I\x1cC'\x11J,/β\x04 '", + "␠ #κ~␂␄␗a=γ$α7χ\uff00T3I\x1cC'\x11J,/β\x04 '", + ], + [ + EncodeLtGt | EncodeLeftSpace | EncodeRightSpace, + " \x19\x0f␃nw\x00\ε(η\n5`2\x10:␅_XVη␙␁βτδ P", + "␠ \x19\x0f␃nw␀\ε(η\n5`2\x10:␅_XVη␙␁βτδ P", + ], + [ + EncodeSquareBracket | EncodeLeftSpace | EncodeRightSpace, + ' mκε6␇?s\x14ς7ψ%ζ@#[2#\vo`%A"ω7 ␉', + '␠ mκε6␇?s\x14ς7ψ%ζ@#[2#\vo`%A"ω7 ␉', + ], + [ + EncodeSemicolon | EncodeLeftSpace | EncodeRightSpace, + ' "2Y4α;πγ w{gKyπ["[χβ␝\b␕\t\\␓ \n', + '␠ "2Y4α;πγ w{gKyπ["[χβ␝\b␕\t\\␓ \n', + ], + [ + EncodeExclamation | EncodeLeftSpace | EncodeRightSpace, + " \x05␜θ|␞k'`␖WλF\r\rτta_η[/θω"\x01h s", + "␠ \x05␜θ|␞k'`␖WλF\r\rτta_η[/θω"\x01h s", + ], + [ + EncodeDollar | EncodeLeftSpace | EncodeRightSpace, + " ␁~*␁]-8C_2␕ω␑q'=?\x02␍νεs/}\r> γ", + "␠ ␁~*␁]-8C_2␕ω␑q'=?\x02␍νεs/}\r> γ", + ], + [ + EncodeDoubleQuote | EncodeLeftSpace | EncodeRightSpace, + " 3\x02$yλ[fYY\x0e?πς␆[7K\r\x10␀ξκ␕I/e n", + "␠ 3\x02$yλ[fYY\x0e?πς␆[7K\r\x10‛␀ξκ␕I/e n", + ], + [ + EncodeColon | EncodeLeftSpace | EncodeRightSpace, + " ␚a\εsK&#gMω?T!\x06ς%'\x1e␅!oφ|\nυ \b", + "␠ ␚a\εsK&#gMω?T!\x06ς%'\x1e␅!oφ|\nυ \b", + ], + [ + EncodeQuestion | EncodeLeftSpace | EncodeRightSpace, + " \x19+g%\x16*ZM3␝,␍\n␞δ~f\x19[δκV|yγT χ", + "␠ \x19+g%\x16*ZM3␝,␍\n␞δ~f\x19[δκV|yγT χ", + ], + [ + EncodeAsterisk | EncodeLeftSpace | EncodeRightSpace, + " α;!\x1eφ\f"2␌\x12/εu'x9]aζ␔σ<␔v`u $", + "␠ α;!\x1eφ\f"2␌\x12/εu'x9]aζ␔σ<␔v`u $", + ], + [ + EncodePipe | EncodeLeftSpace | EncodeRightSpace, + ' ␉;χ}wOσ'KJS<*"F\x15\x18\x14ηθ␑␕:ρ4O \\', + '␠ ␉;χ}wOσ'KJS<*"F\x15\x18\x14ηθ␑␕:ρ4O \\', + ], + [ + EncodeHash | EncodeLeftSpace | EncodeRightSpace, + " f_␟κυ|/z3Uπ:␕␎\x10/`\x03rψ?%uf\nH ␇", + "␠ f_␟κυ|/z3Uπ:␕␎\x10/`\x03rψ?%uf\nH ␇", + ], + [ + EncodePercent | EncodeLeftSpace | EncodeRightSpace, + " π\x04ρ\x02\x10o\x0e?j`cφ␒Kς␂%βZN'ι␔"\x10v !", + "␠ π\x04ρ\x02\x10o\x0e?j`cφ␒Kς␂‛%βZN'ι␔"\x10v !", + ], + [ + EncodeBackSlash | EncodeLeftSpace | EncodeRightSpace, + " ν0\x10u:β"ε?5␞~\nw=␏\"%α|Q"'φ␈\x06 R", + "␠ ν0\x10u:β"ε?5␞~\nw=␏\"%α|Q"'φ␈\x06 R", + ], + [ + EncodeCrLf | EncodeLeftSpace | EncodeRightSpace, + " φιH\x17#ι\x18\x19ς␎λA\\I\x11/I\bEG*?6-␕b ␅", + "␠ φιH\x17#ι\x18\x19ς␎λA\\I\x11/I\bEG*?6-␕b ␅", + ], + [ + EncodeDel | EncodeLeftSpace | EncodeRightSpace, + " y%␇␂<%N/%Ix\/ξτ\x03O;\x0f\x1aιu␟1ω% δ", + "␠ y%␇␂<%N/%Ix\/ξτ\x03O;\x0f\x1aιu␟1ω% δ", + ], + [ + EncodeCtl | EncodeLeftSpace | EncodeRightSpace, + " \x03\rς-eφLMb␄␅␈J`@φο4\x15\x12␖u/\x10;␟ /", + "␠ ␃␍ς-eφLMb‛␄‛␅‛␈J`@φο4␕␒‛␖u/␐;‛␟ /", + ], + [ + EncodeZero | EncodeLeftSpace | EncodeRightPeriod, + " .χ␕␜'}7J␞γωYB>`κ+\b*σ\x01\x1e0\r';/.D", + "␠.χ␕␜'}7J␞γωYB>`κ+\b*σ\x01\x1e0\r';/.D", + ], + [ + EncodeSlash | EncodeLeftSpace | EncodeRightPeriod, + " ._␇\f\x14(\x17π\vψvHlυ/δU?bcb7␋%ω?`.␋", + "␠._␇\f\x14(\x17π\vψvHlυ‛/δU?bcb7␋%ω?`.␋", + ], + [ + EncodeSingleQuote | EncodeLeftSpace | EncodeRightPeriod, + " .\\x13&γFε\x10w␟χ\b␍γξ/^{D3␛9ψ\bA\x1e.\x03", + "␠.\\x13&γFε\x10w␟χ\b␍γξ/^{D3␛9ψ\bA\x1e.\x03", + ], + [ + EncodeBackQuote | EncodeLeftSpace | EncodeRightPeriod, + " .μ␈i@]\x13␓-␊\t/8kφς93κs\x17J1\x16υ\n␕.^", + "␠.μ␈i@]\x13␓-␊\t/8kφς93κs\x17J1\x16υ\n␕.^", + ], + [ + EncodeLtGt | EncodeLeftSpace | EncodeRightPeriod, + " .*λss\x05<*_␊?"\x17\x02`\x15`ξεβAζ␓S@\n].S", + "␠.*λss\x05‛<*_␊?"\x17\x02`\x15`ξεβAζ␓S@\n].S", + ], + [ + EncodeSquareBracket | EncodeLeftSpace | EncodeRightPeriod, + " .␅␆kσb`\n[␞qq\f7`␀␔/,]νκ|T\x15<ψ.χ", + "␠.␅␆kσb`\n[␞qq\f7`‛␀␔/,]νκ|T\x15<ψ.χ", + ], + [ + EncodeSemicolon | EncodeLeftSpace | EncodeRightPeriod, + " .s␃\x1f␗;@y@\x02LφςRπ\x7fαB␒ν␞␃\x11J\x18␚!.w", + "␠.s␃\x1f␗;@y@\x02LφςRπ\x7fαB␒ν␞␃\x11J\x18␚!.w", + ], + [ + EncodeExclamation | EncodeLeftSpace | EncodeRightPeriod, + " .AF(␂␒ηV?vvμ#ω\x10τ\x00?␊\x07!1\x7fρ/\uff00␈.[", + "␠.AF(␂␒ηV?vvμ#ω\x10τ␀?␊\x07‛!1\x7fρ/\uff00␈.[", + ], + [ + EncodeDollar | EncodeLeftSpace | EncodeRightPeriod, + ' .␑ο#1ψ␟υo\bG/"o/"μ|οB;␘\x0eNι`%.b', + '␠.␑ο#1ψ␟υo\bG/"o/"μ|οB;␘\x0eNι`%.b', + ], + [ + EncodeDoubleQuote | EncodeLeftSpace | EncodeRightPeriod, + " .$&ψ'#,j:?ω[kπ␎M%␝\x1fh\x16γ␖\x00R\x15{.g", + "␠.$&ψ'#,j:?ω[kπ␎M%␝\x1fh\x16γ␖␀R\x15{.g", + ], + [ + EncodeColon | EncodeLeftSpace | EncodeRightPeriod, + " .ιυz␆\x19AC/\n!\x7f\x11A%γD(␓␂Vπδ␍\x0e|c.g", + "␠.ιυz␆\x19AC/\n!\x7f\x11A%γD(␓␂Vπδ␍\x0e|c.g", + ], + [ + EncodeQuestion | EncodeLeftSpace | EncodeRightPeriod, + " .␗`r␛xuMθ␇\x01$\\On\x03␁φδ6␙␡8#=χ\x05.υ", + "␠.␗`r␛xuMθ␇\x01$\\On\x03␁φδ6␙␡8#=χ\x05.υ", + ], + [ + EncodeAsterisk | EncodeLeftSpace | EncodeRightPeriod, + " .o␊0-␙+eσk'ψtψf\x1aλζ\x10␇\x00␕\b␎[X..ν", + "␠.o␊0-␙+eσk'ψtψf\x1aλζ\x10␇␀␕\b␎[X..ν", + ], + [ + EncodePipe | EncodeLeftSpace | EncodeRightPeriod, + " .πξcG#\x1b#bιxaPδθ\b␔␄\rR7$<!pA␍.μ", + "␠.πξcG#\x1b#bιxaPδθ\b␔␄\rR7$<!pA␍.μ", + ], + [ + EncodeHash | EncodeLeftSpace | EncodeRightPeriod, + " .${␗δγe]\x1b}Rθ\β7␍>␏~σ\b.ξ␇'/\x02.\r", + "␠.${␗δγe]\x1b}Rθ\β7␍>␏~σ\b.ξ␇'/\x02.\r", + ], + [ + EncodePercent | EncodeLeftSpace | EncodeRightPeriod, + " .␔␀␊η\x1bι"I\x7f.␛-7/Z{vε\x1a:κεuβ\x034.\x1b", + "␠.␔‛␀␊η\x1bι"I\x7f.␛-7/Z{vε\x1a:κεuβ\x034.\x1b", + ], + [ + EncodeBackSlash | EncodeLeftSpace | EncodeRightPeriod, + ' .W␝ν,XR\r1S"\r\x7f␀\x1aqνlXυ;Jιυ"\x07c.$', + '␠.W␝ν,XR\r1S"\r\x7f‛␀\x1aqνlXυ;Jιυ"\x07c.$', + ], + [ + EncodeCrLf | EncodeLeftSpace | EncodeRightPeriod, + " .ρ#l\vβw?␒␘]␏\x1157+εκ\x1eoν^/~Z$\f.;", + "␠.ρ#l\vβw?␒␘]␏\x1157+εκ\x1eoν^/~Z$\f.;", + ], + [ + EncodeDel | EncodeLeftSpace | EncodeRightPeriod, + " .[4||\rh\n␎\x02iαμυ3[αχ␈/δ\b&0p␜␈.e", + "␠.[4||\rh\n␎\x02iαμυ3[αχ␈/δ\b&0p␜␈.e", + ], + [ + EncodeCtl | EncodeLeftSpace | EncodeRightPeriod, + " .D?ROJE␟␚\x10␍%␝κτ\x19\x02O\x1d␔}UH1υ1α.ξ", + "␠.D?ROJE‛␟‛␚␐‛␍%‛␝κτ␙␂O␝‛␔}UH1υ1α.ξ", + ], + [ + EncodeZero | EncodeLeftSpace | EncodeRightCrLfHtVt, + ' \t␊|'ω\r$η{~"δ␙<`\x1fν!Q1_φ␁9b\x12V\tε', + '␠\t␊|'ω\r$η{~"δ␙<`\x1fν!Q1_φ␁9b\x12V\tε', + ], + [ + EncodeSlash | EncodeLeftSpace | EncodeRightCrLfHtVt, + ' \t!u|J[]:K=λ4fοηj"E\x1eπ␍tς\x15;r`\t$', + '␠\t!u|J[]:K=λ4fοηj"E\x1eπ␍tς\x15;r`\t$', + ], + [ + EncodeSingleQuote | EncodeLeftSpace | EncodeRightCrLfHtVt, + " \t%(+h[PD~θζ/πομ%␃*/]\bU\x1e␇m␈/\tD", + "␠\t%(+h[PD~θζ/πομ%␃*/]\bU\x1e␇m␈/\tD", + ], + [ + EncodeBackQuote | EncodeLeftSpace | EncodeRightCrLfHtVt, + ' \tbς␜α0*:\x07PTLζ[*B␍)"κ3\x07\x00\x18"␔␊\t&', + '␠\tbς␜α0*:\x07PTLζ[*B␍)"κ3\x07␀\x18"␔␊\t&', + ], + [ + EncodeLtGt | EncodeLeftSpace | EncodeRightCrLfHtVt, + " \t]␖ρ>\x04~/\x1fhψ\x02!`ηIθ;ι2-\x15`Tn$λ\t"", + "␠\t]␖ρ‛>\x04~/\x1fhψ\x02!`ηIθ;ι2-\x15`Tn$λ\t"", + ], + [ + EncodeSquareBracket | EncodeLeftSpace | EncodeRightCrLfHtVt, + " \tx␜i8@κdwπ\x11␅␜αψ\nτ#\n4F$]y\x0f?p\t\x02", + "␠\tx␜i8@κdwπ\x11␅␜αψ\nτ#\n4F$‛]y\x0f?p\t\x02", + ], + [ + EncodeSemicolon | EncodeLeftSpace | EncodeRightCrLfHtVt, + ' \t␑α-N\t\x12\r7@)"qπι!␕ζ!)␀eyα">`\tβ', + '␠\t␑α-N\t\x12\r7@)"qπι!␕ζ!)‛␀eyα">`\tβ', + ], + [ + EncodeExclamation | EncodeLeftSpace | EncodeRightCrLfHtVt, + " \t5␀)$ι$ψζ3N\x04g}γQ2`␈i␞\x00␡ςJ\x00\t\tο", + "␠\t5‛␀)$ι$ψζ3N\x04g}γQ2`␈i␞␀␡ςJ␀\t\tο", + ], + [ + EncodeDollar | EncodeLeftSpace | EncodeRightCrLfHtVt, + " \t␑hθ\x02\x01\\7␡3ιYδ1\x13R␒κ\x06\x11\ξυ␙E:/\tm", + "␠\t␑hθ\x02\x01\\7␡3ιYδ1\x13R␒κ\x06\x11\ξυ␙E:/\tm", + ], + [ + EncodeDoubleQuote | EncodeLeftSpace | EncodeRightCrLfHtVt, + ' \tνy{\x13]π\r""\x0fδwμ␈␔\x03;PD␞@␏L'ιk\tι', + "␠\tνy{\x13]π\r‛""\x0fδwμ␈␔\x03;PD␞@␏L'ιk\tι", + ], + [ + EncodeColon | EncodeLeftSpace | EncodeRightCrLfHtVt, + ' \t\x1b␉Ardθ\n\x1dγaζ␝:# :τ\x1fCαrO|"K \t␕', + '␠\t\x1b␉Ardθ\n\x1dγaζ␝:# ‛:τ\x1fCαrO|"K \t␕', + ], + [ + EncodeQuestion | EncodeLeftSpace | EncodeRightCrLfHtVt, + " \t[*α␑υ\n=W\x06σx'\uff00␛\r\b\x1bKλεο␝U[\&\t:", + "␠\t[*α␑υ\n=W\x06σx'\uff00␛\r\b\x1bKλεο␝U[\&\t:", + ], + [ + EncodeAsterisk | EncodeLeftSpace | EncodeRightCrLfHtVt, + " \tζ␀#[\x1a␓θr4:sω␍*Zρ-:␛Ak␓`\x1aFξ\tW", + "␠\tζ‛␀#[\x1a␓θr4:sω␍*Zρ-:␛Ak␓`\x1aFξ\tW", + ], + [ + EncodePipe | EncodeLeftSpace | EncodeRightCrLfHtVt, + " \tR\x0eγ/"␗\W/\x11\x14}\η_]\x13\x17ςδ ω␈K[1\tb", + "␠\tR\x0eγ/"␗\W/\x11\x14}\η_]\x13\x17ςδ ω␈K[1\tb", + ], + [ + EncodeHash | EncodeLeftSpace | EncodeRightCrLfHtVt, + " \t\C!j\nρ\x18/␒wa>wε=τ!ρ*\n|sω`I\x7f\t\x1e", + "␠\t\C!j\nρ\x18/␒wa>wε=τ!ρ*\n|sω`I\x7f\t\x1e", + ], + [ + EncodePercent | EncodeLeftSpace | EncodeRightCrLfHtVt, + " \t/ZsFξ␐<φ}\\:r\x174ψ[θ|#ο\f␔o␋σ`\t#", + "␠\t/ZsFξ␐<φ}\\:r\x174ψ[θ|#ο\f␔o␋σ`\t#", + ], + [ + EncodeBackSlash | EncodeLeftSpace | EncodeRightCrLfHtVt, + " \t*␟\x1a'θσO\nψR/\\␏␞eδ␛uθ%/2\x02\x0eOt\tt", + "␠\t*␟\x1a'θσO\nψR/\␏␞eδ␛uθ%/2\x02\x0eOt\tt", + ], + [ + EncodeDel | EncodeLeftSpace | EncodeRightCrLfHtVt, + " \t␈E;%␋␑\x11φn|\x02␂ιξUU[T<\x11VλU\x10θ+\tc", + "␠\t␈E;%␋␑\x11φn|\x02␂ιξUU[T<\x11VλU\x10θ+\tc", + ], + [ + EncodeZero | EncodeLeftPeriod | EncodeLeftTilde, + ".~\x11bSπ;ε␗ε\x17N]S␙%λ&'m\;:ν\x19e#σ~/", + ".~\x11bSπ;ε␗ε\x17N]S␙%λ&'m\;:ν\x19e#σ~/", + ], + [ + EncodeSlash | EncodeLeftPeriod | EncodeLeftTilde, + ".~Mw␖␇lχM␗{\x01s␞fρ|ξ␝<vμu\x02\vXY\x12~α", + ".~Mw␖␇lχM␗{\x01s␞fρ|ξ␝<vμu\x02\vXY\x12~α", + ], + [ + EncodeSingleQuote | EncodeLeftPeriod | EncodeLeftTilde, + ".~[z-π/ξ␎gνπ0_t\x00Dc%>τK>\x1d␀o␃η~@", + ".~[z-π/ξ␎gνπ0_t␀Dc%>τK>\x1d‛␀o␃η~@", + ], + [ + EncodeBackQuote | EncodeLeftPeriod | EncodeLeftTilde, + ".~\x14ς\x14!α␍W*θUt\x06λ~Y81␞+\x07\x1cudg␟N~β", + ".~\x14ς\x14!α␍W*θUt\x06λ~Y81␞+\x07\x1cudg␟N~β", + ], + [ + EncodeLtGt | EncodeLeftPeriod | EncodeLeftTilde, + ".~\x1eρυ\x051E\x1fwd␊F␐Lξ:$5uf\x1aοω/)δ\x00~\x02", + ".~\x1eρυ\x051E\x1fwd␊F␐Lξ:$5uf\x1aοω/)δ␀~\x02", + ], + [ + EncodeSquareBracket | EncodeLeftPeriod | EncodeLeftTilde, + ".~ς>γ<-K#2πα\x01ο␈nE␟\x18>␛{+o␚]<\x0e~#", + ".~ς>γ<-K#2πα\x01ο␈nE␟\x18>␛{+o␚]<\x0e~#", + ], + [ + EncodeSemicolon | EncodeLeftPeriod | EncodeLeftTilde, + ".~\x17σ␉/θ\tχ8D␜χ|)r␍os\x16σDq!\x1a\x10μ\x01~␇", + ".~\x17σ␉/θ\tχ8D␜χ|)r␍os\x16σDq!\x1a\x10μ\x01~␇", + ], + [ + EncodeExclamation | EncodeLeftPeriod | EncodeLeftTilde, + ".~ε␡ζ␞{|ε\x02φ]5\x10␀\f\uff00␄F+\x07α>s\x18b]G~+", + ".~ε␡ζ␞{|ε\x02φ]5\x10‛␀\f\uff00␄F+\x07α>s\x18b]G~+", + ], + [ + EncodeDollar | EncodeLeftPeriod | EncodeLeftTilde, + '.~~w|\x06*Ni:'"!δ]*␀θι\x1dg␞ς9+πo*~e', + '.~~w|\x06*Ni:'"!δ]*‛␀θι\x1dg␞ς9+πo*~e', + ], + [ + EncodeDoubleQuote | EncodeLeftPeriod | EncodeLeftTilde, + ".~μ\x19νκ>␂x},Z\x1d␝_␍Oλ,␅+Dς$gμ␌.~\x01", + ".~μ\x19νκ>␂x},Z\x1d␝_␍Oλ,␅+Dς$gμ␌.~\x01", + ], + [ + EncodeColon | EncodeLeftPeriod | EncodeLeftTilde, + ".~_γi\x1c(τ\b␙\x1fχρ␌\x1aQκ_zC;?Kiξ'+␂~c", + ".~_γi\x1c(τ\b␙\x1fχρ␌\x1aQκ_zC;?Kiξ'+␂~c", + ], + [ + EncodeQuestion | EncodeLeftPeriod | EncodeLeftTilde, + ".~\x04A>ς␌.\b␏\n␇.ε␘}ιM|\rεu*d7␇\x1c␒~g", + ".~\x04A>ς␌.\b␏\n␇.ε␘}ιM|\rεu*d7␇\x1c␒~g", + ], + [ + EncodeAsterisk | EncodeLeftPeriod | EncodeLeftTilde, + '.~ρN\x01"␡5k␘?;"%δγωP<<[␁\nR*κ[/~\x00', + '.~ρN\x01"␡5k␘?;"%δγωP<<[␁\nR*κ[/~␀', + ], + [ + EncodePipe | EncodeLeftPeriod | EncodeLeftTilde, + ".~/`\x18Eζ␊rγι\x16␓A5\x13u,x9ω"$␓θ␘κ~~x", + ".~/`\x18Eζ␊rγι\x16␓A5\x13u,x9ω"$␓θ␘κ~~x", + ], + [ + EncodeHash | EncodeLeftPeriod | EncodeLeftTilde, + ".~?/␔dom@f0␜σ\n\x01␍eοβ[:ζwψτ␑\r\x02~(", + ".~?/␔dom@f0␜σ\n\x01␍eοβ[:ζwψτ␑\r\x02~(", + ], + [ + EncodePercent | EncodeLeftPeriod | EncodeLeftTilde, + ".~<␉ο\x011λ\x1c\x7fι\x1aδ>;\\[{m\n`9UX␔'>γ~f", + ".~<␉ο\x011λ\x1c\x7fι\x1aδ>;\\[{m\n`9UX␔'>γ~f", + ], + [ + EncodeBackSlash | EncodeLeftPeriod | EncodeLeftTilde, + ".~␝@␏lτ␊\x1a3dω\x12/η>ςj\x19/␒:~J;,:ζ~/", + ".~␝@␏lτ␊\x1a3dω\x12/η>ςj\x19/␒:~J;,:ζ~/", + ], + [ + EncodeCrLf | EncodeLeftPeriod | EncodeLeftTilde, + ".~*\n`ν#\x07␊*Br\x03q.ωY0␌␋θU4>␈\nυ␞~λ", + ".~*␊`ν#\x07‛␊*Br\x03q.ωY0␌␋θU4>␈␊υ␞~λ", + ], + [ + EncodeDel | EncodeLeftPeriod | EncodeLeftTilde, + ".~\x01|σ/α␙$>S␂r␔\x11v\x10azLφ4η(η␃␌{~H", + ".~\x01|σ/α␙$>S␂r␔\x11v\x10azLφ4η(η␃␌{~H", + ], + [ + EncodeCtl | EncodeLeftPeriod | EncodeLeftTilde, + ".~\\'χ5␕χ<zψ\x18KH\x1c\x15\tC␍`␜χ%κ/\x1eL␛~"", + ".~\\'χ5‛␕χ<zψ␘KH␜␕␉C‛␍`‛␜χ%κ/␞L‛␛~"", + ], + [ + EncodeZero | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + ".\t\r'σOT`\x1a['\x7f"{dz.ν<φ7\x11ψδ␌␘8|\tε", + ".\t\r'σOT`\x1a['\x7f"{dz.ν<φ7\x11ψδ␌␘8|\tε", + ], + [ + EncodeSlash | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + '.\tE\n␂τE\x18ωVYησ␁␟n␇␙`_ρ.Pn"υ\x19<\tO', + '.\tE\n␂τE\x18ωVYησ␁␟n␇␙`_ρ.Pn"υ\x19<\tO', + ], + [ + EncodeSingleQuote | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + ".\t&/+\r}τμ␌p_zxχοL\x12+D!␀␁Tα!␖"\t\x1b", + ".\t&/+\r}τμ␌p_zxχοL\x12+D!‛␀␁Tα!␖"\t\x1b", + ], + [ + EncodeBackQuote | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + ".\t␌\ry␄␜>\nη[>τε{\v\\(io\x1c-L4υγ␒!\t␇", + ".\t␌\ry␄␜>\nη[>τε{\v\\(io\x1c-L4υγ␒!\t␇", + ], + [ + EncodeLtGt | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + ".\tδY\x0fψ\P\x06W␘w␞E\tlδ\x03|␒␘␍rδ5C\x05σ\t␏", + ".\tδY\x0fψ\P\x06W␘w␞E\tlδ\x03|␒␘␍rδ5C\x05σ\t␏", + ], + [ + EncodeSquareBracket | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + ".\t\r=]*Eτ`Jρ\x12 /\x13{n:␎&\x11␇φ9>Hψι\t\x17", + ".\t\r=‛]*Eτ`Jρ\x12 /\x13{n:␎&\x11␇φ9>Hψι\t\x17", + ], + [ + EncodeSemicolon | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + ".\tF|\x1co'\x15γξ.δz!ςθ/YU␐\x101%υ␝␒␂[\t5", + ".\tF|\x1co'\x15γξ.δz!ςθ/YU␐\x101%υ␝␒␂[\t5", + ], + [ + EncodeExclamation | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + ".\tizξ+'./'lκ\x07#(?\uff00\x19υ|1ο<γ␗6\x15␍\tτ", + ".\tizξ+'./'lκ\x07#(?\uff00\x19υ|1ο<γ␗6\x15␍\tτ", + ], + [ + EncodeDollar | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + ".\t\x01t6/kχ2τG␛␕\nσI]Dκθ*N␅h<[\x0e_\t␟", + ".\t\x01t6/kχ2τG␛␕\nσI]Dκθ*N␅h<[\x0e_\t␟", + ], + [ + EncodeDoubleQuote | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + ".\tοφ␕2\b/F\x1f,\\␏1}τY\x17ι^O\x13H\nq/ι␅\t␐", + ".\tοφ␕2\b/F\x1f,\\␏1}τY\x17ι^O\x13H\nq/ι␅\t␐", + ], + [ + EncodeColon | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + ".\tσw:D(O$]ω\bχδI?\x1c␊\nζP␏␊+␒o\x01)\tM", + ".\tσw:D(O$]ω\bχδI?\x1c␊\nζP␏␊+␒o\x01)\tM", + ], + [ + EncodeQuestion | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + ".\tκψ␂χκ\x157RυU*J␐V␁\x10\fYy3$_␘Dα␀\ta", + ".\tκψ␂χκ\x157RυU*J␐V␁\x10\fYy3$_␘Dα‛␀\ta", + ], + [ + EncodeAsterisk | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + '.\tκ␎ωe[!i␃\t2λχθ␆χ\n^>\\mHQ\x0f\\x"\t/', + '.\tκ␎ωe[!i␃\t2λχθ␆χ\n^>\\mHQ\x0f\\x"\t/', + ], + [ + EncodePipe | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + ".\tG8X6ψκM#%\x10χ*␍γ\x1f\x02␒8]/U)γ<:"\tψ", + ".\tG8X6ψκM#%\x10χ*␍γ\x1f\x02␒8]/U)γ<:"\tψ", + ], + [ + EncodeHash | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + ".\t"\x11]RWξP\n\x1dι39@"θ}A␍␍␇␉πδ\x07:>\t#", + ".\t"\x11]RWξP\n\x1dι39@"θ}A␍␍␇␉πδ\x07:>\t#", + ], + [ + EncodePercent | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + ".\tι␀k␉τ\x16s%9Qh*iγψH\x11>>*␡␂>␅οv\t2", + ".\tι‛␀k␉τ\x16s‛%9Qh*iγψH\x11>>*␡␂>␅οv\t2", + ], + [ + EncodeBackSlash | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + '.\tχ`*E4Mi\ri:?φ\x15?ζ/V␃\uff00}#">9ο␌\tρ', + '.\tχ`*E4Mi\ri:?φ\x15?ζ/V␃\uff00}#">9ο␌\tρ', + ], + [ + EncodeDel | EncodeLeftPeriod | EncodeLeftCrLfHtVt, + ".\tκ␎5δq`<\x18K\x02σσ`1b/μ'␊7C?\x10$Pf\tD", + ".\tκ␎5δq`<\x18K\x02σσ`1b/μ'␊7C?\x10$Pf\tD", + ], + [ + EncodeZero | EncodeLeftPeriod | EncodeRightSpace, + ". |<+\x00%i?KS^κ;$Nαυυ{<\v%j*κ\x12P \x14", + ". |<+␀%i?KS^κ;$Nαυυ{<\v%j*κ\x12P \x14", + ], + [ + EncodeSlash | EncodeLeftPeriod | EncodeRightSpace, + ". γsl<L]␗ξ8␈(\\c>ο;ω␅\x16\x1a$}j\nβ+ ι", + ". γsl<L]␗ξ8␈(\\c>ο;ω␅\x16\x1a$}j\nβ+ ι", + ], + [ + EncodeSingleQuote | EncodeLeftPeriod | EncodeRightSpace, + ". θ|qZκI%\x7f[␙;υω$␑GX"\x1dς+10}\x18\ δ", + ". θ|qZκI%\x7f[␙;υω$␑GX"\x1dς+10}\x18\ δ", + ], + [ + EncodeBackQuote | EncodeLeftPeriod | EncodeRightSpace, + '. ps\x01#\x13H␔N\tJjω␂ρ␚␁ρθ␍Oqκ[υ"\n '', + '. ps\x01#\x13H␔N\tJjω␂ρ␚␁ρθ␍Oqκ[υ"\n '', + ], + [ + EncodeLtGt | EncodeLeftPeriod | EncodeRightSpace, + ". C*τ%#\x1d\x17!dD␉τ|/␗'ψsFFνH8ς`\r /", + ". C*τ%#\x1d\x17!dD␉τ|/␗'ψsFFνH8ς`\r /", + ], + [ + EncodeSquareBracket | EncodeLeftPeriod | EncodeRightSpace, + '. ␎\x12Z-/δ+C\x00"_;μιξ#ς[R\x1eC\x03^␁|β A', + '. ␎\x12Z-/δ+C␀"_;μιξ#ς‛[R\x1eC\x03^␁|β A', + ], + [ + EncodeSemicolon | EncodeLeftPeriod | EncodeRightSpace, + ". ␕::␟#J!π+υ>\x00τ[C\x18ξ\f(\x1b8Jς'␐\ R", + ". ␕::␟#J!π+υ>␀τ[C\x18ξ\f(\x1b8Jς'␐\ R", + ], + [ + EncodeExclamation | EncodeLeftPeriod | EncodeRightSpace, + ". ρ\n>ξN@φWa␂jζ<a|Iu\b\x11ι␐zζg'\x01 .", + ". ρ\n>ξN@φWa␂jζ<a|Iu\b\x11ι␐zζg'\x01 .", + ], + [ + EncodeDollar | EncodeLeftPeriod | EncodeRightSpace, + ". j\x04h'W]'QχA`5χζψQO\x06!ρ␘ψ\x07␓1| '", + ". j\x04h'W]'QχA`5χζψQO\x06!ρ␘ψ\x07␓1| '", + ], + [ + EncodeDoubleQuote | EncodeLeftPeriod | EncodeRightSpace, + ". ε␞\x02τI:/\x17$AV␆OriS7\x00ζ*βεφ␁>\x13 [", + ". ε␞\x02τI:/\x17$AV␆OriS7␀ζ*βεφ␁>\x13 [", + ], + [ + EncodeColon | EncodeLeftPeriod | EncodeRightSpace, + ". \x15ζzU,λ\x13'γ>κ7\\\x16␊/lφPζdM;␋␚\x11 J", + ". \x15ζzU,λ\x13'γ>κ7\\\x16␊/lφPζdM;␋␚\x11 J", + ], + [ + EncodeQuestion | EncodeLeftPeriod | EncodeRightSpace, + ". cμrU\x7f`Aβ␓␇@ξ(ψ9,␆␉εx0␂m\x1d␂! ε", + ". cμrU\x7f`Aβ␓␇@ξ(ψ9,␆␉εx0␂m\x1d␂! ε", + ], + [ + EncodeAsterisk | EncodeLeftPeriod | EncodeRightSpace, + '. oχBνX!μφ#S\V[_"␚␇\x13\x11>␓␙}bfυ \x1b', + '. oχBνX!μφ#S\V[_"␚␇\x13\x11>␓␙}bfυ \x1b', + ], + [ + EncodePipe | EncodeLeftPeriod | EncodeRightSpace, + '. \x1b\x0e\f1-<ksχ[\x02"␒X&θU␍␟π@\x0f%ε␊υ ␉', + '. \x1b\x0e\f1-<ksχ[\x02"␒X&θU␍␟π@\x0f%ε␊υ ␉', + ], + [ + EncodeHash | EncodeLeftPeriod | EncodeRightSpace, + ". \x1b;λm%'κ;␟p\x16δυCavk␌\x1d!J␄υχ@\x17 ?", + ". \x1b;λm%'κ;␟p\x16δυCavk␌\x1d!J␄υχ@\x17 ?", + ], + [ + EncodePercent | EncodeLeftPeriod | EncodeRightSpace, + ". $υοUKz1σ>␙␐␆/e␊2<\ςaμ!|8\\\x1f c", + ". $υοUKz1σ>␙␐␆/e␊2<\ςaμ!|8\\\x1f c", + ], + [ + EncodeBackSlash | EncodeLeftPeriod | EncodeRightSpace, + '. j\x0e␛]\t␇U:5␊"␁\x15υψ;"\v@Jςε␛ξ;R {', + '. j\x0e␛]\t␇U:5␊"␁\x15υψ;"\v@Jςε␛ξ;R {', + ], + [ + EncodeCrLf | EncodeLeftPeriod | EncodeRightSpace, + '. \"\n␒κ\x18gουυ%ο|W(:?NY4\x0f␊c␋/β D', + '. \"␊␒κ\x18gουυ%ο|W(:?NY4\x0f‛␊c␋/β D', + ], + [ + EncodeDel | EncodeLeftPeriod | EncodeRightSpace, + ". S␂]ω|lfγ#␘/[6\\|sj<\x13;ωεO\x01oς C", + ". S␂]ω|lfγ#␘/[6\\|sj<\x13;ωεO\x01oς C", + ], + [ + EncodeCtl | EncodeLeftPeriod | EncodeRightSpace, + '. %{y"KfO␘κ␀:\rρ:\x19\x18lζ␟rλz0<\x13κ ρ', + '. %{y"KfO‛␘κ‛␀:␍ρ:␙␘lζ‛␟rλz0<␓κ ρ', + ], + [ + EncodeZero | EncodeLeftPeriod | EncodeRightPeriod, + "..σbz,!'␆8"␅,%νη\x1a#]\x06␅w"πLWωα.,", + "..σbz,!'␆8"␅,%νη\x1a#]\x06␅w"πLWωα.,", + ], + [ + EncodeSlash | EncodeLeftPeriod | EncodeRightPeriod, + "..ψλ\x1cL,\x7f\x05_χW\x04K␔DλmNd!ν1γ6␑\x1f].C", + "..ψλ\x1cL,\x7f\x05_χW\x04K␔DλmNd!ν1γ6␑\x1f].C", + ], + [ + EncodeSingleQuote | EncodeLeftPeriod | EncodeRightPeriod, + "..\vw\x15%␌␛ν.\φ@Bρ!Nη\\!|]␔ζmζ\x07@.\x1a", + "..\vw\x15%␌␛ν.\φ@Bρ!Nη\\!|]␔ζmζ\x07@.\x1a", + ], + [ + EncodeBackQuote | EncodeLeftPeriod | EncodeRightPeriod, + "..␐_\x0f␄[}+C#fi\x16uα(η␈"G\\φαSCς␇.A", + "..␐_\x0f␄[}+C#fi\x16uα(η␈"G\\φαSCς␇.A", + ], + [ + EncodeLtGt | EncodeLeftPeriod | EncodeRightPeriod, + "..ωωο0^*#\x00β#\\B/␂αξ3]&\x057(F␆␕\x01.g", + "..ωωο0^*#␀β#\\B/␂αξ3]&\x057(F␆␕\x01.g", + ], + [ + EncodeSquareBracket | EncodeLeftPeriod | EncodeRightPeriod, + "..yeP\x16q4A\r#␔ψ␊␆:9\x1bηα␜Yτtσ␙υV.#", + "..yeP\x16q4A\r#␔ψ␊␆:9\x1bηα␜Yτtσ␙υV.#", + ], + [ + EncodeSemicolon | EncodeLeftPeriod | EncodeRightPeriod, + "..\r#ω\x14ζλυ\x18:ςτJg␉:/aX\x10"␄/'g*o.C", + "..\r#ω\x14ζλυ\x18:ςτJg␉:/aX\x10"␄/'g*o.C", + ], + [ + EncodeExclamation | EncodeLeftPeriod | EncodeRightPeriod, + "..ψ\f␉η*␆␖␁λυ/\x11`;γP;\vh%&>\x14␔z\x00.*", + "..ψ\f␉η*␆␖␁λυ/\x11`;γP;\vh%&>\x14␔z␀.*", + ], + [ + EncodeDollar | EncodeLeftPeriod | EncodeRightPeriod, + "..␁\x0faα\x1eb␆␆|\x06φδK^&:*κ\␇l␚␕@Uς.k", + "..␁\x0faα\x1eb␆␆|\x06φδK^&:*κ\␇l␚␕@Uς.k", + ], + [ + EncodeDoubleQuote | EncodeLeftPeriod | EncodeRightPeriod, + '..?\f\x1aQ␙<ω]␍πτ";/"3]μp#p[␙\x1eνo.x', + "..?\f\x1aQ␙<ω]␍πτ";/"3]μp#p[␙\x1eνo.x", + ], + [ + EncodeColon | EncodeLeftPeriod | EncodeRightPeriod, + "..e\\n␖\nχWχ␆sFη;\x0eD\nζ-ι?68γ␋5S.]", + "..e\\n␖\nχWχ␆sFη;\x0eD\nζ-ι?68γ␋5S.]", + ], + [ + EncodeQuestion | EncodeLeftPeriod | EncodeRightPeriod, + '..L!"@?/!<gπ]π`<σ?6oθX9|e␑\x05\x1b.\x1a', + '..L!"@‛?/!<gπ]π`<σ‛?6oθX9|e␑\x05\x1b.\x1a', + ], + [ + EncodeAsterisk | EncodeLeftPeriod | EncodeRightPeriod, + "..>\x17␌ψπPvλ\x13<␂,τ\x1bB?Jsα)␛χn|␞\x07.>", + "..>\x17␌ψπPvλ\x13<␂,τ\x1bB?Jsα)␛χn|␞\x07.>", + ], + [ + EncodePipe | EncodeLeftPeriod | EncodeRightPeriod, + "..θ␗␂i\x03>#\x18ξ␞\x1av\x01␞!θα\\-uyψ:V\x1f`.;", + "..θ␗␂i\x03>#\x18ξ␞\x1av\x01␞!θα\\-uyψ:V\x1f`.;", + ], + [ + EncodeHash | EncodeLeftPeriod | EncodeRightPeriod, + "..\v=γ$␏␘{)δzξ2Q\x19\rπ␊υ$$η'rV␂A.$", + "..\v=γ$␏␘{)δzξ2Q\x19\rπ␊υ$$η'rV␂A.$", + ], + [ + EncodePercent | EncodeLeftPeriod | EncodeRightPeriod, + "..`'*␟}\vτμ\r␄%ρ\rζ\x1e␗β4\x130␊\uff00y\uff00␕␃.ο", + "..`'*␟}\vτμ\r␄%ρ\rζ\x1e␗β4\x130␊\uff00y\uff00␕␃.ο", + ], + [ + EncodeBackSlash | EncodeLeftPeriod | EncodeRightPeriod, + "..␛\x1b:'//,δυ:γ␎␘\x18sφ\"λ:S^␃\bieM.>", + "..␛\x1b:'//,δυ:γ␎␘\x18sφ\"λ:S^␃\bieM.>", + ], + [ + EncodeCrLf | EncodeLeftPeriod | EncodeRightPeriod, + "..ηψ\x02czλ␝ν␔"B]5π␡οe'{␎0ez?\t'.D", + "..ηψ\x02czλ␝ν␔"B]5π␡οe'{␎0ez?\t'.D", + ], + [ + EncodeDel | EncodeLeftPeriod | EncodeRightPeriod, + '.._ι\n␉P␀ σωl[KJ%%τσ'\x13\x1c␂:\x00"V,.<', + '.._ι\n␉P‛␀ σωl[KJ%%τσ'\x13\x1c␂:␀"V,.<', + ], + [ + EncodeCtl | EncodeLeftPeriod | EncodeRightPeriod, + "..>␄;=\x7fφ␘Oμ\x7fkγ$lκV|\x0f␐nκe\bHρ␘.␘", + "..>‛␄;=\x7fφ‛␘Oμ\x7fkγ$lκV|␏‛␐nκe␈Hρ‛␘.‛␘", + ], + [ + EncodeZero | EncodeLeftPeriod | EncodeRightCrLfHtVt, + ".\t\x1b␍\uff00Dκl␒\x1a\x0fκω$q/χ.[α\x05?<ρ|\\-:\t|", + ".\t\x1b␍\uff00Dκl␒\x1a\x0fκω$q/χ.[α\x05?<ρ|\\-:\t|", + ], + [ + EncodeSlash | EncodeLeftPeriod | EncodeRightCrLfHtVt, + ".\tAwo\x15\v'ξωμ$>χ[␜eT\\δ␋␟\x01␕dφ\x12,\t␔", + ".\tAwo\x15\v'ξωμ$>χ[␜eT\\δ␋␟\x01␕dφ\x12,\t␔", + ], + [ + EncodeSingleQuote | EncodeLeftPeriod | EncodeRightCrLfHtVt, + '.\t\fη!Uπ?K!=\r␏E␡bθ␐θ␕␔0rκ"{t^\tγ', + '.\t\fη!Uπ?K!=\r␏E␡bθ␐θ␕␔0rκ"{t^\tγ', + ], + [ + EncodeBackQuote | EncodeLeftPeriod | EncodeRightCrLfHtVt, + ".\tσμ<[␆δ␡A9\t\tζ␕C$5` R␗\x00ψT␝[r\t_", + ".\tσμ<[␆δ␡A9\t\tζ␕C$5` R␗␀ψT␝[r\t_", + ], + [ + EncodeLtGt | EncodeLeftPeriod | EncodeRightCrLfHtVt, + ".\t\x02\x10τQ\\4\x1d␊iB^mς␎$P!εζ/J<[␅ξγ\t␐", + ".\t\x02\x10τQ\\4\x1d␊iB^mς␎$P!εζ/J‛<[␅ξγ\t␐", + ], + [ + EncodeSquareBracket | EncodeLeftPeriod | EncodeRightCrLfHtVt, + ".\t\}>ηu\\3(E\x1bυ␒z[␖κσ\x11φ7␇?␉mε\r\t>", + ".\t\}>ηu\\3(E\x1bυ␒z‛[␖κσ\x11φ7␇?␉mε\r\t>", + ], + [ + EncodeSemicolon | EncodeLeftPeriod | EncodeRightCrLfHtVt, + ".\t;␡o>η(o␔χ␐8Aς[67␙`αK␕ι#?γ\r\t\x02", + ".\t‛;␡o>η(o␔χ␐8Aς[67␙`αK␕ι#?γ\r\t\x02", + ], + [ + EncodeExclamation | EncodeLeftPeriod | EncodeRightCrLfHtVt, + ".\t#wR␇:δ\x12e)i/!7κt␏\x1d\rχβ#/δ\v␡β\tS", + ".\t#wR␇:δ\x12e)i/!7κt␏\x1d\rχβ#/δ\v␡β\tS", + ], + [ + EncodeDollar | EncodeLeftPeriod | EncodeRightCrLfHtVt, + ".\t\x05δ/d0␆\x0eπ\t}␐βυ8u\x7f52\\bu?Fζ//\tψ", + ".\t\x05δ/d0␆\x0eπ\t}␐βυ8u\x7f52\\bu?Fζ//\tψ", + ], + [ + EncodeDoubleQuote | EncodeLeftPeriod | EncodeRightCrLfHtVt, + ".\td:dκNsιl␜␌I\x07^'␜␈bμτφJ?␁#.'\t*", + ".\td:dκNsιl␜␌I\x07^'␜␈bμτφJ?␁#.'\t*", + ], + [ + EncodeColon | EncodeLeftPeriod | EncodeRightCrLfHtVt, + ".\t|`HK$'/-%φ/ρ␄λφ\x1a4`δ\x01H\nwκx\x10\t?", + ".\t|`HK$'/-%φ/ρ␄λφ\x1a4`δ\x01H\nwκx\x10\t?", + ], + [ + EncodeQuestion | EncodeLeftPeriod | EncodeRightCrLfHtVt, + ".\tιο\x10ρ#\4>␟\x01!\x04j4ZpD\x0f~|μ*␁?ζ?\t$", + ".\tιο\x10ρ#\4>␟\x01!\x04j4ZpD\x0f~|μ*␁?ζ‛?\t$", + ], + [ + EncodeAsterisk | EncodeLeftPeriod | EncodeRightCrLfHtVt, + ".\t␕)E\x1ax*\x1fκα␡␇!λι/=θ!\x01d\\\\I␅6j\tρ", + ".\t␕)E\x1ax‛*\x1fκα␡␇!λι/=θ!\x01d\\\\I␅6j\tρ", + ], + [ + EncodePipe | EncodeLeftPeriod | EncodeRightCrLfHtVt, + ".\tγ␟κε*ζ\x15\\x12uψ'␆U0␉nC␆\]αO\\x10ο\x05(<~ςCYx$φχO\x1c=η;,\tκ", + ".\t␚␒?␁␋>\\x10ο\x05(<~ςCYx$φχO\x1c=η;,\tκ", + ], + [ + EncodeZero | EncodeLeftTilde | EncodeLeftCrLfHtVt, + "~\tυXμ0/P/v␃$\x7fμ\x0e\x0eα/.%8M%4!$LT\tK", + "~\tυXμ0/P/v␃$\x7fμ\x0e\x0eα/.%8M%4!$LT\tK", + ], + [ + EncodeSlash | EncodeLeftTilde | EncodeLeftCrLfHtVt, + "~\tχ`␓Tv^[␞~ωλh2j-μ␎␡'\f&?\x10:y*\tι", + "~\tχ`␓Tv^[␞~ωλh2j-μ␎␡'\f&?\x10:y*\tι", + ], + [ + EncodeSingleQuote | EncodeLeftTilde | EncodeLeftCrLfHtVt, + "~\tψι<1'␁%\x14ο!ωνι␍␘'E\x1db\x1e\x01iQE*;\t5", + "~\tψι<1‛'␁%\x14ο!ωνι␍␘‛'E\x1db\x1e\x01iQE*;\t5", + ], + [ + EncodeBackQuote | EncodeLeftTilde | EncodeLeftCrLfHtVt, + "~\t\ξ:υH\r␌j?plρτλ\x02-␕\x06Eι\x02\v'3c␂\t=", + "~\t\ξ:υH\r␌j?plρτλ\x02-␕\x06Eι\x02\v'3c␂\t=", + ], + [ + EncodeLtGt | EncodeLeftTilde | EncodeLeftCrLfHtVt, + "~\t!syπ\x14aSeυYυ\x1d␗ph[\x01␓βα/λh\x19\x10␙\t#", + "~\t!syπ\x14aSeυYυ\x1d␗ph[\x01␓βα/λh\x19\x10␙\t#", + ], + [ + EncodeSquareBracket | EncodeLeftTilde | EncodeLeftCrLfHtVt, + '~\tfσ␓␗δI<OY␜&_\x1e␇η\n\x1cφ␙σ$λ"l<[\t4', + '~\tfσ␓␗δI<OY␜&_\x1e␇η\n\x1cφ␙σ$λ"l<‛[\t4', + ], + [ + EncodeSemicolon | EncodeLeftTilde | EncodeLeftCrLfHtVt, + "~\t␝ι\r>-\x00ωuX␐νjI\x07ι5λ\\\uff00\G␛\vψP\x14\tk", + "~\t␝ι\r>-␀ωuX␐νjI\x07ι5λ\\\uff00\G␛\vψP\x14\tk", + ], + [ + EncodeExclamation | EncodeLeftTilde | EncodeLeftCrLfHtVt, + "~\t␖2xκ␏<{:\x1dη+/#Uζ;$ψ'␜␎␖?4zA\tν", + "~\t␖2xκ␏<{:\x1dη+/#Uζ;$ψ'␜␎␖?4zA\tν", + ], + [ + EncodeDollar | EncodeLeftTilde | EncodeLeftCrLfHtVt, + "~\t&)W\f~]κCω␝τDm@␌5\x00␋ψ\v*yψν␜/\tV", + "~\t&)W\f~]κCω␝τDm@␌5␀␋ψ\v*yψν␜/\tV", + ], + [ + EncodeDoubleQuote | EncodeLeftTilde | EncodeLeftCrLfHtVt, + "~\t*κ&#ω␄N\]χd\x1b0\v;κ%\x10T1;ι"2\x15\uff00\t␄", + "~\t*κ&#ω␄N\]χd\x1b0\v;κ%\x10T1;ι‛"2\x15\uff00\t␄", + ], + [ + EncodeColon | EncodeLeftTilde | EncodeLeftCrLfHtVt, + "~\t␕␐χμ␞c|r%ςσ:G\x00"φγ\x028␞gmr\x1fς\v#␒\x0fd|wτι:I␚τ\tσ", + "~\t*␝'>γ\x028␞gmr\x1fς\v#␒\x0fd|wτι:I␚τ\tσ", + ], + [ + EncodePipe | EncodeLeftTilde | EncodeLeftCrLfHtVt, + "~\tH>?{%␉U9m\x07κ\uff00[φ/(κQ␙]ο␗}m&|\tL", + "~\tH>?{%␉U9m\x07κ\uff00[φ/(κQ␙]ο␗}m&|\tL", + ], + [ + EncodeHash | EncodeLeftTilde | EncodeLeftCrLfHtVt, + "~\t/␇\x07V"ψ|λeHγ\x1e␃\bkλ\x10τ]␃J7~iE,\t8", + "~\t/␇\x07V"ψ|λeHγ\x1e␃\bkλ\x10τ]␃J7~iE,\t8", + ], + [ + EncodePercent | EncodeLeftTilde | EncodeLeftCrLfHtVt, + "~\tHd#[R9ασ\x00(\x17␚Jι5␚{jpF|ζμ\x01\x03π\t#", + "~\tHd#[R9ασ␀(\x17␚Jι5␚{jpF|ζμ\x01\x03π\t#", + ], + [ + EncodeBackSlash | EncodeLeftTilde | EncodeLeftCrLfHtVt, + "~\tqrH6?υ␂-π6*`ε␈\bS\x1b$$␏:/βλ\fγ\t<", + "~\tqrH6?υ␂-π6*`ε␈\bS\x1b$$␏:/βλ\fγ\t<", + ], + [ + EncodeDel | EncodeLeftTilde | EncodeLeftCrLfHtVt, + "~\t\x18δD␅␖#g`Dt\\␝9\x00>ιλ?^β<␍B`ηp\t^", + "~\t\x18δD␅␖#g`Dt\\␝9␀>ιλ?^β<␍B`ηp\t^", + ], + [ + EncodeZero | EncodeLeftTilde | EncodeRightSpace, + '~ ?"β\x15ο=␍|.Ne\x03`GZ\x10␊φdκ␇\x14gQμ\x01 ␜', + '~ ?"β\x15ο=␍|.Ne\x03`GZ\x10␊φdκ␇\x14gQμ\x01 ␜', + ], + [ + EncodeSlash | EncodeLeftTilde | EncodeRightSpace, + '~ [E\f@\x0e\rγVθ"Oψε,\x03D/vx]b␡$κ␌; ξ', + '~ [E\f@\x0e\rγVθ"Oψε,\x03D‛/vx]b␡$κ␌; ξ', + ], + [ + EncodeSingleQuote | EncodeLeftTilde | EncodeRightSpace, + "~ +<hT␊;K\r\rω[π?bC␇αGα/xψ\x12γL| ␓", + "~ +<hT␊;K\r\rω[π?bC␇αGα/xψ\x12γL| ␓", + ], + [ + EncodeBackQuote | EncodeLeftTilde | EncodeRightSpace, + "~ ξ$␊+:+\n%β:\x00\x1dβ\vt\x1aπ|g*T.γ\ι\uff00 `", + "~ ξ$␊+:+\n%β:␀\x1dβ\vt\x1aπ|g*T.γ\ι\uff00 `", + ], + [ + EncodeLtGt | EncodeLeftTilde | EncodeRightSpace, + "~ \x05(υ$␖␐\uff00~ζ␒;6;WAμ.␄\x17ω\x01λρU4\t :", + "~ \x05(υ$␖␐\uff00~ζ␒;6;WAμ.␄\x17ω\x01λρU4\t :", + ], + [ + EncodeSquareBracket | EncodeLeftTilde | EncodeRightSpace, + '~ F}$ς\x00d␉;&\x16"ε\x0fZς<?δκ`I$",H\v X', + '~ F}$ς␀d␉;&\x16"ε\x0fZς<?δκ`I$",H\v X', + ], + [ + EncodeSemicolon | EncodeLeftTilde | EncodeRightSpace, + "~ χυ~|␆"z)␙\x15Vο\x1a\x04\x19␃'κd␍cο%␕[␑+2α H", + "~ \x10σ?|/ς/y|T>\x19␃'κd␍cο%␕[␑+2α H", + ], + [ + EncodeColon | EncodeLeftTilde | EncodeRightSpace, + '~ "␕:<␏ςς␀␐ρχ␊σ/L!.\x05!%!B␜?B M", + "~ #/\x16$λ7(>‛␀␐ρχ␊σ/L!.\x05!%!B␜‛?B M", + ], + [ + EncodeAsterisk | EncodeLeftTilde | EncodeRightSpace, + "~ *1>π+␡π␓*␌ο2]QV␝\x15$#μβ\t#0(\r >", + "~ ‛*1>π+␡π␓‛*␌ο2]QV␝\x15$#μβ\t#0(\r >", + ], + [ + EncodePipe | EncodeLeftTilde | EncodeRightSpace, + "~ χ,]␃ρ<␈`RIλ@ζ\x12!d0`?$"χRγh␎ P", + "~ χ,]␃ρ<␈`RIλ@ζ\x12!d0`?$"χRγh␎ P", + ], + [ + EncodeHash | EncodeLeftTilde | EncodeRightSpace, + "~ ␛ε\x1e5␜\x03␇ISτ!π␕γ;ο\f7dδx/KA<' o", + "~ ␛ε\x1e5␜\x03␇ISτ!π␕γ;ο\f7dδx/KA<' o", + ], + [ + EncodePercent | EncodeLeftTilde | EncodeRightSpace, + "~ δYwν%I;\x11~\x12lsCU␘[φ6>i\x1e\x05cτ␘υ ^", + "~ δYwν‛%I;\x11~\x12lsCU␘[φ6>i\x1e\x05cτ␘υ ^", + ], + [ + EncodeBackSlash | EncodeLeftTilde | EncodeRightSpace, + "~ Tρ?ς␖*␑f␏[δτ␄OC\v#0␊ρx␌#58f u", + "~ Tρ?ς␖*␑f␏[δτ␄OC\v#0␊ρx␌#58f u", + ], + [ + EncodeCrLf | EncodeLeftTilde | EncodeRightSpace, + "~ \x15c<Et`\x05!V\x12δλB␃␊ρn={πψ\x16?␄:μ ␡", + "~ \x15c<Et`\x05!V\x12δλB␃‛␊ρn={πψ\x16?␄:μ ␡", + ], + [ + EncodeDel | EncodeLeftTilde | EncodeRightSpace, + "~ %6R␡&\x1dzπω␀<␑\x1aσ␎ε#\tωrξ,?N`O \", + "~ %6R‛␡&\x1dzπω‛␀<␑\x1aσ␎ε#\tωrξ,?N`O \", + ], + [ + EncodeCtl | EncodeLeftTilde | EncodeRightSpace, + '~ yE\x19mο"]%␀\x03ν\x04␆M_"</\bOπ7τ$θ# \x10', + '~ yE␙mο"]%‛␀␃ν␄‛␆M_"</␈Oπ7τ$θ# ␐', + ], + [ + EncodeZero | EncodeLeftTilde | EncodeRightPeriod, + "~.*Pτλ[o\x14!P␅␅η6ο!]xζtξ[O%E\x1fH.<", + "~.*Pτλ[o\x14!P␅␅η6ο!]xζtξ[O%E\x1fH.<", + ], + [ + EncodeSlash | EncodeLeftTilde | EncodeRightPeriod, + "~.t;␅\n|␍μ\x11x-␙Du\x1f\g\x17νWQ>δ\bλVψ.␖", + "~.t;␅\n|␍μ\x11x-␙Du\x1f\g\x17νWQ>δ\bλVψ.␖", + ], + [ + EncodeSingleQuote | EncodeLeftTilde | EncodeRightPeriod, + "~.\x18␄~␐Bθ\x1fν5ε,9/*X:ο␘luι/5'|\x00.θ", + "~.\x18␄~␐Bθ\x1fν5ε,9/*X:ο␘luι/5'|␀.θ", + ], + [ + EncodeBackQuote | EncodeLeftTilde | EncodeRightPeriod, + "~.δ\x044␚R|VςdJ␜;`uο|=u*:\x06\x11ηzκ}.␙", + "~.δ\x044␚R|VςdJ␜;‛`uο|=u*:\x06\x11ηzκ}.␙", + ], + [ + EncodeLtGt | EncodeLeftTilde | EncodeRightPeriod, + '~.t"Kυζ#Fμ>+πν\x10u␞(y␌\x16θ\x19\x00r7b\x03.M', + '~.t"Kυζ#Fμ‛>+πν\x10u␞(y␌\x16θ\x19␀r7b\x03.M', + ], + [ + EncodeSquareBracket | EncodeLeftTilde | EncodeRightPeriod, + "~.*['`#ηK!>i7θ:\x1bωQ␎␈'␐κ\x04βh-\x13.<", + "~.*‛['`#ηK!>i7θ:\x1bωQ␎␈'␐κ\x04βh-\x13.<", + ], + [ + EncodeSemicolon | EncodeLeftTilde | EncodeRightPeriod, + "~.οχλ]$\x1d\x05?␝6:k\x14hpZ␋␅γ|ε␓#"r,.ψ", + "~.οχλ]$\x1d\x05?␝6:k\x14hpZ␋␅γ|ε␓#"r,.ψ", + ], + [ + EncodeExclamation | EncodeLeftTilde | EncodeRightPeriod, + "~.;I?\x14␘*δU<ρ]K~πP␌μ'\x05[55\t;rz.-", + "~.;I?\x14␘*δU<ρ]K~πP␌μ'\x05[55\t;rz.-", + ], + [ + EncodeDollar | EncodeLeftTilde | EncodeRightPeriod, + "~.iυφ\x04␉␀\x06*+fnγ␕H!1γz{;χ7␅i#\x07."", + "~.iυφ\x04␉‛␀\x06*+fnγ␕H!1γz{;χ7␅i#\x07."", + ], + [ + EncodeDoubleQuote | EncodeLeftTilde | EncodeRightPeriod, + "~.a]␡\x181␀dχ/5τm`.]\x1ck0Tνφ/η␙^e.␃", + "~.a]␡\x181‛␀dχ/5τm`.]\x1ck0Tνφ/η␙^e.␃", + ], + [ + EncodeColon | EncodeLeftTilde | EncodeRightPeriod, + "~.\x03␜␐>"\x16\x15Dκ␕␞\x1f1ι)O␌!L␍\x16[\x04qn*.μ", + "~.\x03␜␐>"\x16\x15Dκ␕␞\x1f1ι)O␌!L␍\x16[\x04qn*.μ", + ], + [ + EncodeQuestion | EncodeLeftTilde | EncodeRightPeriod, + "~.τ\x01␁οS<+.[\x112>B\x11ιβ␙$␜:σ*ιO"␇.␎", + "~.τ\x01␁οS<+.[\x112>B\x11ιβ␙$␜:σ*ιO"␇.␎", + ], + [ + EncodeAsterisk | EncodeLeftTilde | EncodeRightPeriod, + "~.\x17β\n\x06?K?;P␏␌γ_mγ'␡#*oθ␌η\x00RC.\\", + "~.\x17β\n\x06?K?;P␏␌γ_mγ'␡#‛*oθ␌η␀RC.\\", + ], + [ + EncodePipe | EncodeLeftTilde | EncodeRightPeriod, + '~.Y?ι($I?|[u␃r\r`\x1eα[.␚"_ηβμθ%.}', + '~.Y?ι($I?‛|[u␃r\r`\x1eα[.␚"_ηβμθ%.}', + ], + [ + EncodeHash | EncodeLeftTilde | EncodeRightPeriod, + "~.␘χ<\\_NG␅ζχ}*\n`ο<K<[#␅\\\x18␑\x1dη.␚", + "~.␘χ<\\_NG␅ζχ}*\n`ο<K<[#␅\\\x18␑\x1dη.␚", + ], + [ + EncodePercent | EncodeLeftTilde | EncodeRightPeriod, + "~.\x10?3ρρQ|␖κ;λ␋␒y"z'␛\x18\x11␙\x1eP\x11sq.ς", + "~.\x10?3ρρQ|␖κ;λ␋␒y"z'␛\x18\x11␙\x1eP\x11sq.ς", + ], + [ + EncodeBackSlash | EncodeLeftTilde | EncodeRightPeriod, + '~.φ␄ν\x15$οZoy:φδ␑`.\x1f#<C>␊␕$"lθ.M', + '~.φ␄ν\x15$οZoy:φδ␑`.\x1f#<C>␊␕$"lθ.M', + ], + [ + EncodeCrLf | EncodeLeftTilde | EncodeRightPeriod, + "~.?|7'j␊"~\x10νq#kζ!*\r/H(φ\x175Xεκ.\x16", + "~.?|7'j‛␊"~\x10νq#kζ!*␍/H(φ\x175Xεκ.\x16", + ], + [ + EncodeDel | EncodeLeftTilde | EncodeRightPeriod, + '~.γ*L$␌e*ββ\x05\x1cH␐"Cσ$\x19␊[ ␜@␃η!..', + '~.γ*L$␌e*ββ\x05\x1cH␐"Cσ$\x19␊[ ␜@␃η!.‛.', + ], + [ + EncodeCtl | EncodeLeftTilde | EncodeRightPeriod, + "~.`[Vs\ν␁nφv~-ζ\x14<γ\uff00k\x1eK[μ␏␋\x18U.3", + "~.`[Vs\ν‛␁nφv~-ζ␔<γ\uff00k␞K[μ‛␏‛␋␘U.3", + ], + [ + EncodeZero | EncodeLeftTilde | EncodeRightCrLfHtVt, + "~\tM[\x05\x1baNξ\οaυ:β␂ ι␁␁o\x0eτ␏</\x04\x02\t\x1e", + "~\tM[\x05\x1baNξ\οaυ:β␂ ι␁␁o\x0eτ␏</\x04\x02\t\x1e", + ], + [ + EncodeSlash | EncodeLeftTilde | EncodeRightCrLfHtVt, + "~\t`0(␁>Oγ()ς\x11:\t␔σF%\x05τθ␊|y*\x11\x0e\tp", + "~\t`0(␁>Oγ()ς\x11:\t␔σF%\x05τθ␊|y*\x11\x0e\tp", + ], + [ + EncodeSingleQuote | EncodeLeftTilde | EncodeRightCrLfHtVt, + "~\t?π␆λ:#g␈␐εPαceωπM|t@\x7f~1\x04\\\x03\t"", + "~\t?π␆λ:#g␈␐εPαceωπM|t@\x7f~1\x04\\\x03\t"", + ], + [ + EncodeBackQuote | EncodeLeftTilde | EncodeRightCrLfHtVt, + "~\t\x0e;\x10\x1d]NV␐~Nγyλ␕␎E*␚␔δkβE]uγ\tβ", + "~\t\x0e;\x10\x1d]NV␐~Nγyλ␕␎E*␚␔δkβE]uγ\tβ", + ], + [ + EncodeLtGt | EncodeLeftTilde | EncodeRightCrLfHtVt, + '~\t^/␋/\r"\x02␞\r␅[d*]ιQ\x1bνν␄O␚\x1aQpυ\tλ', + '~\t^/␋/\r"\x02␞\r␅[d*]ιQ\x1bνν␄O␚\x1aQpυ\tλ', + ], + [ + EncodeSquareBracket | EncodeLeftTilde | EncodeRightCrLfHtVt, + "~\tZδ␉7>ψw\fw>8ι!␍\v␏␘␃n␇\nb\x03Tζv\tφ", + "~\tZδ␉7>ψw\fw>8ι!␍\v␏␘␃n␇\nb\x03Tζv\tφ", + ], + [ + EncodeSemicolon | EncodeLeftTilde | EncodeRightCrLfHtVt, + "~\tθ#ε\r3tYfιχa^\t3 θ#ε\x1eP\x16B>%␋␙\t'", + "~\tθ#ε\r3tYfιχa^\t3 θ#ε\x1eP\x16B>%␋␙\t'", + ], + [ + EncodeExclamation | EncodeLeftTilde | EncodeRightCrLfHtVt, + "~\tL\uff00Jq␊ψ\x02;xζ␊%G\x0eε;ηvD`ο␚(␃x␛\tg", + "~\tL\uff00Jq␊ψ\x02;xζ␊%G\x0eε;ηvD`ο␚(␃x␛\tg", + ], + [ + EncodeDollar | EncodeLeftTilde | EncodeRightCrLfHtVt, + "~\t9ω:T>C%?␅.γ.$εν␄(zρ\x143␎/8\x1fσ\t]", + "~\t9ω:T>C%?␅.γ.‛$εν␄(zρ\x143␎/8\x1fσ\t]", + ], + [ + EncodeDoubleQuote | EncodeLeftTilde | EncodeRightCrLfHtVt, + "~\t␕νδο\x04"φτβ84\x01;␉0\":␐/\x06'?`'Gy\t0", + "~\t␕νδο\x04‛"φτβ84\x01;␉0":␐/\x06'?`'Gy\t0", + ], + [ + EncodeColon | EncodeLeftTilde | EncodeRightCrLfHtVt, + "~\tN\x00\x12α␛␗\f@φ</χ:i~␒τρν( 4\x1ch$p\tT", + "~\tN␀\x12α␛␗\f@φ</χ:i~␒τρν( 4\x1ch$p\tT", + ], + [ + EncodeQuestion | EncodeLeftTilde | EncodeRightCrLfHtVt, + "~\tθ␄μYπ-/␃\v␡E-\x13(#;;e/γ"w\x16\x10␗κ\tρ", + "~\tθ␄μYπ-/␃\v␡E-\x13(#;;e/γ"w\x16\x10␗κ\tρ", + ], + [ + EncodeAsterisk | EncodeLeftTilde | EncodeRightCrLfHtVt, + "~\t+4;;ομ\n]␞'\x17B5%␃\n*H␏κ*τ/|πZ\tβ", + "~\t+4;;ομ\n]␞'\x17B5%␃\n‛*H␏κ*τ/|πZ\tβ", + ], + [ + EncodePipe | EncodeLeftTilde | EncodeRightCrLfHtVt, + "~\t␞U\x1boLθ␓\x13<zo␏t:\x0fεRτ$!\x1e␀*λ#ζ\tε", + "~\t␞U\x1boLθ␓\x13<zo␏t:\x0fεRτ$!\x1e‛␀*λ#ζ\tε", + ], + [ + EncodeHash | EncodeLeftTilde | EncodeRightCrLfHtVt, + "~\t\x1e*6␌R\x18Kυk$&X;+W\d[\x10μβςς ␄\f\t<", + "~\t\x1e*6␌R\x18Kυk$&X;+W\d[\x10μβςς ␄\f\t<", + ], + [ + EncodePercent | EncodeLeftTilde | EncodeRightCrLfHtVt, + "~\t␅\x02ξ\"tγ␑\x134'␔'R*uDlPm\x04␆nρο␟τ\t#", + "~\t␅\x02ξ\"tγ␑\x134'␔'R*uDlPm\x04␆nρο␟τ\t#", + ], + [ + EncodeBackSlash | EncodeLeftTilde | EncodeRightCrLfHtVt, + "~\tω%*αιF["␍v_φ␂6␌i\x1ca/q\x1eCAω\:\t\\", + "~\tω%*αιF["␍v_φ␂6␌i\x1ca/q\x1eCAω‛\:\t\", + ], + [ + EncodeDel | EncodeLeftTilde | EncodeRightCrLfHtVt, + "~\tvι0␀\'␀mR\x03'λd-\x05d!:Xβ␏JCθ%ι\t\f", + "~\tvι0‛␀\'‛␀mR\x03'λd-\x05d!:Xβ␏JCθ%ι\t\f", + ], + [ + EncodeZero | EncodeLeftCrLfHtVt | EncodeRightSpace, + "\t 8rψI'\x1d\x1d␎αμ5␊Sδ`[M␜␈gF␌λ<5: -", + "␉ 8rψI'\x1d\x1d␎αμ5␊Sδ`[M␜␈gF␌λ<5: -", + ], + [ + EncodeSlash | EncodeLeftCrLfHtVt | EncodeRightSpace, + "\t C/[/\x7fφ>␃φ3*ρ\x039vκNoXψπU\CL0 ␍", + "␉ C‛/[/\x7fφ>␃φ3*ρ\x039vκNoXψπU\CL0 ␍", + ], + [ + EncodeSingleQuote | EncodeLeftCrLfHtVt | EncodeRightSpace, + "\t 2φ␍;␚xMτ|T␏X(pλ|<κ\x11`'/ρ;:␝ β", + "␉ 2φ␍;␚xMτ|T␏X(pλ|<κ\x11`‛'/ρ;:␝ β", + ], + [ + EncodeBackQuote | EncodeLeftCrLfHtVt | EncodeRightSpace, + '\t ␆\x1f;1ιδ?␜<ρQuEτ␌e\x06ζ\x0f"[β8␈\x1c! \x05', + '␉ ␆\x1f;1ιδ?␜<ρQuEτ␌e\x06ζ\x0f"[β8␈\x1c! \x05', + ], + [ + EncodeLtGt | EncodeLeftCrLfHtVt | EncodeRightSpace, + "\t qβ␘'\x02Kχ\x1b,`μk\x0fνuAMω␗\x13n$]2υ! p", + "␉ qβ␘'\x02Kχ\x1b,`μk\x0fνuAMω␗\x13n$]2υ! p", + ], + [ + EncodeSquareBracket | EncodeLeftCrLfHtVt | EncodeRightSpace, + "\t ␟{\x18α:gzρψkLξ;*␊\r␋\x03`&P>~%.\x13 d", + "␉ ␟{\x18α:gzρψkLξ;*␊\r␋\x03`&P>~%.\x13 d", + ], + [ + EncodeSemicolon | EncodeLeftCrLfHtVt | EncodeRightSpace, + "\t ρR\fα%$*%(@$\\␙h[␅ςAβ␟␘iRφE\x01 ω", + "␉ ρR\fα%$*%(@$\\␙h[␅ςAβ␟␘iRφE\x01 ω", + ], + [ + EncodeExclamation | EncodeLeftCrLfHtVt | EncodeRightSpace, + "\t δ7]π\x0e;C6ο3␃:%\x04␍-x␘b%␈oουε# !", + "␉ δ7]π\x0e;C6ο3␃:%\x04␍-x␘b%␈oουε# ‛!", + ], + [ + EncodeDollar | EncodeLeftCrLfHtVt | EncodeRightSpace, + "\t ι\x17κτωRψMP]r#\\␜0␀X?c\x15␈P[(τ Q", + "␉ ι\x17κτωRψMP]r#\\␜0‛␀X?c\x15␈P[(τ Q", + ], + [ + EncodeDoubleQuote | EncodeLeftCrLfHtVt | EncodeRightSpace, + "\t ␈Wφη7ZEρω]f|"V.Ij\\␁<\x0e|γ\x17\v+ τ", + "␉ ␈Wφη7ZEρω]f|‛"V.Ij\\␁<\x0e|γ\x17\v+ τ", + ], + [ + EncodeColon | EncodeLeftCrLfHtVt | EncodeRightSpace, + "\t d,γh\f5ι*G␡;\\o␑\x05␁\ε7ζb!!␁μ ς", + "␉ d,γh\f5ι*G␡;\\o␑\x05␁\ε7ζb!!␁μ ς", + ], + [ + EncodeQuestion | EncodeLeftCrLfHtVt | EncodeRightSpace, + "\t \x11}\x15`<,\x13␍\x19␓␅g\r~␛␜ωu&β:8ς4α\x00 R", + "␉ \x11}\x15`<,\x13␍\x19␓␅g\r~␛␜ωu&β:8ς4α␀ R", + ], + [ + EncodeAsterisk | EncodeLeftCrLfHtVt | EncodeRightSpace, + "\t ␁]A:\x16a␍<gm\x17βη\x12KψpdCλ\f[\x18ιW␖ X", + "␉ ␁]A:\x16a␍<gm\x17βη\x12KψpdCλ\f[\x18ιW␖ X", + ], + [ + EncodePipe | EncodeLeftCrLfHtVt | EncodeRightSpace, + '\t ωaGλ<"ω>"u\x7f`␛\x043zε\x04+=␉␍β\x06␆y ␟', + '␉ ωaGλ<"ω>"u\x7f`␛\x043zε\x04+=␉␍β\x06␆y ␟', + ], + [ + EncodeHash | EncodeLeftCrLfHtVt | EncodeRightSpace, + "\t ␊β␉6λ/␃δm/JπP??\x0fo+7*φξg␙l\x1a ␆", + "␉ ␊β␉6λ/␃δm/JπP??\x0fo+7*φξg␙l\x1a ␆", + ], + [ + EncodePercent | EncodeLeftCrLfHtVt | EncodeRightSpace, + "\t π8x!+b␂$?*@nχ6#|λψ]δys|<W\r ␞", + "␉ π8x!+b␂$?*@nχ6#|λψ]δys|<W\r ␞", + ], + [ + EncodeBackSlash | EncodeLeftCrLfHtVt | EncodeRightSpace, + '\t I"g\uff00␀␍v%z(*ω<r\n*κ!;\r␂pτφ␒ε &', + '␉ I"g\uff00‛␀␍v%z(*ω<r\n*κ!;\r␂pτφ␒ε &', + ], + [ + EncodeDel | EncodeLeftCrLfHtVt | EncodeRightSpace, + "\t ο\x04?;_␈␗ξκ?[\x1b$τF2ςS\x14}5φG'N\v \t", + "␉ ο\x04?;_␈␗ξκ?[\x1b$τF2ςS\x14}5φG'N\v \t", + ], + [ + EncodeZero | EncodeLeftCrLfHtVt | EncodeRightPeriod, + "\t.\x18B\x17ηο;ν\x05p␜γ<}␑\vN{SW<e␊\x05␍w\x1a.␈", + "␉.\x18B\x17ηο;ν\x05p␜γ<}␑\vN{SW<e␊\x05␍w\x1a.␈", + ], + [ + EncodeSlash | EncodeLeftCrLfHtVt | EncodeRightPeriod, + "\t.>ρ␘φA␊␘η(&#)(\n␒1\x1c#+␈nθ]\x14\x1dy.φ", + "␉.>ρ␘φA␊␘η(&#)(\n␒1\x1c#+␈nθ]\x14\x1dy.φ", + ], + [ + EncodeSingleQuote | EncodeLeftCrLfHtVt | EncodeRightPeriod, + "\t.5θ\x10\x14`kJ\x19δ␐T\2\x12`\x00νπ;\x16FV=ο]␒.N", + "␉.5θ\x10\x14`kJ\x19δ␐T\2\x12`␀νπ;\x16FV=ο]␒.N", + ], + [ + EncodeBackQuote | EncodeLeftCrLfHtVt | EncodeRightPeriod, + "\t.␐␍9\x10?␇Fν\\λ[υξ\n␔#'3#ρT#%φI?.O", + "␉.␐␍9\x10?␇Fν\\λ[υξ\n␔#'3#ρT#%φI?.O", + ], + [ + EncodeLtGt | EncodeLeftCrLfHtVt | EncodeRightPeriod, + "\t.\x1fxσ␎␘'|@a␂ζφ$\x15\x02P/J␕υς\x1fi%=\x1e.O", + "␉.\x1fxσ␎␘'|@a␂ζφ$\x15\x02P/J␕υς\x1fi%=\x1e.O", + ], + [ + EncodeSquareBracket | EncodeLeftCrLfHtVt | EncodeRightPeriod, + "\t.αLεη␌]\bJββ!su|>χ;3\x14 \x1f\x01\x0e␊␍8.\x12", + "␉.αLεη␌‛]\bJββ!su|>χ;3\x14 \x1f\x01\x0e␊␍8.\x12", + ], + [ + EncodeSemicolon | EncodeLeftCrLfHtVt | EncodeRightPeriod, + "\t.]"` }␔wη␋μ␊8dζ\neηR\x15#;F$!dο.>", + "␉.]"` }␔wη␋μ␊8dζ\neηR\x15#;F$!dο.>", + ], + [ + EncodeExclamation | EncodeLeftCrLfHtVt | EncodeRightPeriod, + "\t.ξςZA4Tσ␖␔Bζ::μ␌O\x13*]>[\x13>θt\n.\x00", + "␉.ξςZA4Tσ␖␔Bζ::μ␌O\x13*]>[\x13>θt\n.␀", + ], + [ + EncodeDollar | EncodeLeftCrLfHtVt | EncodeRightPeriod, + "\t.\x1dτ␙6φ&\x07B<+.\n\!/␛\x0f␎ληδε>␜\x07%.M", + "␉.\x1dτ␙6φ&\x07B<+.\n\!/␛\x0f␎ληδε>␜\x07%.M", + ], + [ + EncodeDoubleQuote | EncodeLeftCrLfHtVt | EncodeRightPeriod, + '\t.ζ␎~\n␇"XνS␉␊\x068\x1e6>ξo,pυv#ωοb.a', + "␉.ζ␎~\n␇"XνS␉␊\x068\x1e6>ξo,pυv#ωοb.a", + ], + [ + EncodeColon | EncodeLeftCrLfHtVt | EncodeRightPeriod, + "\t.\x01␍}πνfqτμ,F\f/P!\x18␚].ς␁'$<\x1el.τ", + "␉.\x01␍}πνfqτμ,F\f/P!\x18␚].ς␁'$<\x1el.τ", + ], + [ + EncodeQuestion | EncodeLeftCrLfHtVt | EncodeRightPeriod, + "\t.>p'\x14<υ%␊Lχ␏`!B]|Xuγ\x03␖rξ/\x17!.d", + "␉.>p'\x14<υ%␊Lχ␏`!B]|Xuγ\x03␖rξ/\x17!.d", + ], + [ + EncodeAsterisk | EncodeLeftCrLfHtVt | EncodeRightPeriod, + "\t.η>ζ=ς\x1cJ␌$ν>\rvG$a`'L\x0e}<P␆/{.␊", + "␉.η>ζ=ς\x1cJ␌$ν>\rvG$a`'L\x0e}<P␆/{.␊", + ], + [ + EncodePipe | EncodeLeftCrLfHtVt | EncodeRightPeriod, + "\t.␍SRJLυ$␖|UVGVφ␙]$␝␟l?\r\v'ςλ.υ", + "␉.␍SRJLυ$␖‛|UVGVφ␙]$␝␟l?\r\v'ςλ.υ", + ], + [ + EncodeHash | EncodeLeftCrLfHtVt | EncodeRightPeriod, + "\t.F3O␑λH␈ο+'&.||&ψ\x7f:μ\x03␈i\\!␑[.θ", + "␉.F3O␑λH␈ο+'&.||&ψ\x7f:μ\x03␈i\\!␑[.θ", + ], + [ + EncodePercent | EncodeLeftCrLfHtVt | EncodeRightPeriod, + "\t.6"#M/aσβTZ␑βe>β`tς␈ψ)M\x18\x07␖\x18.R", + "␉.6"#M/aσβTZ␑βe>β`tς␈ψ)M\x18\x07␖\x18.R", + ], + [ + EncodeBackSlash | EncodeLeftCrLfHtVt | EncodeRightPeriod, + "\t.\x17\x10ιf␌ς'␌]\x04ςν%ιLAζ␈9\uff00␕P\x1cw␙-.U", + "␉.\x17\x10ιf␌ς'␌]\x04ςν%ιLAζ␈9\uff00␕P\x1cw␙-.U", + ], + [ + EncodeDel | EncodeLeftCrLfHtVt | EncodeRightPeriod, + "\t./␙u&\x7f\x0f␍␎Lω/$_π\x16ω6␘ηHcθ?␝=ρ. ", + "␉./␙u&␡\x0f␍␎Lω/$_π\x16ω6␘ηHcθ?␝=ρ. ", + ], + [ + EncodeZero | EncodeLeftCrLfHtVt | EncodeRightCrLfHtVt, + "\t\tc_X\x7fε\x7f%lτρFξJc&␌␗l\x11S(;␌-\x16\x01\t␂", + "␉\tc_X\x7fε\x7f%lτρFξJc&␌␗l\x11S(;␌-\x16\x01\t␂", + ], + [ + EncodeSlash | EncodeLeftCrLfHtVt | EncodeRightCrLfHtVt, + "\t\tP[o\x16␖B.;!β]\x06=␊␜␉f␔$s␂γξ\uff00mδ\tπ", + "␉\tP[o\x16␖B.;!β]\x06=␊␜␉f␔$s␂γξ\uff00mδ\tπ", + ], + [ + EncodeSingleQuote | EncodeLeftCrLfHtVt | EncodeRightCrLfHtVt, + "\t\tWs\x1bG\x04με\x0exηI!?w]b␔!␈&\n*ξ\x1bτ%\t3", + "␉\tWs\x1bG\x04με\x0exηI!?w]b␔!␈&\n*ξ\x1bτ%\t3", + ], + [ + EncodeBackQuote | EncodeLeftCrLfHtVt | EncodeRightCrLfHtVt, + "\t\tVπ\x0e"\rαX\t␏', + '␉\t␜DDα␌\x1bθ_"\rJ␍:κτ␅\x12"a>π\x0e"\rαX\t␏', + ], + [ + EncodeExclamation | EncodeLeftCrLfHtVt | EncodeRightCrLfHtVt, + "\t\t'`ζA=τ\nU[␖R6U\x19`B3ψφR␞␎␁!m␈\t\x0e", + "␉\t'`ζA=τ\nU[␖R6U\x19`B3ψφR␞␎␁‛!m␈\t\x0e", + ], + [ + EncodeDollar | EncodeLeftCrLfHtVt | EncodeRightCrLfHtVt, + "\t\t*\x02@␔LτM{␃C␊␘I\x17SoαW␚οC␈'|\x17d\tβ", + "␉\t*\x02@␔LτM{␃C␊␘I\x17SoαW␚οC␈'|\x17d\tβ", + ], + [ + EncodeDoubleQuote | EncodeLeftCrLfHtVt | EncodeRightCrLfHtVt, + "\t\tψς␔8R[\x12fv␐ψ|θ'|?PT\r!\x03qρ7␜*\t\v", + "␉\tψς␔8R[\x12fv␐ψ|θ'|?PT\r!\x03qρ7␜*\t␋", + ], + [ + EncodeColon | EncodeLeftCrLfHtVt | EncodeRightCrLfHtVt, + "\t\tKςvξ5\x19*R␍?^yn\x15Vνθ␞␟]χοl/\x17k\t\x18", + "␉\tKςvξ5\x19*R␍?^yn\x15Vνθ␞␟]χοl/\x17k\t\x18", + ], + [ + EncodeQuestion | EncodeLeftCrLfHtVt | EncodeRightCrLfHtVt, + "\t\t\x1c<␉\x1cψ{␖\n?ξ␁YgψA5\x13␅Cvζ\x11f␄Y;\tο", + "␉\t\x1c<␉\x1cψ{␖\n‛?ξ␁YgψA5\x13␅Cvζ\x11f␄Y;\tο", + ], + [ + EncodeAsterisk | EncodeLeftCrLfHtVt | EncodeRightCrLfHtVt, + "\t\tυU␉σ9<8θ-␒\x00␊O%<f\x00\x14NJαζ#\␗1\t\x19", + "␉\tυU␉σ9<8θ-␒␀␊O%<f␀\x14NJαζ#\␗1\t\x19", + ], + [ + EncodePipe | EncodeLeftCrLfHtVt | EncodeRightCrLfHtVt, + "\t\t␔$VοNwCω/αY\␋<`DZ*lγw\x19&';/\t<", + "␉\t␔$VοNwCω/αY\␋<`DZ*lγw\x19&';/\t<", + ], + [ + EncodeHash | EncodeLeftCrLfHtVt | EncodeRightCrLfHtVt, + "\t\t␄␂&^;εςN/g`|α\f\x1aξUKE␏nRZ>\\␘\tζ", + "␉\t␄␂&^;εςN/g`|α\f\x1aξUKE␏nRZ>\\␘\tζ", + ], + [ + EncodePercent | EncodeLeftCrLfHtVt | EncodeRightCrLfHtVt, + "\t\t␞␕]>γBIG3κ\x18␓\x05#\\aγ␆υ~δ\x07r*}'\t`", + "␉\t␞␕]>γBIG3κ\x18␓\x05#\\aγ␆υ~δ\x07r*}'\t`", + ], + [ + EncodeBackSlash | EncodeLeftCrLfHtVt | EncodeRightCrLfHtVt, + '\t\t\x12\"K␟τ*p[ε␐{u␑[\vκ\x15B␗=\x16␓μπr\t:', + '␉\t\x12‛\"K␟τ*p[ε␐{u␑[\vκ\x15B␗=\x16␓μπr\t:', + ], + [ + EncodeDel | EncodeLeftCrLfHtVt | EncodeRightCrLfHtVt, + "\t\tς\x10y␖ν{|\x0e#\#'θ{λYf␜α`i{\x0f\x11␈m\tR", + "␉\tς\x10y␖ν{|\x0e#\#'θ{λYf␜α`i{\x0f\x11␈m\tR", + ], + [ + EncodeZero | EncodeRightSpace | EncodeRightPeriod, + " .:εg6DF␁?\x1e@\\\x01"\x16?νχα\x7f"7␑Jε␝␄.γ", + " .:εg6DF␁?\x1e@\\\x01"\x16?νχα\x7f"7␑Jε␝␄.γ", + ], + [ + EncodeSlash | EncodeRightSpace | EncodeRightPeriod, + " .Zi%\rq␈ζηb4Eψ#p\v`/'D\x02(`␊α#ρ.τ", + " .Zi%\rq␈ζηb4Eψ#p\v`‛/'D\x02(`␊α#ρ.τ", + ], + [ + EncodeSingleQuote | EncodeRightSpace | EncodeRightPeriod, + " .\x1e␋?ζ\rζ;ρi4␛;υ?\rν␔1ι␇ak/)dk.9", + " .\x1e␋?ζ\rζ;ρi4␛;υ?\rν␔1ι␇ak/)dk.9", + ], + [ + EncodeBackQuote | EncodeRightSpace | EncodeRightPeriod, + ' .r"␓Uψ"αδυ1s␆\rdv@rQ$ruμ␍φ:(.␡', + ' .r"␓Uψ"αδυ1s␆\rdv@rQ$ruμ␍φ:(.␡', + ], + [ + EncodeLtGt | EncodeRightSpace | EncodeRightPeriod, + ' .I@␊4\x12dto\x1e)␊␖|εοQ\x12*δ␎x"?Nγ9.\b', + ' .I@␊4\x12dto\x1e)␊␖|εοQ\x12*δ␎x"?Nγ9.\b', + ], + [ + EncodeSquareBracket | EncodeRightSpace | EncodeRightPeriod, + " .χ]'\nD4/ct['\x14ε;␇\x15eο)P␖χ4χσ␞.r", + " .χ]'\nD4/ct‛['\x14ε;␇\x15eο)P␖χ4χσ␞.r", + ], + [ + EncodeSemicolon | EncodeRightSpace | EncodeRightPeriod, + " .\x19mS*|W:S1+>ηθ\x16φκ=␗;%μN␋β␟].\x00", + " .\x19mS*|W:S1+>ηθ\x16φκ=␗;%μN␋β␟].␀", + ], + [ + EncodeExclamation | EncodeRightSpace | EncodeRightPeriod, + " .M]'σ\x1eN"|$?␍>*Dε|(ψn␂θx␟ζ0υ.!", + " .M]'σ\x1eN"|$?␍>*Dε|(ψn␂θx␟ζ0υ.!", + ], + [ + EncodeDollar | EncodeRightSpace | EncodeRightPeriod, + ' .%\x11\vδ␐"@z␏π%@1q\x1f~VG␈`ςω␖υ\bR.υ', + ' .%\x11\vδ␐"@z␏π%@1q\x1f~VG␈`ςω␖υ\bR.υ', + ], + [ + EncodeDoubleQuote | EncodeRightSpace | EncodeRightPeriod, + " .g#;ψ[t␋I6~%$<σ/πσ[\x16`kY}-#`.-", + " .g#;ψ[t␋I6~%$<σ/πσ[\x16`kY}-#`.-", + ], + [ + EncodeColon | EncodeRightSpace | EncodeRightPeriod, + " .␖\x16\x17ρκD?h99\x00#E,\t[␍{UHχ:i␈'δ.ψ", + " .␖\x16\x17ρκD?h99␀#E,\t[␍{UHχ‛:i␈'δ.ψ", + ], + [ + EncodeQuestion | EncodeRightSpace | EncodeRightPeriod, + " ./%Io\x1b1χT'\x01s␟ωJ;τα\vB,*η\x12υ",.;", + " ./%Io\x1b1χT'\x01s␟ωJ;τα\vB,*η\x12υ",.;", + ], + [ + EncodeAsterisk | EncodeRightSpace | EncodeRightPeriod, + " .␛/␎φ|>!\x00␒\x06\x1fο&\x127␇a␆$οςθ2k,w.^", + " .␛/␎φ|>!␀␒\x06\x1fο&\x127␇a␆$οςθ2k,w.^", + ], + [ + EncodePipe | EncodeRightSpace | EncodeRightPeriod, + " .επ␛␖σ%\x7fλVu␏3ω\n%\tc/b\x03ν␞W$'3.N", + " .επ␛␖σ%\x7fλVu␏3ω\n%\tc/b\x03ν␞W$'3.N", + ], + [ + EncodeHash | EncodeRightSpace | EncodeRightPeriod, + " .J|zLj␏B\r␍`␒:>␘1νωh[Yξ\x10θ␊T?.\x14", + " .J|zLj␏B\r␍`␒:>␘1νωh[Yξ\x10θ␊T?.\x14", + ], + [ + EncodePercent | EncodeRightSpace | EncodeRightPeriod, + " .\x0eτeTh>θ␕\x17ρD#q\␐κ#+\x12ν*?␇7yb.η", + " .\x0eτeTh>θ␕\x17ρD#q\␐κ#+\x12ν*?␇7yb.η", + ], + [ + EncodeBackSlash | EncodeRightSpace | EncodeRightPeriod, + " .\tW*\x17Jμ`L?2m\ne"R[α␅\x18#/uι\x15νο.ο", + " .\tW*\x17Jμ`L?2m\ne"R[α␅\x18#/uι\x15νο.ο", + ], + [ + EncodeCrLf | EncodeRightSpace | EncodeRightPeriod, + " .λJhp`γ␊@3*␐ητ"w␄>(θ\x14\x06u(θ\x14\x06u:5t-<]␊<\x14wQqK\x12,υσ␀λ\x1b\t=", + " \tφι\/%>:5t-<]␊<\x14wQqK\x12,υσ‛␀λ\x1b\t=", + ], + [ + EncodeBackQuote | EncodeRightSpace | EncodeRightCrLfHtVt, + " \t\x1d|1)χl!zι\x03␚\\x11␜N␝λ?\x19δ4j-␞␡\x1e\tk", + " \t\x1d|1)χl!zι\x03␚\\x11␜N␝λ?\x19δ4j-␞␡\x1e\tk", + ], + [ + EncodeLtGt | EncodeRightSpace | EncodeRightCrLfHtVt, + " \t,␑:]σ763f␍␏;cn\x1e?\x0f\x0eb$␒\x03σζ;E\tε", + " \t,␑:]σ763f␍␏;cn\x1e?\x0f\x0eb$␒\x03σζ;E\tε", + ], + [ + EncodeSquareBracket | EncodeRightSpace | EncodeRightCrLfHtVt, + " \t\x0ew\x1cr]\\TιμC@`\x7fς\x04␒␐κ`␁␜6K\x19␘ι\t)", + " \t\x0ew\x1cr‛]\\TιμC@`\x7fς\x04␒␐κ`␁␜6K\x19␘ι\t)", + ], + [ + EncodeSemicolon | EncodeRightSpace | EncodeRightCrLfHtVt, + " \t`[t\n6\v??οVχ3ηυ␍ι`:\x06X␑9O␆␍$\tα", + " \t`[t\n6\v??οVχ3ηυ␍ι`:\x06X␑9O␆␍$\tα", + ], + [ + EncodeExclamation | EncodeRightSpace | EncodeRightCrLfHtVt, + " \t$/v]␂nο␓␈Z␆y\x04ηπXEδP(S\x14␓␍ιi\tT", + " \t$/v]␂nο␓␈Z␆y\x04ηπXEδP(S\x14␓␍ιi\tT", + ], + [ + EncodeDollar | EncodeRightSpace | EncodeRightCrLfHtVt, + ' \tp]"^%␘ι;δ␅>v\x1eρ`^`;δe(ψ␑Uυ8\t]', + ' \tp]"^%␘ι;δ␅>v\x1eρ`^`;δe(ψ␑Uυ8\t]', + ], + [ + EncodeDoubleQuote | EncodeRightSpace | EncodeRightCrLfHtVt, + " \tβf/[bλ\x02S␃zHν\x1e\x04π\x1dπ␐␙bBX␇Kaj\t␟", + " \tβf/[bλ\x02S␃zHν\x1e\x04π\x1dπ␐␙bBX␇Kaj\t␟", + ], + [ + EncodeColon | EncodeRightSpace | EncodeRightCrLfHtVt, + " \tn␟l"#^\\\viψιβο#6C␚z␓\uff00\x13hm_\x13␓\t/", + " \tn␟l"#^\\\viψιβο#6C␚z␓\uff00\x13hm_\x13␓\t/", + ], + [ + EncodeQuestion | EncodeRightSpace | EncodeRightCrLfHtVt, + ' \t_/&3oθ\x16␍␕"yω:\x1a+<ι\x03\x0f\x03VF\ξμ"\tψ', + ' \t_/&3oθ\x16␍␕"yω:\x1a+<ι\x03\x0f\x03VF\ξμ"\tψ', + ], + [ + EncodeAsterisk | EncodeRightSpace | EncodeRightCrLfHtVt, + " \t␒~.\x1b+5χ\x11␊]π%Hρ_κ3ω`␍\x00␡tLg\r\t/", + " \t␒~.\x1b+5χ\x11␊]π%Hρ_κ3ω`␍␀␡tLg\r\t/", + ], + [ + EncodePipe | EncodeRightSpace | EncodeRightCrLfHtVt, + " \t βmC␃f\rIν␡\x0f)\uff00\x15\rζ-}$`χ<|␇\x18␚\t%", + " \t βmC␃f\rIν␡\x0f)\uff00\x15\rζ-}$`χ<‛|␇\x18␚\t%", + ], + [ + EncodeHash | EncodeRightSpace | EncodeRightCrLfHtVt, + " \t`<<#\x07J\x16Cσcκχfz#3Aλx␅␄\"υdE\t!", + " \t`<<#\x07J\x16Cσcκχfz#3Aλx␅␄\"υdE\t!", + ], + [ + EncodePercent | EncodeRightSpace | EncodeRightCrLfHtVt, + " \t\x1dM\n9\x1b␊:\x05␊{?␘κφ~␑kQ%Jγτ7δ␋\x03\t:", + " \t\x1dM\n9\x1b␊:\x05␊{?␘κφ~␑kQ%Jγτ7δ␋\x03\t:", + ], + [ + EncodeBackSlash | EncodeRightSpace | EncodeRightCrLfHtVt, + " \t\x03\r␌Rγ"νeχ'β\x1b;πj'=ς\x1c|Y␚␐7␘␁\t?", + " \t\x03\r␌Rγ"νeχ'β\x1b;πj'=ς\x1c|Y␚␐7␘␁\t?", + ], + [ + EncodeDel | EncodeRightSpace | EncodeRightCrLfHtVt, + " \tοβ\x1af[*\x1cGdN␑␞κ%ν)H~␖\x06θ/g>␡D\t]", + " \tοβ\x1af[*\x1cGdN␑␞κ%ν)H~␖\x06θ/g>‛␡D\t]", + ], + [ + EncodeZero | EncodeRightPeriod | EncodeRightCrLfHtVt, + ".\t9L\x16ς]␟\x064\n␁'␆ςωr\x1b\\v>-␂␒ηψ\x1cσ\t␡", + ".\t9L\x16ς]␟\x064\n␁'␆ςωr\x1b\\v>-␂␒ηψ\x1cσ\t␡", + ], + [ + EncodeSlash | EncodeRightPeriod | EncodeRightCrLfHtVt, + ".\t␝␜]υ?,\x14\r0wμ$␃b\x1b]ςν\x11&d}S␙f\x18\tθ", + ".\t␝␜]υ?,\x14\r0wμ$␃b\x1b]ςν\x11&d}S␙f\x18\tθ", + ], + [ + EncodeSingleQuote | EncodeRightPeriod | EncodeRightCrLfHtVt, + ".\t}ξMyk3etβπε4xψ\\%\x10\x13␔;><\x14:␜␊\t-", + ".\t}ξMyk3etβπε4xψ\\%\x10\x13␔;><\x14:␜␊\t-", + ], + [ + EncodeBackQuote | EncodeRightPeriod | EncodeRightCrLfHtVt, + ".\t␐!\x07λψ␔N ο\\P!ς/~\x17k\x02N␝κ!\t5ωe\t␅", + ".\t␐!\x07λψ␔N ο\\P!ς/~\x17k\x02N␝κ!\t5ωe\t␅", + ], + [ + EncodeLtGt | EncodeRightPeriod | EncodeRightCrLfHtVt, + ".\tfB!";ψ;ε;|κy$\x1fηHT\\z␛␅-\n\x1fZ3\tλ", + ".\tfB!";ψ;ε;|κy$\x1fηHT\\z␛␅-\n\x1fZ3\tλ", + ], + [ + EncodeSquareBracket | EncodeRightPeriod | EncodeRightCrLfHtVt, + ".\tv␁_5;pMα{␙␕#wφ<;pζ'␃φ␌jυ.j\t\x00", + ".\tv␁_5;pMα{␙␕#wφ<;pζ'␃φ␌jυ.j\t␀", + ], + [ + EncodeSemicolon | EncodeRightPeriod | EncodeRightCrLfHtVt, + ".\t|]␂<n\x1dςηbTςσH␏\x13vk␔*r␗ν\x14ψ|l\t\x11", + ".\t|]␂<n\x1dςηbTςσH␏\x13vk␔*r␗ν\x14ψ|l\t\x11", + ], + [ + EncodeExclamation | EncodeRightPeriod | EncodeRightCrLfHtVt, + ".\tE!␆[␡Rτ`1λφφφ %p'|ε5?,<\x05w]\tW", + ".\tE!␆[␡Rτ`1λφφφ %p'|ε5?,<\x05w]\tW", + ], + [ + EncodeDollar | EncodeRightPeriod | EncodeRightCrLfHtVt, + ".\t\f\x10-y␕;␁wTU\x04␈\x07;~Vπλκyι\x1dL␆δ␙\t'", + ".\t\f\x10-y␕;␁wTU\x04␈\x07;~Vπλκyι\x1dL␆δ␙\t'", + ], + [ + EncodeDoubleQuote | EncodeRightPeriod | EncodeRightCrLfHtVt, + ".\t>!7\x06τ\x01ξK|\x12␒~|=t␖j␘θυU␇2l\x17]\tR", + ".\t>!7\x06τ\x01ξK|\x12␒~|=t␖j␘θυU␇2l\x17]\tR", + ], + [ + EncodeColon | EncodeRightPeriod | EncodeRightCrLfHtVt, + ".\t/ty~|9κ\f%4:␡=\x14σ%\x0fm␅\rφπ\x1c0/W\tλ", + ".\t/ty~|9κ\f%4‛:␡=\x14σ%\x0fm␅\rφπ\x1c0/W\tλ", + ], + [ + EncodeQuestion | EncodeRightPeriod | EncodeRightCrLfHtVt, + ".\tα[FK|βZ/␌-iκ%οRe␜␒cε\\]\x05\f^2\tρ", + ".\tα[FK|βZ/␌-iκ%οRe␜␒cε\\]\x05\f^2\tρ", + ], + [ + EncodeAsterisk | EncodeRightPeriod | EncodeRightCrLfHtVt, + ".\tαο/@^␊θψ\x19M\x1f\x152I␍SW\x19bο\g␊κA␜\tU", + ".\tαο/@^␊θψ\x19M\x1f\x152I␍SW\x19bο\g␊κA␜\tU", + ], + [ + EncodePipe | EncodeRightPeriod | EncodeRightCrLfHtVt, + ".\tb␄"%Tδ␊@βRT␞␌.K'RDρνβ\f#D\n\x0e\t<", + ".\tb␄"%Tδ␊@βRT␞␌.K'RDρνβ\f#D\n\x0e\t<", + ], + [ + EncodeHash | EncodeRightPeriod | EncodeRightCrLfHtVt, + ".\tW>τP#K$I\x01\rςαν/\x12\t#f␖otY\uff00zν␉\t5", + ".\tW>τP‛#K$I\x01\rςαν/\x12\t‛#f␖otY\uff00zν␉\t5", + ], + [ + EncodePercent | EncodeRightPeriod | EncodeRightCrLfHtVt, + ".\t1/␡^\x1c\b-\n?fνπ␉{φ</Kψ\x03]\x1cμG`d\t␍", + ".\t1/␡^\x1c\b-\n?fνπ␉{φ</Kψ\x03]\x1cμG`d\t‛␍", + ], + [ + EncodeBackSlash | EncodeRightPeriod | EncodeRightCrLfHtVt, + '.\t:'χ␇_"␟υA\x17D␔ζβ\x1fn(aDMu␀ρ\n+\x07\t\\', + '.\t:'χ␇_"␟υA\x17D␔ζβ\x1fn(aDMu‛␀ρ\n+\x07\t\', + ], + [ + EncodeDel | EncodeRightPeriod | EncodeRightCrLfHtVt, + '.\t\fγoG␓"τ$φp\x167Fψeυ/"!\x1d1R{␍ε/\t␊', + '.\t\fγoG␓"τ$φp\x167Fψeυ/"!\x1d1R{␍ε/\t‛␊', + ], +]; + +const EDGE_EXAMPLES = [ + [EncodeLeftSpace, " ", "␠ "], + [EncodeLeftPeriod, "..", ".."], + [EncodeLeftTilde, "~~", "~~"], + [EncodeRightSpace, " ", " ␠"], + [EncodeRightPeriod, "..", ".."], + [EncodeLeftSpace | EncodeRightSpace, " ", "␠␠"], + [EncodeLeftSpace | EncodeRightSpace, " ", "␠ ␠"], + [EncodeRightPeriod | EncodeRightSpace, "a .", "a ."], +]; + +test("encodeName and decodeName pass the cases of rclone for one flag", () => { + assertCases("SINGLE", SINGLE); +}); + +test("encodeName and decodeName pass the cases of rclone for one edge", () => { + assertCases("SINGLE_EDGE", SINGLE_EDGE); + assertCases("EDGE_EXAMPLES", EDGE_EXAMPLES); +}); + +test("encodeName and decodeName pass the cases of rclone for two edges", () => { + assertCases("DOUBLE_EDGE", DOUBLE_EDGE); +}); diff --git a/lib/package.json b/lib/package.json index e409593..3a4fe4c 100644 --- a/lib/package.json +++ b/lib/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { "./crc32": "./crc32.js", + "./encoder": "./encoder.js", "./format": "./format.js", "./headers": "./headers.js", "./fspath": "./fspath.js", @@ -16,6 +17,7 @@ "./obscure": "./obscure.js", "./options": "./options.js", "./overlay": "./overlay.js", + "./parts": "./parts.js", "./path": "./path.js", "./progress": "./progress.js", "./range": "./range.js", diff --git a/lib/parts.js b/lib/parts.js new file mode 100644 index 0000000..a91ffe7 --- /dev/null +++ b/lib/parts.js @@ -0,0 +1,57 @@ +/** + * Cuts a stream into parts of a size, for the uploads in parts of s3 and + * azureblob. + */ + +/** + * Cuts a stream into parts of a size: each part has the size, but the last + * part can be smaller. + */ +export class Parts { + /** @param {ReadableStream} stream @param {number} size */ + constructor(stream, size) { + this.reader = stream.getReader(); + this.size = size; + /** @type {Uint8Array} */ + this.pending = new Uint8Array(0); + /** True when the stream has no more data. */ + this.done = false; + } + + /** + * The next part, or null at the end. + * @returns {Promise} + */ + async next() { + const chunks = this.pending.length ? [this.pending] : []; + let length = this.pending.length; + this.pending = new Uint8Array(0); + while (length < this.size && !this.done) { + const { done, value } = await this.reader.read(); + if (done) { + this.done = true; + break; + } + chunks.push(value); + length += value.length; + } + if (length === 0 && this.done) return null; + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + if (length > this.size) { + this.pending = bytes.slice(this.size); + return bytes.subarray(0, this.size); + } + // A part of the size can be the last part: the next read tells it. + if (!this.done && this.pending.length === 0) { + const { done, value } = await this.reader.read(); + if (done) this.done = true; + else this.pending = value; + } + return bytes; + } +} diff --git a/lib/parts_test.js b/lib/parts_test.js new file mode 100644 index 0000000..c4d9459 --- /dev/null +++ b/lib/parts_test.js @@ -0,0 +1,50 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { Parts } from "./parts.js"; + +/** + * A stream of the bytes, in chunks of the sizes. + * @param {Uint8Array} bytes + * @param {number[]} sizes + */ +function chunked(bytes, sizes) { + let offset = 0; + return new ReadableStream({ + pull(controller) { + if (offset >= bytes.length) return controller.close(); + const size = sizes[offset % sizes.length] || 1; + controller.enqueue(bytes.slice(offset, offset + size)); + offset += size; + }, + }); +} + +test("Parts cuts a stream into parts of the size, for any chunks", async () => { + const bytes = new Uint8Array(1000).map((_, i) => i % 251); + for (const sizes of [[1], [7], [100], [1000], [3, 500, 1]]) { + for (const size of [1, 10, 333, 1000, 2000]) { + const parts = new Parts(chunked(bytes, sizes), size); + const all = []; + for (let part = await parts.next(); part; part = await parts.next()) { + all.push(part); + if (parts.done) break; + } + assert.deepEqual( + all.map((part) => part.length), + Array.from( + { length: Math.ceil(bytes.length / size) }, + (_, i) => Math.min(size, bytes.length - i * size), + ), + `chunks ${sizes}, parts of ${size}`, + ); + assert.deepEqual(new Uint8Array(all.flatMap((part) => [...part])), bytes); + assert.equal(parts.done, true); + } + } +}); + +test("Parts gives no part for an empty stream", async () => { + const parts = new Parts(new Blob([]).stream(), 10); + assert.equal(await parts.next(), null); +}); diff --git a/lib/xml.js b/lib/xml.js index e235790..9771e19 100644 --- a/lib/xml.js +++ b/lib/xml.js @@ -14,6 +14,8 @@ * @property {XMLElement[]} children The elements in the element. * @property {string} text The text in the element, with no text of the * elements in it. + * @property {Record} attributes The attributes, by their + * names as in the document, with no declaration of a namespace. */ const TOKENS = @@ -37,7 +39,13 @@ const XML_NAMESPACES = { */ export function parseXML(text) { /** @type {XMLElement} */ - const document = { name: "", namespace: "", children: [], text: "" }; + const document = { + name: "", + namespace: "", + children: [], + text: "", + attributes: {}, + }; const stack = [document]; /** The namespaces of the prefixes, for each open element. */ const scopes = [{ "": "", ...XML_NAMESPACES }]; @@ -60,8 +68,13 @@ export function parseXML(text) { continue; } let scope = scopes.at(-1); + /** @type {Record} */ + const values = {}; for (const [, key, double, single] of attributes.matchAll(ATTRIBUTES)) { - if (key !== "xmlns" && !key.startsWith("xmlns:")) continue; + if (key !== "xmlns" && !key.startsWith("xmlns:")) { + values[key] = decodeEntities(double ?? single); + continue; + } if (scope === scopes.at(-1)) scope = { ...scope }; scope[key.slice(6)] = decodeEntities(double ?? single); } @@ -69,7 +82,13 @@ export function parseXML(text) { // A prefix with no declaration has no namespace: the answer of a // service can have such a prefix, as `s3:Error` of some services. const namespace = scope[prefix] ?? ""; - const element = { name: local, namespace, children: [], text: "" }; + const element = { + name: local, + namespace, + children: [], + text: "", + attributes: values, + }; top.children.push(element); if (!empty) { stack.push(element); diff --git a/lib/xml_test.js b/lib/xml_test.js index acb1282..c2936a7 100644 --- a/lib/xml_test.js +++ b/lib/xml_test.js @@ -100,3 +100,13 @@ test("isXMLText refuses the characters that XML 1.0 cannot have", () => { assert.ok(!isXMLText(text), JSON.stringify(text)); } }); + +test("parseXML gives the attributes of each element", () => { + const root = parseXML( + `a%20b`, + ); + const [name] = childrenOf(root, "Name"); + assert.deepEqual(name.attributes, { Encoded: "true", "x:a": "1 & 2" }); + assert.deepEqual(root.attributes, {}); + assert.deepEqual(childrenOf(root, "Plain")[0].attributes, {}); +}); diff --git a/package-lock.json b/package-lock.json index 06d4f32..957c635 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,10 +15,12 @@ "dependencies": { "@rfetch/alias": "^0.1.0", "@rfetch/archive": "^0.1.0", + "@rfetch/azureblob": "^0.1.0", "@rfetch/chunker": "^0.1.0", "@rfetch/combine": "^0.1.0", "@rfetch/compress": "^0.1.0", "@rfetch/crypt": "^0.1.0", + "@rfetch/doi": "^0.1.0", "@rfetch/drive": "^0.1.0", "@rfetch/http": "^0.1.0", "@rfetch/lib": "^0.1.0", @@ -62,6 +64,17 @@ "node": ">=22.2.0" } }, + "backend/azureblob": { + "name": "@rfetch/azureblob", + "version": "0.1.0", + "license": "ISC", + "dependencies": { + "@rfetch/lib": "^0.1.0" + }, + "engines": { + "node": ">=22.2.0" + } + }, "backend/chunker": { "name": "@rfetch/chunker", "version": "0.1.0", @@ -112,6 +125,17 @@ "node": ">=22.2.0" } }, + "backend/doi": { + "name": "@rfetch/doi", + "version": "0.1.0", + "license": "ISC", + "dependencies": { + "@rfetch/lib": "^0.1.0" + }, + "engines": { + "node": ">=22.2.0" + } + }, "backend/drive": { "name": "@rfetch/drive", "version": "0.1.0", @@ -235,6 +259,10 @@ "resolved": "backend/archive", "link": true }, + "node_modules/@rfetch/azureblob": { + "resolved": "backend/azureblob", + "link": true + }, "node_modules/@rfetch/chunker": { "resolved": "backend/chunker", "link": true @@ -251,6 +279,10 @@ "resolved": "backend/crypt", "link": true }, + "node_modules/@rfetch/doi": { + "resolved": "backend/doi", + "link": true + }, "node_modules/@rfetch/drive": { "resolved": "backend/drive", "link": true diff --git a/package.json b/package.json index 5041bd5..8b2cdd9 100644 --- a/package.json +++ b/package.json @@ -34,10 +34,12 @@ "dependencies": { "@rfetch/alias": "^0.1.0", "@rfetch/archive": "^0.1.0", + "@rfetch/azureblob": "^0.1.0", "@rfetch/chunker": "^0.1.0", "@rfetch/combine": "^0.1.0", "@rfetch/compress": "^0.1.0", "@rfetch/crypt": "^0.1.0", + "@rfetch/doi": "^0.1.0", "@rfetch/drive": "^0.1.0", "@rfetch/http": "^0.1.0", "@rfetch/lib": "^0.1.0",