Compare commits

..

1 Commits

Author SHA1 Message Date
Florent BEAUCHAMP
f51e25ac28 fix(XenApi): cannot read properties of undefined (reading 'statusCode') 2023-08-29 08:48:40 +02:00
4599 changed files with 66133 additions and 389984 deletions

View File

@@ -15,10 +15,9 @@ module.exports = {
overrides: [
{
files: ['cli.{,c,m}js', '*-cli.{,c,m}js', '**/*cli*/**/*.{,c,m}js', '**/scripts/**.{,c,m}js'],
files: ['cli.{,c,m}js', '*-cli.{,c,m}js', '**/*cli*/**/*.{,c,m}js'],
rules: {
'n/no-process-exit': 'off',
'n/shebang': 'off',
'no-console': 'off',
},
},
@@ -39,12 +38,6 @@ module.exports = {
version: '>=16',
},
],
'n/no-unsupported-features/es-builtins': [
'error',
{
version: '>=16',
},
],
'n/no-unsupported-features/es-syntax': [
'error',
{
@@ -53,275 +46,8 @@ module.exports = {
],
},
},
{
files: ['@xen-orchestra/{web-core,lite,web}/**/*.{vue,ts}'],
parser: 'vue-eslint-parser',
parserOptions: {
sourceType: 'module',
parser: '@typescript-eslint/parser',
},
plugins: ['import'],
extends: [
'plugin:import/recommended',
'plugin:import/typescript',
'plugin:@typescript-eslint/recommended',
'plugin:vue/vue3-essential',
],
settings: {
'import/resolver': {
typescript: true,
'eslint-import-resolver-custom-alias': {
alias: {
'@core': '../web-core/lib',
'@': './src',
},
extensions: ['.ts'],
packages: ['@xen-orchestra/lite', '@xen-orchestra/web'],
},
},
},
globals: {
XO_LITE_VERSION: true,
XO_LITE_GIT_HEAD: true,
},
rules: {
'import/extensions': [
'error',
'ignorePackages',
{
pattern: { ts: 'always', vue: 'always' },
checkTypeImports: true,
// '@core/*' and '@/*' look like scoped packages to the plugin, which 'ignorePackages' would skip
pathGroupOverrides: [
{ pattern: '@core/packages/*', action: 'ignore' }, // barrel files
{ pattern: '@core/icons', action: 'ignore' },
{ pattern: '@core/**', action: 'enforce' },
{ pattern: '@/**', action: 'enforce' },
],
},
],
'import/order': [
'error',
{
groups: ['builtin', 'object', 'internal', ['type', 'external']],
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
pathGroups: [
{
pattern: '@/**',
group: 'internal',
},
{
pattern: '@core/**',
group: 'internal',
},
],
},
],
'no-useless-constructor': 'off',
'@typescript-eslint/no-useless-constructor': 'error',
'no-void': ['error', { allowAsStatement: true }],
'n/no-missing-import': 'off', // using 'import' plugin instead, to support TS aliases
'no-redeclare': 'off', // automatically checked by the TypeScript compiler
'no-dupe-class-members': 'off', // automatically checked by the TypeScript compiler
'no-undef': 'off', // automatically checked by the TypeScript compiler
'no-use-before-define': 'off', // replaced with the rule below
'@typescript-eslint/no-use-before-define': ['error', { functions: false }],
'@typescript-eslint/no-explicit-any': 'off',
'vue/multi-word-component-names': 'off',
// Vue 3 - Strongly Recommended
'vue/attribute-hyphenation': 'error',
'vue/component-definition-name-casing': 'error',
'vue/first-attribute-linebreak': 'error',
'vue/html-closing-bracket-newline': 'off', // Conflicts with Prettier
'vue/html-closing-bracket-spacing': 'error',
'vue/html-end-tags': 'error',
'vue/html-indent': 'off', // Conflicts with Prettier
'vue/html-quotes': 'error',
'vue/html-self-closing': ['error', { html: { void: 'always', normal: 'always', component: 'always' } }],
'vue/max-attributes-per-line': 'off', // Conflicts with Prettier
'vue/multiline-html-element-content-newline': 'error',
'vue/mustache-interpolation-spacing': 'error',
'vue/no-multi-spaces': 'error',
'vue/no-spaces-around-equal-signs-in-attribute': 'error',
'vue/no-template-shadow': 'error',
'vue/prop-name-casing': 'error',
'vue/require-default-prop': 'off', // https://github.com/vuejs/eslint-plugin-vue/issues/2051
'vue/require-explicit-emits': 'error',
'vue/require-prop-types': 'error',
'vue/singleline-html-element-content-newline': 'off',
'vue/v-bind-style': 'error',
'vue/v-on-event-hyphenation': ['error', 'always', { autofix: true }],
'vue/v-on-style': 'error',
'vue/v-slot-style': 'error',
// Vue 3 - Recommended
'vue/attributes-order': 'error',
'vue/no-lone-template': 'error',
'vue/no-multiple-slot-args': 'error',
'vue/no-v-html': 'error',
'vue/this-in-template': 'error',
// Vue 3 - Uncategorized
'vue/block-lang': ['error', { script: { lang: 'ts' }, style: { lang: 'postcss' } }],
'vue/block-order': ['error', { order: ['template', 'script', 'style'] }],
'vue/block-tag-newline': ['error', { singleline: 'consistent', multiline: 'always', maxEmptyLines: 0 }],
'vue/component-api-style': ['error', ['script-setup']],
'vue/component-name-in-template-casing': ['error', 'PascalCase', { registeredComponentsOnly: false }],
'vue/custom-event-name-casing': ['error', 'camelCase'],
'vue/define-emits-declaration': ['error', 'type-literal'],
'vue/define-macros-order': [
'error',
{
order: ['defineOptions', 'defineProps', 'defineEmits', 'defineModel', 'defineSlots'],
defineExposeLast: true,
},
],
'vue/define-props-declaration': ['error', 'type-based'],
'vue/enforce-style-attribute': ['error', { allow: ['scoped', 'module'] }],
'vue/html-button-has-type': 'error',
'vue/html-comment-content-newline': ['error', { singleline: 'never', multiline: 'always' }],
'vue/html-comment-content-spacing': ['error', 'always'],
'vue/no-duplicate-attr-inheritance': 'error',
'vue/no-empty-component-block': 'error',
'vue/no-multiple-objects-in-class': 'error',
'vue/no-ref-object-reactivity-loss': 'error',
'vue/no-static-inline-styles': 'error',
'vue/no-template-target-blank': 'error',
'vue/no-undef-components': ['error', { ignorePatterns: ['RouterLink', 'RouterView', 'I18nT'] }],
'vue/no-undef-properties': 'error',
'vue/no-unused-refs': 'error',
'vue/no-use-v-else-with-v-for': 'error',
'vue/no-useless-mustaches': 'error',
'vue/no-useless-v-bind': 'error',
'vue/no-v-text': 'error',
'vue/padding-line-between-blocks': 'error',
'vue/prefer-define-options': 'error',
'vue/prefer-separate-static-class': 'error',
'vue/prefer-true-attribute-shorthand': 'error',
'vue/require-macro-variable-name': 'error',
'vue/valid-define-options': 'error',
},
},
// Specific rules for XO i18n-related files
{
files: [
'@xen-orchestra/lite/**/*.{vue,ts}',
'@xen-orchestra/web/**/*.{vue,ts}',
'@xen-orchestra/web-core/**/*.{vue,ts,json}',
],
extends: ['plugin:@intlify/vue-i18n/base'],
settings: {
'vue-i18n': {
localeDir: ['@xen-orchestra/web-core/lib/locales/*.json'],
messageSyntaxVersion: '^9.9.0',
},
},
rules: {
strict: 'off',
'no-irregular-whitespace': 'off',
'@intlify/vue-i18n/no-deprecated-i18n-component': 'error',
'@intlify/vue-i18n/no-deprecated-i18n-place-attr': 'error',
'@intlify/vue-i18n/no-deprecated-i18n-places-prop': 'error',
'@intlify/vue-i18n/no-deprecated-modulo-syntax': 'error',
'@intlify/vue-i18n/no-deprecated-tc': 'error',
'@intlify/vue-i18n/no-html-messages': 'error',
'@intlify/vue-i18n/no-i18n-t-path-prop': 'error',
'@intlify/vue-i18n/no-missing-keys': 'error',
'@intlify/vue-i18n/no-raw-text': [
'error',
{
ignoreText: [
'Xen Orchestra Lite',
'Xen Orchestra',
'Xen Orchestra Appliance',
'XO Lite',
'XCP-ng',
'XO 5',
'404',
'⚠️',
],
},
],
'@intlify/vue-i18n/no-v-html': 'error',
'@intlify/vue-i18n/valid-message-syntax': 'error',
'@intlify/vue-i18n/no-duplicate-keys-in-locale': 'error',
'@intlify/vue-i18n/no-missing-keys-in-other-locales': [
'error',
{
ignoreLocales: [
'de',
'fa',
'cs',
'es',
'sv',
'it',
'ru',
'uk',
'nl',
'pt-BR',
'da',
'ko',
'nb-NO',
'ja',
'fi',
'pl',
'zh-Hans',
'sk',
'pt',
'ar',
'tr',
],
},
],
},
},
// Specific rules for XO dev-related files
{
files: [
'@xen-orchestra/lite/src/stories/**/*',
'@xen-orchestra/lite/src/components/component-story/**/*',
'@xen-orchestra/web/src/pages/dev/**/*',
'@xen-orchestra/lite/src/views/story/**/*',
],
rules: {
'@intlify/vue-i18n/no-raw-text': 'off',
},
},
{
files: ['@xen-orchestra/{web-core,lite,web}/src/pages/**/*.vue'],
parserOptions: {
sourceType: 'module',
},
rules: {
'vue/multi-word-component-names': 'off',
},
},
{
files: ['@xen-orchestra/{web-core,lite,web}/src/route-map.d.ts'],
parserOptions: {
sourceType: 'module',
},
rules: {
'eslint-comments/disable-enable-pair': 'off',
'eslint-comments/no-unlimited-disable': 'off',
},
},
{
files: ['*.d.ts'],
parser: '@typescript-eslint/parser',
parserOptions: {
sourceType: 'module',
},
rules: {
'n/no-unsupported-features/es-syntax': 'off',
'no-unused-vars': 'off',
},
},
],
ignorePatterns: ['@vates/fatfs/'],
parserOptions: {
ecmaVersion: 13,
sourceType: 'script',
@@ -342,11 +68,6 @@ module.exports = {
'no-console': ['error', { allow: ['warn', 'error'] }],
// this rule can prevent race condition bugs like parallel `a += await foo()`
//
// as it has a lots of false positive, it is only enabled as a warning for now
'require-atomic-updates': ['warn', { allowProperties: true }],
strict: 'error',
},
}

48
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,48 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: 'status: triaging :triangular_flag_on_post:, type: bug :bug:'
assignees: ''
---
1. ⚠️ **If you don't follow this template, the issue will be closed**.
2. ⚠️ **If your issue can't be easily reproduced, please report it [on the forum first](https://xcp-ng.org/forum/category/12/xen-orchestra)**.
Are you using XOA or XO from the sources?
If XOA:
- which release channel? (`stable` vs `latest`)
- please consider creating a support ticket in [your dedicated support area](https://xen-orchestra.com/#!/member/support)
If XO from the sources:
- Provide **your commit number**. If it's older than a week, we won't investigate
- Don't forget to [read this first](https://xen-orchestra.com/docs/community.html)
- As well as follow [this guide](https://xen-orchestra.com/docs/community.html#report-a-bug)
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment (please provide the following information):**
- Node: [e.g. 16.12.1]
- hypervisor: [e.g. XCP-ng 8.2.0]
**Additional context**
Add any other context about the problem here.

View File

@@ -1,119 +0,0 @@
name: Bug Report
description: Create a report to help us improve
labels: ['type: bug :bug:', 'status: triaging :triangular_flag_on_post:']
body:
- type: markdown
attributes:
value: |
1. ⚠️ **If you don't follow this template, the issue will be closed**.
2. ⚠️ **If your issue can't be easily reproduced, please report it [on the forum first](https://xcp-ng.org/forum/category/12/xen-orchestra)**.
- type: markdown
attributes:
value: '## Are you using XOA or XO from the sources?'
- type: dropdown
id: xo-origin
attributes:
label: Are you using XOA or XO from the sources?
options:
- XOA
- XO from the sources
- both
validations:
required: false
- type: markdown
attributes:
value: '### If XOA:'
- type: dropdown
id: xoa-channel
attributes:
label: Which release channel?
description: please consider creating a support ticket in [your dedicated support area](https://xen-orchestra.com/#!/member/support)
options:
- stable
- latest
- both
validations:
required: false
- type: markdown
attributes:
value: '### If XO from the sources:'
- type: markdown
attributes:
value: |
- Don't forget to [read this first](https://docs.xen-orchestra.com/community)
- As well as follow [this guide](https://docs.xen-orchestra.com/community#report-a-bug)
- type: input
id: xo-sources-commit-number
attributes:
label: Provide your commit number
description: If it's older than a week, we won't investigate
placeholder: e.g. 579f0
validations:
required: false
- type: markdown
attributes:
value: '## Bug description:'
- type: textarea
id: bug-description
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is
validations:
required: true
- type: textarea
id: error-message
attributes:
label: Error message
render: Text
validations:
required: false
- type: textarea
id: steps
attributes:
label: To reproduce
description: 'Steps to reproduce the behavior:'
value: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
validations:
required: false
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem
validations:
required: false
- type: markdown
attributes:
value: '## Environment (please provide the following information):'
- type: input
id: node-version
attributes:
label: Node
placeholder: e.g. 16.12.1
validations:
required: true
- type: input
id: hypervisor-version
attributes:
label: Hypervisor
placeholder: e.g. XCP-ng 8.2.0
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Add any other context about the problem here
validations:
required: false

View File

@@ -1,36 +0,0 @@
name: Changelog
# only pull requests targeting `master`: the check compares the branch to
# `origin/master`, it would report unrelated packages on a PR based on another
# branch
on:
pull_request:
branches:
- master
jobs:
changed-packages:
# the release PR bumps every listed package and empties the list
if: github.head_ref != 'release'
runs-on: ubuntu-latest
steps:
# https://github.com/actions/checkout
- uses: actions/checkout@v4
with:
# the whole history is required to compute the merge base with `master`
fetch-depth: 0
# the check only needs commits and trees: `git diff --name-only
# --no-renames` compares tree entries and never reads file contents,
# skipping the historical blobs halves the fetch (~112 MiB → ~53 MiB)
filter: blob:none
# https://github.com/actions/setup-node
- name: Setup Node environment
uses: actions/setup-node@v4
with:
node-version: '>=22.12'
# the script only uses Node builtins, no `yarn install` necessary
- name: Ensure changed packages are listed in CHANGELOG.unreleased.md
run: ./scripts/check-changed-pkgs.js

View File

@@ -1,50 +1,32 @@
name: Continuous Integration
name: Continous Integration
on: push
jobs:
CI:
runs-on: ubuntu-latest
steps:
# https://github.com/actions/checkout
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Install packages
run: |
sudo apt-get update
sudo apt-get install -y curl qemu-utils python3-vmdkstream git libxml2-utils libfuse2 nbdkit open-iscsi
# https://turbo.build/repo/docs/guides/ci-vendors/github-actions#caching-with-github-actionscache
sudo apt-get install -y curl qemu-utils python3-vmdkstream git libxml2-utils libfuse2 nbdkit
- name: Cache Turbo
uses: actions/cache@v4
# https://github.com/actions/cache
uses: actions/cache@v3
with:
path: '.turbo'
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
# https://github.com/actions/setup-node
path: '**/node_modules/.cache/turbo'
key: ${{ runner.os }}-turbo-cache
- name: Setup Node environment
uses: actions/setup-node@v4
# https://github.com/actions/setup-node
uses: actions/setup-node@v3
with:
node-version: '>=22.12'
node-version: '18'
cache: 'yarn'
- name: Install project dependencies
run: yarn
- name: Ensure packages are sorted in CHANGELOG.unreleased.md
run: ./scripts/gen-deps-list.js --check-order > /dev/null
- name: Ensure yarn.lock is up-to-date
run: git diff --exit-code yarn.lock
- name: Build the project
run: yarn build
- name: Unit tests
run: yarn test-unit
- name: Lint tests
run: yarn test-lint
- name: Integration tests
run: sudo yarn test-integration

View File

@@ -1,73 +0,0 @@
name: Vectura
# only when vectura changes: the rest of the repository does not build it
#
# pull requests rather than every push: GitHub filters a push on the diff with
# the previous push, which after a rebase on `master` (or a merge of it)
# contains the vectura changes of `master`, whereas it filters a pull request
# on the diff with the merge base, i.e. the changes of the branch only
on:
pull_request:
paths:
- '@xen-orchestra/vmware-explorer/vectura/**'
- .github/workflows/vectura.yml
push:
branches:
- master
paths:
- '@xen-orchestra/vmware-explorer/vectura/**'
- .github/workflows/vectura.yml
defaults:
run:
working-directory: '@xen-orchestra/vmware-explorer/vectura'
jobs:
checks:
runs-on: ubuntu-latest
steps:
# https://github.com/actions/checkout
- uses: actions/checkout@v4
# rustup, on the runner image, installs the toolchain rust-toolchain.toml
# names on the first cargo command
- run: cargo fmt --all --check
- run: cargo clippy --all-targets --locked -- -D warnings
- run: cargo test --locked
# https://github.com/taiki-e/install-action
- uses: taiki-e/install-action@v2
with:
tool: cargo-deny@0.20.2,cargo-llvm-cov@0.8.5
- run: cargo deny --locked check
- run: cargo llvm-cov --locked --json --summary-only --output-path target/coverage.json
- name: Hold the coverage floor
run: cargo test --locked --test coverage -- --ignored
# builds the binary as release.sh does, checks it, then checks the one in git,
# which xo-server runs, is that build
binary:
runs-on: ubuntu-latest
steps:
# https://github.com/actions/checkout
- uses: actions/checkout@v4
# https://github.com/mlugg/setup-zig
- uses: mlugg/setup-zig@v2
with:
version: 0.16.0
# https://github.com/taiki-e/install-action
- uses: taiki-e/install-action@v2
with:
tool: cargo-zigbuild@0.23.4
- run: scripts/build.sh "$RUNNER_TEMP/build"
- run: scripts/check-binary.sh "$RUNNER_TEMP/build/vectura"
- name: The binary in git is the build of these sources
run: |
cmp "$RUNNER_TEMP/build/vectura" vectura || {
echo '::error::vectura is not the build of these sources: run scripts/build.sh . in vectura/ and commit the binary'
exit 1
}

7
.gitignore vendored
View File

@@ -5,11 +5,8 @@
/@vates/*/dist/
/@vates/*/node_modules/
/@vates/node-vsphere-soap/test/config-test.mjs
/@xen-orchestra/*/dist/
/@xen-orchestra/*/node_modules/
/@xen-orchestra/rest-api/open-api/spec/swagger.json
/@xen-orchestra/rest-api/src/open-api/routes/routes.ts
/packages/*/dist/
/packages/*/node_modules/
@@ -33,12 +30,8 @@ pnpm-debug.log.*
yarn-error.log
yarn-error.log.*
.env
*.tsbuildinfo
# code coverage
.nyc_output/
coverage/
.turbo/
# https://node-tap.org/dot-tap-folder/
.tap/

View File

@@ -1,11 +0,0 @@
# Ignore Handlebars files because partials are not currently supported
# https://github.com/prettier/prettier/issues/11834
**/*.hbs
# Generated files
/@xen-orchestra/web/src/route-map.d.ts
/@xen-orchestra/lite/src/route-map.d.ts
# Docusaurus MDX content: prettier's markdown parser corrupts JSX
# comments ({/* … */} becomes {/_ … _/}) and other MDX constructs
/docs/docs/**/*.md

View File

@@ -16,7 +16,7 @@ Returns a promise wich rejects as soon as a call to `iteratee` throws or a promi
- `concurrency`: a number which indicates the maximum number of parallel call to `iteratee`, defaults to `10`. The value `0` means no concurrency limit.
- `signal`: an abort signal to stop the iteration
- `stopOnError`: whether to stop iteration of first error, or wait for all calls to finish and throw an `AggregateError`, defaults to `true`
- `stopOnError`: wether to stop iteration of first error, or wait for all calls to finish and throw an `AggregateError`, defaults to `true`
```js
import { asyncEach } from '@vates/async-each'

View File

@@ -34,7 +34,7 @@ Returns a promise wich rejects as soon as a call to `iteratee` throws or a promi
- `concurrency`: a number which indicates the maximum number of parallel call to `iteratee`, defaults to `10`. The value `0` means no concurrency limit.
- `signal`: an abort signal to stop the iteration
- `stopOnError`: whether to stop iteration of first error, or wait for all calls to finish and throw an `AggregateError`, defaults to `true`
- `stopOnError`: wether to stop iteration of first error, or wait for all calls to finish and throw an `AggregateError`, defaults to `true`
```js
import { asyncEach } from '@vates/async-each'

View File

@@ -1,5 +0,0 @@
export function asyncEach<Item>(
iterable: Iterable<Item> | AsyncIterable<Item>,
iteratee: (item: Item, index: number, iterable: Iterable<Item> | AsyncIterable<Item>) => Promise<void>,
opts?: { concurrency?: number; signal?: AbortSignal; stopOnError?: boolean }
): Promise<void>

View File

@@ -11,8 +11,8 @@ class AggregateError extends Error {
/**
* @template Item
* @param {Iterable<Item>|AsyncIterable<Item>} iterable
* @param {(item: Item, index: number, iterable: Iterable<Item>|AsyncIterable<Item>) => Promise<void>} iteratee
* @param {Iterable<Item>} iterable
* @param {(item: Item, index: number, iterable: Iterable<Item>) => Promise<void>} iteratee
* @returns {Promise<void>}
*/
exports.asyncEach = function asyncEach(iterable, iteratee, { concurrency = 10, signal, stopOnError = true } = {}) {
@@ -73,20 +73,20 @@ exports.asyncEach = function asyncEach(iterable, iteratee, { concurrency = 10, s
}
nextIsRunning = true
if (running < concurrency) {
try {
const cursor = await it.next()
if (cursor.done) {
next = () => {
if (running === 0) {
if (errors.length === 0) {
resolve()
} else {
reject(new AggregateError(errors))
}
const cursor = await it.next()
if (cursor.done) {
next = () => {
if (running === 0) {
if (errors.length === 0) {
resolve()
} else {
reject(new AggregateError(errors))
}
}
} else {
++running
}
} else {
++running
try {
const result = iteratee.call(this, cursor.value, index++, iterable)
let then
if (result != null && typeof result === 'object' && typeof (then = result.then) === 'function') {
@@ -94,9 +94,9 @@ exports.asyncEach = function asyncEach(iterable, iteratee, { concurrency = 10, s
} else {
onFulfilled(result)
}
} catch (error) {
onRejected(error)
}
} catch (error) {
onRejected(error)
}
nextIsRunning = false
return next()

View File

@@ -1,6 +1,6 @@
'use strict'
const { describe, it, beforeEach } = require('node:test')
const { describe, it, beforeEach } = require('test')
const assert = require('assert').strict
const { spy } = require('sinon')
@@ -67,17 +67,20 @@ describe('asyncEach', () => {
})
it('stops on first error when stopOnError is true', async () => {
const tracker = new assert.CallTracker()
const error = new Error()
const iteratee = spy((_, i) => {
const iteratee = tracker.calls((_, i) => {
if (i === 1) {
throw error
}
})
}, 2)
assert.deepStrictEqual(
await rejectionOf(asyncEach(iterable, iteratee, { concurrency: 1, stopOnError: true })),
error
)
assert.deepStrictEqual(iteratee.callCount, 2)
tracker.verify()
})
it('rejects AggregateError when stopOnError is false', async () => {
@@ -97,30 +100,19 @@ describe('asyncEach', () => {
})
it('can be interrupted with an AbortSignal', async () => {
const tracker = new assert.CallTracker()
const ac = new AbortController()
const iteratee = spy((_, i) => {
const iteratee = tracker.calls((_, i) => {
if (i === 1) {
ac.abort()
}
})
}, 2)
await assert.rejects(asyncEach(iterable, iteratee, { concurrency: 1, signal: ac.signal }), {
message: 'asyncEach aborted',
})
assert.deepStrictEqual(iteratee.callCount, 2)
})
it('handle error on iterable', async () => {
const iteratee = spy(async () => {})
const iterable = async function* () {
for (const value of [1, 2, 3]) {
if (value === 2) {
throw new Error('iterator error')
}
await randomDelay()
yield value
}
}
await assert.rejects(() => asyncEach(iterable(), iteratee, { concurrency: 1 }), { message: 'iterator error' })
tracker.verify()
})
})
)

View File

@@ -24,16 +24,17 @@
"url": "https://vates.fr"
},
"license": "ISC",
"types": "index.d.ts",
"version": "1.0.3",
"version": "1.0.0",
"engines": {
"node": ">=8.10"
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node --test"
"test": "node--test"
},
"devDependencies": {
"sinon": "^18.0.0"
"sinon": "^15.0.1",
"tap": "^16.3.0",
"test": "^3.2.1"
}
}

View File

@@ -1,6 +1,6 @@
'use strict'
const { describe, it } = require('node:test')
const { describe, it } = require('test')
const assert = require('assert')
const { coalesceCalls } = require('./')

View File

@@ -31,6 +31,9 @@
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node --test"
"test": "node--test"
},
"devDependencies": {
"test": "^3.2.1"
}
}

View File

@@ -1,6 +1,6 @@
'use strict'
const { describe, it } = require('node:test')
const { describe, it } = require('test')
const assert = require('node:assert').strict
const { compose } = require('./')

View File

@@ -20,6 +20,9 @@
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node --test"
"test": "node--test"
},
"devDependencies": {
"test": "^3.2.1"
}
}

View File

@@ -62,42 +62,6 @@ decorateClass(Foo, {
})
```
### `decorateObject(object, map)`
Decorates an object the same way `decorateClass()` decorates a class:
```js
import { decorateObject } from '@vates/decorate-with'
const object = {
get bar() {
// body
},
set bar(value) {
// body
},
baz() {
// body
},
}
decorateObject(object, {
// getter and/or setter
bar: {
// without arguments
get: lodash.memoize,
// with arguments
set: [lodash.debounce, 150],
},
// method (with or without arguments)
baz: lodash.curry,
})
```
### `perInstance(fn, ...args)`
Helper to decorate the method by instance instead of for the whole class.

View File

@@ -80,42 +80,6 @@ decorateClass(Foo, {
})
```
### `decorateObject(object, map)`
Decorates an object the same way `decorateClass()` decorates a class:
```js
import { decorateObject } from '@vates/decorate-with'
const object = {
get bar() {
// body
},
set bar(value) {
// body
},
baz() {
// body
},
}
decorateObject(object, {
// getter and/or setter
bar: {
// without arguments
get: lodash.memoize,
// with arguments
set: [lodash.debounce, 150],
},
// method (with or without arguments)
baz: lodash.curry,
})
```
### `perInstance(fn, ...args)`
Helper to decorate the method by instance instead of for the whole class.

View File

@@ -14,13 +14,10 @@ function applyDecorator(decorator, value) {
}
exports.decorateClass = exports.decorateMethodsWith = function decorateClass(klass, map) {
return decorateObject(klass.prototype, map)
}
function decorateObject(object, map) {
const { prototype } = klass
for (const name of Object.keys(map)) {
const decorator = map[name]
const descriptor = getOwnPropertyDescriptor(object, name)
const descriptor = getOwnPropertyDescriptor(prototype, name)
if (typeof decorator === 'function' || Array.isArray(decorator)) {
descriptor.value = applyDecorator(decorator, descriptor.value)
} else {
@@ -33,11 +30,10 @@ function decorateObject(object, map) {
}
}
defineProperty(object, name, descriptor)
defineProperty(prototype, name, descriptor)
}
return object
return klass
}
exports.decorateObject = decorateObject
exports.perInstance = function perInstance(fn, decorator, ...args) {
const map = new WeakMap()

View File

@@ -1,7 +1,7 @@
'use strict'
const assert = require('assert')
const { describe, it } = require('node:test')
const { describe, it } = require('test')
const { decorateClass, decorateWith, decorateMethodsWith, perInstance } = require('./')
@@ -13,15 +13,12 @@ describe('decorateWith', () => {
const expectedFn = Function.prototype
const newFn = () => {}
const decorator = decorateWith(
function wrapper(fn, ...args) {
assert.deepStrictEqual(fn, expectedFn)
assert.deepStrictEqual(args, expectedArgs)
const decorator = decorateWith(function wrapper(fn, ...args) {
assert.deepStrictEqual(fn, expectedFn)
assert.deepStrictEqual(args, expectedArgs)
return newFn
},
...expectedArgs
)
return newFn
}, ...expectedArgs)
const descriptor = {
configurable: true,

View File

@@ -20,12 +20,15 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "2.1.0",
"version": "2.0.0",
"engines": {
"node": ">=8.10"
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node --test"
"test": "node--test"
},
"devDependencies": {
"test": "^3.2.1"
}
}

View File

@@ -7,7 +7,7 @@
* @param {Array|Buffer|string} data1 - The first data array or buffer to compare.
* @param {T} data2 - The second data array or buffer to compare.
* @param {(index: number, diff: T) => void} [cb] - The callback function to invoke for each difference. If not provided, an array of differences will be returned.
* @returns {Array<number|T>|undefined} - An array of differences if no callback is provided; otherwise, undefined.
* @returns {Array<number|T>|undefined} - An array of differences if no callback is provided, otherwise undefined.
*/
module.exports = function diff(data1, data2, cb) {
let result

View File

@@ -1,7 +1,7 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('node:test')
const test = require('test')
const diff = require('./index.js')

View File

@@ -28,6 +28,9 @@
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node --test"
"test": "node--test"
},
"devDependencies": {
"test": "^3.3.0"
}
}

View File

@@ -14,7 +14,7 @@ import { deduped } from '@vates/disposable/deduped'
// the connection with the passed host will be established once at the first call, then, it will be shared with the next calls
const getConnection = deduped(async function (host)) {
const connection = new Connection(host)
return new Disposable(connection, () => connection.close())
return new Disposabe(connection, () => connection.close())
}, host => [host])
```

View File

@@ -32,7 +32,7 @@ import { deduped } from '@vates/disposable/deduped'
// the connection with the passed host will be established once at the first call, then, it will be shared with the next calls
const getConnection = deduped(async function (host)) {
const connection = new Connection(host)
return new Disposable(connection, () => connection.close())
return new Disposabe(connection, () => connection.close())
}, host => [host])
```

View File

@@ -1,6 +1,6 @@
'use strict'
const { describe, it } = require('node:test')
const { describe, it } = require('test')
const { useFakeTimers, spy, assert } = require('sinon')
const { createDebounceResource } = require('./debounceResource')

View File

@@ -1,6 +1,6 @@
'use strict'
const { describe, it } = require('node:test')
const { describe, it } = require('test')
const { spy, assert } = require('sinon')
const { deduped } = require('./deduped')

View File

@@ -14,21 +14,22 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.6",
"version": "0.1.4",
"engines": {
"node": ">=8.10"
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node --test"
"test": "node--test"
},
"dependencies": {
"@vates/multi-key-map": "^0.2.0",
"@xen-orchestra/async-map": "^0.1.5",
"@xen-orchestra/log": "^0.7.2",
"@vates/multi-key-map": "^0.1.0",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"ensure-array": "^1.0.0"
},
"devDependencies": {
"sinon": "^18.0.0"
"sinon": "^15.0.1",
"test": "^3.2.1"
}
}

View File

@@ -20,9 +20,6 @@ function assertListeners(t, event, listeners) {
}
t.beforeEach(function (t) {
// work around https://github.com/tapjs/tapjs/issues/998
t.context = {}
t.context.ee = new EventEmitter()
t.context.em = new EventListenersManager(t.context.ee)
})

View File

@@ -38,9 +38,9 @@
"version": "1.0.1",
"scripts": {
"postversion": "npm publish --access public",
"test": "tap --allow-incomplete-coverage"
"test": "tap --branches=72"
},
"devDependencies": {
"tap": "^18.7.0"
"tap": "^16.2.0"
}
}

View File

@@ -1,127 +0,0 @@
# @vates/fatfs
This is a fork of the original library [natevw/fatfs](https://github.com/natevw/fatfs).
## Why?
This fork was created to add a missing feature in the original library: the ability to create labels (createLabel).
The original library did not support this functionality, which was required to make Cloudbase-Init work on windows VMs.
See: https://github.com/natevw/fatfs/issues/30, https://github.com/natevw/fatfs/pull/31
A standalone FAT16/FAT32 implementation that takes in a block-access interface and exposes something quite similar to `require('fs')` (i.e. the node.js built-in [Filesystem API](http://nodejs.org/api/fs.html)).
## Installation
`npm install fatfs`
## Example
```js
var fatfs = require('fatfs'),
fs = fatfs.createFileSystem(exampleDriver) // see below
fs.stat('autoexec.bat', function (e, stats) {
if (e) console.error(e)
else console.log(stats)
})
// TODO: open a file and write to it or something…
```
## API
- `fs = fatfs.createFileSystem(vol, [opts], [cb])` — Simply pass in a block driver (see below) mapped to a FAT partition somewhere, and get back an instance of an [`fs`](http://nodejs.org/api/fs.html). An options dictionary can be provided, details are in the next section. You may also optionally provide a callback `cb(err)` which will be automatically registered for the on `'ready'` or `'error'` event.
- `'ready'` event — fired on `fs` when initial volume information has been determined and the API is ready to use. It is safe to call other `fs` methods before this fires **only if** you are sure the first sector will be readable and represents a valid FAT volume.
- `'error'` event — fired if initialization fails for whatever reason.
### Filesystem options
The `opts` dictionary you pass to `fatfs.createFileSystem` can contain any of the following options:
- `ro` — Enables readonly mode if `true`. It defaults to `false`, but if your volume driver does not provide a `writeSectors` method it will be overridden to `true`.
- `noatime` — The FAT filesystem can track the last access time (just a date, actually) but this means every read operation would also incur some write overhead. Defaults to `true`, meaning by default access times will **not** be updated on reads. Set this to `false` to track access times.
- `modmode` — chooses how `fs.chmod` (and the mode field from `fs.stat`–family calls) should map FAT attributes to POSIX permissions. Set to the number `0111` to map the readonly flag to the user's write bit being unset, and the archive/system/hidden flags to the user/group/other executable bits respectively. Set to the number `07000` to map the readonly flag to _all_ write bits being unset, and the archive/system/hidden flags to the sticky/setgid/setuid bits respectively. Set to `null` for readonly mapping. Defaults to `0111`.
- `umask` — any bits _set_ in this octal number will be _unset_ in the 'mode' field from `fs.stat`–family calls. It does not affect anything else. Defaults to `process.umask()`, or `0022` if that is unavailable.
- `uid` — This value will be returned as the 'uid' stat field. It does not affect anything else. Defaults to `process.getuid()`, or `0` if that is unavailable.
- `gid` — This value will be returned as the 'gid' stat field. It does not affect anything else. Defaults to `process.getgid()`, or `0` if that is unavailable.
(Note that these are similar to the options you could use with a POSIX `mount` operation.)
And that's it! The [rest of the API](http://nodejs.org/api/fs.html) (`fs.readdir`, `fs.open`, `fs.createReadStream`, `fs.appendFile`, etc.) is as documented by the node.js project.
Well, sort of…
## Caveats
### Temporary
- **BETA** **BETA** **BETA**. Seriously, this is a _brand new_, _from scratch_, _completely unproven_ filesystem implementation. It does not have full automated test coverage, and it has not been manually tested very much either. Please, please, please, **make sure you have a backup** of any important drive/image/card you unleash this upon.
- A few methods are not quite implemented, either: `fs.rename`, `fs.unlink` and `fs.rmdir`, as well as `fs.watchFile`/`fs.unwatchFile` and `fs.watch`. These are Coming Soon™.
- There are several internal housekeeping items (redundant FAT tables, extra FAT32 information, etc.) that are not done. These do not seem to affect interop, but you may see warnings when repairing a filesystem written by this module.
- Oh, and not to scare you, but if an IO error happens while writing, the library usually just bails — bubbling an error up to your callback as if it were a hot potato. Although some attempt has been made to do separate writes in the safest order (e.g. allocating an additional file cluster, then appending data into it, and then finally updating the file's size), but this behavior has not been thoroughly audited for all operations. There's certainly no attempt to retry/cleanup/rollback if a multi-step change runs into trouble partway through.
### As-planned
Some of the differences between `fatfs` and the node.js `fs` module are "by design" for architectural simplicity and/or due to underlying FAT limitations.
- There are no `fs.*Sync` methods. (The volume driver is async, not to mention that supporting a separate \*Sync codepath would be an enormous duplication of effort of dubious value.)
- This module does [almost] no read/write caching. This should be done in your volume driver, but see notes below.
- You'll need multiple `createFileSystem` instances for multiple volumes; paths are relative to each, and don't share a namespace.
- The FAT filesystem has no concept of symlinks, and hardlinks are not really an intentional feature. You will get an ENOSYS-like error when trying to create either type of link.
## "Volume driver" API
To use 'fatfs', you must provide a driver object with the following properties/methods:
- `driver.sectorSize` — number of bytes per sector on this device
- `driver.numSectors` — count of sectors available on this media
- `driver.readSectors(i, dest, cb)` — Fill `dest` with data starting at the `i`th sector and notify `cb(e)` when finished. You may assume `dest.length` is a multiple of `driver.sectorSize`.
- `driver.writeSectors(i, data, cb)` — (optional) Write `data` starting at the `i`th sector and notify `cb(e)` when finished. You may assume `data.length` is a multiple of `driver.sectorSize`.
If you do not provide a `writeSectors` method, then `fatfs` will work in readonly mode. Pretty simple, eh? And the 'fatfs' module makes a good effort to check the parameters passed to your driver methods!
**TBD:** to facilitate proper cache handling, this module might add an optional `driver.flush(cb)` method at some point in the future.
Here's an example taken from code used to run this module's own tests:
```js
// NOTE: this assumes image at `path` has no partition table.
// If it did, you'd need to translate positions, natch…
var fs = require('fs')
exports.createDriverSync = function (path, opts) {
opts || (opts = {})
var secSize = 512,
ro = opts.readOnly || false,
fd = fs.openSync(path, ro ? 'r' : 'r+'),
s = fs.fstatSync(fd)
return {
sectorSize: secSize,
numSectors: s.size / secSize,
readSectors: function (i, dest, cb) {
if (dest.length % secSize) throw Error('Unexpected buffer length!')
fs.read(fd, dest, 0, dest.length, i * secSize, function (e, n, d) {
cb(e, d)
})
},
writeSectors: ro
? null
: function (i, data, cb) {
if (data.length % secSize) throw Error('Unexpected buffer length!')
fs.write(fd, data, 0, data.length, i * secSize, function (e) {
cb(e)
})
},
}
}
```
## License
© 2014 Nathan Vander Wilt.
Funding for this work was provided by Technical Machine, Inc.
Reuse under your choice of:
- [BSD-2-Clause](http://opensource.org/licenses/BSD-2-Clause)
- [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0.html)

View File

@@ -1,60 +0,0 @@
var _ = require("./helpers.js");
exports.wrapDriver = function (volume, opts) {
opts = _.extend({
maxSectors: 2048
}, opts);
var cache = {},
advice = 'NORMAL',
secSize = volume.sectorSize;
function _freezeBuffer(b) {
var f = _.allocBuffer(b.length);
b.copy(f);
return f;
}
function addToCache(i, data) {
if (advice === 'SEQUENTIAL' || advice === 'NOREUSE') return;
data = _freezeBuffer(data);
cache[i] = data;
//if (data.length > secSize) addToCache(i+1, data.slice(secSize));
while (data.length > secSize) {
data = data.slice(secSize);
cache[++i] = data;
}
// simple highest-sectors-lose eviction policy for now
Object.keys(cache).sort().slice(opts.maxSectors).forEach(function (x) {
delete cache[x];
});
_.log(_.log.DBG, "Cache now contains:", Object.keys(cache).join(','));
}
return {
sectorSize: volume.sectorSize,
numSectors: volume.numSectors,
advice: function (val) {
if (!arguments.length) return advice;
else advice = val;
if (advice === 'SEQUENTIAL' || advice === 'NOREUSE') cache = {};
return this;
},
readSectors: function (i, dest, cb) {
// TODO: handle having partial parts of dest!
if (i in cache && dest.length === secSize) {
cache[i].copy(dest);
setImmediate(cb);
} else volume.readSectors(i, dest, function (e) {
if (e) cb(e);
else addToCache(i, dest), cb();
});
},
writeSectors: (!volume.writeSectors) ? null : function (i, data, cb) {
volume.writeSectors(i, data, function (e) {
if (e) cb(e);
else addToCache(i, data), cb();
});
}
};
};

View File

@@ -1,287 +0,0 @@
var S = require("./structs.js"),
_ = require("./helpers.js");
function _baseChain(vol) {
var chain = {};
chain.sectorSize = vol._sectorSize;
function posFromOffset(off) {
var secSize = chain.sectorSize,
offset = off % secSize,
sector = (off - offset) / secSize;
return {sector:sector, offset:offset};
}
var sectorCache = vol._makeCache();
Object.defineProperty(chain, 'cacheAdvice', {
enumerable: true,
get: function () { return sectorCache.advice(); },
set: function (v) { sectorCache.advice(v); }
});
chain._vol_readSectors = vol._readSectors.bind(vol, sectorCache);
chain._vol_writeSectors = vol._writeSectors.bind(vol, sectorCache);
// cb(error, bytesRead, buffer)
chain.readFromPosition = function (targetPos, buffer, cb) {
if (typeof targetPos === 'number') targetPos = posFromOffset(targetPos);
if (typeof buffer === 'number') buffer = _.allocBuffer(buffer);
/* NOTE: to keep our contract with the volume driver, we need to read on _full_ sector boundaries!
So we divide the read into [up to] three parts: {preface, main, trailer}
This is kind of unfortunate, but in practice should often still be reasonably efficient. */
if (targetPos.offset) chain.readSectors(targetPos.sector, _.allocBuffer(chain.sectorSize), function (e,d) {
if (e || !d) cb(e, 0, buffer);
else { // copy preface into `buffer`
var dBeg = targetPos.offset,
dEnd = dBeg + buffer.length;
d.copy(buffer, 0, dBeg, dEnd);
if (dEnd > d.length) readMain();
else cb(null, buffer.length, buffer);
}
}); else readMain();
function readMain() {
var prefaceLen = targetPos.offset && (chain.sectorSize - targetPos.offset),
trailerLen = (buffer.length - prefaceLen) % chain.sectorSize,
mainSector = (prefaceLen) ? targetPos.sector + 1 : targetPos.sector,
mainBuffer = (trailerLen) ? buffer.slice(prefaceLen, -trailerLen) : buffer.slice(prefaceLen);
if (mainBuffer.length) chain.readSectors(mainSector, mainBuffer, function (e,d) {
if (e || !d) cb(e, prefaceLen, buffer);
else if (!trailerLen) cb(null, buffer.length, buffer);
else readTrailer();
}); else readTrailer();
function readTrailer() {
var trailerSector = mainSector + (mainBuffer.length / chain.sectorSize);
chain.readSectors(trailerSector, _.allocBuffer(chain.sectorSize), function (e,d) {
if (e || !d) cb(e, buffer.length-trailerLen, buffer);
else {
d.copy(buffer, buffer.length-trailerLen, 0, trailerLen);
cb(null, buffer.length, buffer);
}
});
}
}
};
// cb(error)
chain.writeToPosition = function (targetPos, data, cb) {
_.log(_.log.DBG, "WRITING", data.length, "bytes at", targetPos, "in", this.toJSON(), data);
if (typeof targetPos === 'number') targetPos = posFromOffset(targetPos);
var prefaceBuffer = (targetPos.offset) ? data.slice(0, chain.sectorSize-targetPos.offset) : null;
if (prefaceBuffer) _modifySector(targetPos.sector, targetPos.offset, prefaceBuffer, function (e) {
if (e) cb(e);
else if (prefaceBuffer.length < data.length) writeMain();
else cb();
}); else writeMain();
function writeMain() {
var prefaceLen = (prefaceBuffer) ? prefaceBuffer.length : 0,
trailerLen = (data.length - prefaceLen) % chain.sectorSize,
mainSector = (prefaceLen) ? targetPos.sector + 1 : targetPos.sector,
mainBuffer = (trailerLen) ? data.slice(prefaceLen, -trailerLen) : data.slice(prefaceLen);
if (mainBuffer.length) chain.writeSectors(mainSector, mainBuffer, function (e) {
if (e) cb(e);
else if (!trailerLen) cb();
else writeTrailer();
}); else writeTrailer();
function writeTrailer() {
var trailerSector = mainSector + (mainBuffer.length / chain.sectorSize),
trailerBuffer = data.slice(data.length-trailerLen); // WORKAROUND: https://github.com/tessel/runtime/issues/721
_modifySector(trailerSector, 0, trailerBuffer, cb);
}
}
function _modifySector(sec, off, data, cb) {
chain.readSectors(sec, _.allocBuffer(chain.sectorSize), function (e, orig) {
if (e) return cb(e);
orig || (orig = _.allocBuffer(chain.sectorSize, 0));
data.copy(orig, off);
chain.writeSectors(sec, orig, cb);
});
}
};
return chain;
};
exports.clusterChain = function (vol, firstCluster, _parent) {
var chain = _baseChain(vol),
cache = [firstCluster];
chain.firstCluster = firstCluster;
function _cacheIsComplete() {
return cache[cache.length-1] === 'eof';
}
function extendCacheToInclude(i, cb) { // NOTE: may `cb()` before returning!
if (i < cache.length) cb(null, cache[i]);
else if (_cacheIsComplete()) cb(null, 'eof');
else vol.fetchFromFAT(cache[cache.length-1], function (e,d) {
if (e) cb(e);
else if (typeof d === 'string' && d !== 'eof') cb(S.err.IO());
else {
cache.push(d);
extendCacheToInclude(i, cb);
}
});
}
function expandChainToLength(clusterCount, cb) {
if (!_cacheIsComplete()) throw Error("Must be called only when cache is complete!");
else cache.pop(); // remove 'eof' entry until finished
function addCluster(clustersNeeded, lastCluster) {
if (!clustersNeeded) cache.push('eof'), cb();
else vol.allocateInFAT(lastCluster, function (e, newCluster) {
if (e) cb(e);
else vol.storeToFAT(lastCluster, newCluster, function (e) {
if (e) return cb(e);
cache.push(newCluster);
addCluster(clustersNeeded-1, newCluster);
});
});
}
addCluster(clusterCount - cache.length, cache[cache.length - 1]);
}
function shrinkChainToLength(clusterCount, cb) {
if (!_cacheIsComplete()) throw Error("Must be called only when cache is complete!");
else cache.pop(); // remove 'eof' entry until finished
function removeClusters(count, cb) {
if (!count) cache.push('eof'), cb();
else vol.storeToFAT(cache.pop(), 'free', function (e) {
if (e) cb(e);
else removeClusters(count - 1, cb);
});
}
// NOTE: for now, we don't remove the firstCluster ourselves; we should though!
if (clusterCount) removeClusters(cache.length - clusterCount, cb);
else removeClusters(cache.length - 1, cb);
}
// [{firstSector,numSectors},{firstSector,numSectors},…]
function determineSectorGroups(sectorIdx, numSectors, alloc, cb) {
var sectorOffset = sectorIdx % vol._sectorsPerCluster,
clusterIdx = (sectorIdx - sectorOffset) / vol._sectorsPerCluster,
numClusters = Math.ceil((numSectors + sectorOffset) / vol._sectorsPerCluster),
chainLength = clusterIdx + numClusters;
extendCacheToInclude(chainLength-1, function (e,c) {
if (e) cb(e);
else if (c === 'eof' && alloc) expandChainToLength(chainLength, function (e) {
if (e) cb(e);
else _determineSectorGroups();
});
else _determineSectorGroups();
});
function _determineSectorGroups() {
// …now we have a complete cache
var groups = [],
_group = null;
for (var i = clusterIdx; i < chainLength; ++i) {
var c = (i < cache.length) ? cache[i] : 'eof';
if (c === 'eof') break;
else if (_group && c !== _group._nextCluster) {
groups.push(_group);
_group = null;
}
if (!_group) _group = {
_nextCluster: c+1,
firstSector: vol._firstSectorOfCluster(c) + sectorOffset,
numSectors: vol._sectorsPerCluster - sectorOffset
}; else {
_group._nextCluster += 1;
_group.numSectors += vol._sectorsPerCluster;
}
sectorOffset = 0; // only first group is offset
}
if (_group) groups.push(_group);
cb(null, groups, i === chainLength);
}
}
chain.readSectors = function (i, dest, cb) {
var groupOffset = 0, groupsPending;
determineSectorGroups(i, dest.length / chain.sectorSize, false, function (e, groups, complete) {
if (e) cb(e);
else if (!complete) groupsPending = -1, _pastEOF(cb);
else if ((groupsPending = groups.length)) groups.forEach(function (group) {
var groupLength = group.numSectors * chain.sectorSize,
groupBuffer = dest.slice(groupOffset, groupOffset += groupLength);
chain._vol_readSectors(group.firstSector, groupBuffer, function (e,d) {
if (e && groupsPending !== -1) groupsPending = -1, cb(e);
else if (--groupsPending === 0) cb(null, dest);
});
});
else cb(null, dest); // 0-length destination case
});
};
// TODO: does this handle NOSPC condition?
chain.writeSectors = function (i, data, cb) {
var groupOffset = 0, groupsPending;
determineSectorGroups(i, data.length / chain.sectorSize, true, function (e, groups) {
if (e) cb(e);
else if ((groupsPending = groups.length)) groups.forEach(function (group) {
var groupLength = group.numSectors * chain.sectorSize,
groupBuffer = data.slice(groupOffset, groupOffset += groupLength);
chain._vol_writeSectors(group.firstSector, groupBuffer, function (e) {
if (e && groupsPending !== -1) groupsPending = -1, cb(e);
else if (--groupsPending === 0) cb();
});
});
else cb(); // 0-length data case
});
};
chain.truncate = function (numSectors, cb) {
extendCacheToInclude(Infinity, function (e,c) {
if (e) return cb(e);
var currentLength = cache.length-1,
clustersNeeded = Math.ceil(numSectors / vol._sectorsPerCluster);
if (clustersNeeded < currentLength) shrinkChainToLength(clustersNeeded, cb);
else if (clustersNeeded > currentLength) expandChainToLength(clustersNeeded, cb);
else cb();
});
};
chain.toJSON = function () {
return {firstCluster:firstCluster};
};
return chain;
};
exports.sectorChain = function (vol, firstSector, numSectors) {
var chain = _baseChain(vol);
chain.firstSector = firstSector;
chain.numSectors = numSectors;
chain.readSectors = function (i, dest, cb) {
if (i < numSectors) chain._vol_readSectors(firstSector+i, dest, cb);
else _pastEOF(cb);
};
chain.writeSectors = function (i, data, cb) {
if (i < numSectors) chain._vol_writeSectors(firstSector+i, data, cb);
else _.delayedCall(cb, S.err.NOSPC());
};
chain.truncate = function (i, cb) {
_.delayedCall(cb, S.err.INVAL());
};
chain.toJSON = function () {
return {firstSector:firstSector, numSectors:numSectors};
};
return chain;
};
// NOTE: used with mixed feelings, broken out to mark uses
function _pastEOF(cb) { _.delayedCall(cb, null, null); }

View File

@@ -1,372 +0,0 @@
var S = require("./structs.js"),
_ = require("./helpers.js");
var dir = exports;
dir.iterator = function (dirChain, opts) {
opts || (opts = {});
var cache = {buffer:null, n: null};
function getSectorBuffer(n, cb) {
if (cache.n === n) cb(null, cache.buffer);
else cache.n = cache.buffer = null, dirChain.readSectors(n, _.allocBuffer(dirChain.sectorSize), function (e,d) {
if (e) cb(e);
else if (!d) return cb(null, null);
else {
cache.n = n;
cache.buffer = d;
getSectorBuffer(n, cb);
}
});
}
var secIdx = 0,
off = {bytes:0},
long = null;
function getNextEntry(cb) {
if (off.bytes >= dirChain.sectorSize) {
secIdx += 1;
off.bytes -= dirChain.sectorSize;
}
var entryPos = {chain:dirChain, sector:secIdx, offset:off.bytes};
getSectorBuffer(secIdx, function (e, buf) {
if (e) return cb(S.err.IO());
else if (!buf) return cb(null, null, entryPos);
var entryIdx = off.bytes,
signalByte = buf[entryIdx];
if (signalByte === S.entryDoneFlag) return cb(null, null, entryPos);
else if (signalByte === S.entryFreeFlag) {
off.bytes += S.dirEntry.size;
long = null;
if (opts.includeFree) return cb(null, {_free:true,_pos:entryPos}, entryPos);
else return getNextEntry(cb); // usually just skip these
}
var attrByte = buf[entryIdx+S.dirEntry.fields.Attr.offset],
entryType = (attrByte === S.longDirFlag) ? S.longDirEntry : S.dirEntry_simple;
var entry = entryType.valueFromBytes(buf, off);
entry._pos = entryPos;
_.log(_.log.DBG, "entry:", entry, secIdx, entryIdx);
if (entryType === S.longDirEntry) {
var firstEntry;
if (entry.Ord & S.lastLongFlag) {
firstEntry = true;
entry.Ord &= ~S.lastLongFlag;
long = {
name: -1,
sum: entry.Chksum,
_rem: entry.Ord-1,
_arr: []
}
}
if (firstEntry || long && entry.Chksum === long.sum && entry.Ord === long._rem--) {
var S_lde_f = S.longDirEntry.fields,
namepart = entry.Name1;
if (entry.Name1.length === S_lde_f.Name1.size/2) {
namepart += entry.Name2;
if (entry.Name2.length === S_lde_f.Name2.size/2) {
namepart += entry.Name3;
}
}
long._arr.push(namepart);
if (!long._rem) {
long.name = long._arr.reverse().join('');
delete long._arr;
delete long._rem;
}
} else long = null;
} else if ((attrByte & 0x08) === 0) { // NOTE: checks `!entry.Attr.volume_id`
var bestName = null;
if (long && long.name) {
var pos = entryIdx + S.dirEntry.fields['Name'].offset,
sum = _.checksumName(buf, pos);
if (sum === long.sum) bestName = long.name;
}
if (!bestName) {
if (signalByte === S.entryIsE5Flag) entry.Name.filename = '\u00E5'+entry.Name.filename.slice(1);
var nam = entry.Name.filename.replace(/ +$/, ''),
ext = entry.Name.extension.replace(/ +$/, '');
// TODO: lowercase bits http://en.wikipedia.org/wiki/8.3_filename#Compatibility
// via NTRes, bits 0x08 and 0x10 http://www.fdos.org/kernel/fatplus.txt.1
bestName = (ext) ? nam+'.'+ext : nam;
}
entry._name = bestName;
// OPTIMIZATION: avoid processing any fields for non-matching entries
// TODO: we could make this automatic via getters, but…?
var _entryBuffer = buf.slice(off.bytes-S.dirEntry.size, off.bytes);
entry._full = function () {
var _entry = S.dirEntry.valueFromBytes(_entryBuffer);
_.extend(entry, _entry);
entry._size = entry.FileSize;
entry._firstCluster = (entry.FstClusHI << 16) + entry.FstClusLO;
return entry;
};
long = null;
return cb(null, entry, entryPos);
} else long = null;
getNextEntry(cb);
});
}
function iter(cb) {
getNextEntry(cb);
return iter; // TODO: previous value can't be re-used, so why make caller re-assign?
}
return iter;
};
function _updateEntry(vol, entry, newStats) {
if ('size' in newStats) entry._size = entry.FileSize = newStats.size;
if ('_touch' in newStats) newStats.archive = newStats.atime = newStats.mtime = true;
if ('archive' in newStats) entry.Attr.archive = true; // TODO: also via newStats.mode?
var _now;
function applyDate(d, prefix, timeToo, tenthToo) {
if (d === true) d = _now || (_now = new Date());
entry[prefix+'Date'] = {year:d.getFullYear()-1980, month:d.getMonth()+1, day:d.getDate()};
if (timeToo) {
entry[prefix+'Time'] = {hours:d.getHours(), minutes:d.getMinutes(), seconds_2:d.getSeconds()>>>1};
if (tenthToo) {
var msec = (d.getSeconds() % 2)*1000 + d.getMilliseconds();
entry[prefix+'TimeTenth'] = Math.floor(msec / 100);
}
}
}
if ('ctime' in newStats) applyDate(newStats.ctime, 'Crt', true, true);
if ('mtime' in newStats) applyDate(newStats.mtime, 'Wrt', true);
if ('atime' in newStats) applyDate(newStats.atime, 'LstAcc');
if ('mode' in newStats) {
entry.Attr.directory = (newStats.mode & S._I.FDIR) ? true : false;
entry.Attr.volume_id = (newStats.mode & S._I.FREG) ? false : true;
if (vol.opts.modmode === 0111) {
entry.Attr.archive = (newStats.mode & S._I.XUSR) ? true : false;
entry.Attr.system = (newStats.mode & S._I.XGRP) ? true : false;
entry.Attr.hidden = (newStats.mode & S._I.XOTH) ? true : false;
entry.Attr.readonly = (newStats.mode & S._I.WUSR) ? false : true;
} else if (vol.opts.modmode === 07000) {
entry.Attr.archive = (newStats.mode & S._I.SVTX) ? true : false;
entry.Attr.system = (newStats.mode & S._I.SGID) ? true : false;
entry.Attr.hidden = (newStats.mode & S._I.SUID) ? true : false;
entry.Attr.readonly = (
newStats.mode & S._I.WUSR ||
newStats.mode & S._I.WGRP ||
newStats.mode & S._I.WOTH
) ? false : true;
}
}
if ('firstCluster' in newStats) {
entry.FstClusLO = newStats.firstCluster & 0xFFFF;
entry.FstClusHI = newStats.firstCluster >>> 16;
entry._firstCluster = newStats.firstCluster;
}
return entry;
}
dir.makeStat = function (vol, entry) {
var stats = {}; // TODO: return an actual `instanceof fs.Stat` somehow?
stats.isFile = function () {
return (!entry.Attr.volume_id && !entry.Attr.directory);
};
stats.isDirectory = function () {
return entry.Attr.directory;
};
stats.isBlockDevice = function () { return false; }
stats.isCharacterDevice = function () { return false; }
stats.isSymbolicLink = function () { return false; }
stats.isFIFO = function () { return false; }
stats.isSocket = function () { return false; }
stats.size = entry.FileSize;
stats.blksize = vol._sectorsPerCluster*vol._sectorSize;
stats.blocks = Math.ceil(stats.size / stats.blksize) || 1;
stats.nlink = 1;
stats.mode = S._I.RUSR | S._I.RGRP | S._I.ROTH;
if (!entry.Attr.readonly) stats.mode |= S._I.WUSR | S._I.WGRP | S._I.WOTH;
if (entry.Attr.directory) stats.mode |= S._I.FDIR;
else if (!entry.Attr.volume_id) stats.mode |= S._I.FREG;
// NOTE: discussion at https://github.com/natevw/fatfs/issues/7
if (vol.opts.modmode === 0111) {
// expose using executable bits, like Samba
if (entry.Attr.archive) stats.mode |= S._I.XUSR;
if (entry.Attr.system) stats.mode |= S._I.XGRP;
if (entry.Attr.hidden) stats.mode |= S._I.XOTH;
} else if (vol.opts.modmode === 07000) {
// expose using setXid/sticky bits, like MKS
if (entry.Attr.archive) stats.mode |= S._I.SVTX;
if (entry.Attr.system) stats.mode |= S._I.SGID;
if (entry.Attr.hidden) stats.mode |= S._I.SUID;
}
stats.mode &= ~vol.opts.umask;
stats.uid = vol.opts.uid;
stats.gid = vol.opts.gid;
function extractDate(prefix) {
var date = entry[prefix+'Date'],
time = entry[prefix+'Time'] || {hours:0, minutes:0, seconds_2:0},
secs = time.seconds_2 * 2,
sect = entry[prefix+'TimeTenth'] || 0;
if (sect > 100) {
secs += 1;
sect -= 100;
}
return new Date(date.year+1980, date.month-1, date.day, time.hours, time.minutes, secs, sect*100);
}
stats.atime = extractDate('LstAcc');
stats.mtime = extractDate('Wrt');
stats.ctime = extractDate('Crt');
entry = { // keep immutable copy (with only the fields we need)
Attr: _.extend({},entry.Attr),
};
return stats;
};
dir.init = function (vol, dirInfo, cb) {
var dirChain = dirInfo.chain,
isRootDir = ('numSectors' in dirChain), // HACK: all others would be a clusterChain
initialCluster = _.allocBuffer(dirChain.sectorSize*vol._sectorsPerCluster),
entriesOffset = {bytes:0};
initialCluster.fill(0);
function writeEntry(name, clusterNum) {
while (name.length < 8) name += " ";
S.dirEntry.bytesFromValue(_updateEntry(vol, {
Name: {filename:name, extension:" "},
Attr: {directory:true}
}, {firstCluster:clusterNum, _touch:true,ctime:true}), initialCluster, entriesOffset);
}
if (!isRootDir) {
writeEntry(".", dirChain.firstCluster);
writeEntry("..", dirInfo.parent.chain.firstCluster);
};
dirChain.writeToPosition(0, initialCluster, cb);
};
dir.addFile = function (vol, dirChain, entryInfo, opts, cb) {
if (typeof opts === 'function') {
cb = opts;
opts = {};
}
var name = entryInfo.name,
entries = [], mainEntry;
entries.push(mainEntry = {
Name: _.shortname(name),
Attr: {directory:opts.dir||false},
_name: name
});
if (1 || mainEntry.Name._lossy) { // HACK: always write long names until `._lossy` is more useful!
var workaroundTessel427 = ('\uFFFF'.length !== 1);
if (workaroundTessel427) throw Error("Your JS runtime does not have proper Unicode string support. (If Tessel, is your firmware up-to-date?)");
// name entries should be 0x0000-terminated and 0xFFFF-filled
var S_lde_f = S.longDirEntry.fields,
ENTRY_CHUNK_LEN = (S_lde_f.Name1.size + S_lde_f.Name2.size + S_lde_f.Name3.size)/2,
partialLen = name.length % ENTRY_CHUNK_LEN,
paddingNeeded = partialLen && (ENTRY_CHUNK_LEN - partialLen);
if (paddingNeeded--) name += '\u0000';
while (paddingNeeded-- > 0) name += '\uFFFF';
// now fill in as many entries as it takes
var off = 0,
ord = 1;
while (off < name.length) entries.push({
Ord: ord++,
Name1: name.slice(off, off+=S_lde_f.Name1.size/2),
Attr_raw: S.longDirFlag,
Chksum: null,
Name2: name.slice(off, off+=S_lde_f.Name2.size/2),
Name3: name.slice(off, off+=S_lde_f.Name3.size/2)
});
entries[entries.length - 1].Ord |= S.lastLongFlag;
}
if (entryInfo.tail) {
var name = mainEntry.Name.filename,
suffix = '~'+entryInfo.tail,
endIdx = name.indexOf(' '),
sufIdx = (~endIdx) ? Math.min(endIdx, name.length-suffix.length) : name.length-suffix.length;
if (sufIdx < 0) return cb(S.err.NAMETOOLONG()); // TODO: would EXIST be more correct?
mainEntry.Name.filename = name.slice(0,sufIdx)+suffix+name.slice(sufIdx+suffix.length);
_.log(_.log.DBG, "Shortname amended to:", mainEntry.Name);
}
vol.allocateInFAT(dirChain.firstCluster || 2, function (e,fileCluster) {
if (e) return cb(e);
var nameBuf = S.dirEntry.fields['Name'].bytesFromValue(mainEntry.Name),
nameSum = _.checksumName(nameBuf);
// TODO: finalize initial properties… (via `opts.mode` instead?)
_updateEntry(vol, mainEntry, {firstCluster:fileCluster, size:0, ctime:true,_touch:true});
mainEntry._pos = _.adjustedPos(vol, entryInfo.target, S.dirEntry.size*(entries.length-1));
entries.slice(1).forEach(function (entry) {
entry.Chksum = nameSum;
});
entries.reverse();
if (entryInfo.lastEntry) entries.push({});
var entriesData = _.allocBuffer(S.dirEntry.size*entries.length),
dataOffset = {bytes:0};
entries.forEach(function (entry) {
var entryType = ('Ord' in entry) ? S.longDirEntry : S.dirEntry;
entryType.bytesFromValue(entry, entriesData, dataOffset);
});
_.log(_.log.DBG, "Writing", entriesData.length, "byte directory entry", mainEntry, "into", dirChain.toJSON(), "at", entryInfo.target);
dirChain.writeToPosition(entryInfo.target, entriesData, function (e) {
// TODO: if we get error, what/should we clean up?
if (e) cb(e);
else cb(null, mainEntry, vol.chainForCluster(fileCluster, dirChain));
});
});
};
dir.findInDirectory = function (vol, dirChain, name, opts, cb) {
var matchName = name.toUpperCase(),
tailName = (opts.prepareForCreate) ? _.shortname(name) : null,
maxTail = 0;
function processNext(next) {
next = next(function (e, d, entryPos) {
if (e) cb(e);
else if (!d) cb(S.err.NOENT(), {tail:maxTail, target:entryPos, lastEntry:true});
else if (d._free) processNext(next); // TODO: look for long enough reusable run
else if (d._name.toUpperCase() === matchName) return cb(null, d._full());
else if (!opts.prepareForCreate) processNext(next);
else {
var dNum = 1,
dName = d.Name.filename,
dTail = dName.match(/(.*)~(\d+)/);
if (dTail) {
dNum = +dTail[2];
dName = dTail[1];
}
if (tailName.extension === d.Name.extension &&
tailName.filename.indexOf(dName) === 0)
{
maxTail = Math.max(dNum+1, maxTail);
}
processNext(next);
}
});
}
processNext(dir.iterator(dirChain, {includeFree:(0 && opts.prepareForCreate)}));
};
dir.updateEntry = function (vol, entry, newStats, cb) {
if (!entry._pos || !entry._pos.chain) throw Error("Entry source unknown!");
var entryPos = entry._pos,
newEntry = _updateEntry(vol, entry, newStats),
data = S.dirEntry.bytesFromValue(newEntry);
_.log(_.log.DBG, "UPDATING ENTRY", newStats, newEntry, entryPos, data);
// TODO: if write fails, then entry becomes corrupt!
entryPos.chain.writeToPosition(entryPos, data, cb);
};

View File

@@ -1,206 +0,0 @@
var S = require("./structs.js"),
_xok = require('xok');
// ponyfills for older node.js
exports.allocBuffer = Buffer.alloc || function (len, val) {
var b = Buffer(len);
if (arguments.length > 1) b.fill(val);
return b;
};
exports.bufferFrom = Buffer.from || function (arg0, arg1) {
return (arguments.length > 1) ? Buffer(arg0, arg1) : Buffer(arg0);
};
// flag for WORKAROUND: https://github.com/tessel/beta/issues/380
exports.workaroundTessel380 = !Buffer.from && function () {
var b = Buffer([0]),
s = b.slice(0);
return ((s[0] = 0xFF) !== b[0]);
}();
// WORKAROUND: https://github.com/tessel/beta/issues/433
var oldslice;
if (!Buffer.alloc && Buffer(5).slice(10).length < 0) oldslice = Buffer.prototype.slice, Buffer.prototype.slice = function (s, e) {
if (s > this.length) s = this.length;
// ~WORKAROUND: https://github.com/tessel/beta/issues/434
return (arguments.length > 1) ? oldslice.call(this, s, e) : oldslice.call(this, s);
}
exports.absoluteSteps = function (path) {
var steps = [];
path.split('/').forEach(function (str) {
// NOTE: these should actually be fine, just wasteful…
if (str === '..') steps.pop();
else if (str && str !== '.') steps.push(str);
});
return steps.map(exports.longname);
};
exports.absolutePath = function (path) {
return '/'+exports.absoluteSteps(path).join('/');
};
exports.parseFlags = function (flags) {
// read, write, append, create, truncate, exclusive
var info, _dir; // NOTE: there might be more clever ways to "parse", but…
if (flags[0] === '\\') {
// internal flag used internally to `fs.open` directories without `S.err.ISDIR()`
flags = flags.slice(1);
_dir = true;
}
switch (flags) {
case 'r': info = {read:true, write:false, create:false}; break;
case 'r+': info = {read:true, write:true, create:false}; break;
case 'rs': info = {read:true, write:false, create:false, sync:true}; break;
case 'rs+': info = {read:true, write:true, create:false, sync:true}; break;
case 'w': info = {read:false, write:true, create:true, truncate:true}; break;
case 'wx': info = {read:false, write:true, create:true, exclusive:true}; break;
case 'w+': info = {read:true, write:true, create:true, truncate:true}; break;
case 'wx+': info = {read:true, write:true, create:true, exclusive:true}; break;
case 'a': info = {read:false, write:true, create:true, append:true}; break;
case 'ax': info = {read:false, write:true, create:true, append:true, exclusive:true}; break;
case 'a+': info = {read:true, write:true, create:true, append:true}; break;
case 'ax+': info = {read:true, write:true, create:true, append:true, exclusive:true}; break;
default: throw Error("Unknown mode: "+flags); // TODO: throw as `S.err.INVAL`
}
if (info.sync) throw Error("Mode not implemented."); // TODO: what would this require of us?
if (_dir) info._openDir = true;
return info;
};
// TODO: these are great candidates for special test coverage!
var _snInvalid = /[^A-Z0-9$%'-_@~`!(){}^#&.]/g; // NOTE: '.' is not valid but we split it away
exports.shortname = function (name) {
var lossy = false;
// TODO: support preservation of case for otherwise non-lossy name!
name = name.toUpperCase().replace(/ /g, '').replace(/^\.+/, '');
name = name.replace(_snInvalid, function () {
lossy = true;
return '_';
});
var parts = name.split('.'),
basis3 = parts.pop(),
basis8 = parts.join('');
if (!parts.length) {
basis8 = basis3;
basis3 = ' ';
}
if (basis8.length > 8) {
basis8 = basis8.slice(0,8);
// NOTE: technically, spec's "lossy conversion" flag is NOT set by excess length.
// But since lossy conversion and truncated names both need a numeric tail…
lossy = true;
} else while (basis8.length < 8) basis8 += ' ';
if (basis3.length > 3) {
basis3 = basis3.slice(0,3);
lossy = true;
} else while (basis3.length < 3) basis3 += ' ';
return {filename:basis8, extension:basis3, _lossy:lossy};
return {basis:[basis8,basis3], lossy:lossy};
};
//shortname("autoexec.bat") => {basis:['AUTOEXEC','BAT'],lossy:false}
//shortname("autoexecutable.batch") => {basis:['AUTOEXEC','BAT'],lossy:true}
// TODO: OS X stores `shortname("._.Trashes")` as ['~1', 'TRA'] — should we?
var _lnInvalid = /[^a-zA-Z0-9$%'-_@~`!(){}^#&.+,;=[\] ]/g;
exports.longname = function (name) {
name = name.trim().replace(/\.+$/, '').replace(_lnInvalid, function (c) {
if (c.length > 1) throw Error("Internal problem: unexpected match length!");
if (c.charCodeAt(0) > 127) return c;
else throw Error("Invalid character "+JSON.stringify(c)+" in name.");
lossy = true;
return '_';
});
if (name.length > 255) throw Error("Name is too long.");
return name;
};
function nameChkSum(sum, c) {
return ((sum & 1) ? 0x80 : 0) + (sum >>> 1) + c & 0xFF;
}
// WORKAROUND: https://github.com/tessel/beta/issues/335
function reduceBuffer(buf, start, end, fn, res) {
// NOTE: does not handle missing `res` like Array.prototype.reduce would
for (var i = start; i < end; ++i) {
res = fn(res, buf[i]);
}
return res;
}
exports.checksumName = function (buf,off) {
off || (off = 0);
var len = S.dirEntry.fields['Name'].size;
return reduceBuffer(buf, off, off+len, nameChkSum, 0);
};
/* comparing C rounding trick from FAT spec with Math.ceil
function tryBoth(d) {
var a = ((D.RootEntCnt * 32) + (D.BytsPerSec - 1)) / D.BytsPerSec >>> 0,
b = Math.ceil((D.RootEntCnt * 32) / D.BytsPerSec);
if (b !== a) console.log("try", b, a, (b === a) ? '' : '*');
return (b === a);
}
// BytsPerSec — "may take on only the following values: 512, 1024, 2048 or 4096"
[512, 1024, 2048, 4096].forEach(function (bps) {
// RootEntCnt — "a count that when multiplied by 32 results in an even multiple of BPB_BytsPerSec"
for (var evenMultiplier = 0; evenMultiplier < 1024*1024*16; evenMultiplier += 2) {
var rec = (bps * evenMultiplier) / 32;
tryBoth({RootEntCnt:rec, BytsPerSec:bps});
}
});
*/
exports.fmtHex = function (n, ff) {
return (1+ff+n).toString(16).slice(1);
};
exports.delayedCall = function (fn) {
if (!fn) throw Error("No function provided!"); // debug aid
var ctx = this,
args = Array.prototype.slice.call(arguments, 1);
setImmediate(function () {
fn.apply(ctx, args);
});
};
exports.adjustedPos = function (vol, pos, bytes) {
var _pos = {
chain: pos.chain,
sector: pos.sector,
offset: pos.offset + bytes
}, secSize = vol._sectorSize;
while (_pos.offset >= secSize) {
_pos.sector += 1;
_pos.offset -= secSize;
}
return _pos;
};
exports.extend = _xok;
var _prevDbg = Date.now(),
_thresh = 50;
function log(level) {
if (level < log.level) return;
var now = Date.now(),
diff = now - _prevDbg;
arguments[0] = ((diff < _thresh) ? " " : '') + diff.toFixed(0) + "ms";
console.log.apply(console, arguments);
_prevDbg = now;
}
log.DBG = -4;
log.INFO = -3;
log.WARN = -2;
log.ERR = -1;
log.level = log.WARN;
exports.log = log;

View File

@@ -1,25 +0,0 @@
var fs = require('fs');
exports.createDriverSync = function (path, opts) {
opts || (opts = {});
var secSize = 512,
ro = opts.readOnly || false,
fd = fs.openSync(path, (ro) ? 'r' : 'r+'),
s = fs.fstatSync(fd);
return {
sectorSize: secSize,
numSectors: s.size / secSize,
readSectors: function (i, dest, cb) {
fs.read(fd, dest, 0, dest.length, i*secSize, function (e,n,d) {
cb(e,d);
});
},
writeSectors: (ro) ? null : function (i, data, cb) {
fs.write(fd, data, 0, data.length, i*secSize, function (e) {
cb(e);
});
}
};
};

View File

@@ -1,771 +0,0 @@
var events = require('events'),
streams = require('stream'),
fifolock = require('fifolock'),
S = require('./structs.js'),
_ = require('./helpers.js')
//_.log.level = _.log.DBG;
exports.createFileSystem = function (volume, opts, cb) {
if (typeof opts === 'function') {
cb = opts
opts = null
}
opts = _.extend(
{
// c.f. https://www.kernel.org/doc/Documentation/filesystems/vfat.txt
ro: false,
noatime: true,
modmode: 0111, // or `07000`
umask: 'umask' in process ? process.umask() : 0022,
uid: 'getuid' in process ? process.getuid() : 0,
gid: 'getgid' in process ? process.getgid() : 0,
},
opts
)
if (!volume.writeSectors) opts.ro = true
if (opts.ro) opts.noatime = true // natch
var fs = new events.EventEmitter(),
vol = null,
dir = require('./dir.js'),
c = require('./chains.js'),
q = fifolock()
var GROUP =
_.log.level < _.log.INFO
? q.TRANSACTION_WRAPPER.bind({
postAcquire: function (proceed) {
_.log(_.log.DBG, '=== Starting GROUP ===')
proceed()
},
preRelease: function (finish) {
_.log(_.log.DBG, '=== Finishing GROUP ===')
finish()
},
})
: q.TRANSACTION_WRAPPER
q.acquire(function (unlock) {
// because of this, callers can start before 'ready'
var d = _.allocBuffer(volume.sectorSize)
volume.readSectors(0, d, function (e) {
if (e) fs.emit('error', e)
else {
try {
init(d)
} catch (e) {
fs.emit('error', e)
unlock()
return
}
fs.emit('ready')
}
unlock()
})
})
if (cb) fs.on('error', cb).on('ready', cb.bind(null, null))
function init(bootSector) {
vol = require('./vol.js').init(volume, opts, bootSector)
fs._dirIterator = dir.iterator.bind(dir)
var entryInfoByPath = {},
baseEntry = {
_refs: 0,
_record: function () {
entryInfoByPath[this.path] = this
if (this.parent) this.parent.retain()
return this
},
retain: function () {
if (!this._refs) this._record()
this._refs += 1
return this
},
release: function () {
this._refs -= 1
if (!this._refs) this._rescind()
},
_rescind: function () {
if (this.parent) this.parent.release()
delete entryInfoByPath[this.path]
},
}
fs._createSharedEntry = function (path, entry, chain, parent) {
return _.extend(Object.create(baseEntry), {
_refs: 0, // WORKAROUND: https://github.com/tessel/beta/issues/455
path: path,
entry: entry,
chain: chain,
parent: parent,
}).retain()
}
fs._createSharedEntry('/', { Attr: { directory: true } }, vol.rootDirectoryChain)
fs._sharedEntryForSteps = function (steps, opts, cb) {
// NOTE: may `cb` before returning!
var path = steps.join('/') || '/',
name = steps.pop(), // n.b.
info = entryInfoByPath[path]
if (info) cb(null, info.retain())
else
fs._sharedEntryForSteps(steps, {}, function (e, parentInfo) {
// n.b. `steps` don't include `name`
if (e) cb(e)
else if (!parentInfo.entry.Attr.directory) cb(S.err.NOTDIR())
else
dir.findInDirectory(vol, parentInfo.chain, name, opts, function (e, entry) {
if (e && !opts.prepareForCreate) cb(e)
else if (e) cb(e, { missingChild: _.extend(entry, { name: name }), parent: parentInfo })
else cb(null, fs._createSharedEntry(path, entry, vol.chainForCluster(entry._firstCluster), parentInfo))
})
})
}
fs._updateEntry = dir.updateEntry.bind(dir, vol)
fs._makeStat = dir.makeStat.bind(dir, vol)
fs._addFile = dir.addFile.bind(dir, vol)
fs._initDir = dir.init.bind(dir, vol)
}
/**** ---- CORE API ---- ****/
// NOTE: we really don't share namespace, but avoid first three anyway…
var fileDescriptors = [null, null, null]
fs.open = function (path, flags, mode, cb, _n_) {
if (typeof mode === 'function') {
_n_ = cb
cb = mode
mode = 0666
}
cb = GROUP(
cb,
function () {
var _fd = { flags: null, entry: null, chain: null, pos: 0 },
f = _.parseFlags(flags)
if (vol.opts.ro && (f.write || f.create || f.truncate)) return _.delayedCall(cb, S.err.ROFS())
else _fd.flags = f
fs._sharedEntryForSteps(_.absoluteSteps(path), { prepareForCreate: f.create }, function (e, info) {
if (e && !(e.code === 'NOENT' && f.create && info)) cb(e)
else if (e)
fs._addFile(info.parent.chain, info.missingChild, { dir: f._openDir }, function (e, newEntry, newChain) {
if (e) cb(e)
else finish(fs._createSharedEntry(_.absolutePath(path), newEntry, newChain, info.parent))
})
else if (info && f.exclusive) cb(S.err.EXIST())
else if (info.entry.Attr.directory && !f._openDir) cb(S.err.ISDIR())
else if (f.write && info.entry.Attr.readonly) cb(S.err.ACCES())
else finish(info)
function finish(fileInfo) {
var fd = fileDescriptors.push(_fd) - 1
_fd.info = fileInfo
_fd.entry = fileInfo.entry
_fd.chain = fileInfo.chain
if (f.append) _fd.pos = _fd.entry._size
if (f._openDir) _fd.chain.cacheAdvice = 'WILLNEED'
if (f.truncate && _fd.entry._size)
fs.ftruncate(
fd,
0,
function (e) {
cb(e, fd)
},
'_nested_'
)
else _.delayedCall(cb, null, fd) // (delay in case fs._sharedEntryForSteps all cached!)
}
})
},
_n_ === '_nested_'
)
}
fs.fstat = function (fd, cb, _n_) {
cb = GROUP(
cb,
function () {
var _fd = fileDescriptors[fd]
if (!_fd || !_fd.flags.read) _.delayedCall(cb, S.err.BADF())
else _.delayedCall(cb, null, fs._makeStat(_fd.entry))
},
_n_ === '_nested_'
)
}
fs.futimes = function (fd, atime, mtime, cb, _n_) {
cb = GROUP(
cb,
function () {
var _fd = fileDescriptors[fd]
if (!_fd || !_fd.flags.write) _.delayedCall(cb, S.err.BADF())
// NOTE: ctime would get touched on POSIX; but we map that to create time!
else fs._updateEntry(_fd.entry, { atime: atime || true, mtime: mtime || true }, cb)
},
_n_ === '_nested_'
)
}
fs.fchmod = function (fd, mode, cb, _n_) {
cb = GROUP(
cb,
function () {
var _fd = fileDescriptors[fd]
if (!_fd || !_fd.flags.write) _.delayedCall(cb, S.err.BADF())
else {
mode &= S._I._chmoddable
if (_fd.entry.Attr.directory) mode |= S._I.FDIR
else if (!_fd.entry.Attr.volume_id) mode |= S._I.FREG
fs._updateEntry(_fd.entry, { mode: mode }, cb)
}
},
_n_ === '_nested_'
)
}
fs.read = function (fd, buf, off, len, pos, cb, _n_) {
cb = GROUP(
cb,
function () {
var _fd = fileDescriptors[fd]
if (!_fd || !_fd.flags.read) return _.delayedCall(cb, S.err.BADF())
var _pos = pos === null ? _fd.pos : pos,
_len = Math.min(len, _fd.entry._size - _pos),
_buf = buf.slice(off, off + _len)
_fd.chain.readFromPosition(_pos, _buf, function (e, bytes, slice) {
if (_.workaroundTessel380) _buf.copy(buf, off) // WORKAROUND: https://github.com/tessel/beta/issues/380
_fd.pos = _pos + bytes
if (e || vol.opts.noatime) finish(e)
else fs._updateEntry(_fd.entry, { atime: true }, finish)
function finish(e) {
cb(e, bytes, buf)
}
})
},
_n_ === '_nested_'
)
}
fs._readdir = function (fd, cb, _n_) {
cb = GROUP(
cb,
function () {
var _fd = fileDescriptors[fd]
if (!_fd || !_fd.flags.read) return _.delayedCall(cb, S.err.BADF())
var entryNames = [],
getNextEntry = fs._dirIterator(_fd.chain)
function processNext() {
getNextEntry(function (e, d) {
if (e) cb(e)
else if (!d && !entryNames.length)
cb(null, entryNames) // WORKAROUND: https://github.com/tessel/beta/issues/435
else if (!d)
cb(null, entryNames.sort()) // NOTE: sort not required, but… [simplifies tests for starters!]
else {
if (d._name !== '.' && d._name !== '..') entryNames.push(d._name)
processNext()
}
})
}
processNext()
},
_n_ === '_nested_'
)
}
fs._mkdir = function (fd, cb, _n_) {
cb = GROUP(
cb,
function () {
var _fd = fileDescriptors[fd]
if (!_fd || !_fd.flags.write) _.delayedCall(cb, S.err.BADF())
else fs._initDir(_fd.info, cb)
},
_n_ === '_nested_'
)
}
fs.write = function (fd, buf, off, len, pos, cb, _n_) {
cb = GROUP(
cb,
function () {
var _fd = fileDescriptors[fd]
if (!_fd || !_fd.flags.write) return _.delayedCall(cb, S.err.BADF())
var _pos = pos === null || _fd.flags.append ? _fd.pos : pos,
_buf = buf.slice(off, off + len)
if (_pos > _fd.entry._size) {
// TODO: handle huge jumps by zeroing clusters individually?
var padLen = _pos - _fd.entry._size,
padBuf = _.allocBuffer(padLen + _buf.length)
padBuf.fill(0x00, 0, padLen)
_buf.copy(padBuf, padLen)
_pos = _fd.entry._size
_buf = padBuf
}
_fd.chain.writeToPosition(_pos, _buf, function (e) {
_fd.pos = _pos + len
var newSize = Math.max(_fd.entry._size, _fd.pos),
newInfo = { size: newSize, _touch: true }
fs._updateEntry(_fd.entry, newInfo, function (ee) {
cb(e || ee, len, buf)
})
})
},
_n_ === '_nested_'
)
}
fs.ftruncate = function (fd, len, cb, _n_) {
cb = GROUP(
cb,
function () {
var _fd = fileDescriptors[fd]
if (!_fd || !_fd.flags.write) return _.delayedCall(cb, S.err.BADF())
var newStats = { size: len, _touch: true }
// NOTE: we order operations for best state in case of only partial success
if (len === _fd.entry._size) _.delayedCall(cb)
else if (len < _fd.entry._size)
fs._updateEntry(_fd.entry, newStats, function (e) {
if (e) cb(e)
else _fd.chain.truncate(Math.ceil(len / _fd.chain.sectorSize), cb)
})
// TODO: handle huge file expansions without as much memory pressure
else
_fd.chain.writeToPosition(_fd.entry._size, _.allocBuffer(len - _fd.entry._size, 0x00), function (e) {
if (e) cb(e)
else fs._updateEntry(_fd.entry, newStats, cb)
})
},
_n_ === '_nested_'
)
}
// 'NORMAL', 'SEQUENTIAL', 'RANDOM', 'WILLNEED', 'DONTNEED', 'NOREUSE'
fs._fadviseSync = function (fd, off, len, advice) {
if (off !== 0 || len !== 0) throw Error('Cache advise can currently be given only for whole file!')
var _fd = fileDescriptors[fd]
if (!_fd) throw S.err.BADF()
else _fd.chain.cacheAdvice = advice
}
fs.fsync = function (fd, cb) {
// NOTE: we'll need to flush write cache here once we have one…
var _fd = fileDescriptors[fd]
if (!_fd) _.delayedCall(cb, S.err.BADF())
else _.delayedCall(cb)
}
fs.close = function (fd, cb) {
var _fd = fileDescriptors[fd]
if (!_fd) _.delayedCall(cb, S.err.BADF())
else setTimeout(_fd.info.release.bind(_fd.info), 500), _.delayedCall(cb, (fileDescriptors[fd] = null))
}
/* STREAM WRAPPERS */
var workaroundTessel436
try {
new require('stream').Readable({ encoding: 'utf8' })
new streams.Readable({ encoding: 'utf8' })
} catch (e) {
workaroundTessel436 = true
}
function _createStream(StreamType, path, opts) {
// [NOT REALLY A] WORKAROUND: https://github.com/tessel/beta/issues/436
if (workaroundTessel436 && 'encoding' in opts) {
console.warn('Tessel does not currently support encoding option for Readable streams, discarding!')
delete opts.encoding
}
var fd = opts.fd !== null ? opts.fd : '_opening_',
pos = opts.start,
stream = new StreamType(opts)
if (fd === '_opening_')
fs.open(path, opts.flags, opts.mode, function (e, _fd) {
if (e) {
fd = '_open_error_'
stream.emit('error', e)
} else {
fd = _fd
fs._fadviseSync(fd, 0, 0, 'SEQUENTIAL')
stream.emit('open', fd)
}
})
function autoClose(tombstone) {
// NOTE: assumes caller will clear `fd`
if (opts.autoClose)
fs.close(fd, function (e) {
if (e) stream.emit('error', e)
else stream.emit('close')
})
fd = tombstone
}
if (StreamType === streams.Readable) {
stream._read = function (n) {
var buf
// TODO: optimize to fetch at least a full sector regardless of `n`…
n = Math.min(n, opts.end - pos)
if (fd === '_opening_')
stream.once('open', function () {
stream._read(n)
})
else if (pos > opts.end) stream.push(null)
else if (n > 0)
(buf = _.allocBuffer(n)),
fs.read(fd, buf, 0, n, pos, function (e, n, d) {
if (e) {
autoClose('_read_error_')
stream.emit('error', e)
} else stream.push(n ? d.slice(0, n) : null)
}),
(pos += n)
else stream.push(null)
}
stream.once('end', function () {
autoClose('_ended_')
})
} else if (StreamType === streams.Writable) {
stream.bytesWritten = 0
stream._write = function (data, _enc, cb) {
if (fd === '_opening_')
stream.once('open', function () {
stream._write(data, null, cb)
})
else
fs.write(fd, data, 0, data.length, pos, function (e, n) {
if (e) {
autoClose('_write_error_')
cb(e)
} else {
stream.bytesWritten += n
cb()
}
}),
(pos += data.length)
}
stream.once('finish', function () {
autoClose('_finished_')
})
}
return stream
}
fs.createReadStream = function (path, opts) {
return _createStream(
streams.Readable,
path,
_.extend(
{
start: 0,
end: Infinity,
flags: 'r',
mode: 0666,
encoding: null,
fd: null, // ??? see https://github.com/joyent/node/issues/7708
autoClose: true,
},
opts
)
)
}
fs.createWriteStream = function (path, opts) {
return _createStream(
streams.Writable,
path,
_.extend(
{
start: 0,
flags: 'w',
mode: 0666,
//encoding: null, // see https://github.com/joyent/node/issues/7710
fd: null, // ??? see https://github.com/joyent/node/issues/7708
autoClose: true,
},
opts,
{ decodeStrings: true, objectMode: false }
)
)
}
/* PATH WRAPPERS (albeit the only public interface for some folder operations) */
function _fdOperation(path, opts, fn, cb) {
cb = GROUP(cb, function () {
opts.advice || (opts.advice = 'NORMAL')
fs.open(
path,
opts.flag,
function (e, fd) {
if (e) cb(e)
else
fs._fadviseSync(fd, 0, 0, opts.advice),
fn(fd, function () {
var ctx = this,
args = arguments
fs.close(
fd,
function (closeErr) {
cb.apply(ctx, args)
},
'_nested_'
)
})
},
'_nested_'
)
})
}
fs.stat = fs.lstat = function (path, cb) {
_fdOperation(
path,
{ flag: 'r' },
function (fd, cb) {
fs.fstat(fd, cb, '_nested_')
},
cb
)
}
fs.exists = function (path, cb) {
fs.stat(path, function (err) {
cb(err ? false : true)
})
}
fs.readFile = function (path, opts, cb) {
if (typeof opts === 'function') {
cb = opts
opts = {}
}
opts.flag || (opts.flag = 'r')
opts.advice || (opts.advice = 'NOREUSE')
_fdOperation(
path,
opts,
function (fd, cb) {
fs.fstat(
fd,
function (e, stat) {
if (e) return cb(e)
else {
var buffer = _.allocBuffer(stat.size)
fs.read(
fd,
buffer,
0,
buffer.length,
null,
function (e) {
if (e) cb(e)
else cb(null, opts.encoding ? buffer.toString(opts.encoding) : buffer)
},
'_nested_'
)
}
},
'_nested_'
)
},
cb
)
}
fs.writeFile = function (path, data, opts, cb) {
if (typeof opts === 'function') {
cb = opts
opts = {}
}
opts.flag || (opts.flag = 'w')
opts.advice || (opts.advice = 'NOREUSE')
_fdOperation(
path,
opts,
function (fd, cb) {
if (typeof data === 'string') data = _.bufferFrom(data, opts.encoding || 'utf8')
fs.write(
fd,
data,
0,
data.length,
null,
function (e) {
cb(e)
},
'_nested_'
)
},
cb
)
}
fs.appendFile = function (path, data, opts, cb) {
if (typeof opts === 'function') {
cb = opts
opts = {}
}
opts.flag || (opts.flag = 'a')
fs.writeFile(path, data, opts, cb)
}
fs.truncate = function (path, len, cb) {
_fdOperation(
path,
{ flag: 'r+' },
function (fd, cb) {
fs.ftruncate(fd, len, cb, '_nested_')
},
cb
)
}
fs.readdir = function (path, cb) {
_fdOperation(
path,
{ flag: '\\r' },
function (fd, cb) {
fs._readdir(fd, cb, '_nested_')
},
cb
)
}
fs.mkdir = function (path, mode, cb) {
if (typeof mode === 'function') {
cb = mode
mode = 0777
}
_fdOperation(
path,
{ flag: '\\wx' },
function (fd, cb) {
fs._mkdir(fd, cb, '_nested_')
},
cb
)
}
fs.utimes = function (path, atime, mtime, cb) {
_fdOperation(
path,
{ flag: 'r+' },
function (fd, cb) {
fs.futimes(fd, atime, mtime, cb, '_nested_')
},
cb
)
}
fs.chmod = fs.lchmod = function (path, mode, cb) {
_fdOperation(
path,
{ flag: '\\r+' },
function (fd, cb) {
fs.fchmod(fd, mode, cb, '_nested_')
},
cb
)
}
fs.chown = fs.lchown = function (path, uid, gid, cb) {
_fdOperation(
path,
{ flag: '\\r+' },
function (fd, cb) {
fs.fchown(fd, uid, gid, cb, '_nested_')
},
cb
)
}
/* STUBS */
fs.link = function (src, dst, cb) {
// NOTE: theoretically we _could_ do hard links [with untracked `stat.nlink` count…]
_.delayedCall(cb, S.err.NOSYS())
}
fs.symlink = function (src, dst, type, cb) {
if (typeof type === 'function') {
cb = type
type = null
}
_.delayedCall(cb, S.err.NOSYS())
}
fs.readlink = function (path, cb) {
_fdOperation(
path,
{ flag: '\\r' },
function (fd, cb) {
// the named file is *never* a symbolic link…
// NOTE: we still use _fdOperation for catching e.g. NOENT/NOTDIR errors…
cb(S.err.INVAL())
},
cb
)
}
fs.realpath = function (path, cache, cb) {
if (typeof cache === 'function') {
cb = cache
cache = null
}
if (cache)
_.delayedCall(cb, S.err.NOSYS()) // TODO: what would be involved here?
else
_fdOperation(
path,
{ flag: '\\r' },
function (fd, cb) {
cb(null, _.absolutePath(path))
},
cb
)
}
fs.fchown = function (fd, uid, gid, cb, _n_) {
cb = GROUP(
cb,
function () {
var _fd = fileDescriptors[fd]
if (!_fd || !_fd.flags.write) _.delayedCall(cb, S.err.BADF())
else _.delayedCall(cb, S.err.NOSYS())
},
_n_ === '_nested_'
)
}
// See https://github.com/natevw/fatfs/pull/31/files
fs.createLabel = function (name, cb) {
fs.open(name, 'a', (e, _fd) => {
if (e) {
cb(e)
} else {
let fd = fileDescriptors[_fd]
fd.entry.Attr.volume_id = true
fd.entry.FstClusLO = 0
fd.entry.FstClusHI = 0
fs._updateEntry(fd.entry, {}, cb)
}
})
}
return fs
}

View File

@@ -1,36 +0,0 @@
#! /bin/sh
# NOTE: this only works on OS X
# TODO: Linux could use mkfs stuff without attach?
# ./make_sample.sh /tmp/fat16.img FAT16
FILE=$1
: ${FILE:?must be provided as first argument}
TYPE=$2
: ${TYPE:?must be provided as second argument}
SIZE=$3 # in MB
case "$TYPE" in
"FAT12") : ${SIZE:=1} # <128
;;
"FAT16") : ${SIZE:=5} # >4
;;
"FAT32") : ${SIZE:=33} # >32
;;
*) : ${SIZE:=1} # e.g. ExFAT
esac
echo Making $TYPE of $SIZE MB at $FILE
if [[ "$TYPE" = FAT* ]]
then
TYPE="MS-DOS $TYPE"
fi
dd if=/dev/zero of=$FILE bs=1048576 count=$SIZE
DEV=`hdiutil attach $FILE -nomount`
diskutil eraseVolume "$TYPE" "FATFS TEST" $DEV
hdiutil detach $DEV

View File

@@ -1,34 +0,0 @@
{
"private": false,
"name": "@vates/fatfs",
"version": "0.11.1",
"description": "fs implementation on top of raw FAT16/FAT32 block source",
"main": "index.js",
"scripts": {
"postversion": "npm publish --access public"
},
"repository": {
"directory": "@vates/fatfs",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"keywords": [
"filesystem",
"fs",
"fat",
"fat32",
"fat16"
],
"author": "Vates SAS",
"license": "Apache-2.0",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/fatfs",
"dependencies": {
"fifolock": "^1.0.0",
"struct-fu": "^1.2.1",
"xok": "^1.0.0"
},
"engines": {
"node": ">=18"
}
}

View File

@@ -1,255 +0,0 @@
// see http://staff.washington.edu/dittrich/misc/fatgen103.pdf
// and http://www.cse.scu.edu/~tschwarz/COEN252_09/Lectures/FAT.html
var _ = require('struct-fu'),
__ = require("./helpers.js");
var bootBase = _.struct([
_.byte('jmpBoot', 3),
_.char('OEMName', 8),
_.uint16le('BytsPerSec'),
_.uint8('SecPerClus'),
_.uint16le('ResvdSecCnt'), // Rsvd in table, but Resvd in calcs…
_.uint8('NumFATs'),
_.uint16le('RootEntCnt'),
_.uint16le('TotSec16'),
_.uint8('Media'),
_.uint16le('FATSz16'),
_.uint16le('SecPerTrk'),
_.uint16le('NumHeads'),
_.uint32le('HiddSec'),
_.uint32le('TotSec32')
]);
var bootInfo = _.struct([
_.uint8('DrvNum'),
_.uint8('Reserved1'),
_.uint8('BootSig'),
_.uint32le('VolID'),
_.char('VolLab', 11),
_.char('FilSysType', 8)
]);
exports.boot16 = _.struct([
bootBase,
bootInfo
]);
exports.boot32 = _.struct([
bootBase,
_.uint32le('FATSz32'),
_.struct('ExtFlags', [
_.ubit('NumActiveFAT', 4),
_.ubit('_reserved1', 3),
_.bool('MirroredFAT'),
_.ubit('_reserved2', 8)
].reverse()),
_.struct('FSVer', [
_.uint8('Major'),
_.uint8('Minor')
]),
_.uint32le('RootClus'),
_.uint16le('FSInfo'),
_.uint16le('BkBootSec'),
_.byte('Reserved', 12),
bootInfo
]);
var _time = _.struct([
_.ubit('hours',5),
_.ubit('minutes',6),
_.ubit('seconds_2',5)
]), time = {
valueFromBytes: function (buf, off) {
off || (off = {bytes:0});
var _buf = __.bufferFrom([buf[off.bytes+1], buf[off.bytes+0]]),
val = _time.valueFromBytes(_buf);
off.bytes += this.size;
return val;
},
bytesFromValue: function (val, buf, off) {
val || (val = {hours:0, minutes:0, seconds_2:0});
buf || (buf = __.allocBuffer(this.size));
off || (off = {bytes:0});
var _buf = _time.bytesFromValue(val);
buf[off.bytes+1] = _buf[0];
buf[off.bytes+0] = _buf[1];
off.bytes += this.size;
return buf;
},
size: _time.size
};
var _date = _.struct([
_.ubit('year',7),
_.ubit('month',4),
_.ubit('day',5)
]), date = {
valueFromBytes: function (buf, off) {
off || (off = {bytes:0});
var _buf = __.bufferFrom([buf[off.bytes+1], buf[off.bytes+0]]),
val = _date.valueFromBytes(_buf);
off.bytes += this.size;
return val;
},
bytesFromValue: function (val, buf, off) {
val || (val = {year:0, month:0, day:0});
buf || (buf = __.allocBuffer(this.size));
off || (off = {bytes:0});
var _buf = _date.bytesFromValue(val);
buf[off.bytes+1] = _buf[0];
buf[off.bytes+0] = _buf[1];
off.bytes += this.size;
return buf;
},
size: _date.size
};
exports.dirEntry = _.struct([
_.struct('Name', [
_.char('filename',8),
_.char('extension',3)
]),
_.struct('Attr', [
_.bool('readonly'),
_.bool('hidden'),
_.bool('system'),
_.bool('volume_id'),
_.bool('directory'),
_.bool('archive'),
_.ubit('reserved', 2)
].reverse()),
_.byte('NTRes', 1),
_.uint8('CrtTimeTenth'),
_.struct('CrtTime', [time]),
_.struct('CrtDate', [date]),
_.struct('LstAccDate', [date]),
_.uint16le('FstClusHI'),
_.struct('WrtTime', [time]),
_.struct('WrtDate', [date]),
_.uint16le('FstClusLO'),
_.uint32le('FileSize')
]);
exports.entryDoneFlag = 0x00;
exports.entryFreeFlag = 0xE5;
exports.entryIsE5Flag = 0x05;
exports.dirEntry_simple = _.struct([
_.struct('Name', [
_.char('filename',8),
_.char('extension',3)
]),
_.padTo(exports.dirEntry.size)
/*
_.uint8('Attr_raw'),
_.byte('NTRes', 1),
_.byte('Crt_raw', 1+2+2),
_.byte('Lst_raw', 2),
_.uint16le('FstClusHI'),
_.byte('Wrt_raw', 2+2),
_.uint16le('FstClusLO'),
_.uint32le('FileSize')
*/
]);
exports.lastLongFlag = 0x40;
exports.longDirFlag = 0x0F;
exports.longDirEntry = _.struct([
_.uint8('Ord'),
_.char16le('Name1', 10),
_.uint8('Attr_raw'),
_.uint8('Type'),
_.uint8('Chksum'),
_.char16le('Name2', 12),
_.uint16le('FstClusLO'),
_.char16le('Name3', 4)
]);
if (exports.longDirEntry.size !== exports.dirEntry.size) throw Error("Structs ain't right!");
exports.fatField = {
'fat12': _.struct('Status', [
_.ubit('field0bc', 8),
_.ubit('field1c', 4),
_.ubit('field0a', 4),
_.ubit('field1ab', 8),
]),
'fat16': _.uint16le('Status'),
'fat32': _.uint32le('Status') // more properly this 4 bits reserved + uint28le
};
exports.fatPrefix = {
'fat12': 0xF00,
'fat16': 0xFF00,
'fat32': 0x0FFFFF00
};
exports.fatStat = {
free: 0x00,
_undef: 0x01,
rsvMin: 0xF0,
bad: 0xF7,
eofMin: 0xF8,
eof: 0xFF
};
exports._I = {
RUSR: 0400,
WUSR: 0200,
XUSR: 0100,
RGRP: 0040,
WGRP: 0020,
XGRP: 0010,
ROTH: 0004,
WOTH: 0002,
XOTH: 0001,
SUID: 04000,
SGID: 02000,
SVTX: 01000,
FDIR: 040000,
FREG: 0100000,
};
exports._I.RWXU = exports._I.RUSR | exports._I.WUSR | exports._I.XUSR;
exports._I.RWXG = exports._I.RGRP | exports._I.WGRP | exports._I.XGRP;
exports._I.RWXO = exports._I.ROTH | exports._I.WOTH | exports._I.XOTH;
exports._I._sss = exports._I.SUID | exports._I.SGID | exports._I.SVTX;
exports._I._chmoddable = exports._I.RWXU | exports._I.RWXG | exports._I.RWXO | exports._I._sss;
var _errors = {
IO: "Input/output error",
NOENT: "No such file or directory",
INVAL: "Invalid argument",
EXIST: "File exists",
NAMETOOLONG: "Filename too long",
NOSPC: "No space left on device",
NOSYS: "Function not supported",
ROFS: "ROFLCopter file system",
NOTDIR: "Not a directory",
BADF: "Bad file descriptor",
EXIST: "File exists",
ISDIR: "Is a directory",
ACCES: "Permission denied",
NOSYS: "Function not implemented",
_TODO: "Not implemented yet!"
};
exports.err = {};
Object.keys(_errors).forEach(function (sym) {
var msg = _errors[sym];
exports.err[sym] = function () {
var e = new Error(msg);
e.code = sym;
return e;
};
});

View File

@@ -1,338 +0,0 @@
// can be called from CLI with image type, absolute path to an image, or, required as a module
var _ = require("./helpers.js");
var type = process.argv[2];
if (module.parent) exports.startTests = startTests;
else if (!type) throw "Usage: node test [FAT12|FAT16|FAT32|ExFAT|…|/path/to/image]";
else if (type[0] === '/') testWithImage(type);
else {
var uniq = Math.random().toString(36).slice(2),
IMG = require('os').tmpdir()+"fatfs-test-"+uniq+".img";
require('child_process').exec("./make_sample.sh "+JSON.stringify(IMG)+" "+JSON.stringify(type), function (e,out,err) {
if (e) throw e;
console.warn(err.toString());
//console.log(out.toString());
testWithImage(IMG);
//console.log("open", IMG);
//return;
require('fs').unlink(IMG, function (e) {
if (e) console.warn("Error cleaning up test image", e);
});
});
}
function testWithImage(imagePath) {
var vol = require("./img_volume.js").createDriverSync(imagePath);
startTests(vol);
}
function startTests(vol, waitTime) {
var fatfs = require("./"),
fs = fatfs.createFileSystem(vol, {umask:0020, uid:99, gid:42});
waitTime || (waitTime = 0.5e3);
[
'mkdir','readdir',
//'rename','unlink','rmdir',
'close','open','fsync',
'ftruncate','truncate',
'write','read','readFile','writeFile', 'appendFile',
'chown','lchown','fchown',
'chmod','lchmod', 'fchmod',
'utimes','futimes',
'stat','lstat','fstat','exists',
'link','symlink','readlink','realpath',
//'watchFile','unwatchFile','watch'
].forEach(function (method) { assert(method in fs, "fs."+method+" has implementation."); });
var BASE_DIR = "/fat_test-"+Math.random().toFixed(20).slice(2),
FILENAME = "Simple File.txt",
TEXTDATA = "Hello world!";
var isReady = false;
fs.on('ready', function () {
assert(isReady = true, "Driver is ready.");
}).on('error', function (e) {
assert(e, "If fs driver fires 'error' event, it should include error object.");
assert(false, "…but driver should not error when initializing in our case.");
});
setTimeout(function () {
assert(isReady, "Driver fired ready event in timely fashion.");
}, waitTime);
fs.readdir("/", function (e,files) {
assert(isReady, "Method completed after 'ready' event.");
assert(!e, "No error reading root directory.");
assert(Array.isArray(files), "Got a list of files: "+files);
});
fs.mkdir(BASE_DIR, function (e) {
assert(!e, "No error from fs.mkdir");
fs.readdir(BASE_DIR, function (e,arr) {
assert(!e, "No error from fs.readdir");
assert(arr.length === 0 , "No files in BASE_DIR yet.");
});
var file = [BASE_DIR,FILENAME].join('/');
fs.writeFile(file, TEXTDATA, function (e) {
assert(!e, "No error from fs.writeFile");
startStreamTests();
fs.realpath(file, function (e,path) {
assert(!e, "No error from basic fs.realpath call.");
assert(path === file, "We already had the real path.");
});
fs.realpath([BASE_DIR,".","garbage",".","..",FILENAME].join('/'), function (e,path) {
assert(!e, "No error from fluffy fs.realpath call.");
assert(path === file, "Fixed fluffy path matches normal one.");
});
fs.realpath([BASE_DIR,"non","existent","path"].join('/'), function (e) {
assert(e, "Expected error calling fs.realpath on nonexistent file.");
});
fs.readdir(BASE_DIR, function (e, arr) {
assert(!e, "Still no error from fs.readdir");
assert(arr.length === 2, "Test directory contains two files."); // (ours + startStreamTests's)
assert(arr[0] === FILENAME, "Filename is correct.");
fs.stat(file, function (e,d) {
assert(!e, "No error from fs.stat");
assert(d.isFile() === true, "Result is a file…");
assert(d.isDirectory() === false, "…and not a directory.");
assert(d.size === Buffer.byteLength(TEXTDATA), "Size matches length of content written.");
});
fs.exists(file, function (bool) {
assert(bool === true, "File exists.");
});
fs.readFile(file, {encoding:'utf8'}, function (e, d) {
assert(!e, "No error from fs.readFile");
assert(d === TEXTDATA, "Data matches what was written.");
});
// now, overwrite the same file and make sure that goes well too
fs.writeFile(file, _.bufferFrom([0x42]), function (e) {
assert(!e, "Still no error from fs.writeFile");
fs.readdir(BASE_DIR, function (e, arr) {
assert(!e, "No error from fs.readdir");
assert(arr.length === 2, "Test directory still contains two files.");
assert(arr[0] === FILENAME, "Filename still correct.");
fs.stat(file, function (e,d) {
assert(!e, "Still no error from fs.stat");
assert(d.isFile() === true, "Result is still a file…");
assert(d.isDirectory() === false, "…and not a directory.");
assert(d.size === 1, "Size matches length of now-truncated content.");
});
fs.readFile(file, function (e, d) {
assert(!e, "Still no error from fs.readFile");
assert(Buffer.isBuffer(d), "Result without encoding is a buffer.");
assert(d.length === 1, "Buffer is correct size.");
assert(d[0] === 0x42, "Buffer content is correct.");
fs.truncate(file, 1025, function (e) {
assert(!e, "No error from fs.truncate (extending)");
fs.readFile(file, function (e, d) {
assert(!e, "Still no error from fs.readFile after extension");
assert(d.length === 1025, "Read after extension is correct size.");
assert(d[0] === 0x42, "First byte is still correct.");
var allZeroes = true;
for (var i = 1, len = d.length; i < len; ++i) if (d[i] !== 0) allZeroes = false;
assert(allZeroes, "Extended portion of file is zero-filled.");
fs.truncate(file, 3, function (e) {
assert(!e, "No error from fs.truncate (shortening)");
fs.readFile(file, function (e, d) {
assert(!e, "Still no error from fs.readFile after shortening.");
assert(d.length === 3, "Read after shortening is correct size.");
assert(d[0] === 0x42, "First byte is still correct.");
assert(d[1] === 0x00, "Second byte is still correct.");
assert(d[2] === 0x00, "Third byte is still correct.");
proceedWithMoreTests();
});
});
});
});
});
});
});
});
});
function proceedWithMoreTests() {
var fd;
fs.open(file, 'r', function (e, _fd) {
assert(!e, "No error from fs.open.");
fd = _fd;
});
var was = "\u0042\u0000\u0000",
str = "abc";
fs.appendFile(file, str, function (e) {
assert(!e, "No error from fs.appendFile.");
assert(fd, "File descriptor opened before appendFile called.");
var buf = _.allocBuffer(str.length);
fs.read(fd, buf, 0, buf.length, was.length, function (e,n,d) {
assert(!e, "No error from fs.read after append.");
assert(n === str.length, "All appended data was readable.");
assert(d === buf, "Buffer returned from fs.read matched what was passed in.");
assert(d.toString() === str, "Correct data found where expected.");
});
});
fs.readFile(file, {encoding:'ascii'}, function (e,d) {
assert(!e, "No error from fs.appendFile.");
assert(d.length === 6, "Read is correct size after append.");
assert(d === was+str, "Read string matches what was written and then appended.");
fs.open(file, 'a', function (e, fd2) {
assert(!e, "No error from second open of file.");
var str2 = "zyx",
buf2 = _.allocBuffer(str2.length+2);
buf2.write(str2, 1);
fs.write(fd2, buf2, 1, buf2.length-2, was.length, function (e,n,d) {
assert(!e, "No error from appending fs.write.");
assert(n === buf2.length-2, "Wrote proper amount from buffer.");
assert(d === buf2, "Returned original buffer.");
buf2.fill(0);
buf2[0] = 0xFF;
fs.read(fd, buf2, 1, buf2.length-1, was.length, function (e,n,d) {
assert(!e, "No error from twice-appended read.");
assert(n === buf2.length-1, "Read proper amount into buffer.");
assert(buf2[0] === 0xFF, "Read left first byte in buffer properly alone.");
assert(d.slice(1).toString() === (str+str2).slice(0, buf2.length-1), "Data was appended, not written at position.");
});
});
});
});
var F = [BASE_DIR,"Manually inspect from time to time, please!.txt"].join('/'),
S = 512,
N = 16,
b = _.allocBuffer(S*N);
for (var i = 0; i < N; ++i) b.slice(S*i, S*i+S).fill(i.toString(16).charCodeAt(0));
fs.writeFile(F, b, function (e) {
assert(!e, "No error from fs.writeFile with counting blocks.");
fs.readFile(F, function (e,d) {
assert(!e, "No error from fs.readFile with counting blocks.");
assert(d.length === b.length, "Readback is correct size");
var matched = true;
for (var i = 0; i < S*N; ++i) if (b[i] !== d[i]) matched = false;
assert(matched, "Readback matches write byte-for-byte");
});
});
fs.stat(F, function (e,d) {
assert(!e, "No error from fs.stat on counting blocks file.");
assert(d.atime instanceof Date && !isNaN(d.atime.getTime()), "Access time is a valid date.");
assert(d.mtime instanceof Date && !isNaN(d.mtime.getTime()), "Modify time is a valid date.");
assert(d.ctime instanceof Date && !isNaN(d.ctime.getTime()), "Change^WCreate time is a valid date.");
var tf = d.ctime.getTime(),
ct = Date.now();
assert(tf - 2*waitTime < ct && ct < tf + 2*waitTime, "Create time is within ± two `waitTime`s of now.");
fs.utimes(F, new Date(2009, 7-1, 2), null, function (e) {
assert(!e, "No error from fs.utimes.");
fs.stat(F, function (e,d2) {
assert(!e, "No fs.stat error after touching timestamps.");
assert(+d.ctime === +d2.ctime, "Create time not changed by fs.utimes");
assert(d2.atime.toString().indexOf("Jul 02 2009 00:00:00") === 4, "Access time set correctly");
var tf = d2.mtime.getTime(),
ct = Date.now();
assert(tf-2e3 < ct && ct < tf+2e3+waitTime, "Modify time is within ± a few seconds of now.");
// NOTE: due to serialization, this can check results of the `fs.chmod` below, too!
assert(!(d2.mode & 0100), "Archive bit is now unset.");
assert(!(d2.mode & 0222), "Writable perms are unset.");
assert(d2.mode & 0100000, "Regular file bit is set.");
});
});
assert(d.uid === 99, "Desired UID applied.");
assert(d.gid === 42, "Desired GID applied.");
assert(d.mode & 0100, "Archive bit is set.");
assert(d.mode & 0200, "Writable perm is set for user.");
assert((d.mode & 0022) === 0002, "Writable perm is only masked out for group.");
fs.chmod(F, 0422, function (e) {
assert(!e, "No error from fs.chmod.");
});
});
fs.chown(F, 99, 256, function (e) {
assert(e && e.code === 'NOSYS', "Expected error from fs.fchown.");
});
}
function startStreamTests() {
var file2 = [BASE_DIR,FILENAME+"2"].join('/'),
outStream = fs.createWriteStream(file2);
var outStreamOpened = false;
outStream.on('open', function (fd) {
outStreamOpened = true;
assert(typeof fd === 'number', "Got file descriptor on fs.createWriteStream open.");
}).on('error', function (e) {
assert(e, "If fs.createWriteStream fires 'error' event, it should include error object.");
assert(false, "But, fs.createWriteStream should not error during these tests.");
});
setTimeout(function () {
assert(outStreamOpened, "outStream fired 'open' event in a timely fashion.");
}, waitTime);
var TEXT_MOD = TEXTDATA.toLowerCase()+"\n",
NUM_REPS = (waitTime <= 1e3) ? 1024 : 16;
outStream.write(TEXT_MOD, 'utf16le');
outStream.write("Ο καλύτερος χρόνος να φυτευτεί ένα \ud83c\udf31 είναι δέκα έτη πριν.", 'utf16le');
outStream.write("La vez del segundo mejor ahora está.\n", 'utf16le');
for (var i = 0; i < NUM_REPS; ++i) outStream.write("123456789\n", 'ascii');
outStream.write("JavaScript how do they work\n", 'utf16le');
outStream.write("The end, almost.\n", 'utf16le');
outStream.end(TEXTDATA, 'utf16le');
var outStreamFinished = false;
outStream.on('finish', function () {
outStreamFinished = true;
var inStream = fs.createReadStream(file2, {start:NUM_REPS*10, encoding:'utf16le', autoClose:false}),
gotData = false, gotEOF = false, inStreamFD = null;
inStream.on('open', function (fd) {
assert(typeof fd === 'number', "Got file descriptor on fs.createReadStream open.");
inStreamFD = fd;
});
inStream.on('data', function (d) {
gotData = true;
assert(typeof d === 'string', "Data returned as string.");
assert(d.slice(d.length-TEXTDATA.length) === TEXTDATA, "End of file matches what was written.");
});
inStream.on('end', function () {
gotEOF = true;
var len = Buffer.byteLength(TEXT_MOD, 'utf16le'),
buf = _.allocBuffer(len);
fs.fsync(inStreamFD, function (e) {
assert(!e, "No error from proper fsync.");
});
fs.fsync('garbage', function (e) {
assert(e, "Expected error from garbage fsync.");
});
fs.read(inStreamFD, buf, 0, len, 0, function (e,n,d) {
assert(!e, "No error reading from beginning of inStream's file descriptor.");
assert(n === len, "Read complete buffer at beginning of inStream's fd.");
assert(d.toString('utf16le') === TEXT_MOD, "Data matches at beginning of inStream's fd.");
fs.close(inStreamFD, function (e) {
assert(!e, "No error closing inStream's fd.");
});
});
});
setTimeout(function () {
assert(gotData, "inStream fired 'data' event in a timely fashion.");
setTimeout(function () {
assert(gotEOF, "inStream fired 'eof' event in a timely fashion.");
}, waitTime);
}, waitTime);
});
setTimeout(function () {
assert(outStreamFinished, "outStream fired 'finish' event in a timely fashion.");
}, 2*waitTime);
}
});
}
function assert(b,msg) { if (!msg) console.warn("no msg", Error().stack); if (!b) throw Error("Assertion failure. "+msg); else console.log(msg); }

View File

@@ -1,165 +0,0 @@
var S = require("./structs.js"),
c = require("./chains.js"),
$ = require("./cache.js"),
_ = require("./helpers.js");
exports.init = function (volume, opts, bootSector) {
if (bootSector[510] !== 0x55 || bootSector[511] !== 0xAA) throw Error("Invalid volume signature!");
var isFAT16 = bootSector.readUInt16LE(S.boot16.fields['FATSz16'].offset),
bootStruct = (isFAT16) ? S.boot16 : S.boot32,
BS = bootStruct.valueFromBytes(bootSector);
_.log(_.log.DBG, "Boot sector info:", BS);
bootSector = null; // allow GC
if (!BS.BytsPerSec) throw Error("This looks like an ExFAT volume! (unsupported)");
else if (BS.BytsPerSec !== volume.sectorSize) throw Error("Sector size mismatch with FAT table.");
var FATSz = (isFAT16) ? BS.FATSz16 : BS.FATSz32,
rootDirSectors = Math.ceil((BS.RootEntCnt * 32) / BS.BytsPerSec),
firstDataSector = BS.ResvdSecCnt + (BS.NumFATs * FATSz) + rootDirSectors,
totSec = (BS.TotSec16) ? BS.TotSec16 : BS.TotSec32,
dataSec = totSec - firstDataSector,
countofClusters = Math.floor(dataSec / BS.SecPerClus);
// avoid corrupting sectors from other partitions or whatnot
if (totSec > volume.numSectors) throw Error("Volume size mismatch!");
var fatType;
if (countofClusters < 4085) {
fatType = 'fat12';
} else if (countofClusters < 65525) {
fatType = 'fat16';
} else {
fatType = 'fat32';
}
_.log(_.log.DBG, "rootDirSectors", rootDirSectors, "firstDataSector", firstDataSector, "countofClusters", countofClusters, "=>", fatType);
var vol = {};
vol.opts = opts;
vol._sectorSize = BS.BytsPerSec;
vol._sectorsPerCluster = BS.SecPerClus;
vol._firstSectorOfCluster = function (n) {
return firstDataSector + (n-2)*vol._sectorsPerCluster;
};
vol._makeCache = function () {
return $.wrapDriver(volume);
};
vol._readSectors = function (cache, secNum, dest, cb) {
if (typeof dest === 'function') {
cb = dest;
dest = _.allocBuffer(vol._sectorSize);
}
_.log(_.log.DBG, "vol._readSectors", secNum, dest.length);
if (secNum < volume.numSectors) cache.readSectors(secNum, dest, function (e) { cb(e, dest); });
else throw Error("Invalid sector number!");
};
vol._writeSectors = function (cache, secNum, data, cb) {
_.log(_.log.DBG, "vol._writeSectors", secNum, data.length);
// NOTE: these are internal assertions, public API will get proper `S.err`s
if (data.length % volume.sectorSize) throw Error("Buffer length not a multiple of sector size");
else if (opts.ro) throw Error("Read-only filesystem");
else if (secNum < volume.numSectors) cache.writeSectors(secNum, data, cb);
else throw Error("Invalid sector number!");
};
function fatInfoForCluster(n) {
var entryStruct = S.fatField[fatType],
FATOffset = (fatType === 'fat12') ? Math.floor(n/2) * entryStruct.size : n * entryStruct.size,
SecNum = BS.ResvdSecCnt + Math.floor(FATOffset / BS.BytsPerSec);
EntOffset = FATOffset % BS.BytsPerSec;
return {sector:SecNum-BS.ResvdSecCnt, offset:EntOffset, struct:entryStruct};
}
// TODO: all this FAT manipulation is crazy inefficient! needs read caching *and* write caching
// …the best place for cache might be in `volume` handler, though. add a `flush` method to that spec?
// TODO: how should we handle redundant FATs? mirror every write? just ignore completely? copy-on-eject?
var fatChain = c.sectorChain(vol, BS.ResvdSecCnt, FATSz);
fatChain.cacheAdvice = 'RANDOM';
vol.fetchFromFAT = function (clusterNum, cb) {
var info = fatInfoForCluster(clusterNum);
fatChain.readFromPosition(info, info.struct.size, function (e,n,d) {
if (e) return cb(e);
var status = info.struct.valueFromBytes(d), prefix;
if (fatType === 'fat12') {
if (clusterNum % 2) {
status = (status.field1ab << 4) + status.field1c;
} else {
status = (status.field0a << 8) + status.field0bc;
}
}
else if (fatType === 'fat32') {
status &= 0x0FFFFFFF;
}
var prefix = S.fatPrefix[fatType];
if (status === S.fatStat.free) cb(null, 'free');
else if (status === S.fatStat._undef) cb(null, '-invalid-');
else if (status > prefix+S.fatStat.eofMin) cb(null, 'eof');
else if (status === prefix+S.fatStat.bad) cb(null, 'bad');
else if (status > prefix+S.fatStat.rsvMin) cb(null, 'reserved');
else cb(null, status);
});
};
vol.storeToFAT = function (clusterNum, status, cb) {
if (typeof status === 'string') {
status = S.fatStat[status];
status += S.fatPrefix[fatType];
}
var info = fatInfoForCluster(clusterNum);
// TODO: technically fat32 needs to *preserve* the high 4 bits
if (fatType === 'fat12') fatChain.readFromPosition(info, info.struct.size, function (e,n,d) {
var value = info.struct.valueFromBytes(d);
if (clusterNum % 2) {
value.field1ab = status >>> 4;
value.field1c = status & 0x0F;
} else {
value.field0a = status >>> 8;
value.field0bc = status & 0xFF;
}
var entry = info.struct.bytesFromValue(value);
fatChain.writeToPosition(info, entry, cb);
}); else {
var entry = info.struct.bytesFromValue(status);
fatChain.writeToPosition(info, entry, cb);
}
};
vol.allocateInFAT = function (hint, cb) {
if (typeof hint === 'function') {
cb = hint;
hint = 2; // TODO: cache a better starting point?
}
function searchForFreeCluster(num, cb) {
if (num < countofClusters) vol.fetchFromFAT(num, function (e, status) {
if (e) cb(e);
else if (status === 'free') cb(null, num);
else searchForFreeCluster(num+1, cb);
}); else cb(S.err.NOSPC()); // TODO: try searching backwards from hint…
}
searchForFreeCluster(hint, function (e, clusterNum) {
if (e) cb(e);
else vol.storeToFAT(clusterNum, 'eof', cb.bind(null,null,clusterNum));
});
};
vol.rootDirectoryChain = (isFAT16) ?
c.sectorChain(vol, firstDataSector - rootDirSectors, rootDirSectors) :
c.clusterChain(vol, BS.RootClus);
vol.rootDirectoryChain.cacheAdvice = 'WILLNEED';
vol.chainForCluster = c.clusterChain.bind(c, vol);
vol.chainFromJSON = function (d) {
return ('numSectors' in d) ?
c.sectorChain(vol, d.firstSector, d.numSectors) :
c.clusterChain(vol, d.firstCluster);
};
return vol;
}

View File

@@ -1,49 +0,0 @@
Mount a vhd generated by xen-orchestra to filesystem
### Library
```js
import { mount } from 'fuse-vhd'
// return a disposable, see promise-toolbox/Disposable
// unmount automatically when disposable is disposed
// in case of differencing VHD, it mounts the full chain
await mount(handler, diskId, mountPoint)
```
### cli
From the install folder:
```
cli.mjs <remoteUrl> <vhdPathInRemote> <mountPoint>
```
After installing the package
```
xo-fuse-vhd <remoteUrl> <vhdPathInRemote> <mountPoint>
```
The `remoteUrl` can be found using the following command from the XOA CLI, and then picking the right `url` property:
`xo-server-db ls remote`
The `vhdPathInRemote` follows a file structure described in [VM backups](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/VM%20backups/README.md).
## Restore a file from a VHD using `fuse-vhd` CLI
1. Mount a VHD to the Filesystem (see **Usage**).
2. Verify the VHD is Correctly Mounted:
`mount | grep fuse`
3. Get the `START` and `SIZE` of the disk whose file you want to restore. Multiply `START` by 512 (block size) to get the offset value. (`mountedVhdPath` is the `mountPoint` value from earlier, followed by `/vhd0`, for example). Depending on the partition type, you may need to do some additional setup to discover the partitions.
`partx --bytes --output=NR,START,SIZE,NAME,UUID,TYPE --pairs <mountedVhdPath>`
4. Mount the disk. Depending on the partition type, you may need to add some additional options. The `norecovery` option is used for ext3/ext4/xfs file systems; otherwise, remove this option.
`mount --options=loop,ro,norecovery,sizelimit=<SIZE>,offset=<START*512> --source=<mountedVhdPath> --target=<diskMountPoint>`
5. Copy your files from the mounted partition, then unmount the partition.

View File

@@ -1,80 +0,0 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/fuse-vhd
[![Package Version](https://badgen.net/npm/v/@vates/fuse-vhd)](https://npmjs.org/package/@vates/fuse-vhd) ![License](https://badgen.net/npm/license/@vates/fuse-vhd) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/fuse-vhd)](https://bundlephobia.com/result?p=@vates/fuse-vhd) [![Node compatibility](https://badgen.net/npm/node/@vates/fuse-vhd)](https://npmjs.org/package/@vates/fuse-vhd)
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/fuse-vhd):
```sh
npm install --save @vates/fuse-vhd
```
## Usage
Mount a vhd generated by xen-orchestra to filesystem
### Library
```js
import { mount } from 'fuse-vhd'
// return a disposable, see promise-toolbox/Disposable
// unmount automatically when disposable is disposed
// in case of differencing VHD, it mounts the full chain
await mount(handler, diskId, mountPoint)
```
### cli
From the install folder:
```
cli.mjs <remoteUrl> <vhdPathInRemote> <mountPoint>
```
After installing the package
```
xo-fuse-vhd <remoteUrl> <vhdPathInRemote> <mountPoint>
```
The `remoteUrl` can be found using the following command from the XOA CLI, and then picking the right `url` property:
`xo-server-db ls remote`
The `vhdPathInRemote` follows a file structure described in [VM backups](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/VM%20backups/README.md).
## Restore a file from a VHD using `fuse-vhd` CLI
1. Mount a VHD to the Filesystem (see **Usage**).
2. Verify the VHD is Correctly Mounted:
`mount | grep fuse`
3. Get the `START` and `SIZE` of the disk whose file you want to restore. Multiply `START` by 512 (block size) to get the offset value. (`mountedVhdPath` is the `mountPoint` value from earlier, followed by `/vhd0`, for example). Depending on the partition type, you may need to do some additional setup to discover the partitions.
`partx --bytes --output=NR,START,SIZE,NAME,UUID,TYPE --pairs <mountedVhdPath>`
4. Mount the disk. Depending on the partition type, you may need to add some additional options. The `norecovery` option is used for ext3/ext4/xfs file systems; otherwise, remove this option.
`mount --options=loop,ro,norecovery,sizelimit=<SIZE>,offset=<START*512> --source=<mountedVhdPath> --target=<diskMountPoint>`
5. Copy your files from the mounted partition, then unmount the partition.
## Contributions
Contributions are _very_ welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
you've encountered;
- fork and create a pull request.
## License
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)

View File

@@ -1,26 +0,0 @@
#!/usr/bin/env node
import Disposable from 'promise-toolbox/Disposable'
import { getSyncedHandler } from '@xen-orchestra/fs'
import { mount } from './index.mjs'
async function* main([remoteUrl, vhdPathInRemote, mountPoint]) {
if (mountPoint === undefined) {
throw new TypeError('missing arg: cli <remoteUrl> <vhdPathInRemote> <mountPoint>')
}
const handler = yield getSyncedHandler({ url: remoteUrl })
const mounted = await mount(handler, vhdPathInRemote, mountPoint)
let disposePromise
process.on('SIGINT', async () => {
// ensure single dispose
if (!disposePromise) {
disposePromise = mounted.dispose()
}
await disposePromise
process.exit()
})
}
Disposable.wrap(main)(process.argv.slice(2))

View File

@@ -1,12 +1,9 @@
import Fuse from 'fuse-native'
import LRU from 'lru-cache'
import { createLogger } from '@xen-orchestra/log'
import Fuse from 'fuse-native'
import { VhdSynthetic } from 'vhd-lib'
import { Disposable, fromCallback } from 'promise-toolbox'
const { warn } = createLogger('vates:fuse-vhd')
// build a stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
// build a s stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
const stat = st => ({
mtime: st.mtime || new Date(),
atime: st.atime || new Date(),
@@ -20,120 +17,48 @@ const stat = st => ({
export const mount = Disposable.factory(async function* mount(handler, diskPath, mountDir) {
const vhd = yield VhdSynthetic.fromVhdChain(handler, diskPath)
const cache = new LRU({
max: 16, // each cached block is 2MB in size
})
await vhd.readBlockAllocationTable()
const { blockSize } = vhd.header
// A zeroed buffer returned for VHD blocks that are not present in the chain.
// Never mutated — safe to share across all reads.
const EMPTY_BLOCK = Buffer.alloc(blockSize, 0)
// --- coalescing block read queue ---
//
// FUSE read requests from NTFS-3g arrive in small chunks (typically 4 KiB)
// while VHD blocks are 2 MiB. Reading blocks one at a time from the backing
// store (CIFS/NFS/…) bounds libuv threadpool usage to 1 concurrent I/O call.
//
// Coalescing: all pending requests for the same blockId share a single
// readBlock() call. When the block resolves, every waiter is fulfilled at once.
//
// Together these two properties prevent the FUSE-on-FUSE threadpool deadlock:
// concurrent NTFS-3g reads no longer starve the VHD block reads they depend on.
// In-memory block cache. Each VHD block is 2 MiB; 16 blocks = 32 MiB.
// This is the primary tool against the 300× CIFS amplification: NTFS-3g
// reads a 2 MiB block in ~512 sequential 4 KiB FUSE requests. Without a
// cache, each request causes a full 2 MiB CIFS read. With the cache, only
// the first request reads from CIFS; the rest are served from RAM.
const blockCache = new LRU({ max: 64 })
const pendingBlocks = new Map() // blockId → Promise<Buffer>
const readQueue = [] // { blockId, resolve, reject }[]
let queueRunning = false
// log stats every 10 s while there is activity
function enqueueBlock(blockId) {
// 1. serve from cache if available
if (blockCache.has(blockId)) {
return Promise.resolve(blockCache.get(blockId))
}
// 2. coalesce: join an existing in-flight read for this block
if (pendingBlocks.has(blockId)) {
return pendingBlocks.get(blockId)
}
const p = new Promise((resolve, reject) => {
readQueue.push({ blockId, resolve, reject })
})
pendingBlocks.set(blockId, p)
p.then(
data => {
pendingBlocks.delete(blockId)
blockCache.set(blockId, data)
},
() => pendingBlocks.delete(blockId)
)
drainQueue()
return p
}
function drainQueue() {
if (queueRunning) return
queueRunning = true
// async IIFE — errors are forwarded to individual reject() callbacks,
// so the outer floating promise is always fulfilled
;(async () => {
while (readQueue.length > 0) {
const { blockId, resolve, reject } = readQueue.shift()
try {
resolve((await vhd.readBlock(blockId)).data)
} catch (err) {
reject(err)
}
}
queueRunning = false
})()
}
async function readData(buf, len, pos) {
if (len === 0) return 0
const startBlockId = Math.floor(pos / blockSize)
// use (pos + len - 1) so an exact block boundary doesn't include the next block
const endBlockId = Math.floor((pos + len - 1) / blockSize)
let copied = 0
for (let blockId = startBlockId; blockId <= endBlockId; blockId++) {
const data = vhd.containsBlock(blockId) ? await enqueueBlock(blockId) : EMPTY_BLOCK
const offsetStart = blockId === startBlockId ? pos % blockSize : 0
const offsetEnd = blockId === endBlockId ? ((pos + len - 1) % blockSize) + 1 : blockSize
data.copy(buf, copied, offsetStart, offsetEnd)
copied += offsetEnd - offsetStart
}
return copied
}
const fuse = new Fuse(mountDir, {
async readdir(path, cb) {
if (path === '/') return cb(null, ['vhd0'])
if (path === '/') {
return cb(null, ['vhd0'])
}
cb(Fuse.ENOENT)
},
async getattr(path, cb) {
if (path === '/') return cb(null, stat({ mode: 'dir', size: 4096 }))
if (path === '/vhd0') return cb(null, stat({ mode: 'file', size: vhd.footer.currentSize }))
if (path === '/') {
return cb(
null,
stat({
mode: 'dir',
size: 4096,
})
)
}
if (path === '/vhd0') {
return cb(
null,
stat({
mode: 'file',
size: vhd.footer.currentSize,
})
)
}
cb(Fuse.ENOENT)
},
read(path, fd, buf, len, pos, cb) {
if (path === '/vhd0') {
return readData(buf, len, pos).then(cb, error => {
warn('read error', { path, len, pos, error })
cb(Fuse.EIO)
})
return vhd.readRawData(pos, len, cache, buf).then(cb)
}
throw new Error(`read file ${path} not exists`)
},
})
return new Disposable(
() => {
return fromCallback(cb => fuse.unmount(cb))
},
fromCallback(cb => fuse.mount(cb))
() => fromCallback(() => fuse.unmount()),
fromCallback(() => fuse.mount())
)
})

View File

@@ -1,6 +1,6 @@
{
"name": "@vates/fuse-vhd",
"version": "2.1.3",
"version": "2.0.0",
"license": "ISC",
"private": false,
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/fuse-vhd",
@@ -19,15 +19,10 @@
},
"main": "./index.mjs",
"dependencies": {
"@xen-orchestra/fs": "^4.9.5",
"@xen-orchestra/log": "^0.7.2",
"fuse-native": "^2.2.6",
"lru-cache": "^7.14.0",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.16.2"
},
"bin": {
"xo-fuse-vhd": "./cli.mjs"
"vhd-lib": "^4.5.0"
},
"scripts": {
"postversion": "npm publish --access public"

View File

@@ -1,58 +0,0 @@
A toolbox to ease the use of generator
### Timeout
wrap a source async generator to have it throw an error when timeout is reached
timeout is a positive number in milliseconds
```js
import { Timeout } from '@vates/generator-toolbox'
const wrappedGenerator = new Timeout(sourceGenerator, timeout)
```
### Throttle
wrap a source async generator to have it respect a max speed ( in bytes per seconds).
speed is either a strictly positive number or a function returning a strictly positive number. A speed change will be used for the next emitted packet.
The source generator must yield object with a length property.
Optimized for small yields regarding to the speed, since it won't split incoming packet.
If the generator reached the max speed it will be paused, limiting memory consumption.
```js
import { Throttle } from '@vates/generator-toolbox'
const wrappedGenerator = new Throttle(sourceGenerator, speed)
```
### Synchronized
Fork a generator. The rules are:
- if the source returns, all the forks return
- if the forks error, all the forks error with the same error
- if a fork returns, it is stopped, but the generator continue with the other
- if a fork errors, it is stopped, but the generator continue with the other
- if all the forks return, the source is stopped
- if all the forks error, the source is errored with the last error
- the source start producing a packet when the fastest forked ask for it
- the source forks get the packet only when all the forks asked for it, no buffer stores in memory
```ts
import { Synchronized } from '@vates/generator-toolbox'
async function consume(generator: AsyncGenerator) {
for await (const val of generator) {
console.log({ val })
}
}
const forker = new Synchronized(generator)
const first = forker.fork('first')
const second = forker.fork('second')
await Promise.all([consume(first), consume(second)])
```
Note: you can stop early a generator by calling `generator.return()`, and you can stop in in error by calling `generator.throw(error)`

View File

@@ -1 +0,0 @@
../../scripts/npmignore

View File

@@ -1,89 +0,0 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/generator-toolbox
[![Package Version](https://badgen.net/npm/v/@vates/generator-toolbox)](https://npmjs.org/package/@vates/generator-toolbox) ![License](https://badgen.net/npm/license/@vates/generator-toolbox) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/generator-toolbox)](https://bundlephobia.com/result?p=@vates/generator-toolbox) [![Node compatibility](https://badgen.net/npm/node/@vates/generator-toolbox)](https://npmjs.org/package/@vates/generator-toolbox)
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/generator-toolbox):
```sh
npm install --save @vates/generator-toolbox
```
## Usage
A toolbox to ease the use of generator
### Timeout
wrap a source async generator to have it throw an error when timeout is reached
timeout is a positive number in milliseconds
```js
import { Timeout } from '@vates/generator-toolbox'
const wrappedGenerator = new Timeout(sourceGenerator, timeout)
```
### Throttle
wrap a source async generator to have it respect a max speed ( in bytes per seconds).
speed is either a strictly positive number or a function returning a strictly positive number. A speed change will be used for the next emitted packet.
The source generator must yield object with a length property.
Optimized for small yields regarding to the speed, since it won't split incoming packet.
If the generator reached the max speed it will be paused, limiting memory consumption.
```js
import { Throttle } from '@vates/generator-toolbox'
const wrappedGenerator = new Throttle(sourceGenerator, speed)
```
### Synchronized
Fork a generator. The rules are:
- if the source returns, all the forks return
- if the forks error, all the forks error with the same error
- if a fork returns, it is stopped, but the generator continue with the other
- if a fork errors, it is stopped, but the generator continue with the other
- if all the forks return, the source is stopped
- if all the forks error, the source is errored with the last error
- the source start producing a packet when the fastest forked ask for it
- the source forks get the packet only when all the forks asked for it, no buffer stores in memory
```ts
import { Synchronized } from '@vates/generator-toolbox'
async function consume(generator: AsyncGenerator) {
for await (const val of generator) {
console.log({ val })
}
}
const forker = new Synchronized(generator)
const first = forker.fork('first')
const second = forker.fork('second')
await Promise.all([consume(first), consume(second)])
```
Note: you can stop early a generator by calling `generator.return()`, and you can stop in in error by calling `generator.throw(error)`
## Contributions
Contributions are _very_ welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
you've encountered;
- fork and create a pull request.
## License
[MIT](https://spdx.org/licenses/MIT) © [Vates SAS](https://vates.fr)

View File

@@ -1,6 +0,0 @@
// @ts-check
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
export default tseslint.config(eslint.configs.recommended, tseslint.configs.recommended)

View File

@@ -1,38 +0,0 @@
{
"name": "@vates/generator-toolbox",
"version": "1.1.3",
"main": "dist/index.mjs",
"license": "MIT",
"private": false,
"type": "module",
"devDependencies": {
"@eslint/js": "^9.19.0",
"rimraf": "^6.0.1",
"@types/node": "^20.6",
"typescript": "~5.6",
"typescript-eslint": "^8.61.0"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"postversion": "npm publish --access public",
"prebuild": "rimraf dist/",
"predev": "yarn run prebuild",
"test": "tsc && node --test **/*.test.mjs",
"clean": "rimraf dist/"
},
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/generator-toolbox",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/generator-toolbox",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"engines": {
"node": ">=20.18"
}
}

View File

@@ -1,3 +0,0 @@
export { Synchronized } from './synchronized.mjs'
export { Throttle } from './throttle.mjs'
export { Timeout } from './timeout.mjs'

View File

@@ -1,150 +0,0 @@
import assert from 'node:assert'
export class Synchronized<T, TReturn, TNext> {
#source: AsyncGenerator<T, TReturn, TNext>
#forks = new Map<string, Forked<T, TReturn, TNext>>()
#removedForks = new Set<string>()
#waitingForks = new Set<string>()
#started = false
#nextValueForksReady?: {
promise: Promise<IteratorResult<T>>
forksWaitingReject: (error: Error) => void
forksWaitingResolve: () => void
}
constructor(source: AsyncGenerator<T, TReturn, TNext>) {
this.#source = source
}
fork(uid: string): AsyncGenerator<T, TReturn, TNext> {
assert.strictEqual(this.#started, false, `can't create a fork after consuming the data`)
assert.strictEqual(this.#forks.has(uid), false, `a fork with id ${uid} already exists`)
const fork = new Forked<T, TReturn, TNext>(this, uid)
this.#forks.set(uid, fork)
return fork
}
async #resolveWhenAllForksReady(): Promise<IteratorResult<T>> {
if (!this.#nextValueForksReady) {
throw new Error('Can t wait forks if there are noone waiting')
}
const { promise, forksWaitingResolve } = this.#nextValueForksReady
if (this.#waitingForks.size === this.#forks.size) {
// reset value
this.#waitingForks.clear()
this.#nextValueForksReady = undefined
forksWaitingResolve() // for the other forks waiting
}
return promise
}
async next(uid: string): Promise<IteratorResult<T>> {
if (this.#removedForks.has(uid)) {
return { done: true, value: undefined }
}
if (!this.#forks.has(uid)) {
throw new Error(`trying to advance fork ${uid} that is not a fork of this one`)
}
if (this.#waitingForks.has(uid)) {
throw new Error(`Fork ${uid} is already waiting`)
}
this.#started = true
if (this.#nextValueForksReady === undefined) {
let forksWaitingResolve = () => {}
let forksWaitingReject: (reason?: Error) => void = () => {}
const next = this.#source.next().catch(async error => {
const e = new Error(`Error in the source generator ${error.message}`, { cause: error })
forksWaitingReject(e)
// source has failed, kill everything, and stop the forks
for (const uid of [...this.#forks.keys()]) {
await this.remove(uid, error)
}
})
const promise = Promise.all([
next,
new Promise((_resolve, _reject) => {
forksWaitingResolve = () => _resolve(undefined)
forksWaitingReject = _reject
}),
]).then(([_]) => _ as IteratorResult<T>)
this.#nextValueForksReady = { promise, forksWaitingResolve, forksWaitingReject }
}
this.#waitingForks.add(uid)
return this.#resolveWhenAllForksReady()
}
async remove(uid: string, error?: Error): Promise<IteratorResult<T>> {
const fork = this.#forks.get(uid)
if (fork === undefined) {
if (this.#removedForks.has(uid)) {
// already removed
return { done: true, value: undefined }
}
throw new Error(`trying to remove fork with uid ${uid} that is not a fork of this one`)
}
this.#forks.delete(uid)
this.#waitingForks.delete(uid)
this.#removedForks.add(uid)
try {
if (error === undefined) {
await fork.return()
} else {
await fork.throw(error)
}
} catch (cleaningError) {
console.error('Error while cleaning the forked', {
cleaningError,
sourceError: error,
})
}
if (this.#forks.size === 0) {
if (error === undefined) {
await this.#source.return(undefined as TReturn)
} else {
await this.#source.throw(error)
}
// Reject any pending forks waiting for the next value
if (this.#nextValueForksReady) {
this.#nextValueForksReady.forksWaitingReject(new Error('Source generator terminated.', { cause: error }))
this.#nextValueForksReady = undefined
}
// clear state
this.#removedForks.clear()
this.#waitingForks.clear()
} else {
// this fork was maybe blocking the others
if (this.#nextValueForksReady) {
await this.#resolveWhenAllForksReady()
}
}
return { done: true, value: undefined }
}
}
class Forked<T, TReturn, TNext> implements AsyncGenerator<T, TReturn, TNext> {
#parent: Synchronized<T, TReturn, TNext>
#uid: string
constructor(parent: Synchronized<T, TReturn, TNext>, uid: string) {
this.#parent = parent
this.#uid = uid
}
next(): Promise<IteratorResult<T>> {
return this.#parent.next(this.#uid)
}
async return(): Promise<IteratorResult<T>> {
return this.#parent.remove(this.#uid)
}
async throw(e: Error): Promise<IteratorResult<T>> {
return this.#parent.remove(this.#uid, e)
}
[Symbol.asyncIterator](): AsyncGenerator<T, TReturn, TNext> {
return this
}
}

View File

@@ -1,173 +0,0 @@
import assert from 'node:assert'
import { suite, test } from 'node:test'
import { Synchronized } from './synchronized.mjs'
async function* makeRangeGenerator(end = Infinity, progress = { yielded: 0 }, onYielded = (_val: unknown) => {}) {
for (let i = 0; i < end; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
yield i
onYielded(i)
progress.yielded = i
}
return progress
}
async function consume(
iterable: AsyncGenerator,
delay = 2500,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onConsumed = (_val: unknown, _iterable: AsyncGenerator) => Promise.resolve(false)
) {
for await (const val of iterable) {
await new Promise(resolve => setTimeout(resolve, delay))
if (await onConsumed(val, iterable)) {
iterable.return(undefined)
}
}
}
suite('success', () => {
test('for-await-of break stops the source generator', async () => {
let sourceClosed = false
const source = (async function* () {
try {
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
yield i
}
} finally {
sourceClosed = true
}
})()
const forker = new Synchronized(source)
const fork = forker.fork('first')
// for-await-of must call fork.return() on break, which propagates to the source
for await (const val of fork) {
if (val === 2) break
}
assert.strictEqual(sourceClosed, true)
})
test('if works with multiple consumer', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(3, progress)
const forker = new Synchronized(generator)
const first = forker.fork('first')
const second = forker.fork('second')
await Promise.all([consume(first, 50), consume(second, 500)])
assert.strictEqual(progress.yielded, 2)
})
test('if works with one consumer returning', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(10, progress)
const forker = new Synchronized(generator)
const first = forker.fork('first')
const second = forker.fork('second')
await new Promise(resolve => setTimeout(resolve, 500))
await Promise.all([
consume(first, 1),
consume(second, 1, async (val: unknown) => {
return Promise.resolve(val === 2)
}),
])
assert.strictEqual(progress.yielded, 9)
})
test('if works with all consumers returning', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(10, progress)
const forker = new Synchronized(generator)
const first = forker.fork('first')
const second = forker.fork('second')
await new Promise(resolve => setTimeout(resolve, 500))
await Promise.all([
consume(first, 1, async (val: unknown) => {
return Promise.resolve(val === 5)
}),
consume(second, 1, async (val: unknown) => {
return Promise.resolve(val === 2)
}),
])
assert.strictEqual(progress.yielded, 4)
})
})
suite('error', () => {
test('it fails all if error on source', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(5, progress, (val: unknown) => {
if (val === 2) {
throw new Error('source stop')
}
})
const forker = new Synchronized(generator)
const first = forker.fork('first')
const second = forker.fork('second')
await assert.rejects(Promise.all([consume(first, 1), consume(second, 1)]))
assert.strictEqual(progress.yielded, 1)
})
test('it flows until the end if one consumer throws', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(10, progress)
const forker = new Synchronized(generator)
const first = forker.fork('first')
const second = forker.fork('second')
const firstConsume = consume(first, 1)
const secondConsume = assert.rejects(() =>
consume(second, 1, async (val: unknown, iterable) => {
if (val === 1) {
await iterable.throw(new Error('error on second'))
}
return false
})
)
await Promise.allSettled([firstConsume, secondConsume])
assert.strictEqual(progress.yielded, 9)
})
test('it throw if ALL consumer throws', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(5, progress)
const forker = new Synchronized(generator)
const first = forker.fork('first')
const second = forker.fork('second')
const firstConsume = consume(first, 1, async (val: unknown, iterable) => {
if (val === 3) {
await iterable.throw(new Error('error on first'))
}
return false
})
const secondConsume = consume(second, 1, async (val: unknown, iterable) => {
if (val === 1) {
await iterable.throw(new Error('error on second'))
}
return false
})
await assert.rejects(Promise.all([firstConsume, secondConsume]))
assert.strictEqual(progress.yielded, 2)
})
test('It should not allow a new fork after the start', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(5, progress)
const forker = new Synchronized(generator)
const first = forker.fork('first')
await first.next()
assert.throws(() => forker.fork('second'))
})
test('It should not allow fork with the same uid', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(5, progress)
const forker = new Synchronized(generator)
forker.fork('first')
forker.fork('second')
assert.throws(() => forker.fork('first'))
})
})

View File

@@ -1,68 +0,0 @@
import assert from 'node:assert'
/**
*
* This class will throttle the production of a group of generators
*
* limits: depends on the event loop, so don't expect it to be very precise
* works better with more small blocks
* The source generator must yield object with a length property
* Changing the speed will only be takin into account fot the next packets asked
*/
export class Throttle {
#previousSlot = 0
#bytesPerSecond: number | (() => number)
get speed(): number {
let speed: number
if (typeof this.#bytesPerSecond === 'function') {
speed = this.#bytesPerSecond()
} else {
speed = this.#bytesPerSecond
}
assert.ok(speed >= 0, `speed must be positive, ${speed} computed`)
return speed
}
constructor(speed: number | (() => number)) {
this.#bytesPerSecond = speed
}
getNextSlot(length: number): { timeout?: ReturnType<typeof setTimeout>; promise?: Promise<unknown> } {
assert.notStrictEqual(length, undefined, `throttled stream need to expose a length property }`)
assert.ok(length > 0, `throttled stream must expose a positive length property , ${length} given }`)
if (this.speed === 0) {
return {}
}
const previous = this.#previousSlot
const nextSlot = Math.round(previous + (length * 1000) / this.speed)
if (nextSlot < Date.now()) {
// we're above the limit, go now
this.#previousSlot = Date.now()
return {}
}
this.#previousSlot = nextSlot
// wait till the next slot
// it won't be extremely precise since the event loop is not
let timeout: ReturnType<typeof setTimeout> | undefined = undefined
const promise = new Promise(function (resolve) {
timeout = setTimeout(resolve, nextSlot - Date.now())
})
return { promise, timeout }
}
async *createThrottledGenerator(source: AsyncGenerator<{ length: number }>): AsyncGenerator<{ length: number }> {
let timeout: ReturnType<typeof setTimeout> | undefined
try {
for await (const value of source) {
const res = this.getNextSlot(value.length)
timeout = res.timeout
// wait for the time slot before yielding the data
if (res.promise !== undefined) {
await res.promise
}
yield value
}
} finally {
clearTimeout(timeout)
}
}
}

View File

@@ -1,63 +0,0 @@
import { Throttle } from './throttle.mjs'
import { suite, test } from 'node:test'
import assert from 'node:assert'
async function* makeGenerator(chunkSize = 1024 * 1024, nbChunks = 100) {
for (let i = 0; i < nbChunks; i++) {
yield Buffer.allocUnsafe(chunkSize)
}
}
async function consumes(generator: AsyncGenerator) {
// eslint-disable-next-line no-empty,@typescript-eslint/no-unused-vars
for await (const data of generator) {
}
}
suite('it throttle one', () => {
test('base test', async () => {
// 10MB/s
const throttler = new Throttle(10 * 1024 * 1024)
const throttled = throttler.createThrottledGenerator(makeGenerator(1024 * 1024, 20))
const start = Date.now()
await consumes(throttled)
const end = Date.now()
assert.strictEqual(Math.round((end - start) / 1000), 2)
})
test('variable speed test', async () => {
// 10MB/s
const throttler = new Throttle(() => 10 * 1024 * 1024)
const throttled = throttler.createThrottledGenerator(makeGenerator(1024 * 1024, 20))
const start = Date.now()
await consumes(throttled)
const end = Date.now()
assert.strictEqual(Math.round((end - start) / 1000), 2)
})
test('multiple generator test', async () => {
// 10MB/s
const throttler = new Throttle(10 * 1024 * 1024)
const first = throttler.createThrottledGenerator(makeGenerator(1024 * 1024, 10))
const second = throttler.createThrottledGenerator(makeGenerator(1024 * 1024, 10))
const start = Date.now()
await Promise.all([consumes(first), consumes(second)])
const end = Date.now()
assert.strictEqual(Math.round((end - start) / 1000), 2)
})
test(`it doesn't accept negative value`, async () => {
// 10MB/s
let throttler = new Throttle(-10 * 1024 * 1024)
let generator = throttler.createThrottledGenerator(makeGenerator(1024 * 1024, 10))
await assert.rejects(() => consumes(generator))
let index = 5
throttler = new Throttle(() => {
index--
return index > 0 ? 10 * 1024 * 1024 : -10 * 1024 * 1024
})
generator = throttler.createThrottledGenerator(makeGenerator(1024 * 1024, 10))
await assert.rejects(() => consumes(generator))
})
})

View File

@@ -1,49 +0,0 @@
import assert from 'node:assert'
const TIMEOUT = Symbol('timeout')
export class Timeout<T> implements AsyncGenerator {
#source: AsyncGenerator<T>
#timeout: number
constructor(source: AsyncGenerator<T>, timeout: number) {
assert.ok(timeout > 0, `Timeout value must be positive, ${timeout} received`)
this.#source = source
this.#timeout = timeout
}
async next(): Promise<IteratorResult<T>> {
let timeoutHandle: ReturnType<typeof setTimeout>
const sourceNext = this.#source.next()
const result = await Promise.race([
sourceNext.then(res => {
clearTimeout(timeoutHandle)
return res
}),
new Promise<typeof TIMEOUT>(resolve => {
timeoutHandle = setTimeout(() => resolve(TIMEOUT), this.#timeout)
}),
])
if (result === TIMEOUT) {
// stop the source once the in-flight next settles to avoid data loss on the next call
// we don't want to await for the end of timeouted code
sourceNext.then(
() => this.#source.return(undefined).catch(() => {}),
() => {} // source already errored, nothing to clean up
)
throw new Error('Timeout reached')
}
return result
}
return(): Promise<IteratorResult<T>> {
return this.#source.return(undefined)
}
throw(e: Error): Promise<IteratorResult<T>> {
return this.#source.throw(e)
}
[Symbol.asyncIterator](): AsyncGenerator<T> {
return this
}
}

View File

@@ -1,87 +0,0 @@
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { Timeout } from './timeout.mjs'
describe('Timeout class', () => {
it('should reject a timeout with a negative or zero value ', async () => {
// Create a mock async generator that never resolves
const mockGenerator = (async function* () {
yield 1
})()
assert.throws(() => new Timeout(mockGenerator, 0))
assert.throws(() => new Timeout(mockGenerator, -10))
})
it('should resolve with the value from the source generator if it completes before the timeout', async () => {
// Create a mock async generator that resolves immediately
const mockGenerator = (async function* () {
yield 1
})()
const timeout = new Timeout(mockGenerator, 100) // 100ms timeout
const result = await timeout.next()
assert.strictEqual(result.value, 1)
assert.strictEqual(result.done, false)
})
it('should reject with a timeout error if the source generator takes too long', async () => {
// Create a mock async generator that never resolves
// eslint-disable-next-line require-yield
const mockGenerator = (async function* () {
await new Promise(() => {}) // Never resolves
})()
const timeout = new Timeout(mockGenerator, 10) // 10ms timeout
await assert.rejects(timeout.next(), 'Expected timeout error was not thrown')
})
it('should allow iteration using for-await-of', async () => {
// Create a mock async generator that yields values
const mockGenerator: AsyncGenerator<number> = (async function* () {
yield 1
yield 2
yield 3
})()
const timeout = new Timeout(mockGenerator, 100) // 100ms timeout
const results: Array<number> = []
for await (const value of timeout) {
results.push(value as number)
}
assert.deepStrictEqual(results, [1, 2, 3])
})
it('should handle the throw method correctly', async () => {
// Create a mock async generator with a throw method
const mockGenerator = (async function* () {
yield 1
})()
const timeout = new Timeout(mockGenerator, 100) // 100ms timeout
await assert.rejects(timeout.throw(new Error('Test error')), 'Expected error was not thrown')
})
it('should close the source generator after a timeout', async () => {
let sourceClosed = false
const slowSource = (async function* () {
try {
// resolves after 100ms so the in-flight next() eventually settles
await new Promise(resolve => setTimeout(resolve, 100))
yield 1
} finally {
sourceClosed = true
}
})()
const timeout = new Timeout(slowSource, 10) // fires before source yields
await assert.rejects(() => timeout.next(), /Timeout reached/)
// wait for the in-flight next() to settle so source.return() can be called
await new Promise(resolve => setTimeout(resolve, 200))
assert.strictEqual(sourceClosed, true)
})
})

View File

@@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "es2023",
"module": "ESNext",
"types": ["node"], // Only use types from Node.js and packages that are explicitly imported
"moduleResolution": "bundler",
"strict": true,
"declaration": true, // Generates `.d.ts` files for type safety
"outDir": "dist" // Outputs compiled code to the `dist` directory
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,65 +0,0 @@
### `create([opts], [requestListener])`
Creates a new server instance. `opts` may contain `createServer` and `createSecureServer` factories (defaults to `http.createServer` and `https.createServer`). `requestListener` is registered as a `request` event listener.
```js
import { create } from '@vates/http-server-plus'
const server = create(function onRequest(req, res) {
res.end('Hello')
})
```
### `server.listen(opts)`
Starts listening. Returns a promise that resolves to a human-readable address string, or rejects with `error.niceAddress` set.
```js
// HTTP on a specific port
await server.listen({ port: 8080 })
// HTTPS — detected automatically when cert+key are provided
await server.listen({
port: 443,
cert: fs.readFileSync('certificate.pem'),
key: fs.readFileSync('private_key.pem'),
})
// Unix socket
await server.listen({ socket: '/var/run/http.sock' })
// File descriptor
await server.listen({ fd: 3 })
// Systemd socket activation
await server.listen({ systemdSocket: 0 })
```
> As a convenience, if `hostname` is `'localhost'`, it will listen on both `127.0.0.1` and `::1`, similar to what Node does when no hostname is provided.
Multiple endpoints can be started on the same instance:
```js
const [httpAddr, httpsAddr] = await Promise.all([server.listen({ port: 80 }), server.listen({ port: 443, cert, key })])
console.log('listening on', httpAddr, httpsAddr)
```
### HTTP/2
Pass a custom `createSecureServer` factory to enable HTTP/2:
```js
import { createSecureServer } from 'node:http2'
import { create } from '@vates/http-server-plus'
const server = create({ createSecureServer }, app)
await server.listen({ port: 443, cert, key })
```
### `server.close()`
Closes all underlying servers. Returns a promise that resolves once the `close` event is emitted.
```js
await server.close()
```

View File

@@ -1 +0,0 @@
../../scripts/npmignore

View File

@@ -1,98 +0,0 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/http-server-plus
[![Package Version](https://badgen.net/npm/v/@vates/http-server-plus)](https://npmjs.org/package/@vates/http-server-plus) ![License](https://badgen.net/npm/license/@vates/http-server-plus) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/http-server-plus)](https://bundlephobia.com/result?p=@vates/http-server-plus) [![Node compatibility](https://badgen.net/npm/node/@vates/http-server-plus)](https://npmjs.org/package/@vates/http-server-plus)
> Augmented &#x60;http.Server&#x60;, HTTP/HTTPS/HTTP2 and multiple ports on the same instance
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/http-server-plus):
```sh
npm install --save @vates/http-server-plus
```
## Usage
### `create([opts], [requestListener])`
Creates a new server instance. `opts` may contain `createServer` and `createSecureServer` factories (defaults to `http.createServer` and `https.createServer`). `requestListener` is registered as a `request` event listener.
```js
import { create } from '@vates/http-server-plus'
const server = create(function onRequest(req, res) {
res.end('Hello')
})
```
### `server.listen(opts)`
Starts listening. Returns a promise that resolves to a human-readable address string, or rejects with `error.niceAddress` set.
```js
// HTTP on a specific port
await server.listen({ port: 8080 })
// HTTPS — detected automatically when cert+key are provided
await server.listen({
port: 443,
cert: fs.readFileSync('certificate.pem'),
key: fs.readFileSync('private_key.pem'),
})
// Unix socket
await server.listen({ socket: '/var/run/http.sock' })
// File descriptor
await server.listen({ fd: 3 })
// Systemd socket activation
await server.listen({ systemdSocket: 0 })
```
> As a convenience, if `hostname` is `'localhost'`, it will listen on both `127.0.0.1` and `::1`, similar to what Node does when no hostname is provided.
Multiple endpoints can be started on the same instance:
```js
const [httpAddr, httpsAddr] = await Promise.all([server.listen({ port: 80 }), server.listen({ port: 443, cert, key })])
console.log('listening on', httpAddr, httpsAddr)
```
### HTTP/2
Pass a custom `createSecureServer` factory to enable HTTP/2:
```js
import { createSecureServer } from 'node:http2'
import { create } from '@vates/http-server-plus'
const server = create({ createSecureServer }, app)
await server.listen({ port: 443, cert, key })
```
### `server.close()`
Closes all underlying servers. Returns a promise that resolves once the `close` event is emitted.
```js
await server.close()
```
## Contributions
Contributions are _very_ welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
you've encountered;
- fork and create a pull request.
## License
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)

View File

@@ -1,268 +0,0 @@
'use strict'
// ===================================================================
const EventEmitter = require('events').EventEmitter
const formatUrl = require('url').format
const fromEvent = require('promise-toolbox/fromEvent')
const inherits = require('util').inherits
const resolvePath = require('path').resolve
// ===================================================================
const forwardedEvents = [
'checkContinue',
'checkExpectation',
'clientError',
'connect',
'connection',
'listening',
'request',
'secureConnection',
'upgrade',
]
function extractProperty(obj, prop) {
const value = obj[prop]
if (value !== undefined) {
delete obj[prop]
return value
}
}
function forOwn(obj, cb) {
const keys = Object.keys(obj)
for (let i = 0, n = keys.length; i < n; ++i) {
cb(obj[keys[i]])
}
}
function getSystemdFd(index) {
if (process.pid !== +process.env.LISTEN_PID) {
throw new Error('systemd sockets are not meant for us (LISTEN_PID)')
}
const listenFds = process.env.LISTEN_FDS
if (listenFds == null) {
throw new Error('no systemd sockets found (LISTEN_FDS)')
}
const max = listenFds - 1
if (index > max) {
throw new Error('no such systemd socket found')
}
return index + 3 // 3 = SD_LISTEN_FDS_START
}
const isEmpty = obj => Object.keys(obj).length === 0
// ===================================================================
function onNewListener(event, listener) {
if (forwardedEvents.includes(event)) {
forOwn(this._servers, server => {
server.on(event, listener)
})
}
}
function onRemoveListener(event, listener) {
if (forwardedEvents.includes(event)) {
forOwn(this._servers, server => {
server.off(event, listener)
})
}
}
function Server(opts) {
EventEmitter.call(this)
this._createServer = (opts != null && opts.createServer) || require('http').createServer
this._createSecureServer = (opts != null && opts.createSecureServer) || require('https').createServer
this._servers = Object.create(null)
this._niceAddresses = Object.create(null)
this.on('removeListener', onRemoveListener).on('newListener', onNewListener)
}
inherits(Server, EventEmitter)
const proto = Server.prototype
function getAddress(serverId) {
return this[serverId].address()
}
proto.addresses = function Server$addresses() {
const servers = this._servers
return Object.keys(servers).map(getAddress, servers)
}
function getNiceAddress(serverId) {
return this[serverId]
}
proto.niceAddresses = function Server$niceAddresses() {
const niceAddresses = this._niceAddresses
return Object.keys(niceAddresses).map(getNiceAddress, niceAddresses)
}
function close(server) {
server.close()
}
proto.close = function Server$close(callback) {
if (callback) {
this.once('close', callback)
}
// Emit the close event even if there are no registered servers.
if (isEmpty(this._servers)) {
const self = this
setImmediate(function () {
self.emit('close')
})
return Promise.resolve()
}
// Closes each servers.
forOwn(this._servers, close)
return fromEvent(this, 'close')
}
let nextId = 0
proto.listen = function Server$listen(opts) {
opts = Object.assign({}, opts)
if (opts.hostname === 'localhost') {
return Promise.all([
this.listen(Object.assign(opts, { hostname: '::1' })),
this.listen(Object.assign(opts, { hostname: '127.0.0.1' })),
]).then(addresses => addresses[0])
}
const hostname = extractProperty(opts, 'hostname')
let port = extractProperty(opts, 'port')
const systemdSocket = extractProperty(opts, 'systemdSocket')
let fd = extractProperty(opts, 'fd')
let socket = extractProperty(opts, 'socket')
const servers = this._servers
const niceAddresses = this._niceAddresses
let server, protocol
if (opts.pfx || opts.SNICallback || (opts.cert && opts.key)) {
server = this._createSecureServer(opts)
protocol = 'https'
} else {
server = this._createServer(opts)
protocol = 'http'
}
const id = nextId++
servers[id] = server
if (systemdSocket != null) {
fd = getSystemdFd(systemdSocket)
}
// Compute a temporary nice address to display in case of error.
let niceAddress
if (fd != null) {
server.listen({ fd })
niceAddress = protocol + '://<fd:' + fd + '>'
} else if (socket != null) {
socket = resolvePath(socket)
server.listen(socket)
niceAddress = protocol + '://' + socket
} else {
if (port == null) {
port = protocol === 'https' ? 443 : 80
}
server.listen(port, hostname)
niceAddress = formatUrl({
protocol,
// Hostname default to localhost.
hostname: hostname || 'localhost',
// No port means random, unknown for now.
port: port || '<unknown>',
})
}
const emit = this.emit.bind(this)
server.once('close', function onClose() {
delete servers[id]
delete niceAddresses[id]
if (isEmpty(servers)) {
emit('close')
}
})
server.on('error', function onError() {
delete servers[id]
// FIXME: Should it be forwarded and be fatal if there are no
// listeners?
})
forwardedEvents.forEach(event => {
this.listeners(event).forEach(listener => {
server.on(event, listener)
})
})
return fromEvent(server, 'listening').then(
function () {
const address = server.address()
const niceAddress =
typeof address === 'string'
? protocol + '://' + address
: formatUrl({
protocol,
hostname: address.address,
port: address.port,
})
niceAddresses[id] = niceAddress
return niceAddress
},
function (error) {
error.niceAddress = niceAddress
throw error
}
)
}
function ref(server) {
server.ref()
}
proto.ref = function Server$ref() {
forOwn(this._servers, ref)
}
function unref(server) {
server.unref()
}
proto.unref = function Server$unref() {
forOwn(this._servers, unref)
}
// ===================================================================
module.exports = exports = Server
exports.create = function create(opts, requestListener) {
if (typeof opts === 'function') {
requestListener = opts
opts = undefined
}
const server = new Server(opts)
if (requestListener !== undefined) {
server.on('request', requestListener)
}
return server
}

View File

@@ -1,303 +0,0 @@
'use strict'
// ===================================================================
const assert = require('assert/strict')
const { EventEmitter } = require('events')
const resolvePath = require('path').resolve
const { describe, it } = require('node:test')
const HttpServerPlus = require('./')
// ===================================================================
// Creates a minimal mock server that can be used as a drop-in for http/https.Server.
// `address` is the value returned by mock.address() — use an object for TCP, a string for sockets.
function createMock(address) {
const mock = new EventEmitter()
mock.listen = function () {
setImmediate(() => this.emit('listening'))
}
mock.address = () => address
mock.close = function () {
setImmediate(() => this.emit('close'))
}
return mock
}
// ===================================================================
describe('HttpServerPlus', function () {
describe('#create()', function () {
it('creates a new instance', function () {
assert(HttpServerPlus.create() instanceof HttpServerPlus)
})
it('can register a `request` listener', function () {
const listener = function () {}
const server = HttpServerPlus.create(listener)
assert.deepEqual(server.listeners('request'), [listener])
})
})
describe('.addresses()', function () {
it('returns the list of addresses the server is listening at', function () {
const addr = { address: '127.0.0.1', port: 8080, family: 'IPv4' }
const mock = createMock(addr)
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ port: 8080 }).then(function () {
assert.deepEqual(server.addresses(), [addr])
return server.close()
})
})
})
describe('.niceAddresses()', function () {
it('returns the list of human readable addresses the server is listening at', function () {
const mock = createMock({ address: '127.0.0.1', port: 8080 })
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ hostname: '127.0.0.1', port: 8080 }).then(function (address) {
assert.deepEqual(server.niceAddresses(), [address])
return server.close()
})
})
})
describe('secureConnection event forwarding', function () {
function createMockHttpsServer() {
const server = new EventEmitter()
server.listen = function () {
setImmediate(() => this.emit('listening'))
}
server.address = () => ({ address: '127.0.0.1', port: 8443 })
server.close = function () {
setImmediate(() => this.emit('close'))
}
return server
}
function createServerWithMock() {
let mockServer
const server = HttpServerPlus.create({
createSecureServer: () => {
mockServer = createMockHttpsServer()
return mockServer
},
})
return {
server,
getMock: () => mockServer,
}
}
it('forwards secureConnection to listeners registered before listen()', function () {
const { server, getMock } = createServerWithMock()
const received = []
server.on('secureConnection', socket => received.push(socket))
const fakeSocket = {}
return server.listen({ cert: 'fake-cert', key: 'fake-key' }).then(function () {
getMock().emit('secureConnection', fakeSocket)
assert.deepEqual(received, [fakeSocket])
return server.close()
})
})
it('forwards secureConnection to listeners registered after listen()', function () {
const { server, getMock } = createServerWithMock()
return server.listen({ cert: 'fake-cert', key: 'fake-key' }).then(function () {
const received = []
server.on('secureConnection', socket => received.push(socket))
const fakeSocket = {}
getMock().emit('secureConnection', fakeSocket)
assert.deepEqual(received, [fakeSocket])
return server.close()
})
})
it('stops forwarding secureConnection after listener is removed', function () {
const { server, getMock } = createServerWithMock()
const received = []
const listener = socket => received.push(socket)
server.on('secureConnection', listener)
return server.listen({ cert: 'fake-cert', key: 'fake-key' }).then(function () {
server.off('secureConnection', listener)
getMock().emit('secureConnection', {})
assert.deepEqual(received, [])
return server.close()
})
})
})
describe('.listen()', function () {
it('can use a host:port', function () {
let listenArgs
const mock = createMock({ address: '127.0.0.1', port: 8080 })
mock.listen = function (...args) {
listenArgs = args
setImmediate(() => this.emit('listening'))
}
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ hostname: '127.0.0.1', port: 8080 }).then(function () {
assert.deepEqual(listenArgs, [8080, '127.0.0.1'])
return server.close()
})
})
it('can use a systemd socket', function () {
const SD_LISTEN_FDS_START = 3
const SOCKET_INDEX = 0
let listenArgs
const mock = createMock('http://<fd:3>')
mock.listen = function (...args) {
listenArgs = args
setImmediate(() => this.emit('listening'))
}
const server = HttpServerPlus.create({ createServer: () => mock })
const env = process.env
env.LISTEN_FDS = String(SOCKET_INDEX + 1)
env.LISTEN_PID = String(process.pid)
return server.listen({ systemdSocket: SOCKET_INDEX }).then(function () {
delete env.LISTEN_FDS
delete env.LISTEN_PID
assert.deepEqual(listenArgs, [{ fd: SOCKET_INDEX + SD_LISTEN_FDS_START }])
return server.close()
})
})
it('can use a socket', function () {
const socketPath = '/tmp/http-server-plus-test.sock'
let listenArgs
const mock = createMock(socketPath)
mock.listen = function (...args) {
listenArgs = args
setImmediate(() => this.emit('listening'))
}
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ socket: socketPath }).then(function () {
assert.deepEqual(listenArgs, [resolvePath(socketPath)])
return server.close()
})
})
it('can use a HTTPS certificate', function () {
let capturedOpts
const mock = createMock({ address: '127.0.0.1', port: 8443 })
const server = HttpServerPlus.create({
createSecureServer: opts => {
capturedOpts = opts
return mock
},
})
return server.listen({ cert: 'my-cert', key: 'my-key', port: 8443 }).then(function (address) {
assert.equal(capturedOpts.cert, 'my-cert')
assert.equal(capturedOpts.key, 'my-key')
assert.ok(address.startsWith('https://'), `expected https URL, got: ${address}`)
return server.close()
})
})
it('returns a promise which will resolve once listening', function () {
const mock = createMock({ address: '127.0.0.1', port: 8080 })
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ port: 8080 }).then(function (address) {
assert.equal(typeof address, 'string')
assert.ok(address.startsWith('http://'), `expected http URL, got: ${address}`)
return server.close()
})
})
it('returns a promise which will reject if failure', function () {
const listenError = new Error('EADDRINUSE')
const mock = createMock(null)
mock.listen = function () {
setImmediate(() => this.emit('error', listenError))
}
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ port: 8080 }).then(
function () {
assert.fail('should have rejected')
},
function (err) {
assert.equal(err, listenError)
assert.equal(typeof err.niceAddress, 'string')
}
)
})
})
describe('.close()', function () {
it('closes all servers', function () {
const closed = []
let callCount = 0
const server = HttpServerPlus.create({
createServer: () => {
const id = callCount++
const mock = createMock({ address: '127.0.0.1', port: id })
mock.close = function () {
closed.push(id)
setImmediate(() => this.emit('close'))
}
return mock
},
})
return Promise.all([server.listen({ port: 0 }), server.listen({ port: 0 })])
.then(function () {
return server.close()
})
.then(function () {
assert.equal(closed.length, 2)
})
})
it('emit the `close` event when all servers are closed', function () {
const mock = createMock({ address: '127.0.0.1', port: 8080 })
const server = HttpServerPlus.create({ createServer: () => mock })
let closeEmitted = false
server.on('close', () => {
closeEmitted = true
})
return server
.listen({ port: 8080 })
.then(function () {
return server.close()
})
.then(function () {
assert.ok(closeEmitted)
})
})
it('emit the `close` event even if there are no servers', function () {
const server = HttpServerPlus.create()
// close() with no servers returns Promise.resolve() immediately (before emitting
// 'close' via setImmediate), so we must wait for the event itself, not the promise.
return new Promise(function (resolve) {
server.close(resolve)
})
})
})
})

View File

@@ -1,38 +0,0 @@
{
"name": "@vates/http-server-plus",
"version": "2.0.2",
"license": "ISC",
"description": "Augmented `http.Server`, HTTP/HTTPS/HTTP2 and multiple ports on the same instance",
"keywords": [
"http",
"https",
"server",
"ports"
],
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/http-server-plus",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/http-server-plus",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"promise-toolbox": "^0.21.0"
},
"devDependencies": {
"eslint-plugin-n": "^15.1.0"
},
"scripts": {
"pretest": "eslint .",
"test": "node --test index.spec.js",
"postversion": "npm publish --access public"
},
"private": false,
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
}
}

View File

@@ -1,261 +0,0 @@
A small, dependency-light iSCSI **target** (server) written in TypeScript. It
exposes exactly one read/write LUN backed by a pluggable byte-range
`BlockDevice`, and interoperates with the standard Linux **open-iscsi**
initiator (the same one XCP-ng/XAPI uses to attach an `lvmoiscsi` SR).
### Scope
The target drives login negotiation onto a single, fixed code path:
- single initiator, **`MaxConnections=1`**, **`ErrorRecoveryLevel=0`**
- no header/data digests (`HeaderDigest=None`, `DataDigest=None`)
- all writes are R2T-solicited (`InitialR2T=Yes`, `ImmediateData=No`)
- one LUN (LUN 0)
It implements SendTargets discovery, login (`AuthMethod=None` or one-way
**CHAP** — see [Authentication](#authentication-chap)), the ~10 SCSI
commands a Linux initiator issues to attach and use a block device
(`INQUIRY` + VPD `0x00`/`0x80`/`0x83`/`0xb0`, `REPORT LUNS`, `READ CAPACITY 10/16`,
`READ`/`WRITE 10/16`, `TEST UNIT READY`, `REQUEST SENSE`, `MODE SENSE`,
`SYNCHRONIZE CACHE`), the full R2T / Data-Out write path, NOP keepalive replies,
and Logout. Commands may be outstanding concurrently (Data-Out PDUs are routed
by Initiator Task Tag), so it does not stall under real guest I/O.
The package also ships a userspace **initiator** (`IscsiInitiator`) and an
`IscsiDisk` adapter that exposes a remote LUN as an `@xen-orchestra/disk-transform`
`RandomAccessDisk` — see [Initiator](#initiator).
Not implemented (yet): mutual (two-way) CHAP, Task Management (abort/reset),
target-initiated NOP keepalives, multiple connections/LUNs.
What a peer can make the target do is bounded:
- connections that have not logged in yet are limited in time (`loginTimeoutMs`)
and number (`maxPendingConnections`), and `allowedInitiatorAddresses` (the
source addresses of the expected initiators) closes the other ones as soon as
they are accepted, reporting them to `onConnectionRefused`
- a PDU data segment is at most 8 KiB during login, then the declared
`MaxRecvDataSegmentLength` (256 KiB), checked before it is read
- a single `READ`/`WRITE` is at most `maxTransferLength` (8 MiB by default),
advertised in the block limits VPD page so that initiators split their I/O
- a `BlockDevice` with `readOnly: true` is reported write-protected, and `WRITE`
is refused with `DATA PROTECT` before any data is solicited
### CLI
The package ships a `vates-iscsi` binary that serves a file as a LUN:
```sh
> vates-iscsi /srv/lun.img
> vates-iscsi /srv/lun.img --serial MYSERIAL01 --port 3260
> vates-iscsi /srv/new.img --size 10G # create/grow the backing file first
```
```
Usage: vates-iscsi <file> [options]
Options:
--serial <serial> LUN serial number (default: derived from the IQN).
--size <bytes> Create/grow the backing file (suffixes K, M, G, T).
--iqn <iqn> Target IQN (default: iqn.2024-01.tech.vates:<file>).
--host <host> Listen address (default: 0.0.0.0).
--port <port> Listen port (default: 3260).
--block-size <bytes> Logical block size (default: 512).
-h, --help Show this help.
```
### Library
```js
import { IscsiTarget, FileBlockDevice } from '@vates/iscsi'
// The backing file must already exist and be sized to the LUN capacity,
// e.g. `fs.truncate('/srv/lun.img', 10 * 1024 ** 3)`.
const target = new IscsiTarget({
iqn: 'iqn.2024-01.tech.vates:lun0',
host: '0.0.0.0',
port: 3260,
lun: new FileBlockDevice({ path: '/srv/lun.img', blockSize: 512 }),
})
await target.listen()
// ... target is now discoverable and attachable by open-iscsi ...
await target.close()
```
#### Adding a target backend
A "backend" is whatever stores the LUN's bytes. The protocol layer translates
SCSI `READ`/`WRITE` CDBs (LBA + block count) into byte offsets/lengths against a
single small interface, so a backend only has to provide flat random access — it
never sees a PDU. `FileBlockDevice` (a sparse file) is the built-in one; implement
`BlockDevice` to expose anything else (a raw device, an object store, an
`@xen-orchestra/disk-transform` `RandomAccessDisk`, …):
```ts
interface BlockDevice {
open?(): Promise<void> // optional, awaited once before serving I/O
getSize(): number // total capacity in bytes; MUST be a multiple of getBlockSize()
getBlockSize(): number // logical block size, typically 512
read(offset: number, length: number): Promise<Buffer> // exactly `length` bytes at byte `offset`
write(offset: number, data: Buffer): Promise<void> // write `data.length` bytes at byte `offset`
flush(): Promise<void> // SYNCHRONIZE CACHE — persist any buffering
close(): Promise<void> // release resources
}
```
Contract to respect:
- **`getSize()` must be a multiple of `getBlockSize()`** and stable for the life of
the device. `open()` is the place to compute it (e.g. `stat` the file).
- **`read` must return exactly `length` bytes** — never short. Zero-fill a sparse
or past-EOF tail rather than returning fewer bytes; the SCSI layer already
bounds `offset + length` to the capacity.
- `offset` and `length` are always block-aligned (multiples of `getBlockSize()`).
- `read`/`write` may be **called concurrently** (commands are interleaved), so the
backend must be safe under overlapping calls; honour back-pressure and don't
buffer unboundedly.
- Throwing from `read`/`write`/`flush` tears the connection down; fail fast and
attach context to the error.
Minimal example — expose a read-only `RandomAccessDisk` (VHD / qcow2 / raw / a
remote NBD or iSCSI LUN) as a target LUN by mapping byte reads onto block reads:
```js
import { IscsiTarget } from '@vates/iscsi'
class RandomAccessDiskLun {
// `disk` is an initialized @xen-orchestra/disk-transform RandomAccessDisk.
constructor(disk) {
this.disk = disk
this.blockSize = disk.getBlockSize()
}
getSize() {
return this.disk.getVirtualSize()
}
getBlockSize() {
return this.blockSize
}
async read(offset, length) {
const out = Buffer.alloc(length) // pre-zeroed: sparse/holes read as zeros
const first = Math.floor(offset / this.blockSize)
const last = Math.floor((offset + length - 1) / this.blockSize)
for (let index = first; index <= last; index++) {
if (!this.disk.hasBlock(index)) {
continue // unallocated: leave zeros
}
const { data } = await this.disk.readBlock(index)
const blockStart = index * this.blockSize
const from = Math.max(0, offset - blockStart)
const to = Math.min(this.blockSize, offset + length - blockStart)
data.copy(out, blockStart + from - offset, from, to)
}
return out
}
// reported write-protected: WRITE is refused before reaching write()
readOnly = true
write() {
throw new Error('read-only LUN')
}
async flush() {}
async close() {
await this.disk.close()
}
}
const target = new IscsiTarget({ iqn: 'iqn.2024-01.tech.vates:disk0', lun: new RandomAccessDiskLun(disk) })
await target.listen()
```
For a writable backend, implement `write(offset, data)` symmetrically and make
`flush()` durable (e.g. `fsync`), as `FileBlockDevice` does.
### Initiator
`IscsiInitiator` is a minimal userspace iSCSI **client**: it connects to one
target portal, logs in, and reads the LUN. `IscsiDisk` wraps it as an
`@xen-orchestra/disk-transform` `RandomAccessDisk`, so a remote LUN drops straight
into the backup/transform pipeline (the iSCSI counterpart of `@vates/nbd-client`'s
`NbdDisk`). It is **read-only**, single-connection, digests off.
```js
import { IscsiInitiator, IscsiDisk } from '@vates/iscsi'
// Low-level byte reads:
const initiator = new IscsiInitiator({
host: '10.0.0.10',
port: 3260,
targetIqn: 'iqn.2024-01.tech.vates:lun0',
chap: { user: 'alice', secret: 's3cr3t' }, // optional, see Authentication
})
await initiator.connect()
console.log(initiator.getSize(), initiator.getBlockSize())
const bytes = await initiator.read(/* offset */ 0, /* length */ 1024 * 1024)
await initiator.close()
// …or as a RandomAccessDisk (blockSize must be a multiple of the LUN block size):
const disk = new IscsiDisk(
{ host: '10.0.0.10', targetIqn: 'iqn.2024-01.tech.vates:lun0' },
{ blockSize: 2 * 1024 * 1024 }
)
await disk.init()
// disk.getVirtualSize(), disk.getBlockIndexes(), await disk.readBlock(i), …
await disk.close()
```
First-cut `IscsiDisk` treats the LUN as fully allocated (every block reported as
present, like `RawDisk`); a changed-block map (e.g. a Pure "Volume Difference")
can narrow it later.
### Authentication (CHAP)
The target and initiator support **one-way CHAP** (MD5) in opposite roles; mutual
(two-way) CHAP is not yet implemented.
**Target as authenticator.** Pass `chap` to require each _data_ session to prove a
credential; the target challenges the initiator and rejects the login (Login
Response status-class _Initiator Error_ / _authentication failure_) on a mismatch:
```js
const target = new IscsiTarget({
iqn: 'iqn.2024-01.tech.vates:lun0',
lun,
chap: { user: 'alice', secret: 's3cr3t' },
})
```
This interoperates with the Linux **open-iscsi** initiator (the one XCP-ng uses);
configure its node record's incoming credential:
```sh
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.authmethod -v CHAP
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.username -v alice
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.password -v s3cr3t
```
CHAP is enforced only on **Normal (data) sessions**; **Discovery** sessions (which
can only run SendTargets and never reach the LUN) stay unauthenticated, so plain
`iscsiadm -m discovery` keeps working. open-iscsi requires the secret to be 12–16
characters.
**Initiator as responder.** Pass `chap` to `IscsiInitiator`/`IscsiDisk` to answer a
target's challenge (e.g. a Pure array acting as authenticator). If the target
_requires_ mutual CHAP the login is cleanly rejected (we never challenge back).
### Security
iSCSI has no data-path encryption of its own. CHAP authenticates the login only;
with no digests, **CDBs and data remain cleartext**. Anyone who can reach an
unauthenticated port can read/write the LUN, and a CHAP-protected one is still
exposed to eavesdropping and tampering on the wire. Expose targets only on a
trusted, isolated storage network, and use **IPsec** (the RFC-specified mechanism)
or equivalent if confidentiality is required.
### Testing
- `yarn test` — fast unit tests (PDU framing, SCSI encoders/decoders, login
negotiation, sequence-number arithmetic).
- `yarn test-integration` — a self-contained loopback test (an in-process
minimal initiator drives discovery, identity, write/read-back, and command
interleaving), plus an open-iscsi interop test that **requires root and
`iscsiadm`** and skips gracefully otherwise.

View File

@@ -1 +0,0 @@
../../scripts/npmignore

View File

@@ -1,124 +0,0 @@
# Protocol coverage
`@vates/iscsi` implements a deliberately narrow subset of iSCSI (RFC 7143, formerly RFC 3720) and the SCSI
command set it carries (SPC-3 primary commands, SBC-3 block commands) — enough to serve or consume exactly one
read/write LUN over a single connection, not a general-purpose iSCSI stack. This is the detailed companion to
the README's "Scope" section, verified directly against the source (`constants.mts`, `login.mts`,
`connection.mts`, `types.mts`, `initiator.mts`, `chap.mts`) rather than inferred.
The package has two independent roles with very different maturity:
- **Target** (`IscsiTarget`) — the primary, production-used role (backs `@xen-orchestra/mixins/live-mount`).
Handles real-world Linux/XCP-ng initiators robustly: concurrent commands, real write ordering, keepalives,
connection recovery.
- **Initiator** (`IscsiInitiator`) — a minimal, read-only client used to read from a third-party array as a
disk source. Built against `IscsiTarget` and standard-tooling targets (`tgt`); interop testing this session
found it does **not** complete login against a LIO kernel target (see below) — a real, previously-unknown
gap, not yet fixed.
## Session and connection
| Aspect | Target | Initiator |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- |
| Connections per session | `MaxConnections=1`, enforced | opens exactly one |
| Session reinstatement | **no** — no ISID tracking; once a new connection has established a Normal session (CHAP included), it tears down and replaces whatever connection currently exists, rather than RFC 7143's ISID-matched reinstatement (see `index.mts`'s class doc for the reasoning: safe here because the target is ephemeral, single-consumer, and CHAP-guarded). Until then it does not affect the current one, and is bounded by `loginTimeoutMs` and `maxPendingConnections` | n/a (single connection) |
| Multiple LUNs | **no** — exactly one LUN, always LUN 0 | reads are hardcoded to LUN 0 |
| `ErrorRecoveryLevel` | `0` only | requests `0` |
| Header/data digests | `HeaderDigest=None`, `DataDigest=None` — no CRC32C on the wire | same |
| Markers (`IFMarker`/`OFMarker`) | not offered (RFC-deprecated) | not offered |
## Login / text negotiation
**Target** (`login.mts`) reactively answers whatever the initiator proposes, fixed to:
`HeaderDigest=None`, `DataDigest=None`, `MaxConnections=1`, `InitialR2T=Yes`, `ImmediateData=No`,
`ErrorRecoveryLevel=0`, `DataPDUInOrder=Yes`, `DataSequenceInOrder=Yes`, `DefaultTime2Wait=2`,
`DefaultTime2Retain=0`, `MaxOutstandingR2T=1` — `MaxBurstLength`/`FirstBurstLength` are the only two keys
actually negotiated down from the initiator's offer (capped at an internal maximum). Both `Normal` and
`Discovery` session types are supported (`SessionType`), with CHAP skipped for `Discovery` per RFC — text-mode
negotiation beyond that is limited to `SendTargets` discovery (`#handleText`), not general text negotiation.
**Initiator** (`initiator.mts#operationalLogin`) sends exactly **one** operational-stage login PDU proposing
its own fixed key set (`HeaderDigest=None`, `DataDigest=None`, `InitialR2T=Yes`, `ImmediateData=No`,
`ErrorRecoveryLevel=0`, `MaxRecvDataSegmentLength`) with the transit bit (`T=1`) set immediately, and only
checks whether the reply also has `T=1` — there is no second round to answer a target's own counter-proposed
keys if it declines transit on the first exchange.
- Works against `tgt` and against this package's own `IscsiTarget` — both transit on the first round.
- **Does not complete against a LIO kernel target** (`targetcli-fb`): LIO counter-proposes its own operational
keys (`MaxBurstLength`, `DefaultTime2Wait`, `DefaultTime2Retain`, `MaxOutstandingR2T`) in the same response
and withholds transit until they're answered — confirmed directly via `dmesg`:
```
No response for proposed key "MaxBurstLength".
iSCSI Login negotiation failed.
```
This is a real, previously-unexercised limitation of the initiator's login state machine (single
operational round-trip only), not a bug on LIO's side. Worth weighing given the initiator's real intended
use — reading from unknown-strictness third-party array software, not `tgt`/this package's own target.
## Authentication (CHAP)
- One-way only (RFC 1994, algorithm MD5 — the only one RFC 7143 defines, so this isn't a partial
implementation of a wider set).
- **Target as authenticator**: when `chap` is configured, every login must present the right `CHAP_N`/`CHAP_R`
or the login is rejected (`INITIATOR_ERROR`/`AUTHENTICATION_FAILURE`). Interop-tested against the real
`open-iscsi` kernel initiator (both correct-secret and wrong-secret paths).
- **Initiator as responder**: when `chap` is configured, answers a target's challenge with the same MD5
scheme. Never issues its own challenge.
- **Not supported**: mutual (two-way) CHAP in either role — a target that _requires_ mutual auth will reject
the initiator's login, surfaced as a normal login failure rather than attempted.
## SCSI commands
Decoded explicitly (`types.mts#decodeCdb`); anything else maps to `unsupported` → `CHECK CONDITION` /
`ILLEGAL_REQUEST` / `INVALID COMMAND OPERATION CODE`:
| Command | Variants |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `INQUIRY` | standard + EVPD `0x00`/`0x80`/`0x83` (vendor/product/revision/serial from `identity`) and `0xb0` (block limits: `maxTransferLength`) |
| `REPORT LUNS` | |
| `READ CAPACITY` | `(10)` and `(16)` |
| `READ` | `(10)` and `(16)`; longer than `maxTransferLength` → `INVALID FIELD IN CDB` |
| `WRITE` | `(10)` and `(16)`; longer than `maxTransferLength` → `INVALID FIELD IN CDB`; on a `readOnly` LUN → `DATA PROTECT` / `WRITE PROTECTED`, without soliciting data |
| `TEST UNIT READY` | |
| `REQUEST SENSE` | |
| `MODE SENSE` | `(6)` and `(10)`; `WP` set on a `readOnly` LUN |
| `SYNCHRONIZE CACHE` | `(10)` and `(16)` — flushes the backing `BlockDevice` |
**Not implemented**: persistent reservations, VAAI/thin-provisioning primitives (`UNMAP`, `WRITE SAME`, `COMPARE
AND WRITE`, ...), any vendor-specific commands — anything beyond the ~10 commands a Linux initiator actually
issues to attach and use a plain block device.
## Data transfer
- **Read**: `Data-In`, concurrent commands dispatched up to `readConcurrency` (default 16) regardless of the
advertised command window — bounds how many `lun.read()` calls run at once against the real backend.
Residual under/overflow reported against the CDB's requested transfer length.
- **Write**: always solicited (`InitialR2T=Yes`, `ImmediateData=No`) — every write goes through `R2T` →
`Data-Out`, one outstanding R2T at a time (`MaxOutstandingR2T=1`). `Data-Out` PDUs are routed by Initiator
Task Tag, so writes/reads for different commands can interleave; within one write, out-of-order or
overlapping `Data-Out` segments are rejected outright (`writePath.mts`) rather than silently accepted, to
avoid leaking uninitialized staging-buffer memory to the LUN (a real bug this was hardened against — see
`VALIDATION.md`).
- **Not implemented**: unsolicited/immediate data, `MaxOutstandingR2T > 1`.
## Task management, error recovery, keepalives
- **SCSI Task Management** (`ABORT TASK`, `LUN RESET`, etc.) — not implemented; any `SCSI Task Mgmt Request`
is rejected (`COMMAND_NOT_SUPPORTED`).
- **SNACK** (retransmission requests, part of `ErrorRecoveryLevel > 0`) — not implemented; rejected the same
way. Consistent with `ErrorRecoveryLevel=0` — nothing should ever need to SNACK.
- **NOP / keepalives**:
- Target replies to an initiator-sent `NOP-Out` ping, but does **not** proactively send its own periodic
`NOP-In` probes (no `nop_interval` equivalent) — it relies entirely on the initiator (or TCP-level
connection loss) to notice a dead peer.
- Initiator answers a target-initiated `NOP-In` ping with `NOP-Out` and keeps its read loop draining for the
life of the connection specifically so it never misses one (this was a real, fixed bug — see
`VALIDATION.md`, item 5).
## Summary: not implemented
Mutual (two-way) CHAP · SCSI Task Management (abort/reset) · target-initiated NOP-In keepalives · multiple
connections per session · multiple LUNs · session reinstatement (ISID) · header/data digests · unsolicited
data / `MaxOutstandingR2T>1` · SNACK/error recovery beyond level 0 · persistent reservations and VAAI-style
primitives · general text-mode negotiation beyond `SendTargets` · initiator-side multi-round operational login
negotiation (works against this package's own target and `tgt`; does not against LIO — see above).

View File

@@ -1,294 +0,0 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/iscsi
[![Package Version](https://badgen.net/npm/v/@vates/iscsi)](https://npmjs.org/package/@vates/iscsi) ![License](https://badgen.net/npm/license/@vates/iscsi) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/iscsi)](https://bundlephobia.com/result?p=@vates/iscsi) [![Node compatibility](https://badgen.net/npm/node/@vates/iscsi)](https://npmjs.org/package/@vates/iscsi)
> Minimal iSCSI target and initiator: expose or read a single LUN, with one-way CHAP
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/iscsi):
```sh
npm install --save @vates/iscsi
```
## Usage
A small, dependency-light iSCSI **target** (server) written in TypeScript. It
exposes exactly one read/write LUN backed by a pluggable byte-range
`BlockDevice`, and interoperates with the standard Linux **open-iscsi**
initiator (the same one XCP-ng/XAPI uses to attach an `lvmoiscsi` SR).
### Scope
The target drives login negotiation onto a single, fixed code path:
- single initiator, **`MaxConnections=1`**, **`ErrorRecoveryLevel=0`**
- no header/data digests (`HeaderDigest=None`, `DataDigest=None`)
- all writes are R2T-solicited (`InitialR2T=Yes`, `ImmediateData=No`)
- one LUN (LUN 0)
It implements SendTargets discovery, login (`AuthMethod=None` or one-way
**CHAP** — see [Authentication](#authentication-chap)), the ~10 SCSI
commands a Linux initiator issues to attach and use a block device
(`INQUIRY` + VPD `0x00`/`0x80`/`0x83`/`0xb0`, `REPORT LUNS`, `READ CAPACITY 10/16`,
`READ`/`WRITE 10/16`, `TEST UNIT READY`, `REQUEST SENSE`, `MODE SENSE`,
`SYNCHRONIZE CACHE`), the full R2T / Data-Out write path, NOP keepalive replies,
and Logout. Commands may be outstanding concurrently (Data-Out PDUs are routed
by Initiator Task Tag), so it does not stall under real guest I/O.
The package also ships a userspace **initiator** (`IscsiInitiator`) and an
`IscsiDisk` adapter that exposes a remote LUN as an `@xen-orchestra/disk-transform`
`RandomAccessDisk` — see [Initiator](#initiator).
Not implemented (yet): mutual (two-way) CHAP, Task Management (abort/reset),
target-initiated NOP keepalives, multiple connections/LUNs.
What a peer can make the target do is bounded:
- connections that have not logged in yet are limited in time (`loginTimeoutMs`)
and number (`maxPendingConnections`), and `allowedInitiatorAddresses` (the
source addresses of the expected initiators) closes the other ones as soon as
they are accepted, reporting them to `onConnectionRefused`
- a PDU data segment is at most 8 KiB during login, then the declared
`MaxRecvDataSegmentLength` (256 KiB), checked before it is read
- a single `READ`/`WRITE` is at most `maxTransferLength` (8 MiB by default),
advertised in the block limits VPD page so that initiators split their I/O
- a `BlockDevice` with `readOnly: true` is reported write-protected, and `WRITE`
is refused with `DATA PROTECT` before any data is solicited
### CLI
The package ships a `vates-iscsi` binary that serves a file as a LUN:
```sh
> vates-iscsi /srv/lun.img
> vates-iscsi /srv/lun.img --serial MYSERIAL01 --port 3260
> vates-iscsi /srv/new.img --size 10G # create/grow the backing file first
```
```
Usage: vates-iscsi <file> [options]
Options:
--serial <serial> LUN serial number (default: derived from the IQN).
--size <bytes> Create/grow the backing file (suffixes K, M, G, T).
--iqn <iqn> Target IQN (default: iqn.2024-01.tech.vates:<file>).
--host <host> Listen address (default: 0.0.0.0).
--port <port> Listen port (default: 3260).
--block-size <bytes> Logical block size (default: 512).
-h, --help Show this help.
```
### Library
```js
import { IscsiTarget, FileBlockDevice } from '@vates/iscsi'
// The backing file must already exist and be sized to the LUN capacity,
// e.g. `fs.truncate('/srv/lun.img', 10 * 1024 ** 3)`.
const target = new IscsiTarget({
iqn: 'iqn.2024-01.tech.vates:lun0',
host: '0.0.0.0',
port: 3260,
lun: new FileBlockDevice({ path: '/srv/lun.img', blockSize: 512 }),
})
await target.listen()
// ... target is now discoverable and attachable by open-iscsi ...
await target.close()
```
#### Adding a target backend
A "backend" is whatever stores the LUN's bytes. The protocol layer translates
SCSI `READ`/`WRITE` CDBs (LBA + block count) into byte offsets/lengths against a
single small interface, so a backend only has to provide flat random access — it
never sees a PDU. `FileBlockDevice` (a sparse file) is the built-in one; implement
`BlockDevice` to expose anything else (a raw device, an object store, an
`@xen-orchestra/disk-transform` `RandomAccessDisk`, …):
```ts
interface BlockDevice {
open?(): Promise<void> // optional, awaited once before serving I/O
getSize(): number // total capacity in bytes; MUST be a multiple of getBlockSize()
getBlockSize(): number // logical block size, typically 512
read(offset: number, length: number): Promise<Buffer> // exactly `length` bytes at byte `offset`
write(offset: number, data: Buffer): Promise<void> // write `data.length` bytes at byte `offset`
flush(): Promise<void> // SYNCHRONIZE CACHE — persist any buffering
close(): Promise<void> // release resources
}
```
Contract to respect:
- **`getSize()` must be a multiple of `getBlockSize()`** and stable for the life of
the device. `open()` is the place to compute it (e.g. `stat` the file).
- **`read` must return exactly `length` bytes** — never short. Zero-fill a sparse
or past-EOF tail rather than returning fewer bytes; the SCSI layer already
bounds `offset + length` to the capacity.
- `offset` and `length` are always block-aligned (multiples of `getBlockSize()`).
- `read`/`write` may be **called concurrently** (commands are interleaved), so the
backend must be safe under overlapping calls; honour back-pressure and don't
buffer unboundedly.
- Throwing from `read`/`write`/`flush` tears the connection down; fail fast and
attach context to the error.
Minimal example — expose a read-only `RandomAccessDisk` (VHD / qcow2 / raw / a
remote NBD or iSCSI LUN) as a target LUN by mapping byte reads onto block reads:
```js
import { IscsiTarget } from '@vates/iscsi'
class RandomAccessDiskLun {
// `disk` is an initialized @xen-orchestra/disk-transform RandomAccessDisk.
constructor(disk) {
this.disk = disk
this.blockSize = disk.getBlockSize()
}
getSize() {
return this.disk.getVirtualSize()
}
getBlockSize() {
return this.blockSize
}
async read(offset, length) {
const out = Buffer.alloc(length) // pre-zeroed: sparse/holes read as zeros
const first = Math.floor(offset / this.blockSize)
const last = Math.floor((offset + length - 1) / this.blockSize)
for (let index = first; index <= last; index++) {
if (!this.disk.hasBlock(index)) {
continue // unallocated: leave zeros
}
const { data } = await this.disk.readBlock(index)
const blockStart = index * this.blockSize
const from = Math.max(0, offset - blockStart)
const to = Math.min(this.blockSize, offset + length - blockStart)
data.copy(out, blockStart + from - offset, from, to)
}
return out
}
// reported write-protected: WRITE is refused before reaching write()
readOnly = true
write() {
throw new Error('read-only LUN')
}
async flush() {}
async close() {
await this.disk.close()
}
}
const target = new IscsiTarget({ iqn: 'iqn.2024-01.tech.vates:disk0', lun: new RandomAccessDiskLun(disk) })
await target.listen()
```
For a writable backend, implement `write(offset, data)` symmetrically and make
`flush()` durable (e.g. `fsync`), as `FileBlockDevice` does.
### Initiator
`IscsiInitiator` is a minimal userspace iSCSI **client**: it connects to one
target portal, logs in, and reads the LUN. `IscsiDisk` wraps it as an
`@xen-orchestra/disk-transform` `RandomAccessDisk`, so a remote LUN drops straight
into the backup/transform pipeline (the iSCSI counterpart of `@vates/nbd-client`'s
`NbdDisk`). It is **read-only**, single-connection, digests off.
```js
import { IscsiInitiator, IscsiDisk } from '@vates/iscsi'
// Low-level byte reads:
const initiator = new IscsiInitiator({
host: '10.0.0.10',
port: 3260,
targetIqn: 'iqn.2024-01.tech.vates:lun0',
chap: { user: 'alice', secret: 's3cr3t' }, // optional, see Authentication
})
await initiator.connect()
console.log(initiator.getSize(), initiator.getBlockSize())
const bytes = await initiator.read(/* offset */ 0, /* length */ 1024 * 1024)
await initiator.close()
// …or as a RandomAccessDisk (blockSize must be a multiple of the LUN block size):
const disk = new IscsiDisk(
{ host: '10.0.0.10', targetIqn: 'iqn.2024-01.tech.vates:lun0' },
{ blockSize: 2 * 1024 * 1024 }
)
await disk.init()
// disk.getVirtualSize(), disk.getBlockIndexes(), await disk.readBlock(i), …
await disk.close()
```
First-cut `IscsiDisk` treats the LUN as fully allocated (every block reported as
present, like `RawDisk`); a changed-block map (e.g. a Pure "Volume Difference")
can narrow it later.
### Authentication (CHAP)
The target and initiator support **one-way CHAP** (MD5) in opposite roles; mutual
(two-way) CHAP is not yet implemented.
**Target as authenticator.** Pass `chap` to require each _data_ session to prove a
credential; the target challenges the initiator and rejects the login (Login
Response status-class _Initiator Error_ / _authentication failure_) on a mismatch:
```js
const target = new IscsiTarget({
iqn: 'iqn.2024-01.tech.vates:lun0',
lun,
chap: { user: 'alice', secret: 's3cr3t' },
})
```
This interoperates with the Linux **open-iscsi** initiator (the one XCP-ng uses);
configure its node record's incoming credential:
```sh
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.authmethod -v CHAP
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.username -v alice
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.password -v s3cr3t
```
CHAP is enforced only on **Normal (data) sessions**; **Discovery** sessions (which
can only run SendTargets and never reach the LUN) stay unauthenticated, so plain
`iscsiadm -m discovery` keeps working. open-iscsi requires the secret to be 12–16
characters.
**Initiator as responder.** Pass `chap` to `IscsiInitiator`/`IscsiDisk` to answer a
target's challenge (e.g. a Pure array acting as authenticator). If the target
_requires_ mutual CHAP the login is cleanly rejected (we never challenge back).
### Security
iSCSI has no data-path encryption of its own. CHAP authenticates the login only;
with no digests, **CDBs and data remain cleartext**. Anyone who can reach an
unauthenticated port can read/write the LUN, and a CHAP-protected one is still
exposed to eavesdropping and tampering on the wire. Expose targets only on a
trusted, isolated storage network, and use **IPsec** (the RFC-specified mechanism)
or equivalent if confidentiality is required.
### Testing
- `yarn test` — fast unit tests (PDU framing, SCSI encoders/decoders, login
negotiation, sequence-number arithmetic).
- `yarn test-integration` — a self-contained loopback test (an in-process
minimal initiator drives discovery, identity, write/read-back, and command
interleaving), plus an open-iscsi interop test that **requires root and
`iscsiadm`** and skips gracefully otherwise.
## Contributions
Contributions are _very_ welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
you've encountered;
- fork and create a pull request.
## License
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)

View File

@@ -1,170 +0,0 @@
# Validation notes
Extracted from a longer production-readiness pass covering both this package and the `LiveMount` mixin that
consumes it; this file keeps only what's specific to `@vates/iscsi` itself — protocol-level correctness,
concurrency, and interop with standard tooling. Local testing notes covering the LiveMount/XAPI integration,
VM boot mechanics, and deployment specifics live elsewhere and aren't reproduced here.
## Bugs found and fixed
### In this validation pass
1. **Read-path error isolation** (`scsi.mts`) — `lun.read()` failures were previously uncaught, killing the
whole connection for one bad block. Now wrapped in try/catch, reporting `CHECK_CONDITION`/`MEDIUM_ERROR` for
that command only, mirroring the write path's existing `#handleDataOut` error handling.
- Unit test: `scsi.test.mts` — "reports a failed lun.read() as CHECK CONDITION / MEDIUM ERROR, not an
uncaught throw".
- Regression test: `loopback.integ.mts`, `describe('READ error isolation', ...)` — a LUN whose `read()`
throws for one LBA still returns `CHECK_CONDITION` for that command; the connection stays alive and a
different, healthy LBA read afterward still succeeds.
2. **Connection replacement instead of refusal** (`index.mts`) — found via a real end-to-end guest-boot test
against an S3-backed backup repository: `IscsiTarget#onConnection` used to permanently _refuse_ any new
connection while an existing one was tracked (`MaxConnections=1` enforcement), with no way to tell a
genuinely stale/abandoned connection (initiator gave up and reconnected without ever closing the old TCP
socket) from a healthy one still in use. Under a slow backend this wedges the mount forever: the host's
initiator times out and retries, gets refused, retries again, forever — reproduced live as an 8+ minute
guest boot stall, `WARN refusing concurrent connection (MaxConnections=1)` repeating in the log the whole
time. Now a new connection always tears down whatever connection currently exists and takes over, rather
than being refused. Deliberately not full RFC 7143 session reinstatement (no ISID matching) — chosen as the
simpler, sufficient fix for this target's single-consumer, ephemeral, CHAP-guarded design; see the class doc
comment in `index.mts` for the reasoning and the more rigorous alternative considered.
- Regression test: `loopback.integ.mts`, `describe('connection replacement', ...)` — a second connection is
accepted (not refused) while the first is still open, the first's socket gets torn down, and the new
connection is fully usable (read + logout succeed).
- Re-verified end-to-end after deploying the fix: same S3-backed boot scenario completed successfully
(~5 min total, vs ~20-25s for the equivalent local-storage boot — S3 itself is slow, but the mount no
longer wedges).
- Refined afterwards: the replacement used to happen as soon as the TCP connection was accepted, so any
peer reaching the port (a port scan, a failed CHAP login, a Discovery session) interrupted the session in
use. It now only happens once the new connection has established a Normal session; until then,
connections are bounded by `loginTimeoutMs` and `maxPendingConnections`. Regression tests:
`loopback.integ.mts`, `describe('connection admission', ...)`.
### Independently landed, cross-referenced against this pass's coverage
Five more fixes landed on this branch outside this validation effort. Read in full and checked against what
had (and hadn't) actually been exercised black-box, since two are severe:
1. **`CachedDiskBlockDevice.mts` — acknowledged writes silently and permanently reverted.** A write fully
covering a not-yet-cached block used to skip fetching the source (an "optimization" — the write supplies
all the bytes anyway). But if a _read_ to that same block had already triggered an in-flight fetch, that
fetch could land **after** the write and silently overwrite it with stale source data — guest gets `GOOD`,
data reverts moments later. Fixed by always joining any in-flight fetch before writing. Has a white-box
regression test (pauses a fake disk mid-fetch to force the race). This is exactly the class of bug this
whole effort was looking for, and its vulnerable path — read+write racing on the same never-touched block —
was never actually exercised by this pass's own earlier black-box testing (sequential `dd` and single-job
`fio` both avoid same-offset self-overlap by construction), so the "clean" results up to that point were
consistent with the bug being present, not proof of its absence. Closed that gap afterward (see below).
2. **`connection.mts`/`writePath.mts` — uninitialized heap memory leaked to the LUN on a malformed Data-Out.**
Staging buffer is `Buffer.allocUnsafe`, never zeroed; the old completion check summed received bytes, but
`Buffer.copy` silently clamps on out-of-range/overlapping offsets — a malformed/out-of-order Data-Out
sequence could reach "totalLength received" with gaps of recycled heap memory that then get written to the
LUN and read back by any guest. Real info-disclosure bug, needs a misbehaving/malicious initiator to trigger
(a well-behaved one never sends malformed Data-Out). Fixed with strict contiguity validation; extensively
tested (`connection.test.mts` +586 lines, `writePath.test.mts` +168 lines, both white-box, covering the
malformed-input space directly — not something black-box testing with real tooling can easily reach at all).
3. **`connection.mts` — CmdSN window corrupted by every Data-Out (i.e. every write).** Command-window tracking
read bytes 24-27 as CmdSN off every inbound PDU, but those bytes are reserved on Data-Out per RFC 7143
§11.7 — every write rewound the advertised window. Benign at normal scale (initiators discard the rewind as
stale via serial-number comparison until session CmdSN passes 2³¹) — none of this pass's testing came close
to that scale, so nothing here calls prior results into question. Fixed with an opcode allowlist; has its
own regression test.
4. **`CachedDiskBlockDevice.hydrate()` — no longer throws/orphans on a concurrent unmount.** `hydrate()` now
accepts an `AbortSignal`, stopping at the next block boundary and rejecting cleanly once in-flight blocks
settle, rather than the caller having to serialize hydrate against unmount itself (the orchestration half of
this fix — not running hydrate inside the same long-lived task as the mount — lives in the LiveMount mixin,
outside this package). Tested at the `CachedDiskBlockDevice` level: aborting mid-hydrate keeps everything
already cached, rejects rather than silently returning partial success, and a fresh signal that's already
aborted refuses to start at all.
5. **`initiator.mts` — went silent between commands, dropped by a target's ping timeout.** `IscsiInitiator`'s
read loop used to stop once nothing was outstanding, so during any idle period a target's NOP-In keepalive
sat unread — real targets (LIO's `nopin_response_timeout`, 30s by default) drop sessions with unanswered
pings, with no reconnect logic to recover. Fixed with a loop that keeps draining for the life of the
connection and replies to NOP-In pings (`#pump`/`#replyNop`); added `initiator.integ.mts` (white-box,
real loopback). Never exercised by this pass's own black-box testing at all — the whole effort was
LiveMount-focused, which only ever uses the _target_ role. Attempting to close that gap surfaced two
further, independent findings — see "Initiator interop findings" below.
## Black-box protocol-level testing (standalone target, real tooling)
- **Data integrity under concurrent load**: 30-minute `fio --rw=randrw --bs=4k --iodepth=64 --numjobs=1
--verify=crc32c` soak against the standalone target over real `iscsiadm` loopback — 0 verify errors, ~16.4k
read + ~10.6k write IOPS sustained throughout. (An earlier `numjobs=4` run produced ~116 "bad header"
errors; bisected and reproduced identically against plain local file I/O with zero iSCSI involvement —
a known fio limitation where independent jobs pointed at the same file race each other's own bookkeeping,
not a target bug. `numjobs=1` at higher `iodepth` avoids the false positive while still exercising real
queue depth.)
- **Filesystem round-trip across a full session teardown**: real corpus (2419 files, 53 MB) written, hashed,
then a full cold logout/login cycle plus a host-side page-cache drop before re-reading — all 2419 files
byte-identical.
- **Kill the target process mid-write**: `kill -9`'d mid-transfer with a direct-I/O write in flight. The write
correctly blocked (not a fast, silent "success") until the session recovered, then completed with the exact
intended pattern across the full range — no silent data loss, no torn/partial write at the interruption point.
- **Real cross-machine network partition** (two separate VMs, not loopback): a mid-transfer `iptables DROP`
held for 15s against the target's own port. Compared directly to `tgt` as a baseline over the identical
topology — both stalled through the outage and completed with 0 corrupted bytes once restored; no divergent
behavior from a mature reference implementation.
- **Sequential throughput vs `tgt`** (same two-VM link, plain iSCSI, no XAPI/SM in the path): `@vates/iscsi`
reached ~70% (write) / ~76% (read) of `tgt`'s numbers — noticeably slower but the same order of magnitude,
not a large gap. (A separate, much larger ~10x slowdown measured through the full LiveMount/XAPI/SM stack
was isolated to the host's own `iscsi` SM/tapdisk driver by elimination — outside this package.)
- **Targeted regression testing for bug 1 above**, using a single-job, high-`iodepth`, small-range `fio`
pattern (deliberately avoiding the `numjobs>1` false-positive from the first bullet) to force many concurrent
operations onto the same underlying cache block without needing multiple uncoordinated processes: clean
(0 verify errors) against local storage, against a slow S3-backed remote (a wider fetch-latency window gives
a racing write far more time to land mid-fetch — the exact scenario the bug was about), and against an
actively-running `hydrate()` sweep overlapping guest I/O on the same disk. All three passed.
## Flagged, not fixed
- **`FileBlockDevice.read()` zero-fills past a short/EOF read instead of throwing** — unlike `LocalBlockDevice`
(the actual production cache backend), which throws on the identical condition. Confirmed via grep: only the
standalone CLI tool and this package's own tests use `FileBlockDevice` today, so there's no reachable data-
loss path in production right now — but it's a public export, so a future feature backing a LUN directly
with a file would silently inherit the zero-fill-over-error behavior. This is a design-intent question (is
the CLI tool's "sparse tail" leniency deliberate?), not a clear bug — flagging for a decision rather than
silently changing it.
## Initiator interop findings (this session)
Bug 5's fix (above) had never been exercised against a real target under real conditions — only the white-box
`initiator.integ.mts`. Attempting to close that gap with a live idle-period/NOP-In test against two different
real target implementations surfaced two independent, real findings before the actual keepalive behavior could
be exercised:
- **`tgt` reserves LUN 0 as a built-in, zero-size "controller" device** on every target, and this is not
removable via `tgtadm` (`tgtadm: this logical unit number already exists` / `invalid request` on any attempt
to delete or replace it). `IscsiInitiator.connect()`/`read()` are hardcoded to LUN 0 (matching this package's
own single-LUN-at-0 target design), so `connect()` fails immediately with `CHECK_CONDITION` against `tgt`
regardless of where a real data LUN is added (tried LUN 1). Pure tooling mismatch — `tgt`'s own convention
doesn't fit a client that only ever speaks to LUN 0 — not a bug in either side.
- **LIO (`targetcli-fb`/kernel target) does complete login differently, and `IscsiInitiator` can't get through
it.** With a real data LUN at LUN 0 (LIO doesn't reserve it), `connect()` still fails — `#operationalLogin`
(`initiator.mts`) sends exactly one operational-stage login PDU, requests immediate transit (`T=1`), and only
checks the transit bit on the reply; there's no second round to answer a target's own counter-proposal if it
declines transit on the first exchange. LIO's kernel target does exactly that: it counter-proposes several of
its own operational keys (`MaxBurstLength`, `DefaultTime2Wait`, `DefaultTime2Retain`, `MaxOutstandingR2T`) in
the same response and withholds transit until they're answered, confirmed directly in `dmesg`:
```
No response for proposed key "MaxBurstLength".
No response for proposed key "DefaultTime2Wait".
No response for proposed key "DefaultTime2Retain".
No response for proposed key "MaxOutstandingR2T".
iSCSI Login negotiation failed.
```
This works fine against `tgt` and against this package's own `IscsiTarget` (both transit on the first round),
so it's never been visible before — but it's a **real, previously-unknown limitation of `IscsiInitiator`'s
login state machine**: single operational round-trip only, no handling for a target that insists on its own
multi-round negotiation before agreeing to transit. Worth knowing given the initiator role's real intended
use (reading from a third-party storage array as an initiator) targets unknown-strictness third-party target
software, not `tgt` or this package's own target.
Given both blockers are about _login/LUN setup_, not the keepalive mechanism itself, the actual idle-period/
NOP-In behavior could not be exercised live this session. The white-box `initiator.integ.mts` coverage for
bug 5 stands as the only current test of that specific fix.

View File

@@ -1,6 +0,0 @@
// @ts-check
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
export default tseslint.config(eslint.configs.recommended, tseslint.configs.recommended)

View File

@@ -1,61 +0,0 @@
{
"name": "@vates/iscsi",
"version": "0.2.0",
"main": "dist/index.mjs",
"types": "dist/index.d.mts",
"bin": {
"vates-iscsi": "dist/cli.mjs"
},
"license": "ISC",
"private": false,
"type": "module",
"description": "Minimal iSCSI target and initiator: expose or read a single LUN, with one-way CHAP",
"keywords": [
"iscsi",
"target",
"initiator",
"chap",
"scsi",
"lun",
"block",
"storage"
],
"dependencies": {
"@vates/async-each": "^1.0.3",
"@vates/read-chunk": "^1.2.1",
"@xen-orchestra/disk-transform": "^1.3.0",
"@xen-orchestra/log": "^0.7.2",
"promise-toolbox": "^0.21.0"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/node": "^22.0",
"rimraf": "^6.0.1",
"typescript": "~5.6",
"typescript-eslint": "^8.61.0"
},
"scripts": {
"build": "tsc",
"clean": "rimraf dist/",
"dev": "tsc --watch",
"postversion": "npm publish --access public",
"prebuild": "rimraf dist/",
"predev": "yarn run prebuild",
"test": "tsc && node --test \"dist/**/*.test.mjs\"",
"test-integration": "tsc && node --test \"dist/**/*.integ.mjs\""
},
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/iscsi",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/iscsi",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"engines": {
"node": ">=22"
}
}

View File

@@ -1,321 +0,0 @@
import { asyncEach } from '@vates/async-each'
import { createLogger, type Logger } from '@xen-orchestra/log'
import type { ProgressHandler, RandomAccessDisk } from '@xen-orchestra/disk-transform'
import type { BlockDevice } from './backend.mjs'
const log: Logger = createLogger('vates:iscsi:cached-disk-block-device')
const DEFAULT_BLOCK_SIZE = 512
const DEFAULT_HYDRATE_CONCURRENCY = 8
export interface CachedDiskBlockDeviceOptions {
/**
* Source disk, already `init()`ed by the caller. Only ever read from, one
* whole block at a time.
*/
readonly disk: RandomAccessDisk
/**
* Writable store holding what has been read from {@link disk} so far, already
* `open()`ed and at least as large as the disk's virtual size.
*/
readonly cache: BlockDevice
/**
* Logical block size advertised to initiators, in bytes. Defaults to 512.
* The disk's own block size must be a multiple of it.
*/
readonly blockSize?: number
/**
* Notified with the materialized fraction (0..1) every time a new block is
* cached — on demand or through {@link hydrate} — and via `done()` once
* every block has been. Reused from the `disk-transform` export/import
* pipelines rather than inventing a new shape.
*/
readonly progressHandler?: ProgressHandler
/**
* A previously-persisted bitmap to resume from, one byte per block (see
* {@link CachedDiskBlockDevice}'s class doc). Its length must match the
* block count computed from `disk`/`blockSize`; a mismatch (e.g. the disk
* was resized since it was saved) is treated as stale and discarded rather
* than rejected, so a corrupt/outdated resume file never blocks a mount.
*/
readonly initialBitmap?: Buffer
/**
* Notified with a block's index the moment it is newly cached — narrower
* than `progressHandler`, which only carries an aggregate fraction and
* cannot say *which* block just landed. Meant for persisting the bitmap
* one byte at a time; best-effort like `progressHandler`, a failure here
* must never break block materialization.
*/
readonly onBlockCached?: (index: number) => void
}
/**
* A read-write {@link BlockDevice} over a {@link RandomAccessDisk}, backed by
* a local writable store: each source block is materialized into the store
* the first time it's needed, tracked by a bitmap so repeat access never goes
* back to the source. Call {@link hydrate} to force the whole disk in upfront.
*
* The bitmap is one byte per block, not one bit: a caller that persists it
* incrementally (see `onBlockCached`) writes one block's byte at a time, and
* every block's offset must be independent of every other's for concurrent
* writes to be safe. A real bit-packed bitmap fails that: several blocks'
* bits sharing one byte would need a read-modify-write of that byte, which
* two blocks completing concurrently could race on and lose an update. One
* byte per block costs a little more memory (negligible in absolute terms —
* a few hundred KB even for a huge disk) and buys that independence for free.
*
* Blocks the source doesn't have are marked present without being written —
* relies on the store reading as zero where nothing was ever written.
*
* Writes land in the store only; the source is never written to, so the
* mount diverges from it as soon as anything writes.
*/
export class CachedDiskBlockDevice implements BlockDevice {
readonly #disk: RandomAccessDisk
readonly #cache: BlockDevice
readonly #blockSize: number
readonly #progressHandler?: ProgressHandler
readonly #onBlockCached?: (index: number) => void
readonly #initialBitmap?: Buffer
// one byte per source block, 0 or 1, set once the block is in the cache
#bitmap: Buffer = Buffer.alloc(0)
#blockCount = 0
#cachedCount = 0
// source blocks being materialized right now, so concurrent reads of
// overlapping ranges fetch each block once
readonly #inFlight: Map<number, Promise<void>> = new Map()
#diskBlockSize?: number
#size?: number
constructor({
disk,
cache,
blockSize = DEFAULT_BLOCK_SIZE,
progressHandler,
initialBitmap,
onBlockCached,
}: CachedDiskBlockDeviceOptions) {
if (!Number.isInteger(blockSize) || blockSize <= 0) {
throw new Error(`blockSize must be a positive integer, got ${blockSize}`)
}
this.#disk = disk
this.#cache = cache
this.#blockSize = blockSize
this.#progressHandler = progressHandler
this.#initialBitmap = initialBitmap
this.#onBlockCached = onBlockCached
}
/** Read the source geometry and allocate (or resume) the bitmap. */
async open(): Promise<void> {
const blockSize = this.#blockSize
const diskBlockSize = this.#disk.getBlockSize()
if (diskBlockSize % blockSize !== 0) {
throw new Error(`disk block size (${diskBlockSize}) is not a multiple of the LUN block size (${blockSize})`)
}
const size = this.#disk.getVirtualSize()
if (size <= 0 || size % blockSize !== 0) {
throw new Error(`disk virtual size (${size}) is not a positive multiple of the LUN block size (${blockSize})`)
}
if (this.#cache.getSize() < size) {
throw new Error(`cache (${this.#cache.getSize()} bytes) is smaller than the disk (${size} bytes)`)
}
this.#diskBlockSize = diskBlockSize
this.#size = size
this.#blockCount = Math.ceil(size / diskBlockSize)
const initialBitmap = this.#initialBitmap
if (initialBitmap !== undefined && initialBitmap.length === this.#blockCount) {
this.#bitmap = initialBitmap
let cachedCount = 0
for (const byte of initialBitmap) {
if (byte !== 0) {
cachedCount++
}
}
this.#cachedCount = cachedCount
} else {
if (initialBitmap !== undefined) {
log.warn('discarding stale initial bitmap (block count mismatch)', {
gotLength: initialBitmap.length,
expectedLength: this.#blockCount,
})
}
this.#bitmap = Buffer.alloc(this.#blockCount)
}
log.debug('opened', {
size,
blockSize,
diskBlockSize,
blockCount: this.#blockCount,
resumedBlocks: this.#cachedCount,
})
}
getSize(): number {
const size = this.#size
if (size === undefined) {
throw new Error('CachedDiskBlockDevice.open() must be called before I/O')
}
return size
}
getBlockSize(): number {
return this.#blockSize
}
/** How much of the source has been materialized into the cache. */
getMaterialized(): { blocks: number; total: number } {
return { blocks: this.#cachedCount, total: this.#blockCount }
}
/**
* Force every block into the cache — including holes, so `getMaterialized()`
* reaches `total` once done, matching what a full sequential read would
* naturally mark. Already-materialized blocks are skipped, so this resumes
* rather than redoes whatever on-demand reads (or a previous, interrupted
* hydration) already did.
*
* Hydrating a large disk takes hours, so it must be interruptible: `signal`
* stops it at the next block boundary, rejecting once the blocks already in
* flight settle. Nothing is rolled back — every block cached so far stays
* cached, and because this call resumes, aborting and calling again later
* picks up where it stopped rather than starting over.
*/
async hydrate({
concurrency = DEFAULT_HYDRATE_CONCURRENCY,
signal,
}: { concurrency?: number; signal?: AbortSignal } = {}): Promise<void> {
signal?.throwIfAborted()
const blockCount = this.#blockCount
await asyncEach(
(function* allBlockIndexes() {
for (let index = 0; index < blockCount; index++) {
yield index
}
})(),
index => this.#ensureBlock(index),
{ concurrency, signal }
)
}
#isCached(index: number): boolean {
return this.#bitmap[index] !== 0
}
#setCached(index: number): void {
if (!this.#isCached(index)) {
this.#bitmap[index] = 1
this.#cachedCount++
this.#reportProgress()
// best-effort, same contract as #reportProgress: a synchronous throw
// here must never break block materialization. Any async failure (e.g.
// the persisted-bitmap write itself) is the callback's own job to catch.
try {
this.#onBlockCached?.(index)
} catch (error) {
log.warn('onBlockCached failed', { error, index })
}
}
}
// best-effort: a failure here (sync or async) must never break block
// materialization. Wrapping the body in an `async` function, rather than a
// plain try/catch, is what makes a *synchronous* throw from the handler
// land in the same `.catch()` as an asynchronous rejection.
#reportProgress(): void {
const handler = this.#progressHandler
if (handler === undefined) {
return
}
const fraction = this.#cachedCount / this.#blockCount
;(async () => {
await handler.setProgress(fraction)
// every block is in the cache: nothing more will ever be reported
if (fraction === 1) {
await handler.done()
}
})().catch(error => log.warn('progress handler failed', { error }))
}
/**
* Copy one source block into the cache, unless it is already there. Concurrent
* calls for the same block share a single fetch.
*/
#ensureBlock(index: number): Promise<void> {
if (this.#isCached(index)) {
return Promise.resolve()
}
let pending = this.#inFlight.get(index)
if (pending === undefined) {
// the entry goes away whether the fetch succeeded or not, so a failed
// block is retried later instead of being remembered as broken
pending = this.#fetchBlock(index).finally(() => this.#inFlight.delete(index))
this.#inFlight.set(index, pending)
}
return pending
}
async #fetchBlock(index: number): Promise<void> {
const diskBlockSize = this.#diskBlockSize as number
if (this.#disk.hasBlock(index)) {
const offset = index * diskBlockSize
const { data } = await this.#disk.readBlock(index)
// a source block is always full-size: the last one may run past the end of
// the disk, and the cache must not be written beyond it
const length = Math.min(diskBlockSize, this.getSize() - offset)
await this.#cache.write(offset, length < data.length ? data.subarray(0, length) : data)
}
// a block the source does not have stays as the cache has it, which is
// zeroes as long as nothing has written there
this.#setCached(index)
}
#checkRange(offset: number, length: number): void {
const size = this.getSize()
if (offset < 0 || length < 0 || offset + length > size) {
throw new Error(`access of ${length} bytes at ${offset} is out of range (size ${size})`)
}
}
async read(offset: number, length: number): Promise<Buffer> {
this.#checkRange(offset, length)
if (length === 0) {
return Buffer.alloc(0)
}
const diskBlockSize = this.#diskBlockSize as number
const last = Math.floor((offset + length - 1) / diskBlockSize)
for (let index = Math.floor(offset / diskBlockSize); index <= last; index++) {
await this.#ensureBlock(index)
}
return this.#cache.read(offset, length)
}
async write(offset: number, data: Buffer): Promise<void> {
this.#checkRange(offset, data.length)
if (data.length === 0) {
return
}
// every covered block must be materialized first, even the ones this write
// covers entirely: skipping the fetch would let an already in-flight one
// land *after* the payload and overwrite it with the source's bytes.
// (optimization left out on purpose — it has to join the in-flight fetch)
await this.read(offset, data.length)
await this.#cache.write(offset, data)
}
async flush(): Promise<void> {
await this.#cache.flush()
}
async close(): Promise<void> {
try {
await this.#cache.close()
} finally {
await this.#disk.close()
}
}
}

View File

@@ -1,605 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { RandomAccessDisk, type DiskBlock, type ProgressHandler } from '@xen-orchestra/disk-transform'
import { CachedDiskBlockDevice } from './CachedDiskBlockDevice.mjs'
import type { BlockDevice } from './backend.mjs'
const DISK_BLOCK_SIZE = 4096
const BLOCK_COUNT = 4
const DISK_SIZE = BLOCK_COUNT * DISK_BLOCK_SIZE
const bytePattern = (i: number) => (i * 31 + 11) & 0xff
/** Sparse source: only blocks 0 and 2 exist, like a differencing chain. */
class StubDisk extends RandomAccessDisk {
readBlockCalls: Array<number> = []
closed = false
/** set to hold every fetch open until it resolves */
pause?: Promise<void>
constructor(readonly allocated: Set<number> = new Set([0, 2])) {
super()
}
getBlockSize(): number {
return DISK_BLOCK_SIZE
}
getVirtualSize(): number {
return DISK_SIZE
}
isDifferencing(): boolean {
return false
}
getBlockIndexes(): Array<number> {
return [...this.allocated]
}
hasBlock(index: number): boolean {
return this.allocated.has(index)
}
async init(): Promise<void> {}
async close(): Promise<void> {
this.closed = true
}
async readBlock(index: number): Promise<DiskBlock> {
if (!this.allocated.has(index)) {
throw new Error(`Block ${index} not found in chain`)
}
this.readBlockCalls.push(index)
if (this.pause !== undefined) {
await this.pause
}
const data = Buffer.alloc(DISK_BLOCK_SIZE)
for (let i = 0; i < data.length; i++) {
data[i] = bytePattern(index * DISK_BLOCK_SIZE + i)
}
return { index, data }
}
}
/** In-memory stand-in for the local VDI. */
class StubCache implements BlockDevice {
readonly content: Buffer
writes: Array<{ offset: number; length: number }> = []
closed = false
flushed = 0
failNextWrite = false
constructor(size = DISK_SIZE) {
this.content = Buffer.alloc(size)
}
getSize(): number {
return this.content.length
}
getBlockSize(): number {
return 512
}
async read(offset: number, length: number): Promise<Buffer> {
return Buffer.from(this.content.subarray(offset, offset + length))
}
async write(offset: number, data: Buffer): Promise<void> {
if (this.failNextWrite) {
this.failNextWrite = false
throw new Error('cache write failed')
}
this.writes.push({ offset, length: data.length })
data.copy(this.content, offset)
}
async flush(): Promise<void> {
this.flushed++
}
async close(): Promise<void> {
this.closed = true
}
}
const expectedAt = (offset: number, length: number, allocated: Set<number>): Buffer => {
const expected = Buffer.alloc(length)
for (let i = 0; i < length; i++) {
const absolute = offset + i
if (allocated.has(Math.floor(absolute / DISK_BLOCK_SIZE))) {
expected[i] = bytePattern(absolute)
}
}
return expected
}
const make = async (allocated?: Set<number>, cacheSize?: number) => {
const disk = new StubDisk(allocated)
const cache = new StubCache(cacheSize)
const device = new CachedDiskBlockDevice({ disk, cache })
await device.open()
return { cache, device, disk }
}
describe('open', () => {
it('exposes the disk capacity and a fully cold bitmap', async () => {
const { device } = await make()
assert.equal(device.getSize(), DISK_SIZE)
assert.equal(device.getBlockSize(), 512)
assert.deepEqual(device.getMaterialized(), { blocks: 0, total: BLOCK_COUNT })
})
it('refuses a cache smaller than the disk', async () => {
const device = new CachedDiskBlockDevice({ disk: new StubDisk(), cache: new StubCache(DISK_SIZE - 512) })
await assert.rejects(device.open(), /smaller than the disk/)
})
it('resumes from an initial bitmap of the right length', async () => {
// all blocks allocated, so the not-yet-cached one below is a real fetch,
// not a hole (which would never call readBlock regardless of the bitmap)
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const initialBitmap = Buffer.alloc(BLOCK_COUNT)
initialBitmap[0] = 1
initialBitmap[2] = 1
const device = new CachedDiskBlockDevice({ disk, cache, initialBitmap })
await device.open()
assert.deepEqual(device.getMaterialized(), { blocks: 2, total: BLOCK_COUNT })
// already marked cached: served from the (empty) cache store, source untouched
assert.deepEqual(await device.read(0, 512), Buffer.alloc(512))
assert.deepEqual(disk.readBlockCalls, [])
// not marked cached: still fetched from the source as usual
assert.deepEqual(await device.read(DISK_BLOCK_SIZE, 512), expectedAt(DISK_BLOCK_SIZE, 512, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [1])
})
it('discards an initial bitmap of the wrong length and starts cold', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const device = new CachedDiskBlockDevice({ disk, cache, initialBitmap: Buffer.alloc(BLOCK_COUNT - 1, 1) })
await device.open()
assert.deepEqual(device.getMaterialized(), { blocks: 0, total: BLOCK_COUNT })
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [0])
})
})
describe('read', () => {
it('materializes a block on first read and serves later ones from the cache', async () => {
const { device, disk, cache } = await make()
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [0])
// the whole source block was written, not just the part that was read
assert.deepEqual(cache.writes, [{ offset: 0, length: DISK_BLOCK_SIZE }])
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
// a second read of a different part of the same block does not go back
assert.deepEqual(await device.read(1024, 512), expectedAt(1024, 512, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [0])
assert.equal(cache.writes.length, 1)
})
it('marks a hole as present without writing it', async () => {
const { device, disk, cache } = await make()
assert.deepEqual(await device.read(DISK_BLOCK_SIZE, 512), Buffer.alloc(512))
assert.deepEqual(disk.readBlockCalls, [])
assert.deepEqual(cache.writes, [])
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
})
it('stitches a range spanning a present and a missing block', async () => {
const { device, disk } = await make()
const offset = DISK_BLOCK_SIZE - 512
const length = DISK_BLOCK_SIZE + 1024
assert.deepEqual(await device.read(offset, length), expectedAt(offset, length, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [0, 2])
})
it('fetches a block once for concurrent overlapping reads', async () => {
const { device, disk } = await make()
// hold the fetch open until both reads are in flight
let release = () => {}
disk.pause = new Promise<void>(resolve => {
release = resolve
})
const first = device.read(0, 1024)
const second = device.read(512, 1024)
await new Promise(resolve => setImmediate(resolve))
release()
assert.deepEqual(await first, expectedAt(0, 1024, disk.allocated))
assert.deepEqual(await second, expectedAt(512, 1024, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [0])
})
it('rejects a read past the end of the disk', async () => {
const { device } = await make()
await assert.rejects(device.read(DISK_SIZE - 256, 512), /out of range/)
})
it('clamps the tail block to the disk size', async () => {
// 3 blocks + half a block, so the last source block runs past the end
class ShortDisk extends StubDisk {
getVirtualSize(): number {
return 3 * DISK_BLOCK_SIZE + 2048
}
}
const disk = new ShortDisk(new Set([3]))
const cache = new StubCache(3 * DISK_BLOCK_SIZE + 2048)
const device = new CachedDiskBlockDevice({ disk, cache })
await device.open()
await device.read(3 * DISK_BLOCK_SIZE, 2048)
assert.deepEqual(cache.writes, [{ offset: 3 * DISK_BLOCK_SIZE, length: 2048 }])
})
})
describe('write', () => {
it('lands in the cache and is readable back, without touching the source', async () => {
const { device, cache, disk } = await make()
const payload = Buffer.alloc(512, 0xee)
await device.write(0, payload)
assert.deepEqual(await device.read(0, 512), payload)
// the block was materialized first, since the write covers only part of it
assert.deepEqual(disk.readBlockCalls, [0])
// ... and the bytes after the write are still the source's
assert.deepEqual(await device.read(512, 512), expectedAt(512, 512, disk.allocated))
assert.equal(cache.closed, false)
})
it('materializes the block even when the write covers it entirely', async () => {
const { device, disk } = await make()
await device.write(0, Buffer.alloc(DISK_BLOCK_SIZE, 0xee))
// the fetch cannot be skipped: see the race covered by the next test
assert.deepEqual(disk.readBlockCalls, [0])
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
})
it('is not overwritten by a fetch already in flight when it lands', async () => {
const { device, disk } = await make()
// hold block 0's fetch open, so the write lands while it is in flight
let release = () => {}
disk.pause = new Promise<void>(resolve => {
release = resolve
})
const reading = device.read(0, 512)
await new Promise(resolve => setImmediate(resolve))
const payload = Buffer.alloc(DISK_BLOCK_SIZE, 0xee)
const writing = device.write(0, payload)
await new Promise(resolve => setImmediate(resolve))
release()
await reading
await writing
// the source block was fetched once, and its bytes did not come back on top
assert.deepEqual(disk.readBlockCalls, [0])
assert.deepEqual(await device.read(0, DISK_BLOCK_SIZE), payload)
})
})
describe('hydrate', () => {
it('materializes every block the source has, holes included', async () => {
const { device, disk, cache } = await make()
await device.hydrate()
assert.deepEqual(device.getMaterialized(), { blocks: BLOCK_COUNT, total: BLOCK_COUNT })
// only the allocated ones were actually fetched...
assert.deepEqual([...disk.readBlockCalls].sort(), [0, 2])
// ...and the whole disk now reads correctly straight from the cache
assert.deepEqual(await cache.read(0, DISK_SIZE), expectedAt(0, DISK_SIZE, disk.allocated))
})
it('skips blocks already materialized by an earlier read', async () => {
const { device, disk } = await make()
await device.read(0, 512)
assert.deepEqual(disk.readBlockCalls, [0])
await device.hydrate()
// block 0 was not fetched a second time
assert.deepEqual(disk.readBlockCalls, [0, 2])
assert.deepEqual(device.getMaterialized(), { blocks: BLOCK_COUNT, total: BLOCK_COUNT })
})
it('is a no-op once fully materialized', async () => {
const { device, disk } = await make()
await device.hydrate()
disk.readBlockCalls.length = 0
await device.hydrate()
assert.deepEqual(disk.readBlockCalls, [])
})
it('stops on an aborted signal, keeping what it already cached', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const device = new CachedDiskBlockDevice({ disk, cache })
await device.open()
// every block but the first parks, so the hydration is still mid-flight
// when it is aborted
let release = () => {}
const parked = new Promise<void>(resolve => {
release = resolve
})
disk.readBlock = async index => {
if (index > 0) {
await parked
}
return { index, data: Buffer.alloc(DISK_BLOCK_SIZE, index) }
}
const controller = new AbortController()
const hydrating = device.hydrate({ concurrency: 1, signal: controller.signal })
for (let turn = 0; turn < 20 && device.getMaterialized().blocks === 0; turn++) {
await new Promise(resolve => setImmediate(resolve))
}
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
controller.abort()
// aborting is a failure, not a silent partial success
await assert.rejects(hydrating)
// and the block already cached stays cached, so a later hydration resumes
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
release() // let the parked fetch finish rather than leaving it dangling
})
it('refuses to start on an already-aborted signal', async () => {
const { device, disk } = await make()
const controller = new AbortController()
controller.abort()
await assert.rejects(device.hydrate({ signal: controller.signal }))
assert.deepEqual(disk.readBlockCalls, [])
assert.deepEqual(device.getMaterialized(), { blocks: 0, total: BLOCK_COUNT })
})
it('respects the concurrency limit', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const device = new CachedDiskBlockDevice({ disk, cache })
await device.open()
let inFlight = 0
let maxInFlight = 0
const releases: Array<() => void> = []
disk.readBlock = async index => {
inFlight++
maxInFlight = Math.max(maxInFlight, inFlight)
await new Promise<void>(resolve => releases.push(resolve))
inFlight--
return { index, data: Buffer.alloc(DISK_BLOCK_SIZE) }
}
const hydrating = device.hydrate({ concurrency: 2 })
// let the first batch start
await new Promise(resolve => setImmediate(resolve))
assert.equal(maxInFlight, 2)
while (releases.length > 0) {
releases.shift()!()
await new Promise(resolve => setImmediate(resolve))
}
await hydrating
assert.equal(maxInFlight, 2)
})
})
describe('progress handler', () => {
class StubProgressHandler implements ProgressHandler {
calls: Array<number> = []
doneCalls = 0
throwSync = false
rejectAsync = false
async setProgress(fraction: number): Promise<void> {
this.calls.push(fraction)
if (this.throwSync) {
throw new Error('sync failure')
}
if (this.rejectAsync) {
throw new Error('async failure')
}
}
done(): void {
this.doneCalls++
}
}
it('reports the materialized fraction as blocks are read, once each', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.read(0, 512)
// let the fire-and-forget notification run
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(progressHandler.calls, [1 / BLOCK_COUNT])
// a hole is a materialization too, just with nothing to write
await device.read(DISK_BLOCK_SIZE, 512)
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(progressHandler.calls, [1 / BLOCK_COUNT, 2 / BLOCK_COUNT])
// re-reading an already materialized block reports nothing new
await device.read(0, 512)
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(progressHandler.calls, [1 / BLOCK_COUNT, 2 / BLOCK_COUNT])
})
it('reports a fully-covered write the same way as a read', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.write(0, Buffer.alloc(DISK_BLOCK_SIZE, 0xee))
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(progressHandler.calls, [1 / BLOCK_COUNT])
})
it('does not let a synchronously throwing handler break the read', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
progressHandler.throwSync = true
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
})
it('does not let a rejecting handler surface as an unhandled rejection', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
progressHandler.rejectAsync = true
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
// give the rejected promise's `.catch` a turn to run before the test ends
await new Promise(resolve => setImmediate(resolve))
})
it('calls done() once every block is cached, via hydrate', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.hydrate()
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(progressHandler.calls, [0.25, 0.5, 0.75, 1])
assert.equal(progressHandler.doneCalls, 1)
})
it('calls done() once every block is cached, via on-demand reads alone', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.read(0, DISK_BLOCK_SIZE * BLOCK_COUNT)
await new Promise(resolve => setImmediate(resolve))
assert.equal(progressHandler.doneCalls, 1)
})
it('does not call done() before every block is cached', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.read(0, 512)
await new Promise(resolve => setImmediate(resolve))
assert.equal(progressHandler.doneCalls, 0)
})
it('does not let a throwing done() surface as an unhandled rejection', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
progressHandler.done = () => {
throw new Error('done failure')
}
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.hydrate()
await new Promise(resolve => setImmediate(resolve))
})
})
describe('onBlockCached', () => {
it('fires with the index of each newly cached block, once each', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const cached: Array<number> = []
const device = new CachedDiskBlockDevice({ disk, cache, onBlockCached: index => cached.push(index) })
await device.open()
await device.read(0, 512)
assert.deepEqual(cached, [0])
// a hole is a materialization too
await device.read(DISK_BLOCK_SIZE, 512)
assert.deepEqual(cached, [0, 1])
// re-reading an already materialized block does not fire again
await device.read(0, 512)
assert.deepEqual(cached, [0, 1])
})
it('does not fire for a block resumed from the initial bitmap', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const initialBitmap = Buffer.alloc(BLOCK_COUNT)
initialBitmap[0] = 1
const cached: Array<number> = []
const device = new CachedDiskBlockDevice({ disk, cache, initialBitmap, onBlockCached: index => cached.push(index) })
await device.open()
await device.read(0, 512)
assert.deepEqual(cached, [])
})
it('does not let a synchronously throwing callback break the read', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const device = new CachedDiskBlockDevice({
disk,
cache,
onBlockCached: () => {
throw new Error('persistence failure')
},
})
await device.open()
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
})
})
describe('flush and close', () => {
it('flushes the cache', async () => {
const { device, cache } = await make()
await device.flush()
assert.equal(cache.flushed, 1)
})
it('closes the cache and the disk', async () => {
const { device, cache, disk } = await make()
await device.close()
assert.equal(cache.closed, true)
assert.equal(disk.closed, true)
})
it('still closes the disk when closing the cache fails', async () => {
const { device, cache, disk } = await make()
cache.close = async () => {
throw new Error('nope')
}
await assert.rejects(device.close(), /nope/)
assert.equal(disk.closed, true)
})
})

View File

@@ -1,120 +0,0 @@
import { createLogger, type Logger } from '@xen-orchestra/log'
import type { RandomAccessDisk } from '@xen-orchestra/disk-transform'
import type { BlockDevice } from './backend.mjs'
const log: Logger = createLogger('vates:iscsi:disk-block-device')
const DEFAULT_BLOCK_SIZE = 512
export interface DiskBlockDeviceOptions {
/**
* Source disk, already `init()`ed by the caller (this adapter never calls
* `init()` — the disk may be part of a chain the caller assembled).
*/
readonly disk: RandomAccessDisk
/**
* Logical block size advertised to initiators, in bytes. Defaults to 512.
* The disk's own block size must be a multiple of it.
*/
readonly blockSize?: number
}
/**
* A read-only {@link BlockDevice} for a {@link RandomAccessDisk} (the inverse
* of {@link IscsiDisk}). Unallocated blocks read as zero; the source must
* never be asked for a block it doesn't have (e.g. `RemoteVhdDiskChain`
* throws).
*
* Nothing is cached — every read re-fetches a whole source block. Put a
* caching decorator in front of the source disk, not here.
*/
export class DiskBlockDevice implements BlockDevice {
readonly #disk: RandomAccessDisk
readonly #blockSize: number
#diskBlockSize?: number
#size?: number
constructor({ disk, blockSize = DEFAULT_BLOCK_SIZE }: DiskBlockDeviceOptions) {
if (!Number.isInteger(blockSize) || blockSize <= 0) {
throw new Error(`blockSize must be a positive integer, got ${blockSize}`)
}
this.#disk = disk
this.#blockSize = blockSize
}
/**
* Read the source disk's geometry and check it can be exposed as a LUN.
* Awaited by the target before it serves any I/O.
*/
async open(): Promise<void> {
const blockSize = this.#blockSize
const diskBlockSize = this.#disk.getBlockSize()
if (diskBlockSize % blockSize !== 0) {
throw new Error(`disk block size (${diskBlockSize}) is not a multiple of the LUN block size (${blockSize})`)
}
const size = this.#disk.getVirtualSize()
if (size <= 0 || size % blockSize !== 0) {
throw new Error(`disk virtual size (${size}) is not a positive multiple of the LUN block size (${blockSize})`)
}
this.#diskBlockSize = diskBlockSize
this.#size = size
log.debug('opened', { size, blockSize, diskBlockSize })
}
getSize(): number {
const size = this.#size
if (size === undefined) {
throw new Error('DiskBlockDevice.open() must be called before I/O')
}
return size
}
getBlockSize(): number {
return this.#blockSize
}
async read(offset: number, length: number): Promise<Buffer> {
const size = this.getSize()
if (offset < 0 || length < 0 || offset + length > size) {
throw new Error(`read of ${length} bytes at ${offset} is out of range (size ${size})`)
}
if (length === 0) {
return Buffer.alloc(0)
}
const diskBlockSize = this.#diskBlockSize as number
// zero-filled: unallocated blocks, and any source block shorter than
// announced, are left as zeroes
const result = Buffer.alloc(length)
const end = offset + length
const lastIndex = Math.floor((end - 1) / diskBlockSize)
for (let index = Math.floor(offset / diskBlockSize); index <= lastIndex; index++) {
if (!this.#disk.hasBlock(index)) {
continue
}
const blockStart = index * diskBlockSize
const from = Math.max(offset, blockStart)
const to = Math.min(end, blockStart + diskBlockSize)
const { data } = await this.#disk.readBlock(index)
data.copy(result, from - offset, from - blockStart, to - blockStart)
}
return result
}
// the target refuses WRITE commands before they reach `write()`
readonly readOnly = true
/** Always throws: the source disk is read-only. */
async write(): Promise<void> {
throw new Error('DiskBlockDevice is read-only')
}
async flush(): Promise<void> {
// nothing to flush: read-only
}
async close(): Promise<void> {
await this.#disk.close()
}
}

View File

@@ -1,169 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { RandomAccessDisk, type DiskBlock } from '@xen-orchestra/disk-transform'
import { DiskBlockDevice } from './DiskBlockDevice.mjs'
const BLOCK_SIZE = 4096
/**
* Sparse in-memory {@link RandomAccessDisk}: `blocks` maps a block index to its
* content, every other index is unallocated and must never be read (the real
* `RemoteVhdDiskChain` throws in that case, which is what this asserts).
*/
class StubDisk extends RandomAccessDisk {
readBlockCalls: Array<number> = []
closed = false
constructor(
readonly blocks: Map<number, Buffer>,
readonly virtualSize: number,
readonly blockSize: number = BLOCK_SIZE
) {
super()
}
getBlockSize(): number {
return this.blockSize
}
getVirtualSize(): number {
return this.virtualSize
}
isDifferencing(): boolean {
return false
}
getBlockIndexes(): Array<number> {
return [...this.blocks.keys()]
}
hasBlock(index: number): boolean {
return this.blocks.has(index)
}
async init(): Promise<void> {}
async close(): Promise<void> {
this.closed = true
}
async readBlock(index: number): Promise<DiskBlock> {
const data = this.blocks.get(index)
if (data === undefined) {
throw new Error(`Block ${index} not found`)
}
this.readBlockCalls.push(index)
return { index, data }
}
}
const filled = (byte: number, length = BLOCK_SIZE) => Buffer.alloc(length, byte)
// 4 blocks, only 0 and 2 allocated
const makeDevice = async (blockSize?: number) => {
const disk = new StubDisk(
new Map([
[0, filled(0xaa)],
[2, filled(0xcc)],
]),
4 * BLOCK_SIZE
)
const device = new DiskBlockDevice({ disk, blockSize })
await device.open()
return { disk, device }
}
describe('open', () => {
it('exposes the disk capacity and the requested block size', async () => {
const { device } = await makeDevice()
assert.equal(device.getSize(), 4 * BLOCK_SIZE)
assert.equal(device.getBlockSize(), 512)
})
it('rejects a disk block size that is not a multiple of the LUN block size', async () => {
const disk = new StubDisk(new Map(), 4096, 600)
await assert.rejects(new DiskBlockDevice({ disk }).open(), /not a multiple of the LUN block size/)
})
it('rejects a capacity that is not a multiple of the LUN block size', async () => {
const disk = new StubDisk(new Map(), BLOCK_SIZE + 100)
await assert.rejects(new DiskBlockDevice({ disk }).open(), /not a positive multiple/)
})
it('rejects an invalid block size', () => {
const disk = new StubDisk(new Map(), BLOCK_SIZE)
assert.throws(() => new DiskBlockDevice({ disk, blockSize: 0 }), /positive integer/)
})
it('refuses I/O before open()', () => {
const disk = new StubDisk(new Map(), BLOCK_SIZE)
assert.throws(() => new DiskBlockDevice({ disk }).getSize(), /open\(\) must be called/)
})
})
describe('read', () => {
it('reads a whole allocated block', async () => {
const { device } = await makeDevice()
assert.deepEqual(await device.read(0, BLOCK_SIZE), filled(0xaa))
})
it('reads an unaligned slice inside one block', async () => {
const { device, disk } = await makeDevice()
assert.deepEqual(await device.read(BLOCK_SIZE * 2 + 512, 1024), filled(0xcc, 1024))
// a single source block was fetched, not the whole disk
assert.deepEqual(disk.readBlockCalls, [2])
})
it('zero-fills unallocated blocks instead of reading them', async () => {
const { device, disk } = await makeDevice()
assert.deepEqual(await device.read(BLOCK_SIZE, BLOCK_SIZE), Buffer.alloc(BLOCK_SIZE))
assert.deepEqual(disk.readBlockCalls, [])
})
it('stitches a range spanning allocated and unallocated blocks', async () => {
const { device } = await makeDevice()
const data = await device.read(BLOCK_SIZE - 512, BLOCK_SIZE + 1024)
assert.deepEqual(
data,
Buffer.concat([
filled(0xaa, 512), // tail of block 0
Buffer.alloc(BLOCK_SIZE), // block 1, unallocated
filled(0xcc, 512), // head of block 2
])
)
})
it('reads the last block of the disk', async () => {
const { device } = await makeDevice()
assert.deepEqual(await device.read(3 * BLOCK_SIZE, BLOCK_SIZE), Buffer.alloc(BLOCK_SIZE))
})
it('returns an empty buffer for a zero-length read', async () => {
const { device, disk } = await makeDevice()
assert.equal((await device.read(0, 0)).length, 0)
assert.deepEqual(disk.readBlockCalls, [])
})
it('rejects a read past the end of the disk', async () => {
const { device } = await makeDevice()
await assert.rejects(device.read(4 * BLOCK_SIZE - 512, 1024), /out of range/)
await assert.rejects(device.read(-512, 512), /out of range/)
})
it('zero-pads a source block shorter than announced', async () => {
const disk = new StubDisk(new Map([[0, filled(0xaa, 1024)]]), BLOCK_SIZE)
const device = new DiskBlockDevice({ disk })
await device.open()
assert.deepEqual(await device.read(0, BLOCK_SIZE), Buffer.concat([filled(0xaa, 1024)], BLOCK_SIZE))
})
})
describe('write', () => {
it('always fails', async () => {
const { device } = await makeDevice()
await assert.rejects(device.write(), /read-only/)
})
})
describe('close', () => {
it('closes the underlying disk', async () => {
const { device, disk } = await makeDevice()
await device.close()
assert.equal(disk.closed, true)
})
})

View File

@@ -1,98 +0,0 @@
import { RandomAccessDisk, type DiskBlock } from '@xen-orchestra/disk-transform'
import { IscsiInitiator, type IscsiInitiatorOptions } from './initiator.mjs'
export interface IscsiDiskOptions {
/**
* Block size, in bytes, presented to the disk-transform pipeline. Must be a
* multiple of the LUN's logical block size (checked at {@link IscsiDisk.init}).
* A single SCSI READ(16) fetches one whole block.
*/
readonly blockSize: number
}
/**
* A {@link RandomAccessDisk} backed by a remote iSCSI LUN read through an
* {@link IscsiInitiator} — the iSCSI analogue of `@vates/nbd-client`'s `NbdDisk`.
* It plugs a target LUN into the `@xen-orchestra/disk-transform` pipeline
* (`ReadAhead`, `DiskLargerBlock`, block generator) unchanged.
*
* First implementation: the LUN is treated as fully allocated — every block is
* reported as present (dense, like `RawDisk`). A future version can narrow this
* with a changed-block map (e.g. a Pure "Volume Difference"). Read-only.
*/
export class IscsiDisk extends RandomAccessDisk {
readonly #options: IscsiInitiatorOptions
readonly #blockSize: number
#initiator?: IscsiInitiator
constructor(options: IscsiInitiatorOptions, { blockSize }: IscsiDiskOptions) {
super()
this.#options = options
this.#blockSize = blockSize
}
async init(): Promise<void> {
const initiator = new IscsiInitiator(this.#options)
await initiator.connect()
const lunBlockSize = initiator.getBlockSize()
if (this.#blockSize % lunBlockSize !== 0) {
await initiator.close()
throw new Error(`block size ${this.#blockSize} is not a multiple of the LUN block size ${lunBlockSize}`)
}
this.#initiator = initiator
}
async close(): Promise<void> {
const initiator = this.#initiator
this.#initiator = undefined
await initiator?.close()
}
#requireInitiator(): IscsiInitiator {
const initiator = this.#initiator
if (initiator === undefined) {
throw new Error('IscsiDisk.init() must be called first')
}
return initiator
}
getBlockSize(): number {
return this.#blockSize
}
getVirtualSize(): number {
return this.#requireInitiator().getSize()
}
isDifferencing(): boolean {
return false
}
getBlockIndexes(): Array<number> {
const count = Math.ceil(this.getVirtualSize() / this.#blockSize)
const indexes = new Array<number>(count)
for (let i = 0; i < count; i++) {
indexes[i] = i
}
return indexes
}
hasBlock(index: number): boolean {
return index >= 0 && index * this.#blockSize < this.getVirtualSize()
}
async readBlock(index: number): Promise<DiskBlock> {
const initiator = this.#requireInitiator()
const size = this.getVirtualSize()
const offset = index * this.#blockSize
const length = Math.min(this.#blockSize, size - offset)
let data = await initiator.read(offset, length)
if (data.length < this.#blockSize) {
// Last (short) block on a LUN whose capacity is not a multiple of the
// disk block size: zero-pad the tail to a full block.
data = Buffer.concat([data], this.#blockSize)
}
return { index, data }
}
}

View File

@@ -1,136 +0,0 @@
import { open, type FileHandle } from 'node:fs/promises'
/**
* Byte-range random-access store backing a single iSCSI LUN.
*
* The protocol layer translates SCSI READ/WRITE CDBs (LBA + block count) into
* byte offsets/lengths against this interface, so an implementation only has to
* provide flat random access — it is intentionally decoupled from the wire
* code. `DiskBlockDevice` is such an implementation, over
* `@xen-orchestra/disk-transform`'s `RandomAccessDisk` (VHD/raw/NBD).
*/
export interface BlockDevice {
/** Optional one-time initialization, awaited by the target before serving I/O. */
open?(): Promise<void>
/** Total capacity in bytes. Must be a multiple of {@link BlockDevice.getBlockSize}. */
getSize(): number
/** Logical block size in bytes (typically 512). */
getBlockSize(): number
/**
* When true, the LUN is reported as write-protected and WRITE commands are
* refused up front (DATA PROTECT), without soliciting their data.
*/
readonly readOnly?: boolean
/** Read exactly `length` bytes starting at byte `offset`. */
read(offset: number, length: number): Promise<Buffer>
/** Write `data` starting at byte `offset`. */
write(offset: number, data: Buffer): Promise<void>
/** Flush any buffered data to stable storage (SYNCHRONIZE CACHE). */
flush(): Promise<void>
/** Release underlying resources. */
close(): Promise<void>
}
const DEFAULT_BLOCK_SIZE = 512
export interface FileBlockDeviceOptions {
/** Path to the backing file. It must already exist and be sized to capacity. */
readonly path: string
/** Logical block size in bytes. Defaults to 512. */
readonly blockSize?: number
}
/**
* A {@link BlockDevice} backed by a regular (sparse) file opened read/write.
*
* The file must already exist and be sized to the desired capacity (e.g. via
* `fs.truncate`); this keeps the device a pure data store with no provisioning
* policy of its own.
*/
export class FileBlockDevice implements BlockDevice {
readonly #path: string
readonly #blockSize: number
#handle?: FileHandle
#size = 0
constructor({ path, blockSize = DEFAULT_BLOCK_SIZE }: FileBlockDeviceOptions) {
if (!Number.isInteger(blockSize) || blockSize <= 0) {
throw new Error(`blockSize must be a positive integer, got ${blockSize}`)
}
this.#path = path
this.#blockSize = blockSize
}
/** Open the backing file and read its current size. Must be called before any I/O. */
async open(): Promise<void> {
if (this.#handle !== undefined) {
return
}
const handle = await open(this.#path, 'r+')
try {
const { size } = await handle.stat()
if (size % this.#blockSize !== 0) {
throw new Error(`backing file size (${size}) is not a multiple of block size (${this.#blockSize})`)
}
this.#handle = handle
this.#size = size
} catch (error) {
await handle.close()
throw error
}
}
#requireHandle(): FileHandle {
const handle = this.#handle
if (handle === undefined) {
throw new Error('FileBlockDevice.open() must be called before I/O')
}
return handle
}
getSize(): number {
return this.#size
}
getBlockSize(): number {
return this.#blockSize
}
async read(offset: number, length: number): Promise<Buffer> {
const handle = this.#requireHandle()
const buffer = Buffer.alloc(length, 0)
let read = 0
// A single fh.read may return fewer bytes than requested; loop to fill.
while (read < length) {
const { bytesRead } = await handle.read(buffer, read, length - read, offset + read)
if (bytesRead === 0) {
// Reading past EOF (sparse tail): zero-fill the remainder.
buffer.fill(0, read)
break
}
read += bytesRead
}
return buffer
}
async write(offset: number, data: Buffer): Promise<void> {
const handle = this.#requireHandle()
let written = 0
while (written < data.length) {
const { bytesWritten } = await handle.write(data, written, data.length - written, offset + written)
written += bytesWritten
}
}
async flush(): Promise<void> {
await this.#requireHandle().sync()
}
async close(): Promise<void> {
const handle = this.#handle
if (handle !== undefined) {
this.#handle = undefined
await handle.close()
}
}
}

View File

@@ -1,90 +0,0 @@
// Shared CHAP (RFC 1994) primitives for both login directions: the target as
// authenticator, the initiator as responder. Kept in one place so the MD5
// computation and large-binary codec agree byte-for-byte between the two
// (cross-tested against each other in loopback).
//
// RFC 7143 §11.1.4 pins CHAP to MD5 (algorithm id 5) — a wire-format
// requirement, not a security choice.
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
/** The only CHAP algorithm iSCSI defines / we support: MD5 (RFC 7143 §11.1.4). */
export const CHAP_ALGORITHM_MD5 = 5
/** Default challenge length in bytes; 16 is what open-iscsi and most targets use. */
const DEFAULT_CHALLENGE_LENGTH = 16
/**
* Compute a CHAP response: `MD5(id ‖ secret ‖ challenge)` (RFC 1994 §4.1).
*
* `id` is the single CHAP_I octet (0-255) — the raw byte, NOT its ASCII decimal.
* `secret` is the shared secret (a string is hashed as its UTF-8 bytes).
*/
export function computeResponse(id: number, secret: string | Buffer, challenge: Buffer): Buffer {
return createHash('md5')
.update(Buffer.from([id & 0xff]))
.update(secret)
.update(challenge)
.digest()
}
/**
* Encode a binary value (CHAP_C / CHAP_R) as an iSCSI hex-constant, e.g.
* `0xa1b2…` (RFC 7143 §5.1). We always emit hex; {@link decodeChapValue} accepts
* both hex and base64 so we interoperate with peers that send either.
*/
export function encodeChapValue(value: Buffer): string {
return '0x' + value.toString('hex')
}
/**
* Decode an iSCSI large-binary value: `0x`/`0X` hex-constant or `0b`/`0B`
* base64-constant (RFC 7143 §5.1). Throws on an unrecognized encoding.
*/
export function decodeChapValue(value: string): Buffer {
const prefix = value.slice(0, 2)
const body = value.slice(2)
if (prefix === '0x' || prefix === '0X') {
return Buffer.from(body, 'hex')
}
if (prefix === '0b' || prefix === '0B') {
return Buffer.from(body, 'base64')
}
throw new Error(`unsupported CHAP value encoding: ${value.slice(0, 2)}…`)
}
/** Generate a random CHAP challenge (CHAP_C). */
export function generateChallenge(length: number = DEFAULT_CHALLENGE_LENGTH): Buffer {
return randomBytes(length)
}
/** Generate a random CHAP identifier (CHAP_I), a single octet. */
export function generateId(): number {
return randomBytes(1)[0]
}
/** Parse a CHAP_A value (`"5"` or a `"5,6,7"` preference list) into algorithm ids. */
export function parseAlgorithmList(value: string): number[] {
return value
.split(',')
.map(part => Number.parseInt(part.trim(), 10))
.filter(id => Number.isInteger(id))
}
/**
* Pick the algorithm we will use from the ones the peer offered: MD5 if present,
* else `undefined` (we only implement MD5, so an offer without it must fail —
* never fall back to the first offered id).
*/
export function selectAlgorithm(offered: readonly number[]): number | undefined {
return offered.includes(CHAP_ALGORITHM_MD5) ? CHAP_ALGORITHM_MD5 : undefined
}
/**
* Constant-time comparison of an expected response against a received one. A
* wrong-length `got` (or the length-mismatch `timingSafeEqual` would throw on)
* counts as a failed authentication, not an exception.
*/
export function verifyResponse(expected: Buffer, got: Buffer): boolean {
return expected.length === got.length && timingSafeEqual(expected, got)
}

View File

@@ -1,108 +0,0 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { describe, it } from 'node:test'
import {
CHAP_ALGORITHM_MD5,
computeResponse,
decodeChapValue,
encodeChapValue,
generateChallenge,
generateId,
parseAlgorithmList,
selectAlgorithm,
verifyResponse,
} from './chap.mjs'
describe('computeResponse', () => {
// Known-answer vector: MD5(0x01 ‖ "s3cr3t" ‖ <16-byte challenge>), computed
// independently so this pins the exact byte layout (id octet first), not MD5
// against itself.
const id = 1
const secret = 's3cr3t'
const challenge = Buffer.from('0102030405060708090a0b0c0d0e0f10', 'hex')
const expected = 'fc14fb49f184c847514a10278d75a10f'
it('hashes id ‖ secret ‖ challenge (RFC 1994)', () => {
assert.equal(computeResponse(id, secret, challenge).toString('hex'), expected)
})
it('treats a string secret as its UTF-8 bytes', () => {
assert.deepEqual(
computeResponse(id, Buffer.from(secret, 'utf8'), challenge),
computeResponse(id, secret, challenge)
)
})
it('uses the id as a raw octet, not its ASCII decimal', () => {
// computeResponse(1, …) must hash the byte 0x01, whereas hashing the ASCII
// text "1" would hash 0x31 — the two must differ.
const rawOctet = createHash('md5')
.update(Buffer.from([1]))
.update(secret)
.update(challenge)
.digest()
const asciiText = createHash('md5').update(Buffer.from('1', 'ascii')).update(secret).update(challenge).digest()
assert.deepEqual(computeResponse(1, secret, challenge), rawOctet)
assert.notDeepEqual(computeResponse(1, secret, challenge), asciiText)
})
})
describe('CHAP value codec', () => {
it('encodes as a 0x hex-constant', () => {
assert.equal(encodeChapValue(Buffer.from([0xab, 0xcd, 0x01])), '0xabcd01')
})
it('round-trips through hex', () => {
const value = generateChallenge()
assert.deepEqual(decodeChapValue(encodeChapValue(value)), value)
})
it('decodes a 0b base64-constant', () => {
const raw = Buffer.from('0102030405060708090a0b0c0d0e0f10', 'hex')
assert.deepEqual(decodeChapValue('0b' + raw.toString('base64')), raw)
})
it('accepts uppercase 0X / 0B prefixes', () => {
assert.deepEqual(decodeChapValue('0Xabcd'), Buffer.from('abcd', 'hex'))
assert.deepEqual(decodeChapValue('0B' + Buffer.from([1, 2]).toString('base64')), Buffer.from([1, 2]))
})
it('rejects an unknown encoding', () => {
assert.throws(() => decodeChapValue('12345'), /unsupported CHAP value encoding/)
})
})
describe('algorithm selection', () => {
it('parses a preference list', () => {
assert.deepEqual(parseAlgorithmList('5,6,7'), [5, 6, 7])
assert.deepEqual(parseAlgorithmList('5'), [5])
})
it('picks MD5 when offered', () => {
assert.equal(selectAlgorithm([1, CHAP_ALGORITHM_MD5, 6]), CHAP_ALGORITHM_MD5)
})
it('returns undefined when MD5 is not offered (never falls back)', () => {
assert.equal(selectAlgorithm([1, 2]), undefined)
})
})
describe('verifyResponse', () => {
const secret = 's3cr3t'
const challenge = generateChallenge()
const id = generateId()
const good = computeResponse(id, secret, challenge)
it('accepts a matching response', () => {
assert.equal(verifyResponse(good, computeResponse(id, secret, challenge)), true)
})
it('rejects a wrong secret', () => {
assert.equal(verifyResponse(good, computeResponse(id, 'wrong', challenge)), false)
})
it('rejects a wrong-length response without throwing', () => {
assert.equal(verifyResponse(good, Buffer.alloc(4)), false)
})
})

View File

@@ -1,141 +0,0 @@
#!/usr/bin/env node
import { stat, truncate, writeFile } from 'node:fs/promises'
import { basename } from 'node:path'
import { parseArgs } from 'node:util'
import { FileBlockDevice, IscsiTarget } from './index.mjs'
const USAGE = `Usage: vates-iscsi <file> [options]
Serve a single read/write iSCSI LUN backed by <file>.
Arguments:
<file> Backing file. Must exist and have a size that is a
multiple of the block size, unless --size is given.
Options:
--serial <serial> LUN serial number (default: derived from the IQN).
--size <bytes> Create/grow the backing file to this many bytes.
Accepts suffixes K, M, G, T (e.g. 10G).
--iqn <iqn> Target IQN (default: iqn.2024-01.tech.vates:<file>).
--host <host> Listen address (default: 0.0.0.0).
--port <port> Listen port (default: 3260).
--block-size <bytes> Logical block size (default: 512).
-h, --help Show this help.
`
function fail(message: string): never {
process.stderr.write(`vates-iscsi: ${message}\n`)
process.exit(1)
}
/** Parse a positive integer with optional K/M/G/T suffix (powers of 1024). */
function parseSize(value: string, label: string): number {
const match = /^(\d+)([KMGT]?)$/i.exec(value.trim())
if (match === null) {
fail(`invalid ${label}: ${value}`)
}
const units: Record<string, number> = { '': 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 }
return Number(match[1]) * units[match[2].toUpperCase()]
}
/** Sanitize a string into the characters allowed in the IQN-specific part. */
function sanitizeIqnPart(value: string): string {
const cleaned = value.toLowerCase().replace(/[^a-z0-9.-]+/g, '-').replace(/^-+|-+$/g, '')
return cleaned.length > 0 ? cleaned : 'lun0'
}
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
serial: { type: 'string' },
size: { type: 'string' },
iqn: { type: 'string' },
host: { type: 'string' },
port: { type: 'string' },
'block-size': { type: 'string' },
help: { type: 'boolean', short: 'h' },
},
})
if (values.help || positionals.length === 0) {
process.stdout.write(USAGE)
process.exit(values.help ? 0 : 1)
}
if (positionals.length > 1) {
fail(`expected a single <file>, got ${positionals.length}`)
}
const path = positionals[0]
const blockSize = values['block-size'] !== undefined ? parseSize(values['block-size'], 'block size') : 512
const port = values.port !== undefined ? Number.parseInt(values.port, 10) : 3260
if (!Number.isInteger(port) || port < 0 || port > 65535) {
fail(`invalid port: ${values.port}`)
}
// Create/grow the backing file when --size is requested.
if (values.size !== undefined) {
const size = parseSize(values.size, 'size')
if (size % blockSize !== 0) {
fail(`--size (${size}) must be a multiple of the block size (${blockSize})`)
}
const exists = await stat(path).then(
() => true,
() => false
)
if (!exists) {
await writeFile(path, Buffer.alloc(0))
}
await truncate(path, size)
}
// Validate the backing file before binding the socket.
const stats = await stat(path).catch(() => fail(`backing file not found: ${path} (create one, e.g. \`truncate -s 10G ${path}\`, or pass --size)`))
if (stats.size === 0) {
fail(`backing file is empty: ${path} (size it first, e.g. \`truncate -s 10G ${path}\`, or pass --size)`)
}
const iqn = values.iqn ?? `iqn.2024-01.tech.vates:${sanitizeIqnPart(basename(path).replace(/\.[^.]+$/, ''))}`
const target = new IscsiTarget({
iqn,
host: values.host,
port,
lun: new FileBlockDevice({ path, blockSize }),
identity: values.serial !== undefined ? { serial: values.serial } : undefined,
})
await target.listen()
const address = target.address()
const listenHost = values.host ?? '0.0.0.0'
const listenPort = address?.port ?? port
process.stdout.write(
[
`iSCSI target listening on ${listenHost}:${listenPort}`,
` IQN: ${iqn}`,
` LUN: ${path} (${stats.size} bytes, ${blockSize}-byte blocks)`,
` serial: ${values.serial ?? iqn}`,
``,
`Discover with: iscsiadm -m discovery -t sendtargets -p ${listenHost === '0.0.0.0' ? '127.0.0.1' : listenHost}:${listenPort}`,
`Press Ctrl-C to stop.`,
``,
].join('\n')
)
let stopping = false
function stop(): void {
if (stopping) {
return
}
stopping = true
target.close().then(
() => process.exit(0),
error => {
process.stderr.write(`vates-iscsi: error on shutdown: ${String(error)}\n`)
process.exit(1)
}
)
}
process.once('SIGINT', stop)
process.once('SIGTERM', stop)

View File

@@ -1,622 +0,0 @@
import type { Socket } from 'node:net'
import { createLogger, type Logger } from '@xen-orchestra/log'
import type { BlockDevice } from './backend.mjs'
import {
Asc,
DATA_IN_FLAG_FINAL,
DATA_IN_FLAG_OVERFLOW,
DATA_IN_FLAG_STATUS,
DATA_IN_FLAG_UNDERFLOW,
FLAG_FINAL,
InitiatorOpcode,
ISCSI_VERSION,
LoginStatusClass,
LogoutResponse,
RejectReason,
RESERVED_TAG,
SCSI_RESP_FLAG_OVERFLOW,
SCSI_RESP_FLAG_UNDERFLOW,
SCSI_RESPONSE_COMMAND_COMPLETED,
ScsiStatus,
SenseKey,
TargetOpcode,
TEXT_FLAG_FINAL,
} from './constants.mjs'
import { buildFixedSense, handleScsiCommand, type ScsiIdentity } from './scsi.mjs'
import {
buildSendTargetsResponse,
LOGIN_MAX_DATA_SEGMENT_LENGTH,
LoginNegotiator,
OUR_MAX_RECV_DATA_SEGMENT_LENGTH,
parseTextKeys,
} from './login.mjs'
import { allocBhs, assemblePdu, type IncomingPdu, readPdu, writePdu } from './pdu.mjs'
import {
decodeCdb,
type ChapCredentials,
type CommandContext,
type NegotiatedParams,
type ScsiResponseOptions,
type SessionType,
} from './types.mjs'
import { buildR2t, receiveDataOut, type PendingWrite } from './writePath.mjs'
import { Semaphore } from './semaphore.mjs'
const log: Logger = createLogger('vates:iscsi')
/** 2^32: the modulus for all iSCSI sequence-number arithmetic. */
export const SERIAL_MODULO = 0x1_0000_0000
/** Increment an iSCSI serial number with 32-bit wraparound. */
export function incrementSerial(value: number): number {
return (value + 1) % SERIAL_MODULO
}
/** Add `delta` to an iSCSI serial number with 32-bit wraparound. */
export function addSerial(value: number, delta: number): number {
return (value + delta) % SERIAL_MODULO
}
/**
* Initiator opcodes whose BHS bytes 24-27 are CmdSN. The two that are missing —
* SCSI Data-Out and SNACK — have those bytes reserved instead; see the comment
* on `Connection`'s `#updateCommandWindow` for what reading them would cost.
*/
const CARRIES_CMD_SN: ReadonlySet<number> = new Set([
InitiatorOpcode.NOP_OUT,
InitiatorOpcode.SCSI_COMMAND,
InitiatorOpcode.SCSI_TASK_MGMT_REQUEST,
InitiatorOpcode.LOGIN_REQUEST,
InitiatorOpcode.TEXT_REQUEST,
InitiatorOpcode.LOGOUT_REQUEST,
])
/** Everything a {@link Connection} needs from the owning target. */
export interface ConnectionDeps {
readonly iqn: string
readonly identity: ScsiIdentity
readonly lun: BlockDevice
/** Drain timeout for outbound PDUs, in ms (0 disables). */
readonly writeTimeoutMs: number
/** Size of the CmdSN command window advertised to the initiator. */
readonly cmdWindow: number
/** Max number of READs served concurrently (see {@link Connection}). */
readonly readConcurrency: number
/** Allocate a fresh, non-zero Target Session Identifying Handle. */
allocateTsih(): number
/** When set, require one-way CHAP: the target challenges and verifies the initiator. */
readonly chap?: ChapCredentials
/**
* Time allowed to establish a Normal session, in ms (0 or unset disables):
* past it, the connection is closed. Also bounds Discovery sessions, which
* never become Normal ones.
*/
readonly loginTimeoutMs?: number
/** Called once this connection has established a Normal session. */
onSessionEstablished?(connection: Connection): void
/** Max length of a single READ or WRITE, in bytes. */
readonly maxTransferLength?: number
}
type Phase = 'login' | 'fullFeature' | 'closed'
/**
* Drives one iSCSI connection: login, command-window/StatSN sequencing, and
* full-feature-phase PDU dispatch. With MaxConnections=1 a connection is also
* the whole session.
*
* Everything but READ runs one at a time; the read loop advances only once a
* command fully completes. READ is dispatched without waiting on its own
* `lun.read()` (can be a slow network fetch) — up to `readConcurrency` at
* once, via `#readGate` — so one slow read can't block every other command.
*
* StatSN is allocated and written to the socket synchronously, with no
* `await` in between, so responses reach the wire in whatever order the event
* loop runs their completions — no extra sequencing needed. This does not
* order a READ against a concurrent WRITE to the same LBA range: it may see
* old or new bytes, like an untagged SCSI command with no ordering barrier.
*/
export class Connection implements CommandContext {
readonly #socket: Socket
readonly #deps: ConnectionDeps
readonly #login: LoginNegotiator
readonly #readGate: Semaphore
#phase: Phase = 'login'
#sessionType: SessionType = 'Normal'
#params: NegotiatedParams = { initiatorMaxRecvDataSegmentLength: 8192, maxBurstLength: 262144 }
#statSN = 0
#expCmdSN = 0
#maxCmdSN = 0
#cmdSNInitialized = false
#tsih = 0
// In-progress WRITEs keyed by Initiator Task Tag. Data-Out PDUs are routed
// here, so writes can be interleaved with (and outstanding alongside) other
// commands without blocking the read loop.
readonly #pendingWrites = new Map<number, PendingWrite>()
#nextTargetTransferTag = 1
#loginTimer?: ReturnType<typeof setTimeout>
constructor(socket: Socket, deps: ConnectionDeps) {
this.#socket = socket
this.#deps = deps
this.#login = new LoginNegotiator(deps.chap)
this.#readGate = new Semaphore(deps.readConcurrency)
}
get lun(): BlockDevice {
return this.#deps.lun
}
/** Forcibly tear down the connection (used when the target is closing). */
destroy(): void {
this.#phase = 'closed'
this.#socket.destroy()
}
/** Read and service PDUs until the peer logs out or the connection drops. */
async serve(): Promise<void> {
const { loginTimeoutMs = 0 } = this.#deps
if (loginTimeoutMs > 0) {
this.#loginTimer = setTimeout(() => {
log.warn('no session established in time, closing the connection', { remote: this.#socket.remoteAddress })
this.destroy()
}, loginTimeoutMs).unref()
}
try {
for (;;) {
const pdu = await readPdu(
this.#socket,
this.#phase === 'login' ? LOGIN_MAX_DATA_SEGMENT_LENGTH : OUR_MAX_RECV_DATA_SEGMENT_LENGTH
)
if (pdu === null) {
break // peer closed the connection
}
const finished = await this.#dispatch(pdu)
if (finished) {
break
}
}
} catch (error) {
this.#fail(error)
} finally {
clearTimeout(this.#loginTimer)
this.#phase = 'closed'
this.#socket.destroy()
}
}
/**
* Log a connection-ending error and tear the socket down — the same outcome
* an uncaught error in the main `serve()` loop already had, now also reached
* by a concurrently-dispatched READ that fails (e.g. a backend read error):
* with nothing else awaiting it directly, it would otherwise surface only as
* an unhandled rejection while the initiator hangs waiting for a response
* that will never come. Safe to call more than once — `socket.destroy()` is
* itself idempotent.
*/
#fail(error: unknown): void {
if (this.#phase === 'closed') {
return
}
const err = error instanceof Error ? error : new Error(String(error))
const code = (err as NodeJS.ErrnoException).code
// A peer dropping the socket (e.g. on logout) is normal, not a fault.
if (code === 'ECONNRESET' || code === 'EPIPE' || code === 'ECONNABORTED') {
log.debug('connection closed by peer', { code })
} else {
log.warn('connection error', err)
}
this.#phase = 'closed'
this.#socket.destroy()
}
// --- sequencing -----------------------------------------------------------
/**
* Advance the command window from a freshly received PDU — but only from one
* that actually carries a CmdSN.
*
* BHS bytes 24-27 hold CmdSN on command PDUs only; on a SCSI Data-Out (RFC
* 7143 §11.7) and a SNACK they are reserved, as is the immediate bit. An
* initiator zeroes them, so treating such a PDU as a command reads CmdSN=0,
* takes the non-immediate branch, and rewinds the window to ExpCmdSN=1 /
* MaxCmdSN=1+window — then advertises it on the very next R2T, SCSI Response,
* or concurrent READ's Data-In. Initiators discard the rewind as stale only
* while the session's CmdSN stays below 2^31; past that it compares as newer
* under serial arithmetic, is adopted, and the window collapses into a stall.
*
* Since every WRITE is R2T-solicited (InitialR2T=Yes), Data-Out is the common
* case, not an edge one. Allowlisting the opcodes that do carry CmdSN, rather
* than skipping the two that don't, keeps a newly handled opcode from
* silently inheriting the bug.
*/
#updateCommandWindow(pdu: IncomingPdu): void {
if (!CARRIES_CMD_SN.has(pdu.opcode)) {
return
}
const cmdSN = pdu.cmdSN
if (!this.#cmdSNInitialized) {
this.#expCmdSN = cmdSN
this.#cmdSNInitialized = true
}
// Immediate PDUs (Login, NOP pings, aborts) do not consume a CmdSN slot.
if (!pdu.immediate) {
this.#expCmdSN = incrementSerial(cmdSN)
}
this.#maxCmdSN = addSerial(this.#expCmdSN, this.#deps.cmdWindow)
}
/** Current StatSN without advancing (for R2T, NOP-In, Reject, non-final Data-In). */
#currentStatSN(): number {
return this.#statSN
}
/** Return the current StatSN and advance it (for status-bearing responses). */
#nextStatSN(): number {
const value = this.#statSN
this.#statSN = incrementSerial(value)
return value
}
#send(buffer: Buffer): Promise<void> {
return writePdu(this.#socket, buffer, this.#deps.writeTimeoutMs)
}
// --- dispatch -------------------------------------------------------------
/** Returns true when the connection should be closed (after a logout). */
async #dispatch(pdu: IncomingPdu): Promise<boolean> {
this.#updateCommandWindow(pdu)
if (this.#phase === 'login') {
if (pdu.opcode === InitiatorOpcode.LOGIN_REQUEST) {
return await this.#handleLogin(pdu)
}
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
return false
}
switch (pdu.opcode) {
case InitiatorOpcode.SCSI_COMMAND:
if (this.#sessionType === 'Discovery') {
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
} else {
await this.#handleScsiCommand(pdu)
}
return false
case InitiatorOpcode.TEXT_REQUEST:
await this.#handleText(pdu)
return false
case InitiatorOpcode.NOP_OUT:
await this.#handleNopOut(pdu)
return false
case InitiatorOpcode.LOGOUT_REQUEST:
await this.#handleLogout(pdu)
return true
case InitiatorOpcode.SCSI_DATA_OUT:
await this.#handleDataOut(pdu)
return false
default:
await this.#sendReject(pdu, RejectReason.COMMAND_NOT_SUPPORTED)
return false
}
}
/** Returns true when the login failed and the connection must be closed. */
async #handleLogin(pdu: IncomingPdu): Promise<boolean> {
const { flagsByte, data, statusClass = LoginStatusClass.SUCCESS, statusDetail = 0 } = this.#login.process(pdu)
if (this.#login.complete && this.#tsih === 0) {
this.#tsih = this.#deps.allocateTsih()
}
const bhs = allocBhs(TargetOpcode.LOGIN_RESPONSE)
bhs[1] = flagsByte
bhs[2] = ISCSI_VERSION // Version-max
bhs[3] = ISCSI_VERSION // Version-active
pdu.bhs.copy(bhs, 8, 8, 14) // echo ISID (6 bytes)
bhs.writeUInt16BE(this.#tsih, 14)
bhs.writeUInt32BE(pdu.itt, 16)
bhs.writeUInt32BE(this.#nextStatSN(), 24)
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
bhs[36] = statusClass
bhs[37] = statusDetail
await this.#send(assemblePdu(bhs, data))
// destroyed while the response was being written (login timeout, target
// closing): the write still succeeds, but this connection must not go on
// and replace the current session
if (this.#phase === 'closed') {
return true
}
if (statusClass !== LoginStatusClass.SUCCESS) {
// RFC 7143 §6.3: the failure response goes out first (above), then the
// target closes the connection.
log.warn('login rejected', { statusClass, statusDetail, initiator: this.#login.initiatorName })
this.#phase = 'closed'
return true
}
if (this.#login.complete) {
this.#phase = 'fullFeature'
this.#sessionType = this.#login.sessionType
this.#params = this.#login.params
log.info('session established', {
initiator: this.#login.initiatorName,
sessionType: this.#sessionType,
target: this.#login.targetName,
})
if (this.#sessionType === 'Normal') {
clearTimeout(this.#loginTimer)
this.#deps.onSessionEstablished?.(this)
}
}
return false
}
async #handleText(pdu: IncomingPdu): Promise<void> {
const keys = parseTextKeys(pdu.data)
// SendTargets discovery: advertise this target at the address the initiator
// actually connected to.
const data =
keys.get('SendTargets') !== undefined
? buildSendTargetsResponse(
this.#deps.iqn,
this.#socket.localAddress ?? '0.0.0.0',
this.#socket.localPort ?? 3260
)
: Buffer.alloc(0)
const bhs = allocBhs(TargetOpcode.TEXT_RESPONSE)
bhs[1] = TEXT_FLAG_FINAL
bhs.writeUInt32BE(pdu.itt, 16)
bhs.writeUInt32BE(RESERVED_TAG, 20) // Target Transfer Tag: none / final
bhs.writeUInt32BE(this.#nextStatSN(), 24)
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
await this.#send(assemblePdu(bhs, data))
}
async #handleNopOut(pdu: IncomingPdu): Promise<void> {
// A NOP-Out with the reserved ITT is the initiator's reply to a NOP-In we
// sent; nothing to do. Otherwise echo it back as a NOP-In ping reply.
if (pdu.itt === RESERVED_TAG) {
return
}
const bhs = allocBhs(TargetOpcode.NOP_IN)
bhs[1] = FLAG_FINAL
pdu.bhs.copy(bhs, 8, 8, 16) // echo LUN
bhs.writeUInt32BE(pdu.itt, 16)
bhs.writeUInt32BE(RESERVED_TAG, 20) // Target Transfer Tag
bhs.writeUInt32BE(this.#currentStatSN(), 24) // NOP-In does not advance StatSN
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
await this.#send(assemblePdu(bhs, pdu.data)) // echo ping data
}
async #handleLogout(pdu: IncomingPdu): Promise<void> {
const bhs = allocBhs(TargetOpcode.LOGOUT_RESPONSE)
bhs[1] = FLAG_FINAL
bhs[2] = LogoutResponse.SUCCESS
bhs.writeUInt32BE(pdu.itt, 16)
bhs.writeUInt32BE(this.#nextStatSN(), 24)
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
await this.#send(assemblePdu(bhs))
}
async #handleScsiCommand(pdu: IncomingPdu): Promise<void> {
// Login declares ImmediateData=No unconditionally, so a data segment here is
// write data we never solicited and have no path to apply. Ignoring it would
// acknowledge a write missing its first bytes — exactly the silent loss the
// solicited path is built to avoid — so fail the command loudly instead and
// let the initiator retry or give up.
if (pdu.data.length > 0) {
log.warn('immediate data is not supported', { itt: pdu.itt, length: pdu.data.length })
const sense = buildFixedSense({ key: SenseKey.ILLEGAL_REQUEST, ...Asc.INVALID_FIELD_IN_CDB })
await this.sendScsiResponse(pdu.itt, ScsiStatus.CHECK_CONDITION, { sense })
return
}
const cdb = pdu.bhs.subarray(32, 48)
if (decodeCdb(cdb).kind === 'read') {
// Not awaited: the read loop must move on immediately rather than block on
// this `lun.read()`. See the class doc for why no extra ordering is needed.
void this.#readGate
.run(() => handleScsiCommand(cdb, pdu.itt, this, this.#deps.identity, this.#deps.maxTransferLength))
.catch(error => {
this.#fail(error)
})
return
}
await handleScsiCommand(cdb, pdu.itt, this, this.#deps.identity, this.#deps.maxTransferLength)
}
async #sendReject(pdu: IncomingPdu, reason: number): Promise<void> {
log.debug('rejecting PDU', { opcode: pdu.opcode, itt: pdu.itt, reason, phase: this.#phase })
const bhs = allocBhs(TargetOpcode.REJECT)
bhs[1] = FLAG_FINAL
bhs[2] = reason
bhs.writeUInt32BE(RESERVED_TAG, 16)
bhs.writeUInt32BE(this.#currentStatSN(), 24) // Reject snapshots StatSN
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
// The rejected PDU's header is returned as the data segment.
await this.#send(assemblePdu(bhs, pdu.bhs))
}
// --- CommandContext implementation ----------------------------------------
async sendReadData(itt: number, data: Buffer, allocationLength: number): Promise<void> {
let payload = data
let residualOverflow = 0
let residualUnderflow = 0
if (data.length > allocationLength) {
payload = data.subarray(0, allocationLength)
residualOverflow = data.length - allocationLength
} else if (data.length < allocationLength) {
residualUnderflow = allocationLength - data.length
}
if (payload.length === 0) {
return this.sendScsiResponse(itt, ScsiStatus.GOOD, { residualOverflow, residualUnderflow })
}
const maxSegment = this.#params.initiatorMaxRecvDataSegmentLength
let offset = 0
let dataSN = 0
while (offset < payload.length) {
const end = Math.min(offset + maxSegment, payload.length)
const chunk = payload.subarray(offset, end)
const isLast = end >= payload.length
const bhs = allocBhs(TargetOpcode.SCSI_DATA_IN)
let flags = 0
if (isLast) {
// Piggyback GOOD status on the final Data-In PDU.
flags |= DATA_IN_FLAG_FINAL | DATA_IN_FLAG_STATUS
if (residualOverflow > 0) {
flags |= DATA_IN_FLAG_OVERFLOW
}
if (residualUnderflow > 0) {
flags |= DATA_IN_FLAG_UNDERFLOW
}
bhs[3] = ScsiStatus.GOOD // Status valid only when the S bit is set
}
bhs[1] = flags
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(RESERVED_TAG, 20) // Target Transfer Tag
if (isLast) {
bhs.writeUInt32BE(this.#nextStatSN(), 24) // StatSN valid only with the S bit
}
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
bhs.writeUInt32BE(dataSN, 36)
bhs.writeUInt32BE(offset, 40) // Buffer Offset
if (isLast) {
bhs.writeUInt32BE(residualOverflow || residualUnderflow, 44)
}
await this.#send(assemblePdu(bhs, chunk))
offset = end
dataSN++
}
}
async beginWrite(itt: number, lunOffset: number, totalLength: number): Promise<void> {
const pending: PendingWrite = {
itt,
targetTransferTag: this.#nextTargetTransferTag++,
lunOffset,
// zero-filled, not `allocUnsafe`: belt to `receiveDataOut`'s braces, so a
// buffer that somehow reaches the LUN partly filled writes zeroes rather
// than recycled heap (which the initiator could then read straight back)
buffer: Buffer.alloc(totalLength),
totalLength,
contiguousReceived: 0,
solicited: 0,
r2tSN: 0,
}
this.#pendingWrites.set(itt, pending)
log.debug('write begin', { itt, lunOffset, totalLength })
await this.#sendNextR2t(pending)
}
/** Solicit the next burst (≤ MaxBurstLength) of a pending write via an R2T. */
async #sendNextR2t(pending: PendingWrite): Promise<void> {
const desiredLength = Math.min(pending.totalLength - pending.solicited, this.#params.maxBurstLength)
const bufferOffset = pending.solicited
const r2tSN = pending.r2tSN
pending.solicited += desiredLength
pending.r2tSN++
await this.#send(
buildR2t(
{ itt: pending.itt, r2tSN, targetTransferTag: pending.targetTransferTag, bufferOffset, desiredLength },
{ statSN: this.#currentStatSN(), expCmdSN: this.#expCmdSN, maxCmdSN: this.#maxCmdSN }
)
)
}
async #handleDataOut(pdu: IncomingPdu): Promise<void> {
const pending = this.#pendingWrites.get(pdu.itt)
if (pending === undefined) {
// Data-Out for an unknown command: protocol desync.
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
return
}
const bufferOffset = pdu.readU32(40) // BufferOffset is relative to the command's data
const outcome = receiveDataOut(pending, {
targetTransferTag: pdu.readU32(20),
bufferOffset,
data: pdu.data,
})
log.debug('write data-out', {
itt: pending.itt,
offset: bufferOffset,
length: pdu.data.length,
received: pending.contiguousReceived,
total: pending.totalLength,
})
if (!outcome.ok) {
// The command is dropped, not just the PDU: leaving it pending would hang
// the initiator on an R2T that never comes, until its command timeout.
this.#pendingWrites.delete(pdu.itt)
log.warn('unplaceable write data-out', { itt: pending.itt, reason: outcome.reason })
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
return
}
if (outcome.complete) {
this.#pendingWrites.delete(pdu.itt)
try {
await this.lun.write(pending.lunOffset, pending.buffer)
} catch (error) {
log.warn('LUN write failed', error instanceof Error ? error : new Error(String(error)))
const sense = buildFixedSense({ key: SenseKey.MEDIUM_ERROR, ...Asc.NO_ADDITIONAL_SENSE })
await this.sendScsiResponse(pending.itt, ScsiStatus.CHECK_CONDITION, { sense })
return
}
await this.sendScsiResponse(pending.itt, ScsiStatus.GOOD)
} else if (outcome.burstComplete) {
// Current burst complete and data remains: solicit the next one.
await this.#sendNextR2t(pending)
}
}
async sendScsiResponse(itt: number, status: number, options: ScsiResponseOptions = {}): Promise<void> {
const { sense, residualOverflow = 0, residualUnderflow = 0 } = options
const bhs = allocBhs(TargetOpcode.SCSI_RESPONSE)
let flags = FLAG_FINAL // bit 7 is always set on a SCSI Response
if (residualOverflow > 0) {
flags |= SCSI_RESP_FLAG_OVERFLOW
}
if (residualUnderflow > 0) {
flags |= SCSI_RESP_FLAG_UNDERFLOW
}
bhs[1] = flags
bhs[2] = SCSI_RESPONSE_COMMAND_COMPLETED
bhs[3] = status
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(this.#nextStatSN(), 24)
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
bhs.writeUInt32BE(residualOverflow || residualUnderflow, 44)
let data: Buffer | undefined
if (sense !== undefined) {
// Sense data in a SCSI Response is prefixed with a 2-byte length.
data = Buffer.alloc(2 + sense.length)
data.writeUInt16BE(sense.length, 0)
sense.copy(data, 2)
}
await this.#send(assemblePdu(bhs, data))
}
}

View File

@@ -1,712 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import type { Socket } from 'node:net'
import { Duplex } from 'node:stream'
import type { BlockDevice } from './backend.mjs'
import {
DATA_IN_FLAG_FINAL,
DATA_IN_FLAG_OVERFLOW,
DATA_IN_FLAG_STATUS,
DATA_IN_FLAG_UNDERFLOW,
FLAG_FINAL,
InitiatorOpcode,
LOGIN_CSG_SHIFT,
LOGIN_FLAG_TRANSIT,
LoginStage,
LoginStatusClass,
OPCODE_IMMEDIATE,
RejectReason,
RESERVED_TAG,
ScsiStatus,
SenseKey,
TargetOpcode,
} from './constants.mjs'
import { Connection, type ConnectionDeps } from './connection.mjs'
import { serializeTextKeys } from './login.mjs'
import { assemblePdu, IncomingPdu } from './pdu.mjs'
import type { ScsiIdentity } from './scsi.mjs'
const IDENTITY: ScsiIdentity = { vendor: 'VATES', product: 'ISCSI LUN', revision: '0001', serial: 'unit-serial-1' }
const BLOCK_SIZE = 512
const LUN_SIZE = 16 * BLOCK_SIZE
// Small enough that a handful of blocks splits into several PDUs / bursts, so
// the chunking and multi-burst paths are exercised without huge buffers.
const MAX_RECV_DATA_SEGMENT_LENGTH = 512
const MAX_BURST_LENGTH = 512
// --- test doubles -----------------------------------------------------------
/**
* Stands in for the TCP socket. `deliver()` queues a PDU for the connection's
* read loop (the readable side); everything the connection writes is captured
* in `sent` rather than transmitted. `Connection` only ever reads, writes, and
* destroys its socket, plus `localAddress`/`localPort` for SendTargets — hence
* the narrow cast in `asSocket()`.
*/
class FakeSocket extends Duplex {
readonly sent: Array<Buffer> = []
readonly localAddress = '10.0.0.1'
readonly localPort = 3260
override _read(): void {}
override _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
this.sent.push(Buffer.from(chunk))
callback()
}
/** Hand a PDU to the connection. */
deliver(pdu: Buffer): void {
this.push(pdu)
}
/** Clean end-of-stream, which ends `serve()`'s read loop. */
finish(): void {
this.push(null)
}
asSocket(): Socket {
return this as unknown as Socket
}
}
class MemoryLun implements BlockDevice {
readonly content = Buffer.alloc(LUN_SIZE)
/** Set to make the next `write()` fail, standing in for a backend error. */
failNextWrite = false
getSize(): number {
return this.content.length
}
getBlockSize(): number {
return BLOCK_SIZE
}
async read(offset: number, length: number): Promise<Buffer> {
return Buffer.from(this.content.subarray(offset, offset + length))
}
async write(offset: number, data: Buffer): Promise<void> {
if (this.failNextWrite) {
this.failNextWrite = false
throw new Error('backend write failed')
}
data.copy(this.content, offset)
}
async flush(): Promise<void> {}
async close(): Promise<void> {}
}
// --- initiator-side PDU builders --------------------------------------------
/** A single-PDU operational login that transits straight to Full Feature Phase. */
const loginPdu = (): Buffer => {
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.LOGIN_REQUEST | OPCODE_IMMEDIATE
bhs[1] = LOGIN_FLAG_TRANSIT | (LoginStage.OPERATIONAL_NEGOTIATION << LOGIN_CSG_SHIFT) | LoginStage.FULL_FEATURE_PHASE
bhs.writeUInt32BE(1, 16) // ITT
return assemblePdu(
bhs,
serializeTextKeys([
['InitiatorName', 'iqn.2026-01.tech.vates:test-initiator'],
['SessionType', 'Normal'],
['HeaderDigest', 'None'],
['DataDigest', 'None'],
['MaxRecvDataSegmentLength', String(MAX_RECV_DATA_SEGMENT_LENGTH)],
['MaxBurstLength', String(MAX_BURST_LENGTH)],
])
)
}
const scsiCommandPdu = ({
itt,
cmdSN,
cdb,
data,
}: {
itt: number
cmdSN: number
cdb: Buffer
data?: Buffer
}): Buffer => {
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.SCSI_COMMAND
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(cmdSN, 24)
cdb.copy(bhs, 32)
return assemblePdu(bhs, data)
}
const dataOutPdu = ({
itt,
targetTransferTag,
bufferOffset,
data,
dataSN = 0,
}: {
itt: number
targetTransferTag: number
bufferOffset: number
data: Buffer
dataSN?: number
}): Buffer => {
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.SCSI_DATA_OUT
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(targetTransferTag, 20)
bhs.writeUInt32BE(dataSN, 36)
bhs.writeUInt32BE(bufferOffset, 40)
return assemblePdu(bhs, data)
}
const cdb10 = (opcode: number, lba: number, blocks: number): Buffer => {
const cdb = Buffer.alloc(16)
cdb[0] = opcode
cdb.writeUInt32BE(lba, 2)
cdb.writeUInt16BE(blocks, 7)
return cdb
}
const read10 = (lba: number, blocks: number) => cdb10(0x28, lba, blocks)
const write10 = (lba: number, blocks: number) => cdb10(0x2a, lba, blocks)
const cdb16 = (opcode: number, lba: bigint, blocks: number): Buffer => {
const cdb = Buffer.alloc(16)
cdb[0] = opcode
cdb.writeBigUInt64BE(lba, 2)
cdb.writeUInt32BE(blocks, 10)
return cdb
}
const read16 = (lba: bigint, blocks: number) => cdb16(0x88, lba, blocks)
const write16 = (lba: bigint, blocks: number) => cdb16(0x8a, lba, blocks)
const inquiry = (allocationLength: number): Buffer => {
const cdb = Buffer.alloc(16)
cdb[0] = 0x12
cdb.writeUInt16BE(allocationLength, 3)
return cdb
}
// --- harness ----------------------------------------------------------------
/** Re-read a PDU the target sent, so assertions can talk in field names. */
const parseSent = (buffer: Buffer): IncomingPdu => {
const dataSegmentLength = buffer.readUIntBE(5, 3)
return new IncomingPdu(buffer.subarray(0, 48), Buffer.alloc(0), buffer.subarray(48, 48 + dataSegmentLength))
}
/**
* Sense key of a SCSI Response: its data segment is a 2-byte sense length then
* the fixed-format sense data, whose byte 2 holds the key in its low nibble.
*/
const senseKey = (response: IncomingPdu): number => response.data[4] & 0x0f
class Harness {
readonly socket = new FakeSocket()
readonly lun = new MemoryLun()
readonly serving: Promise<void>
constructor(overrides: Partial<ConnectionDeps> = {}) {
const deps: ConnectionDeps = {
iqn: 'iqn.2026-01.tech.vates:target',
identity: IDENTITY,
lun: this.lun,
writeTimeoutMs: 0,
cmdWindow: 64,
readConcurrency: 4,
allocateTsih: () => 1,
...overrides,
}
this.serving = new Connection(this.socket.asSocket(), deps).serve()
}
/** Every PDU the target has sent, parsed. */
get sent(): Array<IncomingPdu> {
return this.socket.sent.map(parseSent)
}
/**
* Let the read loop run until the target has sent `count` PDUs in total,
* optionally delivering something first. Bounded so a response that never
* comes fails the test instead of hanging it.
*/
async untilSent(count: number, deliver?: () => void): Promise<Array<IncomingPdu>> {
deliver?.()
for (let turn = 0; turn < 500 && this.socket.sent.length < count; turn++) {
await new Promise(resolve => setImmediate(resolve))
}
const sent = this.sent
assert.ok(
sent.length >= count,
`expected ${count} PDUs, got ${sent.length}: ${sent.map(pdu => pdu.opcode.toString(16)).join(',')}`
)
return sent
}
/** Log in and confirm the target accepted it. */
async login(): Promise<void> {
// relative to what has already been sent, so this works mid-connection too
const baseline = this.socket.sent.length
const response = (await this.untilSent(baseline + 1, () => this.socket.deliver(loginPdu())))[baseline]
assert.equal(response.opcode, TargetOpcode.LOGIN_RESPONSE)
assert.equal(response.readU8(36), LoginStatusClass.SUCCESS)
}
async close(): Promise<void> {
this.socket.finish()
await this.serving
}
}
/** Log in, then run `body`, always tearing the connection down afterwards. */
const withSession = async (body: (harness: Harness) => Promise<void>, overrides?: Partial<ConnectionDeps>) => {
const harness = new Harness(overrides)
try {
await harness.login()
await body(harness)
} finally {
await harness.close()
}
}
const CMD_WINDOW = 64
/** ExpCmdSN (bytes 28-31) and MaxCmdSN (32-35), the command window we advertise. */
const commandWindow = (pdu: IncomingPdu) => ({ expCmdSN: pdu.readU32(28), maxCmdSN: pdu.readU32(32) })
describe('command window', () => {
// Bytes 24-27 are CmdSN only on command PDUs. On a Data-Out (RFC 7143 §11.7)
// and a SNACK they are reserved, so reading a window out of them yields the
// zeroes an initiator puts there — advertising ExpCmdSN=1 mid-session. Real
// initiators discard that as stale only while session CmdSN < 2^31; past the
// wrap it compares as newer, is adopted, and the window collapses to a stall.
it('is not recomputed from the reserved bytes of a Data-Out', async () => {
await withSession(async harness => {
// two bursts, so an R2T is emitted *after* a Data-Out has been processed
harness.socket.deliver(scsiCommandPdu({ itt: 31, cmdSN: 50, cdb: write10(0, 2) }))
const [, firstR2t] = await harness.untilSent(2)
const expected = { expCmdSN: 51, maxCmdSN: 51 + CMD_WINDOW }
assert.deepEqual(commandWindow(firstR2t), expected)
const ttt = firstR2t.readU32(20)
harness.socket.deliver(
dataOutPdu({ itt: 31, targetTransferTag: ttt, bufferOffset: 0, data: Buffer.alloc(BLOCK_SIZE, 0x11) })
)
const [, , secondR2t] = await harness.untilSent(3)
assert.equal(secondR2t.opcode, TargetOpcode.R2T)
assert.deepEqual(commandWindow(secondR2t), expected)
harness.socket.deliver(
dataOutPdu({
itt: 31,
targetTransferTag: ttt,
bufferOffset: BLOCK_SIZE,
data: Buffer.alloc(BLOCK_SIZE, 0x22),
dataSN: 1,
})
)
const [, , , response] = await harness.untilSent(4)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.deepEqual(commandWindow(response), expected)
})
})
it('is not recomputed from an opcode the target does not implement', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 33, cmdSN: 70, cdb: read10(0, 1) }))
const [, dataIn] = await harness.untilSent(2)
const expected = { expCmdSN: 71, maxCmdSN: 71 + CMD_WINDOW }
assert.deepEqual(commandWindow(dataIn), expected)
// SNACK also has bytes 24-27 reserved; it is rejected, but the Reject it
// gets must still carry the window the session actually reached
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.SNACK_REQUEST
harness.socket.deliver(assemblePdu(bhs))
const [, , rejected] = await harness.untilSent(3)
assert.equal(rejected.opcode, TargetOpcode.REJECT)
assert.equal(rejected.readU8(2), RejectReason.COMMAND_NOT_SUPPORTED)
assert.deepEqual(commandWindow(rejected), expected)
})
})
it('advances the window for each command PDU, but not for an immediate one', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 35, cmdSN: 5, cdb: read10(0, 1) }))
const [, dataIn] = await harness.untilSent(2)
assert.deepEqual(commandWindow(dataIn), { expCmdSN: 6, maxCmdSN: 6 + CMD_WINDOW })
// an immediate NOP-Out is answered but consumes no CmdSN slot
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.NOP_OUT | OPCODE_IMMEDIATE
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(37, 16)
bhs.writeUInt32BE(RESERVED_TAG, 20)
bhs.writeUInt32BE(6, 24)
harness.socket.deliver(assemblePdu(bhs))
const [, , nopIn] = await harness.untilSent(3)
assert.equal(nopIn.opcode, TargetOpcode.NOP_IN)
assert.deepEqual(commandWindow(nopIn), { expCmdSN: 6, maxCmdSN: 6 + CMD_WINDOW })
})
})
})
describe('login', () => {
it('reaches full feature phase and only then accepts commands', async () => {
const harness = new Harness()
try {
// a command before login is a protocol error, not a served command
harness.socket.deliver(scsiCommandPdu({ itt: 5, cmdSN: 0, cdb: read10(0, 1) }))
const [rejected] = await harness.untilSent(1)
assert.equal(rejected.opcode, TargetOpcode.REJECT)
assert.equal(rejected.readU8(2), RejectReason.PROTOCOL_ERROR)
await harness.login()
// the same command now gets served
harness.socket.deliver(scsiCommandPdu({ itt: 5, cmdSN: 1, cdb: read10(0, 1) }))
const sent = await harness.untilSent(3)
assert.equal(sent[2].opcode, TargetOpcode.SCSI_DATA_IN)
} finally {
await harness.close()
}
})
})
describe('write path wiring', () => {
it('solicits the data, commits it to the LUN, and answers GOOD', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 9, cmdSN: 1, cdb: write10(2, 1) }))
const [, r2t] = await harness.untilSent(2)
assert.equal(r2t.opcode, TargetOpcode.R2T)
assert.equal(r2t.itt, 9)
assert.equal(r2t.readU32(36), 0) // R2TSN
assert.equal(r2t.readU32(40), 0) // BufferOffset
assert.equal(r2t.readU32(44), BLOCK_SIZE) // DesiredDataTransferLength
const payload = Buffer.alloc(BLOCK_SIZE, 0xab)
harness.socket.deliver(dataOutPdu({ itt: 9, targetTransferTag: r2t.readU32(20), bufferOffset: 0, data: payload }))
const [, , response] = await harness.untilSent(3)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.GOOD)
// ... and the bytes landed at the LBA the CDB asked for, nowhere else
assert.deepEqual(harness.lun.content.subarray(2 * BLOCK_SIZE, 3 * BLOCK_SIZE), payload)
assert.deepEqual(harness.lun.content.subarray(0, 2 * BLOCK_SIZE), Buffer.alloc(2 * BLOCK_SIZE))
assert.deepEqual(harness.lun.content.subarray(3 * BLOCK_SIZE), Buffer.alloc(LUN_SIZE - 3 * BLOCK_SIZE))
})
})
it('solicits one burst at a time for a write larger than MaxBurstLength', async () => {
await withSession(async harness => {
// 2 blocks with a 1-block burst limit: two R2Ts, second only after the first lands
harness.socket.deliver(scsiCommandPdu({ itt: 11, cmdSN: 1, cdb: write10(0, 2) }))
const [, first] = await harness.untilSent(2)
assert.equal(first.readU32(36), 0) // R2TSN
assert.equal(first.readU32(40), 0) // BufferOffset
assert.equal(first.readU32(44), MAX_BURST_LENGTH)
const ttt = first.readU32(20)
harness.socket.deliver(
dataOutPdu({ itt: 11, targetTransferTag: ttt, bufferOffset: 0, data: Buffer.alloc(BLOCK_SIZE, 0x11) })
)
const [, , second] = await harness.untilSent(3)
assert.equal(second.opcode, TargetOpcode.R2T)
assert.equal(second.readU32(36), 1) // R2TSN advanced
assert.equal(second.readU32(40), BLOCK_SIZE) // next burst picks up where the first ended
assert.equal(second.readU32(44), MAX_BURST_LENGTH)
// nothing is committed until the whole command has landed
assert.deepEqual(harness.lun.content.subarray(0, BLOCK_SIZE), Buffer.alloc(BLOCK_SIZE))
harness.socket.deliver(
dataOutPdu({
itt: 11,
targetTransferTag: ttt,
bufferOffset: BLOCK_SIZE,
data: Buffer.alloc(BLOCK_SIZE, 0x22),
dataSN: 1,
})
)
const [, , , response] = await harness.untilSent(4)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.GOOD)
assert.deepEqual(
harness.lun.content.subarray(0, 2 * BLOCK_SIZE),
Buffer.concat([Buffer.alloc(BLOCK_SIZE, 0x11), Buffer.alloc(BLOCK_SIZE, 0x22)])
)
})
})
it('rejects an unplaceable Data-Out and drops the command with it', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 13, cmdSN: 1, cdb: write10(0, 1) }))
const [, r2t] = await harness.untilSent(2)
const ttt = r2t.readU32(20)
// BufferOffset past the end of the command's data: a copy there would land
// nothing, so the write must be refused rather than acknowledged
harness.socket.deliver(
dataOutPdu({
itt: 13,
targetTransferTag: ttt,
bufferOffset: BLOCK_SIZE,
data: Buffer.alloc(BLOCK_SIZE, 0xcc),
})
)
const [, , rejected] = await harness.untilSent(3)
assert.equal(rejected.opcode, TargetOpcode.REJECT)
assert.equal(rejected.readU8(2), RejectReason.PROTOCOL_ERROR)
assert.deepEqual(harness.lun.content, Buffer.alloc(LUN_SIZE))
// the command is gone, not merely stalled: a now-valid Data-Out for it is
// an unknown ITT, and no SCSI Response ever completes it
harness.socket.deliver(
dataOutPdu({ itt: 13, targetTransferTag: ttt, bufferOffset: 0, data: Buffer.alloc(BLOCK_SIZE, 0xdd) })
)
const sent = await harness.untilSent(4)
assert.equal(sent[3].opcode, TargetOpcode.REJECT)
assert.deepEqual(harness.lun.content, Buffer.alloc(LUN_SIZE))
assert.equal(
sent.some(pdu => pdu.opcode === TargetOpcode.SCSI_RESPONSE),
false
)
})
})
it("rejects a Data-Out carrying another command's Target Transfer Tag", async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 15, cmdSN: 1, cdb: write10(0, 1) }))
const [, r2t] = await harness.untilSent(2)
harness.socket.deliver(
dataOutPdu({
itt: 15,
targetTransferTag: r2t.readU32(20) + 1,
bufferOffset: 0,
data: Buffer.alloc(BLOCK_SIZE, 0xee),
})
)
const [, , rejected] = await harness.untilSent(3)
assert.equal(rejected.opcode, TargetOpcode.REJECT)
assert.deepEqual(harness.lun.content, Buffer.alloc(LUN_SIZE))
})
})
it('rejects a Data-Out for a command it knows nothing about', async () => {
await withSession(async harness => {
harness.socket.deliver(
dataOutPdu({ itt: 404, targetTransferTag: 1, bufferOffset: 0, data: Buffer.alloc(BLOCK_SIZE, 0xff) })
)
const [, rejected] = await harness.untilSent(2)
assert.equal(rejected.opcode, TargetOpcode.REJECT)
assert.equal(rejected.readU8(2), RejectReason.PROTOCOL_ERROR)
})
})
it('reports a failed backend write as CHECK CONDITION, not a dropped connection', async () => {
await withSession(async harness => {
harness.lun.failNextWrite = true
harness.socket.deliver(scsiCommandPdu({ itt: 17, cmdSN: 1, cdb: write10(0, 1) }))
const [, r2t] = await harness.untilSent(2)
harness.socket.deliver(
dataOutPdu({
itt: 17,
targetTransferTag: r2t.readU32(20),
bufferOffset: 0,
data: Buffer.alloc(BLOCK_SIZE, 0xab),
})
)
const [, , response] = await harness.untilSent(3)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.CHECK_CONDITION)
assert.equal(senseKey(response), SenseKey.MEDIUM_ERROR)
})
})
it('fails a command carrying immediate data instead of silently dropping it', async () => {
await withSession(async harness => {
// ImmediateData=No is declared at login, so this cannot be honoured — and
// must not be ignored either, since the write would be short by these bytes
harness.socket.deliver(
scsiCommandPdu({ itt: 19, cmdSN: 1, cdb: write10(0, 1), data: Buffer.alloc(BLOCK_SIZE, 0x99) })
)
const [, response] = await harness.untilSent(2)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.CHECK_CONDITION)
assert.equal(senseKey(response), SenseKey.ILLEGAL_REQUEST)
assert.deepEqual(harness.lun.content, Buffer.alloc(LUN_SIZE))
// no R2T either: the command never started
assert.equal(
harness.sent.some(pdu => pdu.opcode === TargetOpcode.R2T),
false
)
})
})
})
describe('Data-In chunking', () => {
/** Read `blocks` blocks of a LUN pre-filled with a recognizable pattern. */
const readBack = async (harness: Harness, blocks: number): Promise<Array<IncomingPdu>> => {
for (let i = 0; i < harness.lun.content.length; i++) {
harness.lun.content[i] = (i * 7 + 3) & 0xff
}
harness.socket.deliver(scsiCommandPdu({ itt: 21, cmdSN: 1, cdb: read10(0, blocks) }))
const expected = Math.ceil((blocks * BLOCK_SIZE) / MAX_RECV_DATA_SEGMENT_LENGTH)
const sent = await harness.untilSent(1 + expected)
return sent.slice(1)
}
it('splits the payload at the initiator MaxRecvDataSegmentLength', async () => {
await withSession(async harness => {
const dataIn = await readBack(harness, 4)
assert.equal(dataIn.length, 4)
for (const pdu of dataIn) {
assert.equal(pdu.opcode, TargetOpcode.SCSI_DATA_IN)
assert.equal(pdu.data.length, MAX_RECV_DATA_SEGMENT_LENGTH)
}
// reassembled, it is exactly what the LUN holds
assert.deepEqual(Buffer.concat(dataIn.map(pdu => pdu.data)), harness.lun.content.subarray(0, 4 * BLOCK_SIZE))
})
})
it('numbers the PDUs with a gapless DataSN and stamps each BufferOffset', async () => {
await withSession(async harness => {
const dataIn = await readBack(harness, 3)
assert.deepEqual(
dataIn.map(pdu => pdu.readU32(36)),
[0, 1, 2]
)
assert.deepEqual(
dataIn.map(pdu => pdu.readU32(40)),
[0, MAX_RECV_DATA_SEGMENT_LENGTH, 2 * MAX_RECV_DATA_SEGMENT_LENGTH]
)
// Target Transfer Tag is unused on Data-In
for (const pdu of dataIn) {
assert.equal(pdu.readU32(20), RESERVED_TAG)
}
})
})
it('carries the status only on the final PDU, and advances StatSN once', async () => {
await withSession(async harness => {
const statSNAfterLogin = parseSent(harness.socket.sent[0]).readU32(24) + 1
const dataIn = await readBack(harness, 3)
for (const pdu of dataIn.slice(0, -1)) {
assert.equal(pdu.flags & DATA_IN_FLAG_FINAL, 0)
assert.equal(pdu.flags & DATA_IN_FLAG_STATUS, 0)
// both fields are defined as meaningless without the S bit, and are left
// unset rather than filled with a value an initiator might act on
assert.equal(pdu.readU8(3), 0)
assert.equal(pdu.readU32(24), 0)
}
const last = dataIn[dataIn.length - 1]
assert.equal(last.flags & DATA_IN_FLAG_FINAL, DATA_IN_FLAG_FINAL)
assert.equal(last.flags & DATA_IN_FLAG_STATUS, DATA_IN_FLAG_STATUS)
assert.equal(last.readU8(3), ScsiStatus.GOOD)
// exactly one StatSN consumed for the whole command, on its final PDU
assert.equal(last.readU32(24), statSNAfterLogin)
})
})
it('sends a single Data-In when the payload fits one segment', async () => {
await withSession(async harness => {
const dataIn = await readBack(harness, 1)
assert.equal(dataIn.length, 1)
assert.equal(
dataIn[0].flags & (DATA_IN_FLAG_FINAL | DATA_IN_FLAG_STATUS),
DATA_IN_FLAG_FINAL | DATA_IN_FLAG_STATUS
)
assert.equal(dataIn[0].readU32(36), 0)
})
})
it('flags residual overflow when the allocation length truncates the payload', async () => {
await withSession(async harness => {
// standard INQUIRY data is 36 bytes; ask for 8
harness.socket.deliver(scsiCommandPdu({ itt: 23, cmdSN: 1, cdb: inquiry(8) }))
const [, dataIn] = await harness.untilSent(2)
assert.equal(dataIn.opcode, TargetOpcode.SCSI_DATA_IN)
assert.equal(dataIn.data.length, 8)
assert.equal(dataIn.flags & DATA_IN_FLAG_OVERFLOW, DATA_IN_FLAG_OVERFLOW)
assert.equal(dataIn.flags & DATA_IN_FLAG_UNDERFLOW, 0)
assert.equal(dataIn.readU32(44), 36 - 8) // Residual Count
})
})
it('flags residual underflow when the payload is shorter than requested', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 25, cmdSN: 1, cdb: inquiry(100) }))
const [, dataIn] = await harness.untilSent(2)
assert.equal(dataIn.data.length, 36)
assert.equal(dataIn.flags & DATA_IN_FLAG_UNDERFLOW, DATA_IN_FLAG_UNDERFLOW)
assert.equal(dataIn.flags & DATA_IN_FLAG_OVERFLOW, 0)
assert.equal(dataIn.readU32(44), 100 - 36)
})
})
it('answers with status alone, no Data-In, when nothing is allocated for the data', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 27, cmdSN: 1, cdb: inquiry(0) }))
const [, response] = await harness.untilSent(2)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.GOOD)
assert.equal(
harness.sent.some(pdu => pdu.opcode === TargetOpcode.SCSI_DATA_IN),
false
)
})
})
})
describe('unservable CDBs', () => {
// An LBA above 2^53 cannot be held exactly by a JS number. Decoding it must
// not throw: the error would unwind out of the dispatch loop and destroy the
// socket, turning one bogus CDB — a buggy or hostile initiator's — into the
// loss of the whole session and every command in flight on it.
for (const [name, cdb] of [
['READ(16)', read16(0xffffffffffffffffn, 1)],
['WRITE(16)', write16(0xffffffffffffffffn, 1)],
] as const) {
it(`answers a ${name} past the addressable range with CHECK CONDITION, keeping the session`, async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 31, cmdSN: 1, cdb }))
const [, response] = await harness.untilSent(2)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.CHECK_CONDITION)
assert.equal(senseKey(response), SenseKey.ILLEGAL_REQUEST)
assert.equal(
harness.sent.some(pdu => pdu.opcode === TargetOpcode.R2T),
false
)
// the connection is still serving: a following command is answered
harness.socket.deliver(scsiCommandPdu({ itt: 32, cmdSN: 2, cdb: inquiry(36) }))
const [, , dataIn] = await harness.untilSent(3)
assert.equal(dataIn.opcode, TargetOpcode.SCSI_DATA_IN)
})
})
}
})

View File

@@ -1,200 +0,0 @@
// iSCSI (RFC 7143 / RFC 3720) and SCSI (SPC-3 / SBC-3) protocol constants.
//
// Only the subset required by a minimal single-LUN target is defined here.
// --- iSCSI PDU layout -------------------------------------------------------
/** Length of the Basic Header Segment, in bytes. Fixed by the protocol. */
export const BHS_LENGTH = 48
/** Bit set in byte 0 for initiator PDUs that must be processed immediately. */
export const OPCODE_IMMEDIATE = 0x40
/** Mask extracting the 6-bit opcode from byte 0. */
export const OPCODE_MASK = 0x3f
/** Final bit, byte 1, set on the last PDU of a sequence. */
export const FLAG_FINAL = 0x80
/** Sentinel for "no tag" in Initiator/Target Task Tag fields. */
export const RESERVED_TAG = 0xffffffff
// --- iSCSI opcodes ----------------------------------------------------------
/** Opcodes sent by the initiator (request PDUs). */
export const InitiatorOpcode = {
NOP_OUT: 0x00,
SCSI_COMMAND: 0x01,
SCSI_TASK_MGMT_REQUEST: 0x02,
LOGIN_REQUEST: 0x03,
TEXT_REQUEST: 0x04,
SCSI_DATA_OUT: 0x05,
LOGOUT_REQUEST: 0x06,
SNACK_REQUEST: 0x10,
} as const
/** Opcodes sent by the target (response PDUs). */
export const TargetOpcode = {
NOP_IN: 0x20,
SCSI_RESPONSE: 0x21,
SCSI_TASK_MGMT_RESPONSE: 0x22,
LOGIN_RESPONSE: 0x23,
TEXT_RESPONSE: 0x24,
SCSI_DATA_IN: 0x25,
LOGOUT_RESPONSE: 0x26,
R2T: 0x31,
ASYNC_MESSAGE: 0x32,
REJECT: 0x3f,
} as const
// --- Login negotiation ------------------------------------------------------
/** Login/text stage codes used in the CSG/NSG fields. */
export const LoginStage = {
SECURITY_NEGOTIATION: 0,
OPERATIONAL_NEGOTIATION: 1,
FULL_FEATURE_PHASE: 3,
} as const
/** Login Request/Response byte 1 flags. */
export const LOGIN_FLAG_TRANSIT = 0x80
export const LOGIN_FLAG_CONTINUE = 0x40
/** Mask + shift for the Current Stage (CSG) field in byte 1. */
export const LOGIN_CSG_MASK = 0x0c
export const LOGIN_CSG_SHIFT = 2
/** Mask for the Next Stage (NSG) field in byte 1. */
export const LOGIN_NSG_MASK = 0x03
/** iSCSI version (only version 0 exists). */
export const ISCSI_VERSION = 0x00
/** Login Response Status-Class values. */
export const LoginStatusClass = {
SUCCESS: 0x00,
REDIRECTION: 0x01,
INITIATOR_ERROR: 0x02,
TARGET_ERROR: 0x03,
} as const
/**
* Login Response Status-Detail values (byte 37), qualifying the Status-Class.
* `AUTHENTICATION_FAILURE` pairs with `INITIATOR_ERROR` to reject a bad CHAP
* login (RFC 7143 §11.1.5).
*/
export const LoginStatusDetail = {
SUCCESS: 0x00,
AUTHENTICATION_FAILURE: 0x01,
} as const
/** `AuthMethod` login-key values we understand (RFC 7143 §12.1). */
export const AuthMethod = {
NONE: 'None',
CHAP: 'CHAP',
} as const
// --- Text/Data flags --------------------------------------------------------
export const TEXT_FLAG_FINAL = 0x80
export const TEXT_FLAG_CONTINUE = 0x40
/** SCSI Data-In byte 1 flags. */
export const DATA_IN_FLAG_FINAL = 0x80
export const DATA_IN_FLAG_ACK = 0x40
export const DATA_IN_FLAG_OVERFLOW = 0x04
export const DATA_IN_FLAG_UNDERFLOW = 0x02
export const DATA_IN_FLAG_STATUS = 0x01
/** SCSI Command (initiator) byte 1 flags. */
export const SCSI_CMD_FLAG_READ = 0x40
export const SCSI_CMD_FLAG_WRITE = 0x20
/** SCSI Response byte 1 residual flags. */
export const SCSI_RESP_FLAG_OVERFLOW = 0x04
export const SCSI_RESP_FLAG_UNDERFLOW = 0x02
/** SCSI Response "Response" field (byte 2). */
export const SCSI_RESPONSE_COMMAND_COMPLETED = 0x00
/** Logout reason codes (byte 1, low 7 bits). */
export const LogoutReason = {
CLOSE_SESSION: 0,
CLOSE_CONNECTION: 1,
REMOVE_CONNECTION_FOR_RECOVERY: 2,
} as const
/** Logout Response codes (byte 2). */
export const LogoutResponse = {
SUCCESS: 0,
} as const
// --- SCSI status codes ------------------------------------------------------
export const ScsiStatus = {
GOOD: 0x00,
CHECK_CONDITION: 0x02,
} as const
// --- SCSI CDB opcodes -------------------------------------------------------
export const ScsiOpcode = {
TEST_UNIT_READY: 0x00,
REQUEST_SENSE: 0x03,
INQUIRY: 0x12,
MODE_SENSE_6: 0x1a,
READ_CAPACITY_10: 0x25,
READ_10: 0x28,
WRITE_10: 0x2a,
SYNCHRONIZE_CACHE_10: 0x35,
MODE_SENSE_10: 0x5a,
READ_16: 0x88,
WRITE_16: 0x8a,
SYNCHRONIZE_CACHE_16: 0x91,
SERVICE_ACTION_IN_16: 0x9e, // READ CAPACITY(16) lives under this opcode
REPORT_LUNS: 0xa0,
} as const
/** Service action (CDB byte 1, low 5 bits) for SERVICE_ACTION_IN_16. */
export const SERVICE_ACTION_READ_CAPACITY_16 = 0x10
// --- SCSI sense keys / additional sense codes -------------------------------
export const SenseKey = {
NO_SENSE: 0x00,
RECOVERED_ERROR: 0x01,
NOT_READY: 0x02,
MEDIUM_ERROR: 0x03,
HARDWARE_ERROR: 0x04,
ILLEGAL_REQUEST: 0x05,
UNIT_ATTENTION: 0x06,
DATA_PROTECT: 0x07,
} as const
/** Additional Sense Code / Qualifier pairs we may emit. */
export const Asc = {
NO_ADDITIONAL_SENSE: { asc: 0x00, ascq: 0x00 },
INVALID_COMMAND_OPERATION_CODE: { asc: 0x20, ascq: 0x00 },
INVALID_FIELD_IN_CDB: { asc: 0x24, ascq: 0x00 },
LOGICAL_BLOCK_ADDRESS_OUT_OF_RANGE: { asc: 0x21, ascq: 0x00 },
WRITE_PROTECTED: { asc: 0x27, ascq: 0x00 },
} as const
/** Reject reason codes (Reject PDU byte 2). */
export const RejectReason = {
PROTOCOL_ERROR: 0x04,
COMMAND_NOT_SUPPORTED: 0x05,
} as const
// --- debugging --------------------------------------------------------------
// Initiator opcodes (0x00-0x10) and target opcodes (0x20-0x3f) never collide,
// so one reverse map names both directions for logs.
const OPCODE_NAMES: Record<number, string> = {
...Object.fromEntries(Object.entries(InitiatorOpcode).map(([name, code]) => [code, name])),
...Object.fromEntries(Object.entries(TargetOpcode).map(([name, code]) => [code, name])),
}
/** Human-readable name of a PDU opcode (for debug logs). */
export function opcodeName(opcode: number): string {
const code = opcode & OPCODE_MASK
return OPCODE_NAMES[code] ?? `0x${code.toString(16).padStart(2, '0')}`
}

View File

@@ -1,30 +0,0 @@
// Ambient declarations for untyped external packages used by this module.
// (CLAUDE.md: "Add declaration.d.ts for untyped external packages.")
declare module '@vates/read-chunk' {
import type { Readable } from 'node:stream'
/**
* Resolves to exactly `size` bytes read from `stream`, or `null` if the
* stream ends before any byte of the requested chunk is available.
*/
export function readChunk(stream: Readable, size?: number): Promise<Buffer | null>
/**
* Resolves to exactly `size` bytes read from `stream`; rejects if the stream
* ends before `size` bytes have been read.
*/
export function readChunkStrict(stream: Readable, size: number): Promise<Buffer>
}
declare module 'promise-toolbox' {
/**
* Reject the promise it is bound to if it does not settle within `delay`
* milliseconds. Used as `pTimeout.call(promise, delay)`.
*/
interface PTimeout {
<T>(this: Promise<T>, delay: number, onReject?: () => void): Promise<T>
call<T>(promise: Promise<T>, delay: number, onReject?: () => void): Promise<T>
}
export const pTimeout: PTimeout
}

View File

@@ -1,281 +0,0 @@
import { createServer, type AddressInfo, type Server, type Socket } from 'node:net'
import { createLogger, type Logger } from '@xen-orchestra/log'
import type { BlockDevice } from './backend.mjs'
import { Connection, type ConnectionDeps } from './connection.mjs'
import { DEFAULT_MAX_TRANSFER_LENGTH, type ScsiIdentity } from './scsi.mjs'
import type { ChapCredentials } from './types.mjs'
export { FileBlockDevice } from './backend.mjs'
export type { BlockDevice, FileBlockDeviceOptions } from './backend.mjs'
export type { ScsiIdentity } from './scsi.mjs'
export type { ChapCredentials } from './types.mjs'
export { IscsiInitiator } from './initiator.mjs'
export type { IscsiInitiatorOptions } from './initiator.mjs'
export { IscsiDisk } from './IscsiDisk.mjs'
export type { IscsiDiskOptions } from './IscsiDisk.mjs'
export { DiskBlockDevice } from './DiskBlockDevice.mjs'
export type { DiskBlockDeviceOptions } from './DiskBlockDevice.mjs'
export { CachedDiskBlockDevice } from './CachedDiskBlockDevice.mjs'
export type { CachedDiskBlockDeviceOptions } from './CachedDiskBlockDevice.mjs'
const log: Logger = createLogger('vates:iscsi')
const DEFAULT_PORT = 3260
const DEFAULT_WRITE_TIMEOUT_MS = 30_000
const DEFAULT_CMD_WINDOW = 64
const DEFAULT_READ_CONCURRENCY = 16
const DEFAULT_LOGIN_TIMEOUT_MS = 15_000
const DEFAULT_MAX_PENDING_CONNECTIONS = 8
const DEFAULT_IDENTITY: ScsiIdentity = {
vendor: 'VATES',
product: 'ISCSI LUN',
revision: '0001',
serial: '',
}
export interface IscsiTargetOptions {
/** The target's iSCSI Qualified Name, e.g. `iqn.2024-01.tech.vates:lun0`. */
readonly iqn: string
/** The single LUN exposed by this target. */
readonly lun: BlockDevice
/** Listen address. Defaults to all interfaces. */
readonly host?: string
/** Listen port. Defaults to 3260. */
readonly port?: number
/** Overrides for the SCSI INQUIRY identity strings. */
readonly identity?: Partial<ScsiIdentity>
/** Drain timeout for outbound PDUs in ms (0 disables). Defaults to 30000. */
readonly writeTimeoutMs?: number
/** CmdSN command-window depth advertised to the initiator. Defaults to 64. */
readonly cmdWindow?: number
/**
* Max number of READ commands served concurrently — the command window lets
* the initiator keep many outstanding, but this caps how many `lun.read()`
* calls actually run at once regardless (e.g. so a deep queue depth doesn't
* fire dozens of simultaneous fetches against a real backend). Defaults to 16.
*/
readonly readConcurrency?: number
/**
* When set, require one-way CHAP: the target challenges each initiator and
* rejects the login unless it proves this credential (interop with the
* open-iscsi `node.session.auth` username/password). Omit for no authentication.
*/
readonly chap?: ChapCredentials
/**
* Time a new connection has to establish a Normal session, in ms (0
* disables). Also bounds Discovery sessions. Defaults to 15000.
*/
readonly loginTimeoutMs?: number
/**
* Max number of connections that have not established a Normal session yet;
* past it, new ones are refused. Defaults to 8.
*/
readonly maxPendingConnections?: number
/**
* Source addresses the initiators may connect from. When set, connections
* from any other address are closed as soon as they are accepted, before any
* login: only these initiators can take a pending connection slot.
* IPv4-mapped IPv6 addresses (`::ffff:a.b.c.d`) match their IPv4 form.
*/
readonly allowedInitiatorAddresses?: Iterable<string>
/**
* Called with the source address of each connection closed because it is
* not in `allowedInitiatorAddresses`, e.g. to tell why an initiator behind a
* NAT cannot connect.
*/
readonly onConnectionRefused?: (address: string) => void
/**
* Max length of a single READ or WRITE, in bytes, advertised in the block
* limits VPD page; longer commands are refused. At least one logical block.
* Defaults to 8 MiB.
*/
readonly maxTransferLength?: number
}
const IPV4_MAPPED_PREFIX = '::ffff:'
function normalizeAddress(address: string): string {
return address.startsWith(IPV4_MAPPED_PREFIX) && address.includes('.')
? address.slice(IPV4_MAPPED_PREFIX.length)
: address
}
/**
* A minimal iSCSI target exposing exactly one read/write LUN.
*
* Single initiator, single connection (`MaxConnections=1`),
* `ErrorRecoveryLevel=0`, no digests.
*
* A new connection replaces the current one rather than being refused (see
* `#onSessionEstablished`): there is no way to tell a stale, abandoned
* connection (initiator gave up without closing the socket) from a healthy
* one, so refusing would let one stuck connection wedge the target forever.
* The replacement only happens once the new connection has established a
* Normal session, CHAP included when configured, so that a port scan, a failed
* login or a Discovery session cannot interrupt the current one. Until then,
* connections are bounded in time (`loginTimeoutMs`) and in number
* (`maxPendingConnections`); with `allowedInitiatorAddresses`, only the expected
* initiators can take those slots. Not RFC 7143 session reinstatement (no ISID
* matching) — safe here since the target is ephemeral, CHAP-guarded, and
* single-consumer.
*/
export class IscsiTarget {
readonly #iqn: string
readonly #lun: BlockDevice
readonly #host?: string
readonly #port: number
readonly #identity: ScsiIdentity
readonly #writeTimeoutMs: number
readonly #cmdWindow: number
readonly #readConcurrency: number
readonly #chap?: ChapCredentials
readonly #loginTimeoutMs: number
readonly #maxPendingConnections: number
readonly #allowedInitiatorAddresses?: ReadonlySet<string>
readonly #onConnectionRefused?: (address: string) => void
readonly #maxTransferLength: number
#server?: Server
// the connection with an established Normal session, serving the LUN
#connection?: Connection
// every open connection, so that close() can drop them all
readonly #connections = new Set<Connection>()
#tsih = 0
constructor(options: IscsiTargetOptions) {
this.#iqn = options.iqn
this.#lun = options.lun
this.#host = options.host
this.#port = options.port ?? DEFAULT_PORT
this.#writeTimeoutMs = options.writeTimeoutMs ?? DEFAULT_WRITE_TIMEOUT_MS
this.#cmdWindow = options.cmdWindow ?? DEFAULT_CMD_WINDOW
this.#readConcurrency = options.readConcurrency ?? DEFAULT_READ_CONCURRENCY
this.#chap = options.chap
this.#loginTimeoutMs = options.loginTimeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS
this.#maxPendingConnections = options.maxPendingConnections ?? DEFAULT_MAX_PENDING_CONNECTIONS
if (options.allowedInitiatorAddresses !== undefined) {
this.#allowedInitiatorAddresses = new Set(Array.from(options.allowedInitiatorAddresses, normalizeAddress))
}
this.#onConnectionRefused = options.onConnectionRefused
this.#maxTransferLength = options.maxTransferLength ?? DEFAULT_MAX_TRANSFER_LENGTH
if (this.#maxTransferLength < options.lun.getBlockSize()) {
throw new RangeError('maxTransferLength must be at least one logical block')
}
this.#identity = {
...DEFAULT_IDENTITY,
// Default the serial to the IQN so the LUN has a stable, unique identity.
serial: options.iqn,
...options.identity,
}
}
#allocateTsih(): number {
this.#tsih = (this.#tsih % 0xffff) + 1 // non-zero, wraps within 16 bits
return this.#tsih
}
#deps(): ConnectionDeps {
return {
iqn: this.#iqn,
identity: this.#identity,
lun: this.#lun,
writeTimeoutMs: this.#writeTimeoutMs,
cmdWindow: this.#cmdWindow,
readConcurrency: this.#readConcurrency,
allocateTsih: () => this.#allocateTsih(),
chap: this.#chap,
loginTimeoutMs: this.#loginTimeoutMs,
onSessionEstablished: connection => this.#onSessionEstablished(connection),
maxTransferLength: this.#maxTransferLength,
}
}
#onConnection(socket: Socket): void {
const allowed = this.#allowedInitiatorAddresses
const remote = normalizeAddress(socket.remoteAddress ?? '')
if (allowed !== undefined && !allowed.has(remote)) {
log.warn('connection from an address that is not allowed, closing it', { remote })
socket.destroy()
this.#onConnectionRefused?.(remote)
return
}
socket.setNoDelay(true)
const connection = new Connection(socket, this.#deps())
this.#connections.add(connection)
// Long-lived per-connection task; serve() handles its own errors and never
// rejects, so detaching it here is safe.
void connection.serve().finally(() => {
this.#connections.delete(connection)
if (this.#connection === connection) {
this.#connection = undefined
}
})
}
#onSessionEstablished(connection: Connection): void {
const existing = this.#connection
this.#connection = connection
if (existing !== undefined) {
// Replaces a possibly-stale connection rather than refusing; see the class doc.
log.warn('replacing existing connection with a newly logged in one')
existing.destroy()
}
}
/** Open the LUN (if needed) and start accepting connections. */
async listen(): Promise<void> {
if (this.#server !== undefined) {
throw new Error('iSCSI target is already listening')
}
await this.#lun.open?.()
const server = createServer(socket => this.#onConnection(socket))
// + 1: the connection serving the LUN does not take a pending slot
server.maxConnections = this.#maxPendingConnections + 1
server.on('drop', data => {
log.warn('too many connections without a session, refusing a new one', { remote: data?.remoteAddress })
})
this.#server = server
try {
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => reject(error)
server.once('error', onError)
server.listen(this.#port, this.#host, () => {
server.removeListener('error', onError)
resolve()
})
})
} catch (error) {
this.#server = undefined
throw error
}
log.info('target listening', { iqn: this.#iqn, address: this.address() })
}
/** The bound address, or undefined if not listening on an IP socket. */
address(): AddressInfo | undefined {
const address = this.#server?.address()
return address !== null && typeof address === 'object' ? address : undefined
}
/** Stop accepting connections, drop every connection, and close the LUN. */
async close(): Promise<void> {
const server = this.#server
const connections = [...this.#connections]
this.#server = undefined
this.#connection = undefined
this.#connections.clear()
if (server !== undefined) {
await new Promise<void>((resolve, reject) => {
server.close(error => (error ? reject(error) : resolve()))
// Drop the live connections so server.close() can complete.
for (const connection of connections) {
connection.destroy()
}
})
}
await this.#lun.close()
}
}

View File

@@ -1,261 +0,0 @@
import assert from 'node:assert/strict'
import { createServer, type Server, type Socket } from 'node:net'
import { once } from 'node:events'
import { describe, it } from 'node:test'
import {
DATA_IN_FLAG_FINAL,
DATA_IN_FLAG_STATUS,
FLAG_FINAL,
InitiatorOpcode,
ISCSI_VERSION,
LOGIN_FLAG_TRANSIT,
LoginStage,
RESERVED_TAG,
ScsiStatus,
TargetOpcode,
} from './constants.mjs'
import { IscsiInitiator } from './initiator.mjs'
import { allocBhs, assemblePdu, type IncomingPdu, readPdu } from './pdu.mjs'
const IQN = 'iqn.2024-01.tech.vates:nop-test'
const BLOCK_SIZE = 512
const BLOCK_COUNT = 2048
/**
* A target that speaks just enough to get a session up — login, READ
* CAPACITY(16), READ(16) — and can then ping the initiator on demand, which
* the real {@link IscsiTarget} never does. Everything here is about what the
* *initiator* does with an unsolicited NOP-In.
*/
class PingingTarget {
readonly #server: Server
#socket?: Socket
/** PDUs received from the initiator after the session was established. */
readonly received: Array<IncomingPdu> = []
#statSN = 0
constructor() {
this.#server = createServer(socket => {
this.#socket = socket
socket.setNoDelay(true)
this.#serve(socket).catch(() => {}) // the initiator hanging up is normal
})
}
async listen(): Promise<number> {
this.#server.listen(0, '127.0.0.1')
await once(this.#server, 'listening')
return (this.#server.address() as { port: number }).port
}
async close(): Promise<void> {
this.#socket?.destroy()
this.#server.close()
}
#send(socket: Socket, buffer: Buffer): Promise<void> {
return new Promise((resolve, reject) => {
socket.write(buffer, error => (error ? reject(error) : resolve()))
})
}
/** Ping the initiator the way a target does when the session goes quiet. */
async ping(targetTransferTag: number, data = Buffer.alloc(0)): Promise<void> {
const socket = this.#socket
assert.ok(socket !== undefined, 'no initiator connected')
const bhs = allocBhs(TargetOpcode.NOP_IN)
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(RESERVED_TAG, 16) // ITT: unsolicited, so none
bhs.writeUInt32BE(targetTransferTag, 20)
bhs.writeUInt32BE(this.#statSN, 24) // NOP-In does not advance StatSN
await this.#send(socket, assemblePdu(bhs, data))
}
/** Resolves once the initiator has sent a PDU with `opcode`. */
async waitFor(opcode: number, timeoutMs = 2000): Promise<IncomingPdu> {
const deadline = Date.now() + timeoutMs
for (;;) {
const found = this.received.find(pdu => pdu.opcode === opcode)
if (found !== undefined) {
return found
}
assert.ok(Date.now() < deadline, `initiator never sent opcode 0x${opcode.toString(16)}`)
await new Promise(resolve => setTimeout(resolve, 10))
}
}
async #serve(socket: Socket): Promise<void> {
let established = false
for (;;) {
const pdu = await readPdu(socket)
if (pdu === null) {
return
}
if (established) {
this.received.push(pdu)
}
switch (pdu.opcode) {
case InitiatorOpcode.LOGIN_REQUEST:
await this.#sendLoginResponse(socket, pdu)
established = true
break
case InitiatorOpcode.SCSI_COMMAND:
await this.#sendReadData(socket, pdu)
break
case InitiatorOpcode.LOGOUT_REQUEST: {
const bhs = allocBhs(TargetOpcode.LOGOUT_RESPONSE)
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(pdu.itt, 16)
bhs.writeUInt32BE(this.#statSN++, 24)
await this.#send(socket, assemblePdu(bhs))
return
}
default: // NOP-Out replies land in `received`; nothing to answer
break
}
}
}
async #sendLoginResponse(socket: Socket, request: IncomingPdu): Promise<void> {
const bhs = allocBhs(TargetOpcode.LOGIN_RESPONSE)
bhs[1] = LOGIN_FLAG_TRANSIT | (LoginStage.OPERATIONAL_NEGOTIATION << 2) | LoginStage.FULL_FEATURE_PHASE
bhs[2] = ISCSI_VERSION
bhs[3] = ISCSI_VERSION
request.bhs.copy(bhs, 8, 8, 14) // echo ISID
bhs.writeUInt16BE(1, 14) // TSIH
bhs.writeUInt32BE(request.itt, 16)
bhs.writeUInt32BE(this.#statSN++, 24)
await this.#send(socket, assemblePdu(bhs))
}
/** Answer any read-type command with a single Data-In carrying GOOD status. */
async #sendReadData(socket: Socket, command: IncomingPdu): Promise<void> {
const cdb = command.bhs.subarray(32, 48)
const expectedLength = command.readU32(20)
let payload
if (cdb[0] === 0x9e) {
// SERVICE ACTION IN(16) / READ CAPACITY(16)
payload = Buffer.alloc(expectedLength)
payload.writeBigUInt64BE(BigInt(BLOCK_COUNT - 1), 0)
payload.writeUInt32BE(BLOCK_SIZE, 8)
} else {
payload = Buffer.alloc(expectedLength, 0x5a)
}
const bhs = allocBhs(TargetOpcode.SCSI_DATA_IN)
bhs[1] = DATA_IN_FLAG_FINAL | DATA_IN_FLAG_STATUS
bhs[3] = ScsiStatus.GOOD
bhs.writeUInt32BE(command.itt, 16)
bhs.writeUInt32BE(RESERVED_TAG, 20)
bhs.writeUInt32BE(this.#statSN++, 24)
await this.#send(socket, assemblePdu(bhs, payload))
}
}
/** Run `body` against a connected initiator, always tearing both ends down. */
const withSession = async (body: (initiator: IscsiInitiator, target: PingingTarget) => Promise<void>) => {
const target = new PingingTarget()
const port = await target.listen()
const initiator = new IscsiInitiator({
host: '127.0.0.1',
port,
targetIqn: IQN,
messageTimeoutMs: 5000,
})
await initiator.connect()
try {
await body(initiator, target)
} finally {
await initiator.close()
await target.close()
}
}
describe('NOP-In keepalives', () => {
// The read loop used to stop as soon as no read was outstanding, and it is
// the only reader of the socket — so a ping arriving while idle sat unread in
// a paused stream. Targets drop a session whose pings go unanswered (30s on
// LIO by default), and nothing here reconnects, so an idle mount died.
it('are answered while the session is idle', async () => {
await withSession(async (initiator, target) => {
// no read in flight, and none will be
await target.ping(7)
const reply = await target.waitFor(InitiatorOpcode.NOP_OUT)
assert.equal(reply.readU32(20), 7) // echoes the Target Transfer Tag
assert.equal(reply.itt, RESERVED_TAG) // unsolicited reply, so no ITT
assert.notEqual(reply.bhs[0] & 0x40, 0) // immediate
})
})
it('echo the ping payload back', async () => {
await withSession(async (initiator, target) => {
const ping = Buffer.from('are you there')
await target.ping(9, ping)
const reply = await target.waitFor(InitiatorOpcode.NOP_OUT)
assert.deepEqual(reply.data, ping)
})
})
it('are answered between reads, without disturbing them', async () => {
await withSession(async (initiator, target) => {
assert.equal((await initiator.read(0, BLOCK_SIZE)).length, BLOCK_SIZE)
await target.ping(11)
await target.waitFor(InitiatorOpcode.NOP_OUT)
// the session is still usable afterwards
const data = await initiator.read(BLOCK_SIZE, BLOCK_SIZE)
assert.deepEqual(data, Buffer.alloc(BLOCK_SIZE, 0x5a))
})
})
it('are ignored when they are a reply to our own ping', async () => {
await withSession(async (initiator, target) => {
// a NOP-In with no Target Transfer Tag is a reply, not a ping: answering
// it would start an endless exchange
await target.ping(RESERVED_TAG)
await new Promise(resolve => setTimeout(resolve, 200))
assert.equal(
target.received.some(pdu => pdu.opcode === InitiatorOpcode.NOP_OUT),
false
)
// and the session still works
assert.equal((await initiator.read(0, BLOCK_SIZE)).length, BLOCK_SIZE)
})
})
})
describe('session teardown', () => {
it('logs out cleanly even though the read loop owns the socket', async () => {
const target = new PingingTarget()
const port = await target.listen()
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, messageTimeoutMs: 5000 })
await initiator.connect()
await initiator.close()
// the Logout Request reached the target, i.e. `close()` did not give up on
// its own response for lack of a reader
await target.waitFor(InitiatorOpcode.LOGOUT_REQUEST)
await target.close()
})
it('fails a read with the reason the session died, rather than timing out', async () => {
const target = new PingingTarget()
const port = await target.listen()
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, messageTimeoutMs: 5000 })
await initiator.connect()
await target.close() // target vanishes while the initiator is idle
await new Promise(resolve => setTimeout(resolve, 100))
const started = Date.now()
await assert.rejects(initiator.read(0, BLOCK_SIZE), /connection closed/)
// far below messageTimeoutMs: the dead session is known, not discovered
assert.ok(Date.now() - started < 1000)
})
})

View File

@@ -1,501 +0,0 @@
import { connect, type Socket } from 'node:net'
import { once } from 'node:events'
import { createLogger, type Logger } from '@xen-orchestra/log'
import { pTimeout } from 'promise-toolbox'
import {
AuthMethod,
DATA_IN_FLAG_STATUS,
FLAG_FINAL,
InitiatorOpcode,
LOGIN_CSG_SHIFT,
LOGIN_FLAG_TRANSIT,
LOGIN_NSG_MASK,
LoginStage,
LoginStatusClass,
OPCODE_IMMEDIATE,
RESERVED_TAG,
SCSI_CMD_FLAG_READ,
ScsiOpcode,
ScsiStatus,
SERVICE_ACTION_READ_CAPACITY_16,
TargetOpcode,
} from './constants.mjs'
import { CHAP_ALGORITHM_MD5, computeResponse, decodeChapValue, encodeChapValue } from './chap.mjs'
import { parseTextKeys, serializeTextKeys } from './login.mjs'
import { allocBhs, assemblePdu, type IncomingPdu, readPdu, writePdu } from './pdu.mjs'
import type { ChapCredentials } from './types.mjs'
const log: Logger = createLogger('vates:iscsi:initiator')
const DEFAULT_PORT = 3260
const DEFAULT_MESSAGE_TIMEOUT_MS = 60_000
const DEFAULT_MAX_RECV_DATA_SEGMENT_LENGTH = 262144
const READ_CAPACITY_16_LENGTH = 32
export interface IscsiInitiatorOptions {
/** Portal address to connect to. */
readonly host: string
/** Portal port. Defaults to 3260. */
readonly port?: number
/** Target IQN (`TargetName`) to log into. */
readonly targetIqn: string
/** Our initiator IQN (`InitiatorName`). Defaults to a generated one. */
readonly initiatorIqn?: string
/**
* One-way CHAP responder credential. When the target requests CHAP we answer
* its challenge with this user/secret. Omit for an unauthenticated login.
*/
readonly chap?: ChapCredentials
/** Per-message timeout for login/read responses, in ms. Defaults to 60000. */
readonly messageTimeoutMs?: number
}
/** An outstanding SCSI read, keyed by its Initiator Task Tag. */
interface Pending {
/** Preallocated buffer sized to the expected transfer length. */
readonly buffer: Buffer
resolve(data: Buffer): void
reject(error: Error): void
}
/**
* A minimal userspace iSCSI initiator (client), mirroring {@link IscsiTarget}'s
* framing and negotiation (read-only, single connection, one LUN, digests
* off, `ErrorRecoveryLevel=0`).
*
* A backlog `Map` keyed by ITT, drained by a single read loop, demultiplexes
* responses so several reads can be outstanding at once (e.g. under a
* `ReadAhead` disk wrapper).
*/
export class IscsiInitiator {
readonly #host: string
readonly #port: number
readonly #targetIqn: string
readonly #initiatorIqn: string
readonly #chap?: ChapCredentials
readonly #messageTimeoutMs: number
#socket?: Socket
#itt = 0
#cmdSN = 0
#expStatSN = 0
#blockSize = 0
#capacityBytes = 0
// Outstanding reads, keyed by ITT. A single #readLoop() (guarded by #reading)
// is the only consumer of readPdu, so it is safe to have many entries here.
readonly #backlog = new Map<number, Pending>()
#reading = false
// set once `close()` starts, so the read loop's own end is not reported as a
// fault and it stops instead of racing the socket teardown
#closing = false
// why the read loop stopped, when it was not us closing
#failure?: Error
// resolved by the read loop when the target's Logout Response arrives
#logoutAnswered?: () => void
constructor(options: IscsiInitiatorOptions) {
this.#host = options.host
this.#port = options.port ?? DEFAULT_PORT
this.#targetIqn = options.targetIqn
this.#initiatorIqn = options.initiatorIqn ?? 'iqn.2024-01.tech.vates:initiator'
this.#chap = options.chap
this.#messageTimeoutMs = options.messageTimeoutMs ?? DEFAULT_MESSAGE_TIMEOUT_MS
}
/** LUN logical block size in bytes (valid after {@link connect}). */
getBlockSize(): number {
return this.#blockSize
}
/** LUN capacity in bytes (valid after {@link connect}). */
getSize(): number {
return this.#capacityBytes
}
// --- lifecycle ------------------------------------------------------------
/** Open the TCP connection, log in, and read the LUN capacity. */
async connect(): Promise<void> {
const socket = connect(this.#port, this.#host)
this.#socket = socket
try {
await once(socket, 'connect')
socket.setNoDelay(true)
// A socket error with no read in flight would otherwise go unnoticed.
socket.on('error', error => this.#rejectAll(error instanceof Error ? error : new Error(String(error))))
// `#login` reads its own responses; from here on the read loop owns the
// socket, and it must be running before anything can answer a NOP-In
await this.#login()
void this.#pump()
await this.#readCapacity()
log.info('session established', { target: this.#targetIqn, capacity: this.#capacityBytes })
} catch (error) {
socket.destroy()
this.#socket = undefined
throw error
}
}
#requireSocket(): Socket {
const socket = this.#socket
if (socket === undefined) {
throw new Error('IscsiInitiator.connect() must be called first')
}
return socket
}
#send(buffer: Buffer): Promise<void> {
return writePdu(this.#requireSocket(), buffer, this.#messageTimeoutMs)
}
#nextItt(): number {
this.#itt = (this.#itt + 1) >>> 0
return this.#itt
}
/** Best-effort Logout, then close the socket. */
async close(): Promise<void> {
const socket = this.#socket
if (socket === undefined) {
return
}
// stops the read loop and keeps its own end from being logged as a fault
this.#closing = true
try {
const bhs = allocBhs(InitiatorOpcode.LOGOUT_REQUEST | OPCODE_IMMEDIATE)
bhs[1] = FLAG_FINAL // reason 0: close the session
bhs.writeUInt32BE(this.#nextItt(), 16)
bhs.writeUInt32BE(this.#cmdSN, 24)
bhs.writeUInt32BE(this.#expStatSN, 28)
// the read loop owns the socket, so the response comes through it: reading
// here too would put two consumers on one stream and split PDUs between them
const answered = new Promise<void>(resolve => {
this.#logoutAnswered = resolve
})
await this.#send(assemblePdu(bhs))
await pTimeout.call(answered, this.#messageTimeoutMs)
} catch (error) {
log.debug('logout failed, closing anyway', { error })
} finally {
this.#logoutAnswered = undefined
this.#socket = undefined
socket.destroy()
}
}
// --- login ----------------------------------------------------------------
/** Build the flags byte (T/CSG/NSG) of a Login Request. */
#loginFlags(transit: boolean, csg: number, nsg: number): number {
return (transit ? LOGIN_FLAG_TRANSIT : 0) | (csg << LOGIN_CSG_SHIFT) | (nsg & LOGIN_NSG_MASK)
}
async #sendLogin(itt: number, flags: number, keys: Array<[string, string]>): Promise<IncomingPdu> {
const bhs = allocBhs(InitiatorOpcode.LOGIN_REQUEST | OPCODE_IMMEDIATE)
bhs[1] = flags
Buffer.from([0x80, 0, 0, 0, 0, 1]).copy(bhs, 8) // ISID (fixed; single session)
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(this.#cmdSN, 24) // login is immediate: does not advance CmdSN
bhs.writeUInt32BE(this.#expStatSN, 28)
log.debug('login request', { flags: `0x${flags.toString(16)}`, keys: Object.fromEntries(keys) })
await this.#send(assemblePdu(bhs, serializeTextKeys(keys)))
const pdu = await pTimeout.call(readPdu(this.#requireSocket()), this.#messageTimeoutMs)
if (pdu === null) {
throw new Error('connection closed during login')
}
if (pdu.opcode !== TargetOpcode.LOGIN_RESPONSE) {
throw new Error(`expected Login Response, got opcode 0x${pdu.opcode.toString(16)}`)
}
const statusClass = pdu.readU8(36)
log.debug('login response', {
statusClass,
statusDetail: pdu.readU8(37),
transit: (pdu.readU8(1) & LOGIN_FLAG_TRANSIT) !== 0,
keys: Object.fromEntries(parseTextKeys(pdu.data)),
})
if (statusClass !== LoginStatusClass.SUCCESS) {
throw new Error(
`login rejected (status-class 0x${statusClass.toString(16)}, detail 0x${pdu.readU8(37).toString(16)})`
)
}
this.#expStatSN = (pdu.readU32(24) + 1) >>> 0
return pdu
}
/**
* Drive the login: an optional CHAP security stage (we are the responder),
* then the operational stage into the full-feature phase. All PDUs of one
* login share a single ITT.
*/
async #login(): Promise<void> {
const itt = this.#nextItt()
const names: Array<[string, string]> = [
['InitiatorName', this.#initiatorIqn],
['TargetName', this.#targetIqn],
['SessionType', 'Normal'],
]
if (this.#chap !== undefined) {
// Round 1: offer CHAP (falling back to None) in the security stage.
const r1 = await this.#sendLogin(
itt,
this.#loginFlags(false, LoginStage.SECURITY_NEGOTIATION, LoginStage.OPERATIONAL_NEGOTIATION),
[...names, ['AuthMethod', `${AuthMethod.CHAP},${AuthMethod.NONE}`]]
)
const method = parseTextKeys(r1.data).get('AuthMethod')
log.debug('chap: target selected AuthMethod', { method })
if (method === AuthMethod.CHAP) {
// Round 2: propose MD5.
const r2 = await this.#sendLogin(
itt,
this.#loginFlags(false, LoginStage.SECURITY_NEGOTIATION, LoginStage.OPERATIONAL_NEGOTIATION),
[['CHAP_A', String(CHAP_ALGORITHM_MD5)]]
)
// Round 3: answer the target's challenge, transiting to operational.
// (Mutual CHAP is not supported: we never challenge the target; a target
// that *requires* mutual auth will reject this login, surfaced above.)
const challenge = parseTextKeys(r2.data)
const id = Number.parseInt(challenge.get('CHAP_I') ?? '', 10)
const challengeValue = challenge.get('CHAP_C')
if (!Number.isInteger(id) || challengeValue === undefined) {
throw new Error('malformed CHAP challenge from target')
}
const response = computeResponse(id, this.#chap.secret, decodeChapValue(challengeValue))
log.debug('chap: answering challenge', { id, user: this.#chap.user })
await this.#sendLogin(
itt,
this.#loginFlags(true, LoginStage.SECURITY_NEGOTIATION, LoginStage.OPERATIONAL_NEGOTIATION),
[
['CHAP_N', this.#chap.user],
['CHAP_R', encodeChapValue(response)],
]
)
await this.#operationalLogin(itt, [])
return
}
if (method !== AuthMethod.NONE && method !== undefined) {
throw new Error(`target selected unsupported AuthMethod=${method}`)
}
// Target did not require CHAP: continue straight to operational.
await this.#operationalLogin(itt, [])
return
}
// No CHAP: a single operational-stage PDU straight to the full-feature phase.
await this.#operationalLogin(itt, names)
}
/** Operational-stage login PDU, transiting to the full-feature phase. */
async #operationalLogin(itt: number, extraKeys: Array<[string, string]>): Promise<void> {
const pdu = await this.#sendLogin(
itt,
this.#loginFlags(true, LoginStage.OPERATIONAL_NEGOTIATION, LoginStage.FULL_FEATURE_PHASE),
[
...extraKeys,
['HeaderDigest', 'None'],
['DataDigest', 'None'],
['InitialR2T', 'Yes'],
['ImmediateData', 'No'],
['ErrorRecoveryLevel', '0'],
['MaxRecvDataSegmentLength', String(DEFAULT_MAX_RECV_DATA_SEGMENT_LENGTH)],
]
)
if ((pdu.readU8(1) & LOGIN_FLAG_TRANSIT) === 0) {
throw new Error('target did not transit to the full-feature phase')
}
}
// --- read path ------------------------------------------------------------
/**
* Read exactly `length` bytes from byte `offset` of the LUN. `offset` and
* `length` must be block-aligned (multiples of {@link getBlockSize}).
*/
read(offset: number, length: number): Promise<Buffer> {
const blockSize = this.#blockSize
if (offset % blockSize !== 0 || length % blockSize !== 0) {
return Promise.reject(new Error(`unaligned read (offset ${offset}, length ${length}, block ${blockSize})`))
}
const cdb = Buffer.alloc(16)
cdb[0] = ScsiOpcode.READ_16
cdb.writeBigUInt64BE(BigInt(offset / blockSize), 2) // LBA
cdb.writeUInt32BE(length / blockSize, 10) // transfer length, in blocks
return this.#scsiRead(cdb, length)
}
/** Issue READ CAPACITY(16) and record the LUN block size and capacity. */
async #readCapacity(): Promise<void> {
const cdb = Buffer.alloc(16)
cdb[0] = ScsiOpcode.SERVICE_ACTION_IN_16
cdb[1] = SERVICE_ACTION_READ_CAPACITY_16
cdb.writeUInt32BE(READ_CAPACITY_16_LENGTH, 10) // allocation length
// Block size is unknown here, so bypass the alignment guard in read().
const data = await this.#scsiRead(cdb, READ_CAPACITY_16_LENGTH)
const maxLba = data.readBigUInt64BE(0)
this.#blockSize = data.readUInt32BE(8)
this.#capacityBytes = Number((maxLba + 1n) * BigInt(this.#blockSize))
log.debug('read capacity', { blockSize: this.#blockSize, capacityBytes: this.#capacityBytes })
}
/** Issue a read-type SCSI command and resolve with its assembled Data-In. */
#scsiRead(cdb: Buffer, expectedLength: number): Promise<Buffer> {
const failure = this.#failure
if (failure !== undefined) {
// the session is already gone: fail with why, rather than writing into a
// half-closed socket and waiting out the message timeout
return Promise.reject(failure)
}
const socket = this.#requireSocket()
const itt = this.#nextItt()
const bhs = allocBhs(InitiatorOpcode.SCSI_COMMAND)
bhs[1] = FLAG_FINAL | SCSI_CMD_FLAG_READ
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(expectedLength, 20) // Expected Data Transfer Length
bhs.writeUInt32BE(this.#cmdSN, 24)
this.#cmdSN = (this.#cmdSN + 1) >>> 0
bhs.writeUInt32BE(this.#expStatSN, 28)
cdb.copy(bhs, 32)
log.debug('scsi read', { itt, cdb: `0x${cdb[0].toString(16)}`, expectedLength, cmdSN: (this.#cmdSN - 1) >>> 0 })
// Register the pending read BEFORE writing, so a fast response cannot arrive
// before the backlog entry exists. No await between allocate → register →
// send → pump, which keeps concurrent reads correct.
const promise = new Promise<Buffer>((resolve, reject) => {
this.#backlog.set(itt, { buffer: Buffer.alloc(expectedLength), resolve, reject })
})
writePdu(socket, assemblePdu(bhs), this.#messageTimeoutMs).catch(error => {
const pending = this.#backlog.get(itt)
if (pending !== undefined) {
this.#backlog.delete(itt)
pending.reject(error instanceof Error ? error : new Error(String(error)))
}
})
void this.#pump()
return pTimeout.call(promise, this.#messageTimeoutMs)
}
/**
* Drain inbound PDUs for as long as the session lives. Only ever one runs at
* a time, and it is the only reader of the socket after login.
*
* It deliberately does not stop when the backlog empties. A target sends its
* NOP-In keepalives precisely when nothing else is going on, and it is the
* only reader — so a loop that parked between reads would leave those pings
* sitting unread in a paused stream. Targets drop a session whose pings go
* unanswered (LIO's `nopin_response_timeout` is 30s by default, arrays are
* similar), and nothing here reconnects, so the session would die during any
* idle period: right after `connect()`, between reads, or while the consumer
* of a mounted disk stalls.
*/
async #pump(): Promise<void> {
if (this.#reading) {
return
}
this.#reading = true
try {
while (!this.#closing) {
const socket = this.#socket
if (socket === undefined) {
throw new Error('connection closed')
}
const pdu = await readPdu(socket)
if (pdu === null) {
throw new Error('connection closed by target')
}
this.#route(pdu)
}
} catch (error) {
const failure = error instanceof Error ? error : new Error(String(error))
if (!this.#closing) {
// The loop now runs while idle, so it is usually the first to notice the
// session die — with nothing in the backlog to reject and nobody to
// return an error to. Log it where it happens, and keep the reason so
// later reads fail with the original cause instead of whatever the dead
// socket produces.
this.#failure = failure
log.warn('read loop stopped, session is dead', { error: failure })
this.#rejectAll(failure)
}
} finally {
this.#reading = false
}
}
/** Route one inbound PDU to its pending read (by ITT), completing it if done. */
#route(pdu: IncomingPdu): void {
switch (pdu.opcode) {
case TargetOpcode.SCSI_DATA_IN: {
const pending = this.#backlog.get(pdu.itt)
if (pending === undefined) {
return // stale/duplicate; ignore
}
pdu.data.copy(pending.buffer, pdu.readU32(40)) // copy at Buffer Offset
const hasStatus = (pdu.flags & DATA_IN_FLAG_STATUS) !== 0
log.debug('data-in', { itt: pdu.itt, offset: pdu.readU32(40), length: pdu.data.length, final: hasStatus })
if (hasStatus) {
this.#expStatSN = (pdu.readU32(24) + 1) >>> 0
this.#complete(pdu.itt, pending, pdu.readU8(3))
}
return
}
case TargetOpcode.SCSI_RESPONSE: {
const pending = this.#backlog.get(pdu.itt)
if (pending !== undefined) {
this.#expStatSN = (pdu.readU32(24) + 1) >>> 0
this.#complete(pdu.itt, pending, pdu.readU8(3))
}
return
}
case TargetOpcode.NOP_IN: {
// A target-initiated NOP ping (TTT set) must be answered to keep the
// session alive; a NOP reply to our own ping (none here) is ignored.
const ttt = pdu.readU32(20)
if (ttt !== RESERVED_TAG) {
void this.#replyNop(ttt, pdu.data)
}
return
}
case TargetOpcode.LOGOUT_RESPONSE:
// awaited by `close()`, which cannot read the socket itself
this.#logoutAnswered?.()
return
default:
// R2T/Async/Reject are not expected on the read-only path; ignore.
log.debug('ignoring unexpected PDU', { opcode: pdu.opcode })
}
}
#complete(itt: number, pending: Pending, status: number): void {
this.#backlog.delete(itt)
log.debug('read complete', { itt, status, pending: this.#backlog.size })
if (status === ScsiStatus.GOOD) {
pending.resolve(pending.buffer)
} else {
pending.reject(new Error(`SCSI command failed with status 0x${status.toString(16)}`))
}
}
async #replyNop(targetTransferTag: number, ping: Buffer): Promise<void> {
const bhs = allocBhs(InitiatorOpcode.NOP_OUT | OPCODE_IMMEDIATE)
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(RESERVED_TAG, 16) // ITT: none (this is an unsolicited reply)
bhs.writeUInt32BE(targetTransferTag, 20)
bhs.writeUInt32BE(this.#cmdSN, 24)
bhs.writeUInt32BE(this.#expStatSN, 28)
await this.#send(assemblePdu(bhs, ping)).catch(error => log.debug('NOP reply failed', { error }))
}
#rejectAll(error: Error): void {
if (this.#backlog.size > 0) {
log.debug('rejecting all pending reads', { count: this.#backlog.size, error: error.message })
}
for (const [itt, pending] of this.#backlog) {
this.#backlog.delete(itt)
pending.reject(error)
}
}
}

View File

@@ -1,372 +0,0 @@
import { isIP } from 'node:net'
import { createLogger, type Logger } from '@xen-orchestra/log'
import {
AuthMethod,
LOGIN_CSG_MASK,
LOGIN_CSG_SHIFT,
LOGIN_FLAG_CONTINUE,
LOGIN_FLAG_TRANSIT,
LOGIN_NSG_MASK,
LoginStage,
LoginStatusClass,
LoginStatusDetail,
} from './constants.mjs'
import {
computeResponse,
encodeChapValue,
decodeChapValue,
generateChallenge,
generateId,
parseAlgorithmList,
selectAlgorithm,
verifyResponse,
} from './chap.mjs'
import type { IncomingPdu } from './pdu.mjs'
import type { ChapCredentials, NegotiatedParams, SessionType } from './types.mjs'
const EMPTY = Buffer.alloc(0)
// Target-side login negotiation trace. Enable the `vates:iscsi:login` debug
// namespace to see every negotiated key and CHAP state change — the usual root
// cause when a particular initiator fails to attach.
const log: Logger = createLogger('vates:iscsi:login')
// Operational values the target imposes. The target drives negotiation, so these
// are pinned to a single code path (see plan): all writes are R2T-solicited
// (InitialR2T=Yes, ImmediateData=No), no digests, one connection.
export const OUR_MAX_RECV_DATA_SEGMENT_LENGTH = 262144
const OUR_MAX_BURST_LENGTH = 262144
const DEFAULT_INITIATOR_MAX_RECV_DATA_SEGMENT_LENGTH = 8192 // RFC 7143 default
// a declared MaxRecvDataSegmentLength only applies once logged in: until then,
// both sides keep to the 8192-byte default (RFC 7143 §13.12)
export const LOGIN_MAX_DATA_SEGMENT_LENGTH = 8192
/** Parse a NUL-delimited iSCSI text key=value data segment. */
export function parseTextKeys(data: Buffer): Map<string, string> {
const keys = new Map<string, string>()
for (const pair of data.toString('utf8').split('\0')) {
if (pair.length === 0) {
continue
}
const eq = pair.indexOf('=')
if (eq !== -1) {
keys.set(pair.slice(0, eq), pair.slice(eq + 1))
}
}
return keys
}
/** Serialize key=value pairs into a NUL-delimited (and NUL-terminated) buffer. */
export function serializeTextKeys(entries: ReadonlyArray<readonly [string, string]>): Buffer {
if (entries.length === 0) {
return Buffer.alloc(0)
}
return Buffer.from(entries.map(([key, value]) => `${key}=${value}\0`).join(''), 'utf8')
}
function parsePositiveInt(value: string | undefined, fallback: number): number {
if (value === undefined) {
return fallback
}
const parsed = Number.parseInt(value, 10)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
/**
* Format an `address:port,portal-group-tag` value for a SendTargets
* `TargetAddress`, per the iSCSI grammar:
* - unwrap IPv4-mapped IPv6 (`::ffff:1.2.3.4`, produced by a dual-stack socket
* accepting an IPv4 connection) to plain IPv4, so initiators get a clean,
* matchable portal;
* - bracket real IPv6 literals (`[2001:db8::1]:3260,1`).
*/
export function formatPortal(address: string, port: number): string {
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address)
const normalized = mapped !== null ? mapped[1] : address
const bracketed = isIP(normalized) === 6 ? `[${normalized}]` : normalized
return `${bracketed}:${port},1`
}
/** Build the SendTargets discovery Text Response data segment. */
export function buildSendTargetsResponse(iqn: string, address: string, port: number): Buffer {
return serializeTextKeys([
['TargetName', iqn],
['TargetAddress', formatPortal(address, port)],
])
}
/** Outcome of processing a single Login Request PDU. */
export interface LoginStepResult {
/** Byte 1 (T/C/CSG/NSG) of the Login Response. */
flagsByte: number
/** Response key=value text segment. */
data: Buffer
/** Login Response Status-Class (byte 36). Defaults to SUCCESS when omitted. */
statusClass?: number
/** Login Response Status-Detail (byte 37). Defaults to 0 when omitted. */
statusDetail?: number
}
/** A rejected login: the Status-Class/Detail bytes the response must carry. */
interface AuthFailure {
statusClass: number
statusDetail: number
}
/** CHAP authentication failure (RFC 7143 §11.1.5): Initiator Error / auth failure. */
const AUTH_FAILURE: AuthFailure = {
statusClass: LoginStatusClass.INITIATOR_ERROR,
statusDetail: LoginStatusDetail.AUTHENTICATION_FAILURE,
}
/** Progress of the target-side one-way CHAP exchange (RFC 7143 §11.1.4). */
type ChapState = 'none' | 'awaitAlgorithm' | 'awaitResponse' | 'authenticated' | 'failed'
/**
* Negotiates one connection's login: the target is authoritative, so every
* key is answered with the pinned value, and RFC 7143's boolean rules
* guarantee the session collapses to the one supported path regardless of
* what the initiator offers.
*
* With {@link ChapCredentials}, the target challenges the initiator during
* security negotiation and refuses to reach the operational stage until it
* proves the secret; without one, it uses the legacy `AuthMethod=None` path.
*/
export class LoginNegotiator {
#firstResponseSent = false
#complete = false
readonly #chap?: ChapCredentials
#chapState: ChapState = 'none'
#chapId = 0
#chapChallenge: Buffer = EMPTY
sessionType: SessionType = 'Normal'
initiatorName?: string
targetName?: string
readonly params: NegotiatedParams = {
initiatorMaxRecvDataSegmentLength: DEFAULT_INITIATOR_MAX_RECV_DATA_SEGMENT_LENGTH,
maxBurstLength: OUR_MAX_BURST_LENGTH,
}
constructor(chap?: ChapCredentials) {
this.#chap = chap
}
/** True once the session has transited to the full-feature phase. */
get complete(): boolean {
return this.#complete
}
/** True once CHAP authentication has definitively failed for this connection. */
get failed(): boolean {
return this.#chapState === 'failed'
}
/** CHAP is enforced only when configured and only for Normal (data) sessions. */
get #chapRequired(): boolean {
return this.#chap !== undefined && this.sessionType === 'Normal'
}
process(pdu: IncomingPdu): LoginStepResult {
const flags = pdu.flags
const csg = (flags & LOGIN_CSG_MASK) >> LOGIN_CSG_SHIFT
const nsg = flags & LOGIN_NSG_MASK
const transit = (flags & LOGIN_FLAG_TRANSIT) !== 0
const cont = (flags & LOGIN_FLAG_CONTINUE) !== 0
const keys = parseTextKeys(pdu.data)
log.debug('login request', { csg, nsg, transit, cont, keys: Object.fromEntries(keys) })
const sessionType = keys.get('SessionType')
if (sessionType !== undefined) {
this.sessionType = sessionType === 'Discovery' ? 'Discovery' : 'Normal'
}
this.initiatorName ??= keys.get('InitiatorName')
this.targetName ??= keys.get('TargetName')
const responseKeys: Array<[string, string]> = []
// A normal session's first Login Response must carry the portal group tag.
if (!this.#firstResponseSent && this.sessionType === 'Normal') {
responseKeys.push(['TargetPortalGroupTag', '1'])
}
let failure: AuthFailure | undefined
if (csg === LoginStage.SECURITY_NEGOTIATION) {
// Act only on a complete key set (C bit clear); while the initiator is
// still sending keys we accumulate without answering the exchange.
if (!cont) {
failure = this.#handleSecurity(keys, responseKeys)
}
} else if (csg === LoginStage.OPERATIONAL_NEGOTIATION) {
if (this.#chapRequired && this.#chapState !== 'authenticated') {
// Normal session jumped to operational negotiation without completing the
// CHAP security stage we require.
this.#chapState = 'failed'
failure = AUTH_FAILURE
} else {
this.#negotiateOperational(keys, responseKeys)
}
}
let flagsByte = csg << LOGIN_CSG_SHIFT
// Transit only when the initiator asked to, is not still sending keys, and
// (if CHAP is required) authentication has succeeded. This single gate blocks
// a premature Transit bit and any transit before/without CHAP.
const authReady = !this.#chapRequired || this.#chapState === 'authenticated'
if (transit && !cont && authReady) {
flagsByte |= LOGIN_FLAG_TRANSIT | nsg
if (nsg === LoginStage.FULL_FEATURE_PHASE) {
this.#complete = true
}
}
this.#firstResponseSent = true
log.debug('login response', {
transit: (flagsByte & LOGIN_FLAG_TRANSIT) !== 0,
nsg: flagsByte & LOGIN_NSG_MASK,
keys: Object.fromEntries(responseKeys),
statusClass: failure?.statusClass ?? 0,
statusDetail: failure?.statusDetail ?? 0,
chapState: this.#chapState,
})
return {
flagsByte,
data: serializeTextKeys(responseKeys),
statusClass: failure?.statusClass,
statusDetail: failure?.statusDetail,
}
}
/**
* Drive one round of the target-side one-way CHAP exchange, mutating
* {@link ChapState} and appending any response keys. Returns an
* {@link AuthFailure} when authentication is rejected, otherwise `undefined`
* (success or "still in progress, awaiting the next PDU").
*/
#handleSecurity(keys: Map<string, string>, responseKeys: Array<[string, string]>): AuthFailure | undefined {
// CHAP applies only to Normal (data) sessions. Discovery sessions can only
// run SendTargets — they never reach the LUN — so they negotiate
// AuthMethod=None, keeping the standard unauthenticated `iscsiadm -m
// discovery` flow working. Without CHAP configured, this is the only path.
const chap = this.#chap
if (chap === undefined || this.sessionType !== 'Normal') {
if (keys.has('AuthMethod')) {
responseKeys.push(['AuthMethod', AuthMethod.NONE])
}
return undefined
}
switch (this.#chapState) {
case 'none': {
const offer = keys.get('AuthMethod')
if (offer === undefined) {
return undefined // still waiting for the AuthMethod offer
}
if (!offer.split(',').includes(AuthMethod.CHAP)) {
// Initiator offered only None (or an unknown method) while we require CHAP.
log.debug('chap: initiator did not offer CHAP', { offered: offer })
this.#chapState = 'failed'
return AUTH_FAILURE
}
responseKeys.push(['AuthMethod', AuthMethod.CHAP])
this.#chapState = 'awaitAlgorithm'
return undefined
}
case 'awaitAlgorithm': {
const offered = keys.get('CHAP_A')
if (offered === undefined) {
return undefined // still waiting for CHAP_A
}
const algorithm = selectAlgorithm(parseAlgorithmList(offered))
if (algorithm === undefined) {
log.debug('chap: no supported algorithm offered', { offered })
this.#chapState = 'failed'
return AUTH_FAILURE
}
this.#chapId = generateId()
this.#chapChallenge = generateChallenge()
responseKeys.push(['CHAP_A', String(algorithm)])
responseKeys.push(['CHAP_I', String(this.#chapId)])
responseKeys.push(['CHAP_C', encodeChapValue(this.#chapChallenge)])
log.debug('chap: issued challenge', { algorithm, id: this.#chapId })
this.#chapState = 'awaitResponse'
return undefined
}
case 'awaitResponse': {
const name = keys.get('CHAP_N')
const response = keys.get('CHAP_R')
if (name === undefined || response === undefined) {
return undefined // still waiting for CHAP_N / CHAP_R
}
let got: Buffer
try {
got = decodeChapValue(response)
} catch {
this.#chapState = 'failed'
return AUTH_FAILURE
}
const expected = computeResponse(this.#chapId, chap.secret, this.#chapChallenge)
if (name === chap.user && verifyResponse(expected, got)) {
log.debug('chap: authenticated', { user: name })
this.#chapState = 'authenticated'
return undefined
}
log.debug('chap: verification failed', { user: name, userMatch: name === chap.user })
this.#chapState = 'failed'
return AUTH_FAILURE
}
default:
return undefined
}
}
#negotiateOperational(keys: Map<string, string>, responseKeys: Array<[string, string]>): void {
// Only answer what the initiator actually offered: for every key below, the
// RFC 7143 default of an unoffered key is already the value we want, so
// staying silent and letting the default stand is equivalent.
const answer = (key: string, value: string) => {
if (keys.has(key)) {
responseKeys.push([key, value])
}
}
answer('HeaderDigest', 'None')
answer('DataDigest', 'None')
answer('MaxConnections', '1')
answer('InitialR2T', 'Yes')
answer('ErrorRecoveryLevel', '0')
// ImmediateData is the one exception: its RFC 7143 default is *Yes*, so an
// initiator that never offers the key may legally put write data in the SCSI
// Command PDU itself — which this target does not implement and would
// otherwise silently drop, acknowledging a write it never applied. Declare
// No whether or not it was offered. Costs nothing: there is no immediate
// data path to lose, and every mainstream initiator offers the key anyway,
// so for them this is byte-identical to before.
responseKeys.push(['ImmediateData', 'No'])
answer('DataPDUInOrder', 'Yes')
answer('DataSequenceInOrder', 'Yes')
answer('DefaultTime2Wait', '2')
answer('DefaultTime2Retain', '0')
answer('MaxOutstandingR2T', '1')
// MaxRecvDataSegmentLength is declarative per direction: capture the
// initiator's (caps our Data-In) and always declare ours (caps its Data-Out).
this.params.initiatorMaxRecvDataSegmentLength = parsePositiveInt(
keys.get('MaxRecvDataSegmentLength'),
this.params.initiatorMaxRecvDataSegmentLength
)
responseKeys.push(['MaxRecvDataSegmentLength', String(OUR_MAX_RECV_DATA_SEGMENT_LENGTH)])
// MaxBurstLength / FirstBurstLength negotiate to the minimum of both offers.
const maxBurst = Math.min(parsePositiveInt(keys.get('MaxBurstLength'), OUR_MAX_BURST_LENGTH), OUR_MAX_BURST_LENGTH)
this.params.maxBurstLength = maxBurst
if (keys.has('MaxBurstLength')) {
responseKeys.push(['MaxBurstLength', String(maxBurst)])
}
if (keys.has('FirstBurstLength')) {
const firstBurst = Math.min(parsePositiveInt(keys.get('FirstBurstLength'), maxBurst), maxBurst)
responseKeys.push(['FirstBurstLength', String(firstBurst)])
}
}
}

View File

@@ -1,313 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
InitiatorOpcode,
LOGIN_CSG_SHIFT,
LOGIN_FLAG_CONTINUE,
LOGIN_FLAG_TRANSIT,
LOGIN_NSG_MASK,
LoginStage,
LoginStatusClass,
LoginStatusDetail,
OPCODE_IMMEDIATE,
} from './constants.mjs'
import { buildSendTargetsResponse, formatPortal, LoginNegotiator, parseTextKeys, serializeTextKeys } from './login.mjs'
import { computeResponse, decodeChapValue, encodeChapValue } from './chap.mjs'
import { IncomingPdu } from './pdu.mjs'
function loginFlags(options: { transit?: boolean; cont?: boolean; csg: number; nsg: number }): number {
return (
(options.transit ? LOGIN_FLAG_TRANSIT : 0) |
(options.cont ? LOGIN_FLAG_CONTINUE : 0) |
(options.csg << LOGIN_CSG_SHIFT) |
(options.nsg & LOGIN_NSG_MASK)
)
}
function makeLoginPdu(flags: number, keys: Record<string, string>): IncomingPdu {
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.LOGIN_REQUEST | OPCODE_IMMEDIATE
bhs[1] = flags
const data = serializeTextKeys(Object.entries(keys))
return new IncomingPdu(bhs, Buffer.alloc(0), data)
}
describe('text key serialization', () => {
it('round-trips NUL-delimited key=value pairs', () => {
const buffer = serializeTextKeys([
['A', '1'],
['HeaderDigest', 'None'],
])
assert.equal(buffer.toString('binary'), 'A=1\0HeaderDigest=None\0')
const keys = parseTextKeys(buffer)
assert.equal(keys.get('A'), '1')
assert.equal(keys.get('HeaderDigest'), 'None')
})
it('ignores empty fragments and malformed pairs', () => {
const keys = parseTextKeys(Buffer.from('\0Key=Value\0bogus\0', 'binary'))
assert.equal(keys.size, 1)
assert.equal(keys.get('Key'), 'Value')
})
})
describe('LoginNegotiator', () => {
it('completes a single-PDU operational login and pins the negotiated values', () => {
const negotiator = new LoginNegotiator()
const pdu = makeLoginPdu(
loginFlags({
transit: true,
csg: LoginStage.OPERATIONAL_NEGOTIATION,
nsg: LoginStage.FULL_FEATURE_PHASE,
}),
{
InitiatorName: 'iqn.1994-05.com.example:initiator',
SessionType: 'Normal',
HeaderDigest: 'None,CRC32C',
DataDigest: 'None,CRC32C',
InitialR2T: 'No',
ImmediateData: 'Yes',
MaxRecvDataSegmentLength: '262144',
MaxBurstLength: '16776192',
}
)
const { flagsByte, data } = negotiator.process(pdu)
assert.equal(negotiator.complete, true)
assert.ok((flagsByte & LOGIN_FLAG_TRANSIT) !== 0)
assert.equal(flagsByte & LOGIN_NSG_MASK, LoginStage.FULL_FEATURE_PHASE)
const keys = parseTextKeys(data)
assert.equal(keys.get('TargetPortalGroupTag'), '1')
assert.equal(keys.get('HeaderDigest'), 'None')
assert.equal(keys.get('DataDigest'), 'None')
// Pinned to the single write code path regardless of the initiator's offer.
assert.equal(keys.get('InitialR2T'), 'Yes')
assert.equal(keys.get('ImmediateData'), 'No')
// MaxBurstLength negotiates down to our cap.
assert.equal(keys.get('MaxBurstLength'), '262144')
assert.equal(negotiator.sessionType, 'Normal')
assert.equal(negotiator.initiatorName, 'iqn.1994-05.com.example:initiator')
assert.equal(negotiator.params.initiatorMaxRecvDataSegmentLength, 262144)
assert.equal(negotiator.params.maxBurstLength, 262144)
})
it('handles a two-stage security then operational login', () => {
const negotiator = new LoginNegotiator()
const security = negotiator.process(
makeLoginPdu(
loginFlags({
transit: true,
csg: LoginStage.SECURITY_NEGOTIATION,
nsg: LoginStage.OPERATIONAL_NEGOTIATION,
}),
{ InitiatorName: 'iqn.x:i', SessionType: 'Normal', AuthMethod: 'None' }
)
)
assert.equal(negotiator.complete, false)
assert.equal(parseTextKeys(security.data).get('AuthMethod'), 'None')
const operational = negotiator.process(
makeLoginPdu(
loginFlags({
transit: true,
csg: LoginStage.OPERATIONAL_NEGOTIATION,
nsg: LoginStage.FULL_FEATURE_PHASE,
}),
{ HeaderDigest: 'None', DataDigest: 'None' }
)
)
assert.equal(negotiator.complete, true)
// The portal group tag is only emitted in the first response.
assert.equal(parseTextKeys(operational.data).get('TargetPortalGroupTag'), undefined)
})
it('omits the portal group tag for a discovery session', () => {
const negotiator = new LoginNegotiator()
const { data } = negotiator.process(
makeLoginPdu(
loginFlags({
transit: true,
csg: LoginStage.OPERATIONAL_NEGOTIATION,
nsg: LoginStage.FULL_FEATURE_PHASE,
}),
{ SessionType: 'Discovery' }
)
)
assert.equal(negotiator.sessionType, 'Discovery')
assert.equal(parseTextKeys(data).get('TargetPortalGroupTag'), undefined)
})
it('does not transit while the initiator is still sending keys (C bit)', () => {
const negotiator = new LoginNegotiator()
const { flagsByte } = negotiator.process(
makeLoginPdu(
loginFlags({
transit: true,
cont: true,
csg: LoginStage.OPERATIONAL_NEGOTIATION,
nsg: LoginStage.FULL_FEATURE_PHASE,
}),
{ HeaderDigest: 'None' }
)
)
assert.equal(negotiator.complete, false)
assert.equal(flagsByte & LOGIN_FLAG_TRANSIT, 0)
})
})
describe('LoginNegotiator CHAP authenticator', () => {
const CHAP = { user: 'alice', secret: 's3cr3t' }
const securityFlags = (transit = false) =>
loginFlags({ transit, csg: LoginStage.SECURITY_NEGOTIATION, nsg: LoginStage.OPERATIONAL_NEGOTIATION })
/** Round 1: offer AuthMethod. */
function offerAuth(negotiator: LoginNegotiator, methods = 'CHAP,None', transit = false) {
return negotiator.process(
makeLoginPdu(securityFlags(transit), { InitiatorName: 'iqn.x:i', SessionType: 'Normal', AuthMethod: methods })
)
}
/** Round 2: offer CHAP_A, return the target's parsed challenge keys. */
function offerAlgorithm(negotiator: LoginNegotiator, list = '5') {
const result = negotiator.process(makeLoginPdu(securityFlags(false), { CHAP_A: list }))
return { result, keys: parseTextKeys(result.data) }
}
/** Round 3: answer with CHAP_N/CHAP_R for the given challenge. */
function answerChallenge(
negotiator: LoginNegotiator,
challengeKeys: Map<string, string>,
{ user = CHAP.user, secret = CHAP.secret } = {}
) {
const id = Number.parseInt(challengeKeys.get('CHAP_I') ?? '', 10)
const challenge = decodeChapValue(challengeKeys.get('CHAP_C') ?? '')
const response = encodeChapValue(computeResponse(id, secret, challenge))
return negotiator.process(makeLoginPdu(securityFlags(true), { CHAP_N: user, CHAP_R: response }))
}
it('challenges then transits only after a correct response', () => {
const negotiator = new LoginNegotiator(CHAP)
// Round 1: we answer AuthMethod=CHAP and do NOT transit.
const r1 = offerAuth(negotiator)
assert.equal(parseTextKeys(r1.data).get('AuthMethod'), 'CHAP')
assert.equal(r1.flagsByte & LOGIN_FLAG_TRANSIT, 0)
assert.equal(r1.statusClass ?? LoginStatusClass.SUCCESS, LoginStatusClass.SUCCESS)
// Round 2: we issue CHAP_A=5, an identifier and a challenge, still no transit.
const { result: r2, keys: challenge } = offerAlgorithm(negotiator)
assert.equal(challenge.get('CHAP_A'), '5')
assert.ok(challenge.has('CHAP_I'))
assert.match(challenge.get('CHAP_C') ?? '', /^0x[0-9a-f]+$/)
assert.equal(r2.flagsByte & LOGIN_FLAG_TRANSIT, 0)
// Round 3: a correct response transits to the operational stage.
const r3 = answerChallenge(negotiator, challenge)
assert.equal(r3.statusClass ?? LoginStatusClass.SUCCESS, LoginStatusClass.SUCCESS)
assert.ok((r3.flagsByte & LOGIN_FLAG_TRANSIT) !== 0, 'transit granted after auth')
assert.equal(r3.flagsByte & LOGIN_NSG_MASK, LoginStage.OPERATIONAL_NEGOTIATION)
assert.equal(negotiator.failed, false)
// The operational stage then completes as usual.
const op = negotiator.process(
makeLoginPdu(
loginFlags({ transit: true, csg: LoginStage.OPERATIONAL_NEGOTIATION, nsg: LoginStage.FULL_FEATURE_PHASE }),
{ HeaderDigest: 'None', DataDigest: 'None' }
)
)
assert.equal(negotiator.complete, true)
assert.equal(parseTextKeys(op.data).get('HeaderDigest'), 'None')
})
it('rejects a wrong secret with Initiator Error / authentication failure', () => {
const negotiator = new LoginNegotiator(CHAP)
offerAuth(negotiator)
const { keys: challenge } = offerAlgorithm(negotiator)
const r3 = answerChallenge(negotiator, challenge, { secret: 'wrong' })
assert.equal(r3.statusClass, LoginStatusClass.INITIATOR_ERROR)
assert.equal(r3.statusDetail, LoginStatusDetail.AUTHENTICATION_FAILURE)
assert.equal(r3.flagsByte & LOGIN_FLAG_TRANSIT, 0)
assert.equal(negotiator.complete, false)
assert.equal(negotiator.failed, true)
})
it('rejects a wrong username even with the right secret', () => {
const negotiator = new LoginNegotiator(CHAP)
offerAuth(negotiator)
const { keys: challenge } = offerAlgorithm(negotiator)
const r3 = answerChallenge(negotiator, challenge, { user: 'mallory' })
assert.equal(r3.statusClass, LoginStatusClass.INITIATOR_ERROR)
assert.equal(negotiator.failed, true)
})
it('fails when the initiator offers only AuthMethod=None', () => {
const negotiator = new LoginNegotiator(CHAP)
const r1 = offerAuth(negotiator, 'None')
assert.equal(r1.statusClass, LoginStatusClass.INITIATOR_ERROR)
assert.equal(r1.statusDetail, LoginStatusDetail.AUTHENTICATION_FAILURE)
assert.equal(negotiator.failed, true)
})
it('fails when CHAP_A does not offer MD5', () => {
const negotiator = new LoginNegotiator(CHAP)
offerAuth(negotiator)
const { result } = offerAlgorithm(negotiator, '6,7')
assert.equal(result.statusClass, LoginStatusClass.INITIATOR_ERROR)
assert.equal(negotiator.failed, true)
})
it('does not transit if the initiator sets the T bit before authenticating', () => {
const negotiator = new LoginNegotiator(CHAP)
// Round 1 with the Transit bit set: must be ignored until CHAP completes.
const r1 = offerAuth(negotiator, 'CHAP,None', true)
assert.equal(r1.flagsByte & LOGIN_FLAG_TRANSIT, 0)
assert.equal(negotiator.complete, false)
})
it('fails if the initiator skips security and jumps to operational negotiation', () => {
const negotiator = new LoginNegotiator(CHAP)
const result = negotiator.process(
makeLoginPdu(
loginFlags({ transit: true, csg: LoginStage.OPERATIONAL_NEGOTIATION, nsg: LoginStage.FULL_FEATURE_PHASE }),
{ HeaderDigest: 'None' }
)
)
assert.equal(result.statusClass, LoginStatusClass.INITIATOR_ERROR)
assert.equal(result.flagsByte & LOGIN_FLAG_TRANSIT, 0)
assert.equal(negotiator.complete, false)
assert.equal(negotiator.failed, true)
})
})
describe('formatPortal', () => {
it('keeps plain IPv4 addresses', () => {
assert.equal(formatPortal('10.0.0.1', 3260), '10.0.0.1:3260,1')
})
it('unwraps IPv4-mapped IPv6 (dual-stack socket artifact)', () => {
assert.equal(formatPortal('::ffff:192.168.1.8', 3260), '192.168.1.8:3260,1')
})
it('brackets real IPv6 literals', () => {
assert.equal(formatPortal('2a01:e0a:e6a:9500::1', 3260), '[2a01:e0a:e6a:9500::1]:3260,1')
})
})
describe('buildSendTargetsResponse', () => {
it('advertises the target name and address', () => {
const keys = parseTextKeys(buildSendTargetsResponse('iqn.2024-01.tech.vates:lun0', '10.0.0.1', 3260))
assert.equal(keys.get('TargetName'), 'iqn.2024-01.tech.vates:lun0')
assert.equal(keys.get('TargetAddress'), '10.0.0.1:3260,1')
})
it('advertises a clean IPv4 portal for an IPv4-mapped local address', () => {
const keys = parseTextKeys(buildSendTargetsResponse('iqn.x:y', '::ffff:192.168.1.8', 3260))
assert.equal(keys.get('TargetAddress'), '192.168.1.8:3260,1')
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,174 +0,0 @@
import type { Socket } from 'node:net'
import type { Readable } from 'node:stream'
import { readChunk, readChunkStrict } from '@vates/read-chunk'
import { pTimeout } from 'promise-toolbox'
import { createLogger, type Logger } from '@xen-orchestra/log'
import { BHS_LENGTH, opcodeName, OPCODE_IMMEDIATE, OPCODE_MASK, FLAG_FINAL } from './constants.mjs'
const EMPTY = Buffer.alloc(0)
// Framing-level wire trace, shared by target and initiator. Enable with the
// `vates:iscsi:pdu` debug namespace to see every PDU that crosses the socket —
// the first thing to look at when a new initiator/target combination misbehaves.
const log: Logger = createLogger('vates:iscsi:pdu')
/** Round `n` up to the next multiple of 4 (iSCSI segments are 4-byte padded). */
export function roundUp4(n: number): number {
return (n + 3) & ~3
}
/**
* A fully-received inbound PDU: the 48-byte Basic Header Segment, any Additional
* Header Segment, and the (unpadded) data segment. Field accessors read the BHS
* at the offsets defined by RFC 7143; callers pick the ones relevant to the
* opcode they are handling.
*/
export class IncomingPdu {
constructor(
readonly bhs: Buffer,
readonly ahs: Buffer,
readonly data: Buffer
) {}
/** 6-bit opcode (immediate bit masked off). */
get opcode(): number {
return this.bhs[0] & OPCODE_MASK
}
/** Whether the immediate bit is set (initiator requests immediate processing). */
get immediate(): boolean {
return (this.bhs[0] & OPCODE_IMMEDIATE) !== 0
}
/** Raw opcode-specific flags byte (byte 1). */
get flags(): number {
return this.bhs[1]
}
get finalBit(): boolean {
return (this.bhs[1] & FLAG_FINAL) !== 0
}
readU8(offset: number): number {
return this.bhs.readUInt8(offset)
}
readU16(offset: number): number {
return this.bhs.readUInt16BE(offset)
}
readU32(offset: number): number {
return this.bhs.readUInt32BE(offset)
}
/** Initiator Task Tag (bytes 16-19), present in every initiator PDU we handle. */
get itt(): number {
return this.bhs.readUInt32BE(16)
}
/** CmdSN (bytes 24-27) — meaningful on command-carrying PDUs. */
get cmdSN(): number {
return this.bhs.readUInt32BE(24)
}
/** ExpStatSN (bytes 28-31). */
get expStatSN(): number {
return this.bhs.readUInt32BE(28)
}
}
/**
* Read exactly one PDU from `stream`, reassembling across TCP boundaries.
* Resolves to `null` on a clean EOF between PDUs; throws on a truncated PDU
* or read error. No header/data digest bytes: digests are pinned off.
*
* No per-read timeout, intentionally — a target may sit idle between commands
* indefinitely. Liveness is a transport concern (socket timeout / NOP
* keepalives), handled elsewhere.
*
* A PDU announcing a data segment longer than `maxDataSegmentLength` is
* rejected before its payload is read, so a peer cannot make us buffer up to
* the 16 MiB the 24-bit length field allows.
*/
export async function readPdu(stream: Readable, maxDataSegmentLength = Infinity): Promise<IncomingPdu | null> {
const bhs = await readChunk(stream, BHS_LENGTH)
if (bhs === null) {
return null // clean EOF between PDUs
}
if (bhs.length !== BHS_LENGTH) {
throw new Error(`truncated iSCSI BHS (got ${bhs.length} of ${BHS_LENGTH} bytes)`)
}
const totalAhsLength = bhs.readUInt8(4)
const dataSegmentLength = bhs.readUIntBE(5, 3)
if (dataSegmentLength > maxDataSegmentLength) {
throw new Error(`iSCSI data segment too long (${dataSegmentLength} > ${maxDataSegmentLength} bytes)`)
}
const ahsBytes = totalAhsLength * 4
const dataBytesPadded = roundUp4(dataSegmentLength)
let ahs = EMPTY
let data = EMPTY
if (ahsBytes + dataBytesPadded > 0) {
const rest = await readChunkStrict(stream, ahsBytes + dataBytesPadded)
if (ahsBytes > 0) {
ahs = rest.subarray(0, ahsBytes)
}
data = rest.subarray(ahsBytes, ahsBytes + dataSegmentLength) // strip 4-byte padding
}
const pdu = new IncomingPdu(bhs, ahs, data)
log.debug('rx', {
opcode: opcodeName(bhs[0]),
itt: pdu.itt,
flags: `0x${bhs[1].toString(16).padStart(2, '0')}`,
ahs: ahsBytes,
data: dataSegmentLength,
})
return pdu
}
/** Allocate a zeroed 48-byte BHS with byte 0 set to `opcode`. */
export function allocBhs(opcode: number): Buffer {
const bhs = Buffer.alloc(BHS_LENGTH)
bhs[0] = opcode
return bhs
}
/** Write the 24-bit DataSegmentLength field (bytes 5-7) of a BHS. */
export function setDataSegmentLength(bhs: Buffer, length: number): void {
bhs.writeUIntBE(length, 5, 3)
}
/**
* Assemble a complete PDU buffer from a 48-byte BHS and an optional data
* segment, setting DataSegmentLength and applying 4-byte padding.
*/
export function assemblePdu(bhs: Buffer, data?: Buffer): Buffer {
const length = data?.length ?? 0
setDataSegmentLength(bhs, length)
if (length === 0) {
return bhs
}
const padding = roundUp4(length) - length
return padding === 0 ? Buffer.concat([bhs, data!]) : Buffer.concat([bhs, data!, Buffer.alloc(padding)])
}
/**
* Write a PDU to the socket, resolving once the kernel has accepted it (honoring
* back-pressure via the write callback). Rejects if the write errors or does not
* drain within `timeoutMs` (0 disables the timeout).
*/
export function writePdu(socket: Socket, buffer: Buffer, timeoutMs: number): Promise<void> {
log.debug('tx', {
opcode: opcodeName(buffer[0]),
itt: buffer.readUInt32BE(16),
flags: `0x${buffer[1].toString(16).padStart(2, '0')}`,
length: buffer.length,
})
const promise = new Promise<void>((resolve, reject) => {
socket.write(buffer, error => (error ? reject(error) : resolve()))
})
return timeoutMs > 0 ? pTimeout.call(promise, timeoutMs) : promise
}

View File

@@ -1,118 +0,0 @@
import assert from 'node:assert/strict'
import { PassThrough } from 'node:stream'
import { describe, it } from 'node:test'
import { InitiatorOpcode } from './constants.mjs'
import { allocBhs, assemblePdu, IncomingPdu, readPdu, roundUp4 } from './pdu.mjs'
function buildScsiCommandPdu(itt: number, cmdSN: number, cdb: Buffer, data?: Buffer): Buffer {
const bhs = allocBhs(InitiatorOpcode.SCSI_COMMAND)
bhs[1] = 0x80 // Final
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(0, 20) // EDTL
bhs.writeUInt32BE(cmdSN, 24)
cdb.copy(bhs, 32)
return assemblePdu(bhs, data)
}
async function readOne(buffer: Buffer): Promise<IncomingPdu> {
const stream = new PassThrough()
stream.end(buffer)
const pdu = await readPdu(stream)
assert.ok(pdu !== null)
return pdu
}
describe('roundUp4', () => {
it('rounds up to the next multiple of 4', () => {
assert.equal(roundUp4(0), 0)
assert.equal(roundUp4(1), 4)
assert.equal(roundUp4(4), 4)
assert.equal(roundUp4(5), 8)
assert.equal(roundUp4(15), 16)
})
})
describe('assemblePdu / readPdu round-trip', () => {
it('preserves opcode and header fields', async () => {
const cdb = Buffer.alloc(16)
cdb[0] = 0x28 // READ(10)
const pdu = await readOne(buildScsiCommandPdu(0xdeadbeef, 42, cdb))
assert.equal(pdu.opcode, InitiatorOpcode.SCSI_COMMAND)
assert.equal(pdu.immediate, false)
assert.equal(pdu.finalBit, true)
assert.equal(pdu.itt, 0xdeadbeef)
assert.equal(pdu.cmdSN, 42)
assert.equal(pdu.bhs.subarray(32, 48)[0], 0x28)
})
it('carries a data segment and strips its 4-byte padding', async () => {
const data = Buffer.from([1, 2, 3, 4, 5]) // 5 bytes -> padded to 8 on the wire
const wire = buildScsiCommandPdu(1, 1, Buffer.alloc(16), data)
assert.equal(wire.length, 48 + 8, 'data segment is padded to a 4-byte boundary')
const pdu = await readOne(wire)
assert.deepEqual(pdu.data, data, 'padding is stripped from the parsed data')
})
})
describe('readPdu framing', () => {
it('rejects a data segment longer than the limit before reading it', async () => {
const bhs = allocBhs(InitiatorOpcode.SCSI_COMMAND)
bhs.writeUIntBE(16 * 1024 * 1024 - 1, 5, 3) // announced data segment length
const stream = new PassThrough()
stream.write(bhs) // the announced payload is never sent
await assert.rejects(readPdu(stream, 8192), /data segment too long/)
})
it('accepts a data segment of exactly the limit', async () => {
const data = Buffer.alloc(8192, 0x5a)
const pdu = await (async () => {
const stream = new PassThrough()
stream.end(buildScsiCommandPdu(1, 1, Buffer.alloc(16), data))
return readPdu(stream, 8192)
})()
assert.deepEqual(pdu?.data, data)
})
it('reassembles a PDU split across multiple TCP chunks', async () => {
const data = Buffer.from('hello world!!') // 13 bytes -> padded to 16
const wire = buildScsiCommandPdu(7, 7, Buffer.alloc(16), data)
const stream = new PassThrough()
const pduPromise = readPdu(stream)
// Feed the bytes in awkward fragments.
stream.write(wire.subarray(0, 10))
stream.write(wire.subarray(10, 48))
stream.write(wire.subarray(48, 55))
stream.end(wire.subarray(55))
const pdu = await pduPromise
assert.ok(pdu !== null)
assert.equal(pdu.itt, 7)
assert.deepEqual(pdu.data, data)
})
it('reads two PDUs coalesced into one chunk', async () => {
const first = buildScsiCommandPdu(1, 1, Buffer.alloc(16), Buffer.from([0xaa]))
const second = buildScsiCommandPdu(2, 2, Buffer.alloc(16), Buffer.from([0xbb, 0xcc]))
const stream = new PassThrough()
stream.end(Buffer.concat([first, second]))
const a = await readPdu(stream)
const b = await readPdu(stream)
assert.equal(a?.itt, 1)
assert.deepEqual(a?.data, Buffer.from([0xaa]))
assert.equal(b?.itt, 2)
assert.deepEqual(b?.data, Buffer.from([0xbb, 0xcc]))
})
it('returns null on a clean end-of-stream', async () => {
const stream = new PassThrough()
stream.end()
assert.equal(await readPdu(stream), null)
})
it('throws on a truncated BHS', async () => {
const stream = new PassThrough()
stream.end(Buffer.alloc(20)) // fewer than 48 bytes, then EOF
await assert.rejects(readPdu(stream), /truncated/)
})
})

View File

@@ -1,293 +0,0 @@
import { createLogger, type Logger } from '@xen-orchestra/log'
import { Asc, ScsiStatus, SenseKey } from './constants.mjs'
import { decodeCdb, type CommandContext, type SenseInfo } from './types.mjs'
// SCSI command trace. Enable `vates:iscsi:scsi` to see every decoded CDB the
// target services — useful when an initiator issues a command we mishandle.
const log: Logger = createLogger('vates:iscsi:scsi')
/** SCSI INQUIRY identity strings advertised by the LUN. */
export interface ScsiIdentity {
/** T10 vendor identification (≤ 8 ASCII chars). */
vendor: string
/** Product identification (≤ 16 ASCII chars). */
product: string
/** Product revision level (≤ 4 ASCII chars). */
revision: string
/** Unit serial number; also seeds the VPD 0x83 device identifier. */
serial: string
}
// --- low-level encoders -----------------------------------------------------
function writeAscii(buffer: Buffer, offset: number, length: number, value: string): void {
buffer.fill(0x20, offset, offset + length) // space pad
buffer.write(value.slice(0, length), offset, 'ascii')
}
/** Standard INQUIRY data (36 bytes) for a direct-access block device. */
export function buildStandardInquiry(identity: ScsiIdentity): Buffer {
const buffer = Buffer.alloc(36)
buffer[0] = 0x00 // peripheral qualifier 0, device type 0x00 (direct-access block device)
buffer[1] = 0x00 // not removable
buffer[2] = 0x05 // version: SPC-3
buffer[3] = 0x02 // response data format 2
buffer[4] = 31 // additional length (total 36 - 5)
writeAscii(buffer, 8, 8, identity.vendor)
writeAscii(buffer, 16, 16, identity.product)
writeAscii(buffer, 32, 4, identity.revision)
return buffer
}
const SUPPORTED_VPD_PAGES = [0x00, 0x80, 0x83, 0xb0]
/** Default max length of a single READ or WRITE, in bytes. */
export const DEFAULT_MAX_TRANSFER_LENGTH = 8 * 1024 * 1024
/**
* Build an INQUIRY EVPD page, or `undefined` for an unsupported page code (the
* caller then returns CHECK CONDITION / INVALID FIELD IN CDB).
*/
export function buildVpd(
pageCode: number,
identity: ScsiIdentity,
maxTransferBlocks = DEFAULT_MAX_TRANSFER_LENGTH / 512
): Buffer | undefined {
switch (pageCode) {
case 0x00: {
// Supported VPD pages.
const buffer = Buffer.alloc(4 + SUPPORTED_VPD_PAGES.length)
buffer[1] = 0x00
buffer.writeUInt16BE(SUPPORTED_VPD_PAGES.length, 2)
SUPPORTED_VPD_PAGES.forEach((page, i) => {
buffer[4 + i] = page
})
return buffer
}
case 0x80: {
// Unit serial number.
const serial = Buffer.from(identity.serial, 'ascii')
const buffer = Buffer.alloc(4 + serial.length)
buffer[1] = 0x80
buffer.writeUInt16BE(serial.length, 2)
serial.copy(buffer, 4)
return buffer
}
case 0x83: {
// Device identification: one T10-vendor-ID-based designator (ASCII).
const designator = Buffer.alloc(8 + identity.serial.length)
writeAscii(designator, 0, 8, identity.vendor)
designator.write(identity.serial, 8, 'ascii')
const descriptor = Buffer.alloc(4 + designator.length)
descriptor[0] = 0x02 // code set: ASCII
descriptor[1] = 0x01 // association: logical unit, designator type: T10 vendor ID based
descriptor[3] = designator.length
designator.copy(descriptor, 4)
const buffer = Buffer.alloc(4 + descriptor.length)
buffer[1] = 0x83
buffer.writeUInt16BE(descriptor.length, 2)
descriptor.copy(buffer, 4)
return buffer
}
case 0xb0: {
// Block limits: initiators split their I/O to the max transfer length
// instead of seeing it refused.
const buffer = Buffer.alloc(64)
buffer[1] = 0xb0
buffer.writeUInt16BE(0x3c, 2)
buffer.writeUInt32BE(maxTransferBlocks, 8) // maximum transfer length
return buffer
}
default:
return undefined
}
}
/** READ CAPACITY(10) parameter data (8 bytes). */
export function buildReadCapacity10(blockCount: number, blockSize: number): Buffer {
const buffer = Buffer.alloc(8)
const maxLba = blockCount - 1
// If the capacity overflows 32 bits, return 0xffffffff to force the initiator
// to issue READ CAPACITY(16).
buffer.writeUInt32BE(maxLba > 0xffffffff ? 0xffffffff : maxLba, 0)
buffer.writeUInt32BE(blockSize, 4)
return buffer
}
/** READ CAPACITY(16) parameter data (32 bytes). */
export function buildReadCapacity16(blockCount: number, blockSize: number): Buffer {
const buffer = Buffer.alloc(32)
buffer.writeBigUInt64BE(BigInt(blockCount - 1), 0)
buffer.writeUInt32BE(blockSize, 8)
return buffer
}
/** REPORT LUNS parameter data advertising a single LUN 0 (16 bytes). */
export function buildReportLuns(): Buffer {
const buffer = Buffer.alloc(16)
buffer.writeUInt32BE(8, 0) // LUN list length: one 8-byte LUN entry
return buffer // bytes 8-15 are LUN 0 (all zero)
}
/** Fixed-format sense data (18 bytes). */
export function buildFixedSense({ key, asc, ascq }: SenseInfo): Buffer {
const buffer = Buffer.alloc(18)
buffer[0] = 0x70 // response code: current error, fixed format
buffer[2] = key
buffer[7] = 10 // additional sense length (18 - 8)
buffer[12] = asc
buffer[13] = ascq
return buffer
}
// WP bit of the device-specific parameter in the mode parameter header
const MODE_SENSE_WRITE_PROTECT = 0x80
/** Minimal MODE SENSE parameter data: a header with no block descriptor or pages. */
export function buildModeSense(long: boolean, writeProtected = false): Buffer {
const deviceSpecific = writeProtected ? MODE_SENSE_WRITE_PROTECT : 0
if (long) {
const buffer = Buffer.alloc(8)
buffer.writeUInt16BE(6, 0) // mode data length (8 - 2)
buffer[3] = deviceSpecific
return buffer
}
const buffer = Buffer.alloc(4)
buffer[0] = 3 // mode data length (4 - 1)
buffer[2] = deviceSpecific
return buffer
}
const ILLEGAL_REQUEST_INVALID_OPCODE: SenseInfo = {
key: SenseKey.ILLEGAL_REQUEST,
...Asc.INVALID_COMMAND_OPERATION_CODE,
}
const ILLEGAL_REQUEST_INVALID_FIELD: SenseInfo = {
key: SenseKey.ILLEGAL_REQUEST,
...Asc.INVALID_FIELD_IN_CDB,
}
const ILLEGAL_REQUEST_LBA_OUT_OF_RANGE: SenseInfo = {
key: SenseKey.ILLEGAL_REQUEST,
...Asc.LOGICAL_BLOCK_ADDRESS_OUT_OF_RANGE,
}
const MEDIUM_ERROR_NO_ADDITIONAL_SENSE: SenseInfo = { key: SenseKey.MEDIUM_ERROR, ...Asc.NO_ADDITIONAL_SENSE }
const DATA_PROTECT_WRITE_PROTECTED: SenseInfo = { key: SenseKey.DATA_PROTECT, ...Asc.WRITE_PROTECTED }
function checkCondition(ctx: CommandContext, itt: number, sense: SenseInfo): Promise<void> {
return ctx.sendScsiResponse(itt, ScsiStatus.CHECK_CONDITION, { sense: buildFixedSense(sense) })
}
/**
* Decode and execute one SCSI Command, driving completion through `ctx`.
*
* `cdb` is the 16-byte CDB from the SCSI Command PDU and `itt` its Initiator
* Task Tag. Read commands stream Data-In; WRITE solicits Data-Out via R2T;
* everything else returns status (with sense on error).
*
* A READ or WRITE longer than `maxTransferLength` bytes, advertised in the
* block limits VPD page, is refused with INVALID FIELD IN CDB: it also bounds
* the buffer a WRITE allocates.
*/
export async function handleScsiCommand(
cdb: Buffer,
itt: number,
ctx: CommandContext,
identity: ScsiIdentity,
maxTransferLength = DEFAULT_MAX_TRANSFER_LENGTH
): Promise<void> {
const { lun } = ctx
const blockSize = lun.getBlockSize()
const blockCount = lun.getSize() / blockSize
const maxTransferBlocks = Math.floor(maxTransferLength / blockSize)
const request = decodeCdb(cdb)
log.debug('scsi command', { itt, ...request })
switch (request.kind) {
case 'inquiry': {
if (!request.evpd) {
if (request.pageCode !== 0) {
// EVPD clear but a page code was given: invalid per SPC.
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
}
return ctx.sendReadData(itt, buildStandardInquiry(identity), request.allocationLength)
}
const page = buildVpd(request.pageCode, identity, maxTransferBlocks)
if (page === undefined) {
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
}
return ctx.sendReadData(itt, page, request.allocationLength)
}
case 'reportLuns':
return ctx.sendReadData(itt, buildReportLuns(), request.allocationLength)
case 'readCapacity10':
return ctx.sendReadData(itt, buildReadCapacity10(blockCount, blockSize), 8)
case 'readCapacity16':
return ctx.sendReadData(itt, buildReadCapacity16(blockCount, blockSize), request.allocationLength)
case 'read': {
if (request.lba + request.blocks > blockCount) {
return checkCondition(ctx, itt, ILLEGAL_REQUEST_LBA_OUT_OF_RANGE)
}
if (request.blocks > maxTransferBlocks) {
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
}
const byteLength = request.blocks * blockSize
if (byteLength === 0) {
return ctx.sendReadData(itt, Buffer.alloc(0), 0)
}
let data: Buffer
try {
data = await lun.read(request.lba * blockSize, byteLength)
} catch (error) {
// A single bad block (backend hiccup, corrupt source) must fail only
// this command, not the whole connection — mirrors how a failed
// lun.write() is handled in connection.mts's #handleDataOut.
log.warn('LUN read failed', error instanceof Error ? error : new Error(String(error)))
return checkCondition(ctx, itt, MEDIUM_ERROR_NO_ADDITIONAL_SENSE)
}
return ctx.sendReadData(itt, data, byteLength)
}
case 'write': {
if (lun.readOnly === true) {
return checkCondition(ctx, itt, DATA_PROTECT_WRITE_PROTECTED)
}
if (request.lba + request.blocks > blockCount) {
return checkCondition(ctx, itt, ILLEGAL_REQUEST_LBA_OUT_OF_RANGE)
}
if (request.blocks > maxTransferBlocks) {
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
}
const byteLength = request.blocks * blockSize
if (byteLength === 0) {
return ctx.sendScsiResponse(itt, ScsiStatus.GOOD)
}
// Hand off to the connection: it solicits the data via R2T and sends the
// SCSI Response once the (possibly interleaved) Data-Out PDUs complete.
return ctx.beginWrite(itt, request.lba * blockSize, byteLength)
}
case 'testUnitReady':
return ctx.sendScsiResponse(itt, ScsiStatus.GOOD)
case 'requestSense': {
// No deferred error model in v1: always report NO SENSE.
const sense = buildFixedSense({ key: SenseKey.NO_SENSE, ...Asc.NO_ADDITIONAL_SENSE })
return ctx.sendReadData(itt, sense, request.allocationLength)
}
case 'modeSense':
return ctx.sendReadData(itt, buildModeSense(request.long, lun.readOnly === true), request.allocationLength)
case 'syncCache':
await lun.flush()
return ctx.sendScsiResponse(itt, ScsiStatus.GOOD)
case 'unsupported':
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_OPCODE)
}
}

View File

@@ -1,302 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import type { BlockDevice } from './backend.mjs'
import { ScsiStatus, SenseKey } from './constants.mjs'
import {
buildReadCapacity10,
buildReadCapacity16,
buildReportLuns,
buildStandardInquiry,
buildVpd,
handleScsiCommand,
type ScsiIdentity,
} from './scsi.mjs'
import { decodeCdb, type CommandContext, type ScsiResponseOptions } from './types.mjs'
const IDENTITY: ScsiIdentity = { vendor: 'VATES', product: 'ISCSI LUN', revision: '0001', serial: 'unit-serial-1' }
// --- a trivial in-memory LUN ------------------------------------------------
class MemoryBlockDevice implements BlockDevice {
readonly #buffer: Buffer
readonly #blockSize: number
constructor(sizeBytes: number, blockSize = 512) {
this.#buffer = Buffer.alloc(sizeBytes)
this.#blockSize = blockSize
}
get buffer(): Buffer {
return this.#buffer
}
getSize(): number {
return this.#buffer.length
}
getBlockSize(): number {
return this.#blockSize
}
async read(offset: number, length: number): Promise<Buffer> {
return Buffer.from(this.#buffer.subarray(offset, offset + length))
}
async write(offset: number, data: Buffer): Promise<void> {
data.copy(this.#buffer, offset)
}
async flush(): Promise<void> {}
async close(): Promise<void> {}
}
class FakeContext implements CommandContext {
readonly reads: Array<{ data: Buffer; allocationLength: number }> = []
readonly responses: Array<{ status: number; options?: ScsiResponseOptions }> = []
readonly writes: Array<{ itt: number; lunOffset: number; totalLength: number }> = []
constructor(readonly lun: BlockDevice) {}
async sendReadData(_itt: number, data: Buffer, allocationLength: number): Promise<void> {
this.reads.push({ data, allocationLength })
}
async beginWrite(itt: number, lunOffset: number, totalLength: number): Promise<void> {
this.writes.push({ itt, lunOffset, totalLength })
}
async sendScsiResponse(_itt: number, status: number, options?: ScsiResponseOptions): Promise<void> {
this.responses.push({ status, options })
}
}
function cdb(...bytes: number[]): Buffer {
const buffer = Buffer.alloc(16)
Buffer.from(bytes).copy(buffer)
return buffer
}
describe('decodeCdb', () => {
it('decodes READ(10) LBA and transfer length', () => {
const buffer = Buffer.alloc(16)
buffer[0] = 0x28
buffer.writeUInt32BE(0x01020304, 2)
buffer.writeUInt16BE(8, 7)
assert.deepEqual(decodeCdb(buffer), { kind: 'read', lba: 0x01020304, blocks: 8 })
})
it('decodes WRITE(16) LBA and transfer length', () => {
const buffer = Buffer.alloc(16)
buffer[0] = 0x8a
buffer.writeBigUInt64BE(123456789n, 2)
buffer.writeUInt32BE(16, 10)
assert.deepEqual(decodeCdb(buffer), { kind: 'write', lba: 123456789, blocks: 16 })
})
it('decodes READ CAPACITY(16) only for the right service action', () => {
assert.equal(decodeCdb(cdb(0x9e, 0x10)).kind, 'readCapacity16')
assert.equal(decodeCdb(cdb(0x9e, 0x12)).kind, 'unsupported')
})
it('clamps an out-of-range 64-bit LBA instead of throwing', () => {
// Past 2^53 an LBA no longer fits a JS number — and is past any capacity we
// can serve, so it must come back as an unservable LBA the range check
// rejects, not as an error that would tear the connection down.
const buffer = Buffer.alloc(16)
buffer[0] = 0x88
buffer.writeBigUInt64BE(0xffffffffffffffffn, 2)
buffer.writeUInt32BE(1, 10)
assert.deepEqual(decodeCdb(buffer), { kind: 'read', lba: Number.MAX_SAFE_INTEGER, blocks: 1 })
})
it('maps unknown opcodes to unsupported', () => {
assert.deepEqual(decodeCdb(cdb(0xff)), { kind: 'unsupported', opcode: 0xff })
})
})
describe('response encoders', () => {
it('builds 36-byte standard INQUIRY data for a block device', () => {
const data = buildStandardInquiry(IDENTITY)
assert.equal(data.length, 36)
assert.equal(data[0], 0x00) // direct-access block device
assert.equal(data[4], 31) // additional length
assert.equal(data.subarray(8, 16).toString('ascii').trim(), 'VATES')
assert.equal(data.subarray(16, 32).toString('ascii').trim(), 'ISCSI LUN')
})
it('builds READ CAPACITY(10) as (maxLba, blockSize)', () => {
const data = buildReadCapacity10(2048, 512)
assert.equal(data.readUInt32BE(0), 2047)
assert.equal(data.readUInt32BE(4), 512)
})
it('clamps READ CAPACITY(10) max LBA to force RC16 on large disks', () => {
const data = buildReadCapacity10(0x1_0000_0001, 512)
assert.equal(data.readUInt32BE(0), 0xffffffff)
})
it('builds READ CAPACITY(16) with a 64-bit max LBA', () => {
const data = buildReadCapacity16(0x1_0000_0001, 512)
assert.equal(data.readBigUInt64BE(0), 0x1_0000_0000n)
assert.equal(data.readUInt32BE(8), 512)
})
it('reports a single LUN 0', () => {
const data = buildReportLuns()
assert.equal(data.readUInt32BE(0), 8) // one 8-byte LUN entry
assert.deepEqual(data.subarray(8, 16), Buffer.alloc(8))
})
it('advertises supported VPD pages and rejects unknown ones', () => {
const page00 = buildVpd(0x00, IDENTITY)
assert.ok(page00 !== undefined)
assert.deepEqual([...page00.subarray(4)], [0x00, 0x80, 0x83, 0xb0])
assert.equal(buildVpd(0x55, IDENTITY), undefined)
})
it('advertises the max transfer length in the block limits page', () => {
const page = buildVpd(0xb0, IDENTITY, 2048)
assert.ok(page !== undefined)
assert.equal(page[1], 0xb0)
assert.equal(page.readUInt16BE(2), 0x3c)
assert.equal(page.readUInt32BE(8), 2048)
})
})
describe('handleScsiCommand', () => {
it('INQUIRY returns standard data trimmed to the allocation length', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(1024 * 1024))
await handleScsiCommand(cdb(0x12, 0x00, 0x00, 0x00, 36), 1, ctx, IDENTITY)
assert.equal(ctx.reads.length, 1)
assert.equal(ctx.reads[0].data.length, 36)
assert.equal(ctx.reads[0].allocationLength, 36)
})
it('READ(10) returns the backing bytes for the requested blocks', async () => {
const lun = new MemoryBlockDevice(4096)
lun.buffer.fill(0xab, 512, 1024) // block 1
const ctx = new FakeContext(lun)
const buffer = Buffer.alloc(16)
buffer[0] = 0x28
buffer.writeUInt32BE(1, 2) // LBA 1
buffer.writeUInt16BE(1, 7) // 1 block
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
assert.equal(ctx.reads.length, 1)
assert.deepEqual(ctx.reads[0].data, Buffer.alloc(512, 0xab))
})
it('WRITE(10) begins a solicited write at the right LUN offset and length', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(4096))
const buffer = Buffer.alloc(16)
buffer[0] = 0x2a
buffer.writeUInt32BE(2, 2) // LBA 2
buffer.writeUInt16BE(3, 7) // 3 blocks
await handleScsiCommand(buffer, 7, ctx, IDENTITY)
// The data transfer + GOOD response are completed later by the connection.
assert.deepEqual(ctx.writes, [{ itt: 7, lunOffset: 2 * 512, totalLength: 3 * 512 }])
assert.equal(ctx.responses.length, 0)
})
it('WRITE(10) of zero blocks returns GOOD immediately with no transfer', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(4096))
const buffer = Buffer.alloc(16)
buffer[0] = 0x2a // LBA 0, 0 blocks
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
assert.equal(ctx.writes.length, 0)
assert.deepEqual(ctx.responses, [{ status: ScsiStatus.GOOD, options: undefined }])
})
it('rejects an out-of-range READ with CHECK CONDITION', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(4096)) // 8 blocks
const buffer = Buffer.alloc(16)
buffer[0] = 0x28
buffer.writeUInt32BE(7, 2) // LBA 7
buffer.writeUInt16BE(4, 7) // 4 blocks -> past end
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
assert.equal(ctx.responses.length, 1)
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
assert.equal(ctx.responses[0].options?.sense?.[2], SenseKey.ILLEGAL_REQUEST)
})
it('reports a failed lun.read() as CHECK CONDITION / MEDIUM ERROR, not an uncaught throw', async () => {
const lun: BlockDevice = {
getSize: () => 4096,
getBlockSize: () => 512,
read: async () => {
throw new Error('backend read failed')
},
write: async () => {},
flush: async () => {},
close: async () => {},
}
const ctx = new FakeContext(lun)
const buffer = Buffer.alloc(16)
buffer[0] = 0x28
buffer.writeUInt32BE(1, 2) // LBA 1
buffer.writeUInt16BE(1, 7) // 1 block
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
assert.equal(ctx.responses.length, 1)
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
assert.equal(ctx.responses[0].options?.sense?.[2], SenseKey.MEDIUM_ERROR)
assert.equal(ctx.reads.length, 0) // sendReadData was never reached
})
it('returns CHECK CONDITION for an unsupported opcode', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(4096))
await handleScsiCommand(cdb(0xff), 1, ctx, IDENTITY)
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
assert.equal(ctx.responses[0].options?.sense?.[12], 0x20) // INVALID COMMAND OPERATION CODE
})
for (const [name, opcode] of [
['READ(10)', 0x28],
['WRITE(10)', 0x2a],
] as const) {
it(`refuses a ${name} longer than the max transfer length, without reading or soliciting data`, async () => {
let reads = 0
const lun = new MemoryBlockDevice(8192)
const read = lun.read.bind(lun)
lun.read = (offset, length) => {
reads++
return read(offset, length)
}
const ctx = new FakeContext(lun)
const buffer = Buffer.alloc(16)
buffer[0] = opcode
buffer.writeUInt16BE(3, 7) // 3 blocks, the limit is 2
await handleScsiCommand(buffer, 1, ctx, IDENTITY, 1024)
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
assert.equal(ctx.responses[0].options?.sense?.[2], SenseKey.ILLEGAL_REQUEST)
assert.equal(ctx.responses[0].options?.sense?.[12], 0x24) // INVALID FIELD IN CDB
assert.equal(reads, 0)
assert.equal(ctx.writes.length, 0)
})
}
describe('read-only LUN', () => {
class ReadOnlyBlockDevice extends MemoryBlockDevice {
readonly readOnly = true
}
it('refuses WRITE(10) with DATA PROTECT, without soliciting its data', async () => {
const ctx = new FakeContext(new ReadOnlyBlockDevice(4096))
const buffer = Buffer.alloc(16)
buffer[0] = 0x2a
buffer.writeUInt16BE(1, 7)
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
assert.equal(ctx.writes.length, 0)
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
assert.equal(ctx.responses[0].options?.sense?.[2], SenseKey.DATA_PROTECT)
assert.equal(ctx.responses[0].options?.sense?.[12], 0x27) // WRITE PROTECTED
})
it('reports write protection in MODE SENSE(6) and MODE SENSE(10)', async () => {
const ctx = new FakeContext(new ReadOnlyBlockDevice(4096))
await handleScsiCommand(cdb(0x1a, 0x00, 0x3f, 0x00, 255), 1, ctx, IDENTITY)
await handleScsiCommand(cdb(0x5a, 0x00, 0x3f, 0, 0, 0, 0, 0x01, 0x00), 2, ctx, IDENTITY)
assert.equal(ctx.reads[0].data[2] & 0x80, 0x80)
assert.equal(ctx.reads[1].data[3] & 0x80, 0x80)
})
it('does not report a writable LUN as write-protected', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(4096))
await handleScsiCommand(cdb(0x1a, 0x00, 0x3f, 0x00, 255), 1, ctx, IDENTITY)
assert.equal(ctx.reads[0].data[2] & 0x80, 0)
})
})
})

View File

@@ -1,33 +0,0 @@
/**
* A minimal counting semaphore: bounds how many `run()`-wrapped tasks execute
* at once, queueing the rest in FIFO order until a slot frees up.
*/
export class Semaphore {
#available: number
readonly #waiters: Array<() => void> = []
constructor(concurrency: number) {
if (!Number.isInteger(concurrency) || concurrency <= 0) {
throw new Error(`concurrency must be a positive integer, got ${concurrency}`)
}
this.#available = concurrency
}
async run<T>(fn: () => Promise<T>): Promise<T> {
if (this.#available > 0) {
this.#available--
} else {
await new Promise<void>(resolve => this.#waiters.push(resolve))
}
try {
return await fn()
} finally {
const next = this.#waiters.shift()
if (next !== undefined) {
next()
} else {
this.#available++
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More