Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
| Package | Location |
| - | - |
| `@exodus/stasis` | [stasis](stasis/) |
| `@exodus/stasis-api` | [stasis-api](stasis-api/) |
| `@exodus/stasis-core` | [stasis-core](stasis-core/) |
| `@exodus/stasis-plugins` | [stasis-plugins](stasis-plugins/) |

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@exodus/stasis-workspace",
"version": "0.0.0",
"description": "Workspace root for @exodus/stasis, @exodus/stasis-core, and @exodus/stasis-plugins",
"description": "Workspace root for @exodus/stasis, @exodus/stasis-api, @exodus/stasis-core, and @exodus/stasis-plugins",
"private": true,
"type": "module",
"engines": {
Expand All @@ -24,6 +24,7 @@
"@babel/plugin-transform-classes": "^7.29.7",
"@exodus/bytes": "^1.15.0",
"@exodus/stasis": "workspace:*",
"@exodus/stasis-api": "workspace:*",
"@exodus/stasis-core": "workspace:*",
"@exodus/stasis-plugins": "workspace:*",
"babel-plugin-module-resolver": "^3.2.0",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@

packages:
- stasis
- stasis-api
- stasis-core
- stasis-plugins

Expand Down
21 changes: 21 additions & 0 deletions stasis-api/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Exodus Movement

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
55 changes: 55 additions & 0 deletions stasis-api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# <img src="/stasis/logo.svg" alt="" width="39" height="39" valign="bottom" /> `@exodus/stasis-api`

Zero-dependency registry API clients used by `@exodus/stasis`.

Transport only: no credential discovery, no caching, and no disk access — except the
`npm/semver` shim, which binds to the semver already bundled with the running Node's npm CLI
rather than installing one.

| Export | What it provides |
| - | - |
| `@exodus/stasis-api/npm` | `advisories(list)` — npm bulk security advisories for `{ name, version }` pairs |
| `@exodus/stasis-api/npm/semver` | lazily-bound `semver` from the bundled npm CLI, so nothing is installed for it |
| `@exodus/stasis-api/github` | `releases()`, `release()`, `latestRelease()`, `asset()` — GitHub releases and their attachments; `subtree()` — a repo tree at an exact ref |

```js
import { asset, latestRelease } from '@exodus/stasis-api/github'

const { tag, assets } = await latestRelease('ExodusOSS/stasis')
const bundle = assets.find((a) => a.name.endsWith('.stasis.code.br'))
// `digest` (when GitHub reports one) is verified before the bytes are returned
const bytes = await asset('ExodusOSS/stasis', bundle.id, { digest: bundle.digest })
console.log(tag, bundle.name, bytes.byteLength)
```

`asset()` buffers the attachment in memory, so a caller fetching several large assets should
bound its own concurrency rather than firing them all at once.

`subtree()` reads a repo tree at an exact commit, tag or branch — no git client, nothing
written to disk. One archive request per call, decompressed and parsed in memory:

```js
import { subtree } from '@exodus/stasis-api/github'

// omit `path` for the whole tree
const { root, files } = await subtree('ExodusOSS/bytes', 'v1.15.1', { path: 'benchmarks' })
console.log(root) // 'ExodusOSS-bytes-c33d586' — the commit the ref resolved to
for (const [path, bytes] of files) console.log(path, bytes.byteLength) // 'benchmarks/…', repo-relative
```

Keys stay repo-relative, so a subtree's paths keep their `path` prefix. Only regular files are
returned: directories and symlinks carry no usable content, and a symlink target is exactly
what could point outside the tree. Rejected rather than sanitized: entries that escape the
tree, a path that appears twice (the second would shadow the first), and archives with more
than one top-level directory. The whole archive is held in memory while it is parsed, bounded
by `maxBytes` (256 MiB by default) — enforced on the download and again on the decompressed
bytes, so a compression bomb fails the same way an oversized repo does.

Every function takes an optional `signal` (defaulting to a timeout) and, for GitHub, an
optional `token` — no token is ever read from the environment.

See main package [GitHub](https://github.com/ExodusOSS/stasis/tree/main/stasis) or [npm](https://npmjs.com/package/@exodus/stasis) for full README.

## License

[MIT](./LICENSE)
28 changes: 28 additions & 0 deletions stasis-api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "@exodus/stasis-api",
"version": "1.0.0-beta.2",
"description": "Zero-dependency registry API clients (npm, GitHub) for @exodus/stasis",
"type": "module",
"exports": {
"./npm": "./src/npm/index.js",
"./npm/semver": "./src/npm/semver.cjs",
"./github": "./src/github/index.js"
},
"files": [
"src"
],
"engines": {
"node": ">=24.14.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/ExodusOSS/stasis.git",
"directory": "stasis-api"
},
"author": "Exodus Movement, Inc.",
"license": "MIT",
"bugs": {
"url": "https://github.com/ExodusOSS/stasis/issues"
},
"homepage": "https://github.com/ExodusOSS/stasis#readme"
}
145 changes: 145 additions & 0 deletions stasis-api/src/archive.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict'
import { promisify } from 'node:util'
import { gunzip } from 'node:zlib'

// In-memory reader for a gzipped ustar archive that has already arrived as bytes (see
// github/subtree.js, the consumer). Returns a `Map` of entry path -> bytes; nothing touches
// disk and no external tar is involved -- Node's zlib decompresses, the ustar framing is
// parsed here, so the package stays dependency-free.
//
// Only regular files are kept. Directories, symlinks, hardlinks and device nodes carry no
// content a caller can use, and a symlink target is precisely the thing that could point
// out of the tree, so they are dropped rather than represented.

const BLOCK = 512

const dotdotRegex = /(?:^|\/)\.\.(?:\/|$)/u

// An entry must land inside the tree it claims to be part of. Rejected: absolute paths, any
// `..` segment, a backslash (a separator that would slip past a posix-only check), and NUL.
export function isSafePath(path) {
if (path === '' || path.startsWith('/') || path.includes('\\') || path.includes('\0')) return false
return !dotdotRegex.test(path)
}

// Copy an entry out of the archive buffer instead of returning a view into it: callers
// typically keep a subtree and drop the rest, and a subarray would pin the entire archive in
// memory for as long as any single file is referenced.
const detach = (bytes) => new Uint8Array(bytes)

// tar pads its text fields with NUL. Read the range directly rather than slicing a view out
// of it first -- this runs three times per entry, so the views alone would outnumber the
// files in the archive several times over.
const field = (buf, start, end) => {
const nul = buf.indexOf(0, start)
return buf.toString('utf8', start, nul === -1 || nul > end ? end : nul)
}

// tar numbers are octal ASCII. GNU/star write sizes above 8 GiB in a base-256 form instead,
// flagged by the high bit of the first byte.
function readNumber(buf, start, end) {
if ((buf[start] & 0x80) !== 0) {
let n = 0n
for (let i = start; i < end; i++) n = (n << 8n) | BigInt(i === start ? buf[i] & 0x7f : buf[i])
assert(n <= BigInt(Number.MAX_SAFE_INTEGER), 'Malformed tar archive: entry too large')
return Number(n)
}
const text = field(buf, start, end).trim()
if (text === '') return 0
const n = Number.parseInt(text, 8)
assert(Number.isSafeInteger(n) && n >= 0, `Malformed tar archive: bad size ${text}`)
return n
}

// ustar splits a long path across `prefix` (345..500) and `name` (0..100).
function ustarName(header) {
const name = field(header, 0, 100)
const prefix = field(header, 345, 500)
return prefix === '' ? name : `${prefix}/${name}`
}

// pax extended headers are a run of `<byteLength> <key>=<value>\n` records, where the length
// counts itself. Only `path` matters here (it overrides the ustar name of the next entry).
function paxPath(data) {
let i = 0
while (i < data.length) {
const space = data.indexOf(0x20, i)
if (space === -1) return null
const length = Number.parseInt(data.subarray(i, space).toString('latin1'), 10)
if (!Number.isSafeInteger(length) || length <= 0 || i + length > data.length) return null
const record = data.subarray(space + 1, i + length - 1).toString('utf8')
const eq = record.indexOf('=')
if (eq !== -1 && record.slice(0, eq) === 'path') return record.slice(eq + 1)
i += length
}
return null
}

function readTar(tar, select) {
const files = new Map()
// Names are tracked separately from the returned entries so the checks below cover the
// whole archive even when `select` keeps only part of it.
const seen = new Set()
// A pax ('x') or GNU longname ('L') block names the entry that FOLLOWS it.
let pending = null
let offset = 0
while (offset + BLOCK <= tar.length) {
const header = tar.subarray(offset, offset + BLOCK)
// The archive ends with zero blocks; the first one is enough to stop.
if (header.every((b) => b === 0)) break

const size = readNumber(header, 124, 136)
const type = String.fromCharCode(header[156])
const start = offset + BLOCK
const end = start + size
assert(end <= tar.length, 'Malformed tar archive: truncated entry')

if (type === 'x' || type === 'L') {
pending = type === 'x' ? paxPath(tar.subarray(start, end)) : field(tar, start, end)
} else if (type !== 'g') {
const name = pending ?? ustarName(header)
pending = null
// '0' and NUL both mean a regular file; every other type carries nothing to keep.
if (type === '0' || type === '\0') {
assert(isSafePath(name), `Unsafe archive path: ${name}`)
// tar can legally hold the same path twice and the reader would keep whichever came
// last, so a second entry could shadow the first -- and the shadowed bytes would
// never be seen. Refuse the archive instead of silently picking a winner.
assert(!seen.has(name), `Duplicate archive path: ${name}`)
seen.add(name)
// `select` maps an entry to the key it is stored under, or drops it by returning
// null. Both checks above run either way, so a dropped entry still cannot smuggle an
// unsafe or duplicated path past them -- it only skips being copied.
const key = select(name)
if (key !== null) files.set(key, detach(tar.subarray(start, end)))
}
}

offset = end + ((BLOCK - (size % BLOCK)) % BLOCK)
}
return files
}

// zlib has no promise API of its own; inflating on the threadpool keeps a decompression
// that can run to hundreds of MB from blocking the event loop for seconds.
const gunzipAsync = promisify(gunzip)

// `select` narrows and re-keys the archive as it is read, so the bytes of an entry the caller
// does not want are never copied out of it -- filtering afterwards would memcpy the whole
// tree to keep a fraction of it.
//
// `maxBytes` bounds the DECOMPRESSED size. gzip can expand a tiny input a thousandfold, so a
// cap on the downloaded bytes alone would still let a compression bomb exhaust the heap here.
export async function readTarGz(bytes, select, maxBytes) {
let tar
try {
tar = await gunzipAsync(bytes, { maxOutputLength: maxBytes })
} catch (cause) {
// The output cap tripping is a size refusal, not a damaged archive -- say which.
if (cause.code === 'ERR_BUFFER_TOO_LARGE') {
throw new Error(`Archive is over the ${maxBytes} byte limit once decompressed; raise maxBytes`, { cause })
}
throw new Error(`Malformed tar.gz archive: ${cause.message}`, { cause })
}
return readTar(tar, select)
}
Loading