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",