diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a908bbf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,114 @@ +# 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 and Go, thus the tests that +# compare rfetch with rclone and with Go 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. +# +# This workflow deploys nothing: the Pages project deploys the branches. +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +# A new push to a branch stops the old run of that branch. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + node: [22, 24, 26] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + + # Go runs the tests of backend/http/list.js against encoding/csv. + - uses: actions/setup-go@v5 + with: + go-version: stable + cache: false + + # rclone reads each variable RCLONE_NAME as the flag --name. Thus the + # version is not in a variable RCLONE_VERSION of the job: rclone reads + # it as the flag --version, and fails. + - name: Install rclone + env: + VERSION: 1.75.1 + SHA256: 982b5aa772841168f8e380f139e9e787b2a105403e32b94da8676a0e1c0a13ab + run: | + mkdir -p "$HOME/bin" "$RUNNER_TEMP/rclone" + cd "$RUNNER_TEMP/rclone" + zip="rclone-v$VERSION-linux-amd64.zip" + curl -fsSLO "https://downloads.rclone.org/v$VERSION/$zip" + echo "$SHA256 $zip" | sha256sum -c - + unzip -q "$zip" + install -m 755 "rclone-v$VERSION-linux-amd64/rclone" "$HOME/bin/rclone" + "$HOME/bin/rclone" version + echo "$HOME/bin" >> "$GITHUB_PATH" + echo "RCLONE=$HOME/bin/rclone" >> "$GITHUB_ENV" + + - run: npm ci + + # A test against rclone or Go 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|Go) is not installed" "$RUNNER_TEMP/test.log"; then + echo "::error::A test skipped because a tool is not installed." + exit 1 + fi + + format: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - uses: denoland/setup-deno@v2 + with: + deno-version: 2.9.6 + + - run: deno fmt --check + + wasm: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + defaults: + run: + working-directory: backend/crypt/native + steps: + - uses: actions/checkout@v4 + + # The compiler of rust-toolchain.toml, with the target of WebAssembly. + - name: Install Rust + run: | + channel=$(sed -n 's/^channel = "\(.*\)"$/\1/p' rust-toolchain.toml) + rustup toolchain install "$channel" --profile minimal \ + --target wasm32-unknown-unknown + + # The tests compare each box with the crate crypto_secretbox, on the + # machine of the job. + - run: cargo test --locked + + - run: ./build.sh + + - name: Compare with the file in the repository + run: git diff --exit-code -- ../secretbox.wasm diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 41583e3..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -@jsr:registry=https://npm.jsr.io diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index aa1c94e..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "deno.enable": true, - "deno.unstable": true -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37b131e..3fab2e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,15 +9,18 @@ Choose a name. The docs here will use `remote` as an example. Note that in rfetch terminology a file system backend is called a remote or an fs. -- `deno init backend/remote` -- Edit "backend/remote/main.js" and "backend/remote/main_test.js" for the new - backend. +- Make the folder "backend/remote" with the files "package.json", "main.js" and + "main_test.js" for the new backend. The tests use the test runner of Node.js + (`node:test`). +- Add the backend to `backends` in `backend/main.js`, as + `remote: () => import("@rfetch/remote")`, and to the `dependencies` of the + root "package.json". rfetch loads a backend at its first use. - Implements a `fetch` default export function that handles "GET", "PUT" and "DELETE". - Uses `backend/local/main.js` as reference, or the boilerplate below: ```ts -import {} from "../../deps.js"; +import { join } from "node:path"; function router(request: Request) { const { method, url } = request; @@ -62,18 +65,325 @@ export default { }; ``` +A backend imports nothing of rfetch, for example `mod.js`. It is a Worker of +Cloudflare: it gets all from the request and from `env`, the second argument of +`fetch`. A backend on top of another remote, for example `crypt` or `chunker`, +sends its requests to that remote with the global `fetch`, for example +`fetch(":memory:/crypt/file.bin")`. The host sets the global `fetch` that reads +such a path: the import of `fetch.js` sets it. `mod.js`, the commands and +`_worker.js` import `fetch.js`. The tests of such a backend import `mod.js`: + +```js +// Sets the global `fetch` that reads the paths of remotes. +import "../../mod.js"; + +backendSuite("crypt", crypt, { + params: { remote: ":memory:/crypt", password }, +}); +``` + +To count or change the requests to the remote in a test, replace the global +`fetch` with `t.mock.method(globalThis, "fetch", …)`. The test runner puts the +original `fetch` back at the end of the test. + +## Options of a backend + +A backend reads its options with `optionsOf(request, "remote", env)` of +`@rfetch/lib/options`, in this order: + +1. a field of the query, for example `?password=…`; +2. the header `RFETCH_REMOTE_NAME` of the request, for example + `RFETCH_CRYPT_PASSWORD`; +3. the variable `RFETCH_REMOTE_NAME` of `env`. + +The prefix `RCLONE_` of rclone also works, for compatibility, for example +`RCLONE_CRYPT_PASSWORD`. In each place, `RFETCH_` takes the place of `RCLONE_`. +Thus a client can give a secret in a header, and a Worker can give it in `env`: +then the secret is not in a URL. A backend makes the links of a list from the +query of the request only, with no secret option. Thus an option of a header or +of `env` is never in a link. + +The module of a backend exports its options with their types, for example +`export const options = { boolean: ["no_head"], string: ["url"] }`. An option in +`default` and in no list is a `string`. The command line makes a flag of each +option, for example `--http-no-head` and `--http-url URL`. An option that is not +in `options` has no flag: its flag is an unknown flag. + +A backend on top of another remote sends the headers +`remoteHeaders(request.headers, "remote", remote)` to that remote. The other +options of the request go on, for example a remote `RFETCH_CONFIG_NAME_TYPE` +that only the headers tell. The options of the backend itself do not go. When +the remote is a URL, no option goes: a secret must not go out to a server. + +The `fetch` of `fetch.js` reads the headers `RFETCH_*` and `RCLONE_*` of a +request as the variables, in the place of the variables of the process. A remote +can come only from variables: `RFETCH_CONFIG_NAME_TYPE` and its options, as in +rclone. The backend gets the options of the path, the connection string and the +flags, in its query, and all the other options in `env`, as +`RFETCH_REMOTE_NAME`. The headers `RFETCH_*` and `RCLONE_*` never go out to a +URL. + +A local path, for example `file.txt`, `../file.txt` or `/tmp/file.txt`, is +relative to the current folder, as in rclone. The `fetch` of `fetch.js` gives +the absolute path to the local backend, with `RFETCH_LOCAL_ROOT: /` in `env`. +`RFETCH_LOCAL_ROOT` is not an option: a config file, a variable, a header or the +query cannot set it. Thus `:local:path` stays in the current folder. + +`rfetch serve http REMOTE:PATH` serves the folder `REMOTE:PATH`, as +`rclone serve http`: the path of a request is a path in that folder, and each +request goes to the `fetch` of `fetch.js`. Thus the remote gets all its options, +from the config file, the variables and the flags. A client only reads: the +server refuses each method but `GET` and `HEAD`, and `TRACE` most of all, +because the answer of `fetch.js` to `TRACE` tells the options of the remote. A +client sets no option: the server removes its query and its headers `RFETCH_*` +and `RCLONE_*`, and a name with a `/` after the decoding gives 400. + +## Workspaces + +Each backend is an npm workspace with the name `@rfetch/NAME`, and can be a +Worker of its own: "main.js" is the entry of the Worker. The "package.json" of a +new backend is: + +```json +{ + "name": "@rfetch/remote", + "version": "0.1.0", + "type": "module", + "exports": { ".": "./main.js" }, + "scripts": { "test": "node --test" }, + "dependencies": { "@rfetch/lib": "^0.1.0" } +} +``` + +Then run `npm install`: npm links the workspace into `node_modules`. + +A backend imports only: + +- the modules `node:` of Node.js, +- the packages of its "package.json", for example `@rfetch/lib/listing`, +- the files in its folder. + +The tests and the benches of a backend can also import its `devDependencies`, +and `../../mod.js` for the global `fetch`. The test `backend/main_test.js` makes +sure of this rule. + +The code that two or more backends use is in the workspace `lib`, the package +`@rfetch/lib`: for example `listing`, `path`, `range`, `mime` and `obscure`. The +shared tests of the backends are in it too, in `@rfetch/lib/testing/suite`. The +code that only one backend uses is in the folder of that backend, for example +`backend/s3/xml.js`. + ## Architecture - `GET /folder/`: displays HTML page with folder content. - `GET /file`: fetches file. +- `HEAD /folder/` and `HEAD /file`: the same headers as `GET`, with no body. - `PUT /folder/`: creates folder. -- `PUT /file`: uploads file. -- `DELETE /folder/`: deletes folder. +- `PUT /file`: uploads file. `PUT` creates the parent folders that do not exist. +- `DELETE /folder/`: deletes folder, and all the files and folders in it. - `DELETE /file`: deletes file. +The answers: + +- `PUT` gives 201 with the path in `Content-Location`. `DELETE` gives 204. +- A file or a folder that does not exist gives 404. +- `GET` of a file gives the headers `Content-Type`, `Content-Length`, + `Last-Modified` and `Accept-Ranges: bytes`. +- `GET` with `Range: bytes=START-END` gives 206 with the bytes of the range, + `Content-Range` and the `Content-Length` of the range, as RFC 9110. A range + with no byte of the file gives 416. A request with more ranges, or with a + range that is not correct, gets all the file. Use `parseRange` of + `@rfetch/lib/range`. + +The path of a request has percent-encoding. Use `decodePath` of +`@rfetch/lib/path` to get the path with no percent-encoding. It gives `null` for +a path that is not safe, for example `/..%2Fsecret.txt`, which becomes +`/../secret.txt` after the decoding. For `null`, give 400. + For displaying folder content, any HTML can be used, but the listing itself should be in a `