diff --git a/package-lock.json b/package-lock.json index 1a074732..7492e746 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "axios": "^0.30.0", "cafe-utility": "^32.2.0", "debug": "^4.4.1", + "hash-wasm": "^4.12.0", "isomorphic-ws": "^4.0.1", "semver": "^7.3.5", "ws": "^8.7.0" @@ -6498,6 +6499,12 @@ "node": ">=8" } }, + "node_modules/hash-wasm": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz", + "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==", + "license": "MIT" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", diff --git a/package.json b/package.json index 9328901f..ac291c5f 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "axios": "^0.30.0", "cafe-utility": "^32.2.0", "debug": "^4.4.1", + "hash-wasm": "^4.12.0", "isomorphic-ws": "^4.0.1", "semver": "^7.3.5", "ws": "^8.7.0" diff --git a/src/bee.ts b/src/bee.ts index bdb9841a..12eb1e5a 100644 --- a/src/bee.ts +++ b/src/bee.ts @@ -7,6 +7,7 @@ import { areAllSequentialFeedsUpdateRetrievable } from './feed/retrievable' import * as bytes from './modules/bytes' import * as bzz from './modules/bzz' import * as chunk from './modules/chunk' +import * as downloadStream from './modules/download-stream' import * as balance from './modules/debug/balance' import * as chequebook from './modules/debug/chequebook' import * as connectivity from './modules/debug/connectivity' @@ -310,6 +311,55 @@ export class Bee { return bytes.downloadReadable(this.getRequestOptionsForCall(requestOptions), new ResourceLocator(resource), options) } + /** + * Downloads raw data as a streaming ReadableStream by fetching chunks in parallel. + * + * This method is optimized for downloading large files as it: + * - Detects encryption automatically (64-byte encrypted references) + * - Fetches chunks in parallel with configurable concurrency + * - Streams data without loading entire file into memory + * - Supports progress callbacks + * + * Use this for downloading data uploaded with {@link uploadData} when you need: + * - Progress tracking + * - Better performance for large files + * - Lower memory usage + * + * @param resource Swarm reference (32 bytes plain, 64 bytes encrypted), Swarm CID, or ENS domain + * @param options Options including onDownloadProgress callback and concurrency + * @param requestOptions Options for making requests, such as timeouts, custom HTTP agents, headers, etc. + * + * @returns ReadableStream of file data + * + * @example + * ```typescript + * const stream = await bee.downloadDataStreaming(reference, { + * onDownloadProgress: ({ total, processed }) => { + * console.log(`Downloaded ${processed}/${total} chunks`) + * }, + * concurrency: 32 + * }) + * ``` + * + * @see [Bee docs - Upload and download](https://docs.ethswarm.org/docs/develop/access-the-swarm/upload-and-download) + */ + async downloadDataStreaming( + resource: Reference | Uint8Array | string, + options?: downloadStream.DownloadStreamOptions, + requestOptions?: BeeRequestOptions, + ): Promise> { + if (options) { + options = { ...prepareDownloadOptions(options), ...options } + } + + return downloadStream.downloadDataStreaming( + this, + resource, + options, + this.getRequestOptionsForCall(requestOptions), + ) + } + /** * Uploads a chunk to the network. * @@ -553,6 +603,67 @@ export class Bee { return bzz.downloadFileReadable(this.getRequestOptionsForCall(requestOptions), reference, path, options) } + /** + * Downloads a file from a manifest as a streaming ReadableStream by fetching chunks in parallel. + * + * This method is optimized for downloading files from collections/manifests: + * - Downloads and parses the manifest + * - Looks up the file at the specified path + * - Detects encryption automatically (64-byte encrypted references) + * - Fetches chunks in parallel with configurable concurrency + * - Returns file metadata (content-type, filename) along with stream + * - Supports progress callbacks + * + * Use this for downloading files from collections when you need: + * - Progress tracking + * - Better performance for large files + * - Lower memory usage + * + * @param resource Swarm manifest reference (32 bytes plain, 64 bytes encrypted), Swarm CID, or ENS domain + * @param path Path within the manifest (e.g., 'index.html', 'images/logo.png') + * @param options Options including onDownloadProgress callback and concurrency + * @param requestOptions Options for making requests, such as timeouts, custom HTTP agents, headers, etc. + * + * @returns FileData with ReadableStream and metadata + * + * @example + * ```typescript + * const file = await bee.downloadFileStreaming(manifestRef, 'document.pdf', { + * onDownloadProgress: ({ total, processed }) => { + * console.log(`Progress: ${(processed/total*100).toFixed(1)}%`) + * }, + * concurrency: 32 + * }) + * + * console.log('Content-Type:', file.contentType) + * console.log('Filename:', file.name) + * + * // Use the stream + * const reader = file.data.getReader() + * ``` + * + * @see [Bee docs - Upload and download](https://docs.ethswarm.org/docs/develop/access-the-swarm/upload-and-download) + * @see [Bee API reference - `GET /bzz`](https://docs.ethswarm.org/api/#tag/BZZ/paths/~1bzz~1%7Breference%7D~1%7Bpath%7D/get) + */ + async downloadFileStreaming( + resource: Reference | Uint8Array | string, + path = '', + options?: downloadStream.DownloadStreamOptions, + requestOptions?: BeeRequestOptions, + ): Promise>> { + if (options) { + options = { ...prepareDownloadOptions(options), ...options } + } + + return downloadStream.downloadFileStreaming( + this, + resource, + path, + options, + this.getRequestOptionsForCall(requestOptions), + ) + } + /** * Upload collection of files to a Bee node * diff --git a/src/chunk/bmt.ts b/src/chunk/bmt.ts index 15eae20d..9b617aab 100644 --- a/src/chunk/bmt.ts +++ b/src/chunk/bmt.ts @@ -38,5 +38,22 @@ function calculateBmtRootHash(payload: Uint8Array): Uint8Array { const input = new Uint8Array(MAX_CHUNK_PAYLOAD_SIZE) input.set(payload) - return Binary.log2Reduce(Binary.partition(input, SEGMENT_SIZE), (a, b) => Binary.keccak256(Binary.concatBytes(a, b))) + // Build BMT by hashing pairs of segments level by level + let currentLevel = Binary.partition(input, SEGMENT_SIZE) + + while (currentLevel.length > 1) { + const nextLevel: Uint8Array[] = [] + + for (let i = 0; i < currentLevel.length; i += 2) { + const left = currentLevel[i] + const right = currentLevel[i + 1] + const combined = Binary.concatBytes(left, right) + const hash = Binary.keccak256(combined) + nextLevel.push(hash) + } + + currentLevel = nextLevel + } + + return currentLevel[0] } diff --git a/src/chunk/encrypted-cac.ts b/src/chunk/encrypted-cac.ts new file mode 100644 index 00000000..d096d0c0 --- /dev/null +++ b/src/chunk/encrypted-cac.ts @@ -0,0 +1,117 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +import { Binary } from 'cafe-utility' +import { Bytes } from '../utils/bytes' +import { Reference, Span } from '../utils/typed-bytes' +import { calculateChunkAddress } from './bmt' +import { MIN_PAYLOAD_SIZE, MAX_PAYLOAD_SIZE } from './cac' +import { newChunkEncrypter, decryptChunkData, KEY_LENGTH, type Key } from './encryption' + +const ENCODER = new TextEncoder() + +/** + * Encrypted chunk interface + * + * The reference includes both the chunk address and the encryption key (64 bytes total) + */ +export interface EncryptedChunk { + readonly data: Uint8Array // encrypted span + encrypted data + readonly encryptionKey: Key // 32 bytes + span: Span // original (unencrypted) span + payload: Bytes // encrypted payload + address: Reference // BMT hash of encrypted data + reference: Reference // 64 bytes: address (32) + encryption key (32) +} + +/** + * Creates an encrypted content addressed chunk + * + * Process: + * 1. Create chunk with span + payload + * 2. Encrypt the chunk data + * 3. Calculate BMT hash on encrypted data + * 4. Return reference = address + encryption key (64 bytes) + * + * @param payloadBytes the data to be stored in the chunk + * @param encryptionKey optional encryption key (if not provided, a random key will be generated) + */ +export function makeEncryptedContentAddressedChunk( + payloadBytes: Uint8Array | string, + encryptionKey?: Key, +): EncryptedChunk { + if (!(payloadBytes instanceof Uint8Array)) { + payloadBytes = ENCODER.encode(payloadBytes) + } + + if (payloadBytes.length < MIN_PAYLOAD_SIZE || payloadBytes.length > MAX_PAYLOAD_SIZE) { + throw new RangeError( + `payload size ${payloadBytes.length} exceeds limits [${MIN_PAYLOAD_SIZE}, ${MAX_PAYLOAD_SIZE}]`, + ) + } + + // Create the original chunk data (span + payload) + const span = Span.fromBigInt(BigInt(payloadBytes.length)) + const chunkData = Binary.concatBytes(span.toUint8Array(), payloadBytes) + + // Encrypt the chunk + const encrypter = newChunkEncrypter() + const { key, encryptedSpan, encryptedData } = encrypter.encryptChunk(chunkData, encryptionKey) + + // Concatenate encrypted span and data + const encryptedChunkData = Binary.concatBytes(encryptedSpan, encryptedData) + + // Calculate BMT address on encrypted data + const address = calculateChunkAddress(encryptedChunkData) + + // Create 64-byte reference: address (32 bytes) + encryption key (32 bytes) + const reference = new Reference(Binary.concatBytes(address.toUint8Array(), key)) + + return { + data: encryptedChunkData, + encryptionKey: key, + span, + payload: new Bytes(encryptedChunkData.slice(Span.LENGTH)), + address, + reference, + } +} + +/** + * Decrypts an encrypted chunk given the encryption key + * + * @param encryptedChunkData The encrypted chunk data (span + payload) + * @param encryptionKey The 32-byte encryption key + */ +export function decryptEncryptedChunk(encryptedChunkData: Uint8Array, encryptionKey: Key): Uint8Array { + return decryptChunkData(encryptionKey, encryptedChunkData) +} + +/** + * Extracts encryption key from a 64-byte encrypted reference + * + * @param reference 64-byte reference (address + key) + */ +export function extractEncryptionKey(reference: Reference): Key { + const refBytes = reference.toUint8Array() + if (refBytes.length !== 64) { + throw new Error(`Invalid encrypted reference length: ${refBytes.length}, expected 64`) + } + + return refBytes.slice(32, 64) +} + +/** + * Extracts the chunk address from a 64-byte encrypted reference + * + * @param reference 64-byte reference (address + key) + */ +export function extractChunkAddress(reference: Reference): Reference { + const refBytes = reference.toUint8Array() + if (refBytes.length !== 64) { + throw new Error(`Invalid encrypted reference length: ${refBytes.length}, expected 64`) + } + + return new Reference(refBytes.slice(0, 32)) +} diff --git a/src/chunk/encryption.ts b/src/chunk/encryption.ts new file mode 100644 index 00000000..6754512e --- /dev/null +++ b/src/chunk/encryption.ts @@ -0,0 +1,286 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +import { Binary } from 'cafe-utility' + +export const KEY_LENGTH = 32 +export const REFERENCE_SIZE = 64 + +export type Key = Uint8Array + +export interface Encrypter { + key(): Key + encrypt(data: Uint8Array): Uint8Array +} + +export interface Decrypter { + key(): Key + decrypt(data: Uint8Array): Uint8Array +} + +export interface EncryptionInterface extends Encrypter, Decrypter { + reset(): void +} + +/** + * Core encryption class implementing CTR-mode encryption with Keccak256 + * This matches the Go implementation in bee/pkg/encryption/encryption.go + */ +export class Encryption implements EncryptionInterface { + private readonly encryptionKey: Key + private readonly keyLen: number + private readonly padding: number + private index: number + private readonly initCtr: number + + constructor(key: Key, padding: number, initCtr: number) { + this.encryptionKey = key + this.keyLen = key.length + this.padding = padding + this.initCtr = initCtr + this.index = 0 + } + + key(): Key { + return this.encryptionKey + } + + /** + * Encrypts data with optional padding + */ + encrypt(data: Uint8Array): Uint8Array { + const length = data.length + let outLength = length + const isFixedPadding = this.padding > 0 + + if (isFixedPadding) { + if (length > this.padding) { + throw new Error(`data length ${length} longer than padding ${this.padding}`) + } + outLength = this.padding + } + + const out = new Uint8Array(outLength) + this.transform(data, out) + + return out + } + + /** + * Decrypts data (caller must know original length if padding was used) + */ + decrypt(data: Uint8Array): Uint8Array { + const length = data.length + + if (this.padding > 0 && length !== this.padding) { + throw new Error(`data length ${length} different than padding ${this.padding}`) + } + + const out = new Uint8Array(length) + this.transform(data, out) + + return out + } + + /** + * Resets the counter - only safe to call after encryption/decryption is completed + */ + reset(): void { + this.index = 0 + } + + /** + * Transforms data by splitting into key-length segments and encrypting sequentially + */ + private transform(input: Uint8Array, out: Uint8Array): void { + const inLength = input.length + + for (let i = 0; i < inLength; i += this.keyLen) { + const l = Math.min(this.keyLen, inLength - i) + this.transcrypt(this.index, input.subarray(i, i + l), out.subarray(i, i + l)) + this.index++ + } + + // Pad the rest if out is longer + // Use deterministic padding based on encryption key + if (out.length > inLength) { + padDeterministic(out.subarray(inLength), this.encryptionKey, inLength) + } + } + + /** + * Segment-wise transformation using XOR with Keccak256-derived keys + * Matches the Go implementation's Transcrypt function + */ + private transcrypt(i: number, input: Uint8Array, out: Uint8Array): void { + // First hash: key with counter (initial counter + i) + const ctrBytes = new Uint8Array(4) + const view = new DataView(ctrBytes.buffer) + view.setUint32(0, i + this.initCtr, true) // little-endian + + const keyAndCtr = new Uint8Array(this.encryptionKey.length + 4) + keyAndCtr.set(this.encryptionKey) + keyAndCtr.set(ctrBytes, this.encryptionKey.length) + const ctrHash = Binary.keccak256(keyAndCtr) + + // Second round of hashing for selective disclosure + const segmentKey = Binary.keccak256(ctrHash) + + // XOR bytes up to length of input (out must be at least as long) + const inLength = input.length + for (let j = 0; j < inLength; j++) { + out[j] = input[j] ^ segmentKey[j] + } + + // Insert padding if out is longer + // Use deterministic padding based on encryption key + if (out.length > inLength) { + padDeterministic(out.subarray(inLength), this.encryptionKey, i * this.keyLen + inLength) + } + } +} + +/** + * Fills buffer with cryptographically secure random data + * Works in both browser (Web Crypto API) and Node.js environments + */ +function getRandomValues(buffer: Uint8Array): void { + if (buffer.length === 0) return + + // Use Web Crypto API for secure random bytes + crypto.getRandomValues(buffer) +} + +/** + * Fills buffer with pseudo-random data derived from a key + * This makes padding deterministic for testing purposes + * + * @param buffer Buffer to fill with padding + * @param key Encryption key to use as seed for deterministic padding + * @param offset Offset within the logical data stream (for uniqueness) + */ +function padDeterministic(buffer: Uint8Array, key: Key, offset: number): void { + if (buffer.length === 0) return + + // Generate deterministic padding by hashing key + offset + let filled = 0 + let counter = offset + + while (filled < buffer.length) { + // Hash: key || counter + const counterBytes = new Uint8Array(4) + new DataView(counterBytes.buffer).setUint32(0, counter, true) + + const seedData = new Uint8Array(key.length + counterBytes.length) + seedData.set(key) + seedData.set(counterBytes, key.length) + + const hash = Binary.keccak256(seedData) + + // Copy as many bytes as needed from the hash + const toCopy = Math.min(hash.length, buffer.length - filled) + buffer.set(hash.subarray(0, toCopy), filled) + + filled += toCopy + counter++ + } +} + +/** + * Fills buffer with cryptographically secure random data (non-deterministic) + */ +function pad(buffer: Uint8Array): void { + getRandomValues(buffer) +} + +/** + * Generates a cryptographically secure random key + */ +export function generateRandomKey(length: number = KEY_LENGTH): Key { + const key = new Uint8Array(length) + getRandomValues(key) + + return key +} + +/** + * Creates encryption interface for chunk span (first 8 bytes) + */ +export function newSpanEncryption(key: Key): EncryptionInterface { + // ChunkSize is typically 4096, so ChunkSize/KeyLength = 128 + const CHUNK_SIZE = 4096 + + return new Encryption(key, 0, Math.floor(CHUNK_SIZE / KEY_LENGTH)) +} + +/** + * Creates encryption interface for chunk data + */ +export function newDataEncryption(key: Key): EncryptionInterface { + const CHUNK_SIZE = 4096 + + return new Encryption(key, CHUNK_SIZE, 0) +} + +export interface ChunkEncrypter { + encryptChunk(chunkData: Uint8Array, key?: Key): { + key: Key + encryptedSpan: Uint8Array + encryptedData: Uint8Array + } +} + +/** + * Default chunk encrypter implementation + */ +export class DefaultChunkEncrypter implements ChunkEncrypter { + encryptChunk(chunkData: Uint8Array, key?: Key): { + key: Key + encryptedSpan: Uint8Array + encryptedData: Uint8Array + } { + const encryptionKey = key || generateRandomKey(KEY_LENGTH) + + // Encrypt span (first 8 bytes) + const spanEncrypter = newSpanEncryption(encryptionKey) + const encryptedSpan = spanEncrypter.encrypt(chunkData.subarray(0, 8)) + + // Encrypt data (remaining bytes) + const dataEncrypter = newDataEncryption(encryptionKey) + const encryptedData = dataEncrypter.encrypt(chunkData.subarray(8)) + + return { + key: encryptionKey, + encryptedSpan, + encryptedData, + } + } +} + +/** + * Creates a new chunk encrypter + */ +export function newChunkEncrypter(): ChunkEncrypter { + return new DefaultChunkEncrypter() +} + +/** + * Decrypts encrypted chunk data using the provided encryption key + */ +export function decryptChunkData(key: Key, encryptedChunkData: Uint8Array): Uint8Array { + // Decrypt span (first 8 bytes) + const spanDecrypter = newSpanEncryption(key) + const decryptedSpan = spanDecrypter.decrypt(encryptedChunkData.subarray(0, 8)) + + // Decrypt data (remaining bytes - should be 4096 bytes due to padding) + const dataDecrypter = newDataEncryption(key) + const decryptedData = dataDecrypter.decrypt(encryptedChunkData.subarray(8)) + + // Concatenate span and data + const result = new Uint8Array(8 + decryptedData.length) + result.set(decryptedSpan) + result.set(decryptedData, 8) + + return result +} diff --git a/src/index.ts b/src/index.ts index f1eee83a..6188a652 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,8 @@ export { MantarayNode } from './manifest/manifest' export { SUPPORTED_BEE_VERSION, SUPPORTED_BEE_VERSION_EXACT } from './modules/debug/status' export * from './types' export { Bytes } from './utils/bytes' +export { ChunkJoiner, type DownloadProgress } from './utils/chunk-joiner' +export { type DownloadStreamOptions } from './modules/download-stream' export * from './utils/constants' export { Duration } from './utils/duration' export * from './utils/error' @@ -14,6 +16,10 @@ export * as Utils from './utils/expose' export { Size } from './utils/size' export * from './utils/tokens' export * from './utils/typed-bytes' +export { makeContentAddressedChunk, type Chunk } from './chunk/cac' +export { makeEncryptedContentAddressedChunk, type EncryptedChunk } from './chunk/encrypted-cac' +export { calculateChunkAddress } from './chunk/bmt' +export { newChunkEncrypter } from './chunk/encryption' export { Bee, BeeDev, Stamper } // for require-like imports @@ -32,6 +38,7 @@ declare global { BeeResponseError: typeof import('./utils/error').BeeResponseError MantarayNode: typeof import('./manifest/manifest').MantarayNode MerkleTree: typeof import('cafe-utility').MerkleTree + ChunkJoiner: typeof import('./utils/chunk-joiner').ChunkJoiner PrivateKey: typeof import('./utils/typed-bytes').PrivateKey PublicKey: typeof import('./utils/typed-bytes').PublicKey EthAddress: typeof import('./utils/typed-bytes').EthAddress diff --git a/src/modules/chunk-stream-ws.ts b/src/modules/chunk-stream-ws.ts new file mode 100644 index 00000000..42d41a7b --- /dev/null +++ b/src/modules/chunk-stream-ws.ts @@ -0,0 +1,287 @@ +import { System } from 'cafe-utility' +import WebSocket from 'isomorphic-ws' +import type { BeeRequestOptions, UploadOptions } from '../types' +import { prepareRequestHeaders } from '../utils/headers' +import { BatchId } from '../utils/typed-bytes' + +const endpoint = 'chunks/stream' + +/** + * Options for WebSocket chunk upload stream + */ +export interface ChunkStreamOptions { + /** + * Maximum number of unacknowledged chunks in flight + */ + concurrency?: number + + /** + * Callback for upload progress + */ + onProgress?: (uploaded: number) => void + + /** + * Callback for errors + */ + onError?: (error: Error) => void +} + +/** + * Result of chunk upload via WebSocket + */ +export interface ChunkStreamResult { + /** + * Total number of chunks uploaded + */ + totalChunks: number + + /** + * Total bytes uploaded + */ + totalBytes: number +} + +/** + * Creates a WebSocket connection for streaming chunk uploads + */ +export class ChunkUploadStream { + private ws: WebSocket | null = null + private queue: Uint8Array[] = [] + private inFlight = 0 + private uploaded = 0 + private totalBytes = 0 + private concurrency: number + private onProgress?: (uploaded: number) => void + private onError?: (error: Error) => void + private resolveClose?: (result: ChunkStreamResult) => void + private rejectClose?: (error: Error) => void + private closed = false + private error: Error | null = null + private lastAckTime = Date.now() + private ackCount = 0 + private sendCount = 0 + + constructor( + private baseUrl: string, + private postageBatchId: BatchId, + private uploadOptions?: UploadOptions, + options?: ChunkStreamOptions, + ) { + this.concurrency = options?.concurrency ?? 64 + this.onProgress = options?.onProgress + this.onError = options?.onError + } + + /** + * Opens the WebSocket connection + */ + async open(): Promise { + return new Promise((resolve, reject) => { + const wsUrl = this.baseUrl.replace(/^http/i, 'ws') + const headers = prepareRequestHeaders(this.postageBatchId, this.uploadOptions) + + // Build query parameters from upload options + const params = new URLSearchParams() + + if (this.uploadOptions?.pin) params.append('pin', 'true') + if (this.uploadOptions?.tag) params.append('tag', this.uploadOptions.tag.toString()) + if (this.uploadOptions?.deferred !== undefined) params.append('deferred', String(this.uploadOptions.deferred)) + + const queryString = params.toString() + const url = queryString ? `${wsUrl}/${endpoint}?${queryString}` : `${wsUrl}/${endpoint}` + + console.log(`[ChunkStream] Opening WebSocket connection to: ${url}`) + console.log(`[ChunkStream] Headers:`, headers) + + if (System.whereAmI() === 'browser') { + // Note: browsers don't support custom headers in WebSocket constructor + // Headers should be passed via query parameters instead + this.ws = new WebSocket(url) + } else { + this.ws = new WebSocket(url, { + headers: headers as Record, + }) + } + + this.ws.binaryType = 'arraybuffer' + + this.ws.onopen = () => { + console.log('[ChunkStream] WebSocket connection opened successfully') + resolve() + } + + this.ws.onerror = (event: any) => { + console.error('[ChunkStream] WebSocket error:', event.message || event) + const error = new Error(`WebSocket error: ${event.message || 'Unknown error'}`) + this.error = error + this.onError?.(error) + reject(error) + } + + this.ws.onmessage = (event: any) => { + const now = Date.now() + const timeSinceLastAck = now - this.lastAckTime + this.ackCount++ + + // Only log every 100 ACKs or if slow + if (this.ackCount % 100 === 0 || timeSinceLastAck > 1000) { + console.log(`[ChunkStream] ACK #${this.ackCount}, uploaded: ${this.uploaded + 1}, inFlight: ${this.inFlight - 1}, time since last: ${timeSinceLastAck}ms`) + } + + this.lastAckTime = now + this.handleAck(event.data) + } + + this.ws.onclose = () => { + console.log('[ChunkStream] WebSocket connection closed, uploaded:', this.uploaded) + if (this.resolveClose && !this.error) { + this.resolveClose({ + totalChunks: this.uploaded, + totalBytes: this.totalBytes, + }) + } else if (this.rejectClose && this.error) { + this.rejectClose(this.error) + } + } + }) + } + + /** + * Uploads a chunk via the WebSocket stream + */ + async uploadChunk(chunk: Uint8Array): Promise { + if (this.closed) { + throw new Error('Stream is closed') + } + + if (this.error) { + throw this.error + } + + // Wait if queue is too large (backpressure) + // This prevents memory exhaustion from queueing thousands of chunks + const maxQueueSize = this.concurrency * 10 // Allow queue to be 10x concurrency + while (this.queue.length >= maxQueueSize && !this.error && !this.closed) { + console.log(`[ChunkStream] Queue full (${this.queue.length}/${maxQueueSize}), waiting for ACKs...`) + await new Promise(resolve => setTimeout(resolve, 100)) + } + + if (this.error) { + throw this.error + } + + this.queue.push(chunk) + this.totalBytes += chunk.length + await this.processQueue() + } + + /** + * Closes the stream and waits for all chunks to be acknowledged + */ + async close(): Promise { + if (this.closed) { + throw new Error('Stream is already closed') + } + + this.closed = true + + // Wait for all in-flight chunks to be acknowledged + while (this.inFlight > 0 || this.queue.length > 0) { + await this.processQueue() + if (this.inFlight > 0) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + } + + return new Promise((resolve, reject) => { + this.resolveClose = resolve + this.rejectClose = reject + + if (this.ws) { + this.ws.close() + } else { + resolve({ + totalChunks: this.uploaded, + totalBytes: this.totalBytes, + }) + } + }) + } + + /** + * Processes the queue and sends chunks when under concurrency limit + */ + private async processQueue(): Promise { + let sent = 0 + while (this.queue.length > 0 && this.inFlight < this.concurrency && this.ws && this.ws.readyState === WebSocket.OPEN) { + const chunk = this.queue.shift()! + this.inFlight++ + sent++ + this.sendCount++ + + try { + this.ws.send(chunk) + } catch (error) { + console.error('[ChunkStream] Error sending chunk:', error) + this.error = error instanceof Error ? error : new Error(String(error)) + this.onError?.(this.error) + throw this.error + } + } + + // Only log every 100 sends to reduce noise + if (sent > 0 && this.sendCount % 100 === 0) { + const rate = this.sendCount / ((Date.now() - this.lastAckTime + 1) / 1000) + console.log(`[ChunkStream] Sent: ${this.sendCount}, ACKs: ${this.ackCount}, inFlight: ${this.inFlight}/${this.concurrency}, queue: ${this.queue.length}`) + } + } + + /** + * Handles acknowledgment from the server + */ + private handleAck(data: ArrayBuffer): void { + // According to the API docs, the server sends a binary response (0) for each uploaded chunk + // However, it appears the server sends an empty binary message as acknowledgment + const view = new Uint8Array(data) + + // Accept both empty messages and messages with a single 0 byte + if (view.length === 0 || (view.length === 1 && view[0] === 0)) { + this.inFlight-- + this.uploaded++ + this.onProgress?.(this.uploaded) + + // Process more chunks from the queue + this.processQueue().catch(error => { + this.error = error instanceof Error ? error : new Error(String(error)) + this.onError?.(this.error) + }) + } else { + // Unexpected response format + console.error(`[ChunkStream] Unexpected ACK format: length=${view.length}, value=${view[0]}`) + const error = new Error(`Unexpected acknowledgment format: length=${view.length}, value=${view[0]}`) + this.error = error + this.onError?.(error) + } + } +} + +/** + * Helper function to upload multiple chunks via WebSocket stream + */ +export async function uploadChunksViaStream( + baseUrl: string, + chunks: Uint8Array[], + postageBatchId: BatchId, + uploadOptions?: UploadOptions, + streamOptions?: ChunkStreamOptions, +): Promise { + const stream = new ChunkUploadStream(baseUrl, postageBatchId, uploadOptions, streamOptions) + + await stream.open() + + for (const chunk of chunks) { + await stream.uploadChunk(chunk) + } + + return stream.close() +} diff --git a/src/modules/download-stream.ts b/src/modules/download-stream.ts new file mode 100644 index 00000000..407d79d0 --- /dev/null +++ b/src/modules/download-stream.ts @@ -0,0 +1,165 @@ +import type { Bee } from '../bee' +import type { BeeRequestOptions, DownloadOptions, FileData } from '../types' +import { MantarayNode } from '../manifest/manifest' +import { Reference } from '../utils/typed-bytes' +import { ChunkJoiner, type ChunkJoinerOptions, type DownloadProgress } from '../utils/chunk-joiner' + +/** + * Options for streaming download operations + */ +export interface DownloadStreamOptions extends DownloadOptions { + /** + * Callback for download progress updates + */ + onDownloadProgress?: (progress: DownloadProgress) => void + + /** + * Maximum number of concurrent chunk downloads + */ + concurrency?: number +} + +/** + * Downloads data as a streaming ReadableStream by fetching chunks in parallel + * + * This function: + * 1. Detects if reference is encrypted (64 bytes) or plain (32 bytes) + * 2. Fetches the root chunk to determine file size + * 3. Recursively fetches all chunks in parallel + * 4. Handles decryption transparently if needed + * 5. Returns a ReadableStream for efficient memory usage + * + * @param bee Bee instance + * @param resource Swarm reference (32 or 64 bytes for encrypted content) + * @param options Options including progress callback and concurrency + * @param requestOptions Request options for HTTP calls + * @returns ReadableStream of file data + * + * @example + * ```typescript + * const stream = await downloadDataStreaming(bee, reference, { + * onDownloadProgress: ({ total, processed }) => { + * console.log(`Downloaded ${processed}/${total} chunks`) + * } + * }) + * + * // Use the stream + * const reader = stream.getReader() + * while (true) { + * const { done, value } = await reader.read() + * if (done) break + * // Process value (Uint8Array) + * } + * ``` + */ +export async function downloadDataStreaming( + bee: Bee, + resource: Reference | Uint8Array | string, + options?: DownloadStreamOptions, + requestOptions?: BeeRequestOptions, +): Promise> { + const reference = new Reference(resource) + + const joinerOptions: ChunkJoinerOptions = { + onDownloadProgress: options?.onDownloadProgress, + downloadOptions: options, + requestOptions, + concurrency: options?.concurrency, + } + + const joiner = new ChunkJoiner(bee, reference, joinerOptions) + return joiner.createReadableStream() +} + +/** + * Downloads a file from a manifest path as a streaming ReadableStream + * + * This function: + * 1. Downloads and parses the manifest at the given reference + * 2. Optionally decrypts the manifest if reference is 64 bytes + * 3. Looks up the file at the specified path + * 4. Uses parallel chunk fetching to stream the file data + * 5. Returns file metadata along with the stream + * + * @param bee Bee instance + * @param resource Swarm manifest reference + * @param path Path within the manifest (e.g., 'index.html' or 'images/logo.png') + * @param options Options including progress callback and concurrency + * @param requestOptions Request options for HTTP calls + * @returns FileData with ReadableStream and metadata (content-type, filename, etc.) + * + * @example + * ```typescript + * const file = await downloadFileStreaming(bee, manifestRef, 'document.pdf', { + * onDownloadProgress: ({ total, processed }) => { + * console.log(`Progress: ${(processed/total*100).toFixed(1)}%`) + * } + * }) + * + * console.log('Content-Type:', file.contentType) + * console.log('Filename:', file.name) + * + * // Use the stream + * const reader = file.data.getReader() + * ``` + */ +export async function downloadFileStreaming( + bee: Bee, + resource: Reference | Uint8Array | string, + path = '', + options?: DownloadStreamOptions, + requestOptions?: BeeRequestOptions, +): Promise>> { + const manifestReference = new Reference(resource) + + // Download and unmarshal the manifest + const manifest = await MantarayNode.unmarshal(bee, manifestReference, options, requestOptions) + + // Load the manifest tree recursively to access all paths + await manifest.loadRecursively(bee, options, requestOptions) + + // Find the node at the specified path + const node = path ? manifest.find(path) : manifest.find('/') + + if (!node) { + throw new Error(`Path not found in manifest: ${path}`) + } + + if (!node.targetAddress) { + throw new Error(`No file at path: ${path}`) + } + + // Extract file metadata from the manifest node + const metadata = node.metadata || {} + const fileReference = new Reference(node.targetAddress) + + // Create the streaming joiner for the file + const joinerOptions: ChunkJoinerOptions = { + onDownloadProgress: options?.onDownloadProgress, + downloadOptions: options, + requestOptions, + concurrency: options?.concurrency, + } + + const joiner = new ChunkJoiner(bee, fileReference, joinerOptions) + const stream = await joiner.createReadableStream() + + // Parse metadata headers into FileData format + // Note: Manifest metadata keys are like 'Content-Type', 'Filename', etc. + const fileData: FileData> = { + name: metadata['Filename'] || path.split('/').pop() || '', + data: stream, + } + + // Add content-type if available + if (metadata['Content-Type']) { + fileData.contentType = metadata['Content-Type'] + } + + // Add content-length if we can determine it + // We could fetch the file size from the joiner if needed + // const size = await joiner.getSize() + // fileData.size = Number(size) + + return fileData +} diff --git a/src/utils/chunk-joiner.ts b/src/utils/chunk-joiner.ts new file mode 100644 index 00000000..fc948799 --- /dev/null +++ b/src/utils/chunk-joiner.ts @@ -0,0 +1,305 @@ +import { Binary } from 'cafe-utility' +import { decryptEncryptedChunk, extractChunkAddress, extractEncryptionKey } from '../chunk/encrypted-cac' +import type { Bee } from '../bee' +import type { BeeRequestOptions, DownloadOptions } from '../types' +import { Reference, Span } from './typed-bytes' +import { MAX_PAYLOAD_SIZE } from '../chunk/cac' +import { NULL_ADDRESS } from './constants' + +/** + * Progress information for download operations + */ +export interface DownloadProgress { + /** + * Total number of chunks to download + */ + total: number + + /** + * Number of chunks already processed + */ + processed: number +} + +/** + * Options for the ChunkJoiner + */ +export interface ChunkJoinerOptions { + /** + * Callback for download progress updates + */ + onDownloadProgress?: (progress: DownloadProgress) => void + + /** + * Download options to pass to bee.downloadChunk + */ + downloadOptions?: DownloadOptions + + /** + * Request options for HTTP calls + */ + requestOptions?: BeeRequestOptions + + /** + * Maximum number of concurrent chunk downloads + */ + concurrency?: number +} + +/** + * ChunkJoiner reconstructs files from Swarm's Merkle tree chunk structure. + * + * It handles: + * - Transparent encryption/decryption for 64-byte encrypted references + * - Parallel chunk fetching with configurable concurrency + * - Progress tracking + * - Streaming via ReadableStream API + * + * The Swarm file structure is a Merkle tree where: + * - Root chunk contains: [8-byte span] + [references to children] + * - Intermediate chunks contain more references + * - Leaf chunks contain actual file data + * - References are 32 bytes (plain) or 64 bytes (encrypted: 32 addr + 32 key) + */ +export class ChunkJoiner { + private bee: Bee + private rootReference: Reference + private rootEncryptionKey?: Uint8Array + private isEncrypted: boolean + private options: ChunkJoinerOptions + private fileSize?: bigint + private totalChunks: number = 0 + private processedChunks: number = 0 + + constructor(bee: Bee, reference: Reference | Uint8Array | string, options: ChunkJoinerOptions = {}) { + this.bee = bee + this.rootReference = new Reference(reference) + this.options = options + + // Detect encryption: 64-byte references are encrypted + this.isEncrypted = this.rootReference.length === 64 + if (this.isEncrypted) { + this.rootEncryptionKey = extractEncryptionKey(this.rootReference) + this.rootReference = extractChunkAddress(this.rootReference) + } + } + + /** + * Gets the file size (must call after initialization or first read) + */ + async getSize(): Promise { + if (this.fileSize !== undefined) { + return this.fileSize + } + + // Fetch root chunk to get span + await this.fetchAndParseRoot() + + return this.fileSize! + } + + /** + * Fetches and parses the root chunk to extract file size and calculate total chunks + */ + private async fetchAndParseRoot(): Promise { + if (this.fileSize !== undefined) { + return + } + + const rootChunkData = await this.downloadChunk(this.rootReference, this.rootEncryptionKey) + const span = Span.fromSlice(rootChunkData, 0) + this.fileSize = span.toBigInt() + + // Calculate total chunks including intermediate chunks + // For a balanced binary tree with N data chunks, we have approximately N-1 intermediate chunks + // So total chunks ≈ 2N - 1 + const dataChunks = Math.ceil(Number(this.fileSize) / MAX_PAYLOAD_SIZE) + + // Estimate intermediate chunks in the tree + // For simplicity, we'll count all chunks we actually process + // Set initial estimate and update as we go + this.totalChunks = dataChunks + this.updateProgress() + } + + /** + * Downloads a chunk and optionally decrypts it + */ + private async downloadChunk(reference: Reference, encryptionKey?: Uint8Array): Promise { + try { + const chunkData = await this.bee.downloadChunk( + reference, + this.options.downloadOptions, + this.options.requestOptions, + ) + + // Decrypt if we have an encryption key + if (encryptionKey) { + return decryptEncryptedChunk(chunkData, encryptionKey) + } + + return chunkData + } catch (error) { + // Provide more helpful error message + if (error && typeof error === 'object' && 'status' in error) { + const status = (error as { status: number }).status + if (status === 404 || status === 500) { + throw new Error( + `Failed to download chunk ${reference.toHex()}: Chunk not found. ` + + `Make sure the data exists on the Bee node.`, + ) + } + } + throw error + } + } + + /** + * Updates and reports progress + */ + private updateProgress(): void { + if (this.options.onDownloadProgress) { + this.options.onDownloadProgress({ + total: this.totalChunks, + processed: this.processedChunks, + }) + } + } + + /** + * Recursively reads data from a chunk reference + * + * @param reference The chunk reference (32 or 64 bytes) + * @param encryptionKey Optional encryption key for this chunk + * @returns The data payload + */ + private async readChunkRecursively(reference: Reference, encryptionKey?: Uint8Array): Promise { + const chunkData = await this.downloadChunk(reference, encryptionKey) + + // Extract span from the chunk + const span = Span.fromSlice(chunkData, 0) + const spanValue = span.toBigInt() + const payload = chunkData.slice(Span.LENGTH) + + // If span is <= MAX_PAYLOAD_SIZE, this is a leaf chunk with actual data + if (spanValue <= BigInt(MAX_PAYLOAD_SIZE)) { + this.processedChunks++ + this.updateProgress() + return payload.slice(0, Number(spanValue)) + } + + // This is an intermediate chunk containing references + // Each reference is either 32 bytes (plain) or 64 bytes (encrypted) + // If the file is encrypted, all references in the tree are 64 bytes + const referenceSize = this.isEncrypted ? 64 : 32 + const childReferences: Array<{ ref: Reference; key?: Uint8Array }> = [] + + for (let offset = 0; offset < payload.length; offset += referenceSize) { + const refBytes = payload.slice(offset, offset + referenceSize) + if (refBytes.length < referenceSize) { + break // End of references + } + + // Extract the address (first 32 bytes) + const addressBytes = refBytes.slice(0, 32) + + // Skip NULL_ADDRESS (all zeros) - these are padding in the tree structure + if (Binary.equals(addressBytes, NULL_ADDRESS)) { + continue + } + + if (this.isEncrypted) { + // Encrypted reference: split into address and key + childReferences.push({ + ref: new Reference(addressBytes), + key: refBytes.slice(32, 64), + }) + } else { + childReferences.push({ + ref: new Reference(addressBytes), + }) + } + } + + // Fetch all child chunks with concurrency control + // Process in batches to avoid exceeding queue capacity + const concurrency = this.options.concurrency ?? 64 + const childDataArray: Uint8Array[] = new Array(childReferences.length) + + // Process chunks in batches + for (let i = 0; i < childReferences.length; i += concurrency) { + const batch = childReferences.slice(i, Math.min(i + concurrency, childReferences.length)) + const batchPromises = batch.map(async ({ ref, key }, batchIndex) => { + const actualIndex = i + batchIndex + const data = await this.readChunkRecursively(ref, key) + childDataArray[actualIndex] = data + }) + + await Promise.all(batchPromises) + } + + // Concatenate all child data (already in correct order) + return Binary.concatBytes(...childDataArray) + } + + /** + * Creates a ReadableStream that streams the file data + */ + async createReadableStream(): Promise> { + // Fetch root to get file size + await this.fetchAndParseRoot() + + const self = this + let started = false + + return new ReadableStream({ + async start(controller) { + try { + started = true + + // Read the entire file recursively + // Note: For very large files, we might want to implement chunked streaming + // but for now, we'll read the whole tree and stream it out + const fileData = await self.readChunkRecursively(self.rootReference, self.rootEncryptionKey) + + // Enqueue the data + controller.enqueue(fileData) + controller.close() + } catch (error) { + controller.error(error) + } + }, + + cancel() { + // Clean up if needed + }, + }) + } + + /** + * Reads the entire file into memory + * + * This is a convenience method for when streaming is not needed. + */ + async readAll(): Promise { + await this.fetchAndParseRoot() + return this.readChunkRecursively(this.rootReference, this.rootEncryptionKey) + } +} + +/** + * Helper function to create a ReadableStream for downloading a file by reference + * + * @param bee Bee instance + * @param reference File reference (32 or 64 bytes) + * @param options Options including progress callback + * @returns ReadableStream of file data + */ +export async function createDownloadStream( + bee: Bee, + reference: Reference | Uint8Array | string, + options?: ChunkJoinerOptions, +): Promise> { + const joiner = new ChunkJoiner(bee, reference, options) + return joiner.createReadableStream() +} diff --git a/src/utils/chunk-stream.ts b/src/utils/chunk-stream.ts index 25c62ea3..502e4edd 100644 --- a/src/utils/chunk-stream.ts +++ b/src/utils/chunk-stream.ts @@ -1,7 +1,8 @@ -import { AsyncQueue, Chunk, MerkleTree, Strings } from 'cafe-utility' +import { Chunk, MerkleTree, Strings } from 'cafe-utility' import { createReadStream } from 'fs' import { Bee, BeeRequestOptions, CollectionUploadOptions, NULL_ADDRESS, UploadOptions, UploadResult } from '..' import { MantarayNode } from '../manifest/manifest' +import { ChunkUploadStream } from '../modules/chunk-stream-ws' import { totalChunks } from './chunk-size' import { makeCollectionFromFS } from './collection.node' import { mimes } from './mime' @@ -32,7 +33,7 @@ export async function hashDirectory(dir: string) { return mantaray.calculateSelfAddress() } -export async function streamDirectory( +export async function streamDirectoryWithWebsocket( bee: Bee, dir: string, postageBatchId: BatchId | string | Uint8Array, @@ -40,7 +41,6 @@ export async function streamDirectory( options?: CollectionUploadOptions, requestOptions?: BeeRequestOptions, ) { - const queue = new AsyncQueue(64, 64) let total = 0 let processed = 0 postageBatchId = new BatchId(postageBatchId) @@ -51,12 +51,115 @@ export async function streamDirectory( let hasIndexHtml = false + // Create a tag for batch upload optimization + const tag = await bee.createTag(requestOptions) + + // Create WebSocket stream for chunk uploads with tag + const uploadOptionsWithTag = { ...options, tag: tag.uid } + const chunkStream = new ChunkUploadStream( + bee.url, + postageBatchId, + uploadOptionsWithTag, + { + concurrency: 64, + onProgress: uploaded => { + onUploadProgress?.({ total, processed: uploaded }) + }, + }, + ) + + await chunkStream.open() + async function onChunk(chunk: Chunk) { - await queue.enqueue(async () => { - await bee.uploadChunk(postageBatchId, chunk.build(), options, requestOptions) - onUploadProgress?.({ total, processed: ++processed }) - }) + await chunkStream.uploadChunk(chunk.build()) + processed++ } + const mantaray = new MantarayNode() + try { + for (const file of files) { + if (!file.fsPath) { + throw Error('File does not have fsPath, which should never happen in node. Please report this issue.') + } + const readStream = createReadStream(file.fsPath) + + const tree = new MerkleTree(onChunk) + for await (const data of readStream) { + await tree.append(data) + } + const rootChunk = await tree.finalize() + const { filename, extension } = Strings.parseFilename(file.path) + mantaray.addFork(file.path, rootChunk.hash(), { + 'Content-Type': maybeEnrichMime(mimes[extension.toLowerCase()] || 'application/octet-stream'), + Filename: filename, + }) + + if (file.path === 'index.html') { + hasIndexHtml = true + } + } + } finally { + // Close the WebSocket stream when done + await chunkStream.close() + } + + if (hasIndexHtml || options?.indexDocument || options?.errorDocument) { + const metadata: Record = {} + + if (options?.indexDocument) { + metadata['website-index-document'] = options.indexDocument + } else if (hasIndexHtml) { + metadata['website-index-document'] = 'index.html' + } + + if (options?.errorDocument) { + metadata['website-error-document'] = options.errorDocument + } + mantaray.addFork('/', NULL_ADDRESS, metadata) + } + + return mantaray.saveRecursively(bee, postageBatchId, options, requestOptions) +} + +function maybeEnrichMime(mime: string) { + if (['text/html', 'text/css'].includes(mime)) { + return `${mime}; charset=utf-8` + } + + return mime +} + +export async function streamDirectoryWithHttp( + bee: Bee, + dir: string, + postageBatchId: BatchId | string | Uint8Array, + onUploadProgress?: (progress: UploadProgress) => void, + options?: CollectionUploadOptions, + requestOptions?: BeeRequestOptions, +) { + let total = 0 + let processed = 0 + postageBatchId = new BatchId(postageBatchId) + const files = await makeCollectionFromFS(dir) + for (const file of files) { + total += totalChunks(file.size) + } + + let hasIndexHtml = false + + // Create a tag for batch upload optimization + const tag = await bee.createTag(requestOptions) + + // Use HTTP endpoint with tag for batch optimization + const uploadOptionsWithTag = { ...options, tag: tag.uid } + + async function onChunk(chunk: Chunk) { + // Upload chunk via HTTP /chunks endpoint + const chunkData = chunk.build() + await bee.uploadChunk(postageBatchId, chunkData, uploadOptionsWithTag, requestOptions) + processed++ + onUploadProgress?.({ total, processed }) + } + const mantaray = new MantarayNode() for (const file of files) { if (!file.fsPath) { @@ -69,7 +172,6 @@ export async function streamDirectory( await tree.append(data) } const rootChunk = await tree.finalize() - await queue.drain() const { filename, extension } = Strings.parseFilename(file.path) mantaray.addFork(file.path, rootChunk.hash(), { 'Content-Type': maybeEnrichMime(mimes[extension.toLowerCase()] || 'application/octet-stream'), @@ -99,12 +201,16 @@ export async function streamDirectory( return mantaray.saveRecursively(bee, postageBatchId, options, requestOptions) } -function maybeEnrichMime(mime: string) { - if (['text/html', 'text/css'].includes(mime)) { - return `${mime}; charset=utf-8` - } - - return mime +// Backwards compatibility: default to WebSocket +export async function streamDirectory( + bee: Bee, + dir: string, + postageBatchId: BatchId | string | Uint8Array, + onUploadProgress?: (progress: UploadProgress) => void, + options?: CollectionUploadOptions, + requestOptions?: BeeRequestOptions, +) { + return streamDirectoryWithWebsocket(bee, dir, postageBatchId, onUploadProgress, options, requestOptions) } export async function streamFiles( diff --git a/src/utils/encrypted-chunk-stream.browser.ts b/src/utils/encrypted-chunk-stream.browser.ts new file mode 100644 index 00000000..8e286e09 --- /dev/null +++ b/src/utils/encrypted-chunk-stream.browser.ts @@ -0,0 +1,92 @@ +import { Bee, BeeRequestOptions, UploadOptions, UploadResult } from '..' +import { processEncryptedFiles, FileSource } from './encrypted-chunk-stream' +import { BatchId } from './typed-bytes' +import { UploadProgress } from './upload-progress' + +const CHUNK_SIZE = 4096 + +/** + * Streams and encrypts files for upload to Swarm (Browser version) + * + * This is similar to streamFiles but with encryption enabled. + * Each chunk is encrypted with its own random key, and the reference + * includes both the chunk address and encryption key (64 bytes total). + * + * @param bee Bee instance + * @param files Files to upload + * @param postageBatchId Postage batch ID + * @param onUploadProgress Progress callback + * @param options Upload options + * @param requestOptions Request options + */ +export async function streamEncryptedFiles( + bee: Bee, + files: File[] | FileList, + postageBatchId: BatchId, + onUploadProgress?: (progress: UploadProgress) => void, + options?: UploadOptions, + requestOptions?: BeeRequestOptions, +): Promise { + // Convert browser Files to FileSource objects + const fileSources: FileSource[] = Array.from(files).map(file => createBrowserFileSource(file)) + + return processEncryptedFiles(bee, fileSources, postageBatchId, { + onUploadProgress, + uploadOptions: options, + requestOptions, + }) +} + +/** + * Creates a FileSource for browser File objects + */ +function createBrowserFileSource(file: File): FileSource { + return { + name: file.name, + relativePath: file.name, + size: file.size, + async readChunks(onChunk: (data: Uint8Array) => Promise) { + return new Promise((resolve, reject) => { + let offset = 0 + const reader = new FileReader() + + reader.onerror = () => { + reject(reader.error) + } + + const readNextChunk = async () => { + if (offset >= file.size) { + resolve() + + return + } + + const slice = file.slice(offset, offset + CHUNK_SIZE) + reader.readAsArrayBuffer(slice) + } + + reader.onload = async event => { + if (!event.target) { + reject(new Error('No event target')) + + return + } + const data = event.target.result + + if (data) { + const chunkData = new Uint8Array(data as ArrayBuffer) + await onChunk(chunkData) + offset += CHUNK_SIZE + } + readNextChunk() + } + + readNextChunk() + }) + }, + } +} + +export async function hashDirectory(_dir: string) { + throw new Error('Hashing directories is not supported in browsers!') +} diff --git a/src/utils/encrypted-chunk-stream.ts b/src/utils/encrypted-chunk-stream.ts new file mode 100644 index 00000000..3d77b97a --- /dev/null +++ b/src/utils/encrypted-chunk-stream.ts @@ -0,0 +1,489 @@ +import { Optional, Strings } from 'cafe-utility' +import * as fs from 'fs' +import * as path from 'path' +import { Bee, BeeRequestOptions, NULL_ADDRESS, UploadOptions, UploadResult } from '..' +import { MantarayNode } from '../manifest/manifest' +import { makeEncryptedContentAddressedChunk } from '../chunk/encrypted-cac' +import { ChunkUploadStream } from '../modules/chunk-stream-ws' +import { totalChunks } from './chunk-size' +import { makeFilePath } from './collection' +import { mimes } from './mime' +import { BatchId, Reference } from './typed-bytes' +import { UploadProgress } from './upload-progress' + +const CHUNK_SIZE = 4096 + +/** + * File source interface - abstracts file reading for browser vs Node + */ +export interface FileSource { + name: string + relativePath: string + size: number + readChunks(onChunk: (data: Uint8Array) => Promise): Promise +} + +/** + * Options for processing encrypted files + */ +export interface EncryptedStreamOptions { + onUploadProgress?: (progress: UploadProgress) => void + uploadOptions?: UploadOptions + requestOptions?: BeeRequestOptions +} + +/** + * Core logic for building encrypted merkle tree - shared between browser and Node + */ +export async function buildEncryptedMerkleTree( + encryptedChunks: Array<{ address: Uint8Array; key: Uint8Array }>, + onChunk: (payload: Uint8Array, address: Uint8Array) => Promise, +): Promise { + // Single chunk case + if (encryptedChunks.length === 1) { + // Return 64-byte reference: address + key + const ref = new Uint8Array(64) + ref.set(encryptedChunks[0].address, 0) + ref.set(encryptedChunks[0].key, 32) + + return new Reference(ref) + } + + // Multi-chunk case: build intermediate chunks + // Each intermediate chunk can hold 64 references (64 bytes each = 4096 bytes) + const REFS_PER_CHUNK = 64 + const intermediateChunks: Array<{ address: Uint8Array; key: Uint8Array }> = [] + + for (let i = 0; i < encryptedChunks.length; i += REFS_PER_CHUNK) { + const refs = encryptedChunks.slice(i, Math.min(i + REFS_PER_CHUNK, encryptedChunks.length)) + + // Build intermediate chunk payload containing all 64-byte references + const payload = new Uint8Array(refs.length * 64) + refs.forEach((ref, idx) => { + payload.set(ref.address, idx * 64) + payload.set(ref.key, idx * 64 + 32) + }) + + // Encrypt the intermediate chunk + const encryptedIntermediate = await makeEncryptedContentAddressedChunk(payload) + + intermediateChunks.push({ + address: encryptedIntermediate.address.toUint8Array(), + key: encryptedIntermediate.encryptionKey, + }) + + // Upload intermediate chunk + await onChunk(payload, encryptedIntermediate.address.toUint8Array()) + } + + // Recursively build tree if we have more than one intermediate chunk + if (intermediateChunks.length > 1) { + return buildEncryptedMerkleTree(intermediateChunks, onChunk) + } + + // Return root reference (64 bytes) + const rootRef = new Uint8Array(64) + rootRef.set(intermediateChunks[0].address, 0) + rootRef.set(intermediateChunks[0].key, 32) + + return new Reference(rootRef) +} + +/** + * Core function to process and upload encrypted files - shared between browser and Node + */ +export async function processEncryptedFiles( + bee: Bee, + fileSources: FileSource[], + postageBatchId: BatchId, + options: EncryptedStreamOptions = {}, +): Promise { + const { onUploadProgress, uploadOptions, requestOptions } = options + postageBatchId = new BatchId(postageBatchId) + + let total = 0 + let processed = 0 + + // Calculate total chunks + for (const file of fileSources) { + total += totalChunks(file.size) + } + + // Create a tag for batch upload optimization + const tag = await bee.createTag(requestOptions) + + // Create WebSocket stream for chunk uploads with tag + const uploadOptionsWithTag = { ...uploadOptions, tag: tag.uid } + const chunkStream = new ChunkUploadStream( + bee.url, + postageBatchId, + uploadOptionsWithTag, + { + concurrency: 64, + onProgress: uploaded => { + onUploadProgress?.({ total, processed: uploaded }) + }, + }, + ) + + await chunkStream.open() + + async function onChunkUpload(chunkPayload: Uint8Array) { + // Encrypt the chunk + const encryptedChunk = await makeEncryptedContentAddressedChunk(chunkPayload) + + // Upload the encrypted chunk data via WebSocket + await chunkStream.uploadChunk(encryptedChunk.data) + processed++ + } + + const mantaray = new MantarayNode() + + try { + for (const fileSource of fileSources) { + const encryptedChunks: Array<{ address: Uint8Array; key: Uint8Array }> = [] + + // Read and encrypt file chunks + await fileSource.readChunks(async chunkData => { + // Encrypt chunk + const encryptedChunk = await makeEncryptedContentAddressedChunk(chunkData) + + // Store reference + encryptedChunks.push({ + address: encryptedChunk.address.toUint8Array(), + key: encryptedChunk.encryptionKey, + }) + + // Upload + await onChunkUpload(chunkData) + }) + + // Build encrypted merkle tree for this file + const rootReference = await buildEncryptedMerkleTree(encryptedChunks, async (payload, address) => { + await onChunkUpload(payload) + }) + + const { filename, extension } = Strings.parseFilename(fileSource.name) + + // Add to manifest with the encrypted root reference + mantaray.addFork(makeFilePath({ name: fileSource.relativePath } as File), rootReference.toUint8Array(), { + 'Content-Type': maybeEnrichMime(mimes[extension.toLowerCase()] || 'application/octet-stream'), + Filename: filename, + }) + + if (filename === 'index.html') { + mantaray.addFork('/', NULL_ADDRESS, { + 'website-index-document': 'index.html', + }) + } + } + } finally { + // Close the WebSocket stream when done + await chunkStream.close() + } + + // Upload the manifest as encrypted chunks recursively using a new stream + return saveEncryptedManifest(mantaray, bee, postageBatchId, uploadOptions, requestOptions) +} + +/** + * Saves the manifest with encryption, returning a 64-byte reference + */ +async function saveEncryptedManifest( + node: MantarayNode, + bee: Bee, + postageBatchId: BatchId, + uploadOptions?: UploadOptions, + requestOptions?: BeeRequestOptions, +): Promise { + // Create a tag for batch upload optimization + const tag = await bee.createTag(requestOptions) + + // Create a WebSocket stream for manifest uploads with tag + const uploadOptionsWithTag = { ...uploadOptions, tag: tag.uid } + const manifestStream = new ChunkUploadStream( + bee.url, + postageBatchId, + uploadOptionsWithTag, + { + concurrency: 64, + }, + ) + + await manifestStream.open() + + try { + // Helper to upload a manifest node + async function uploadNode(n: MantarayNode): Promise { + // Recursively save child nodes first + for (const fork of n['forks'].values()) { + await uploadNode(fork.node) + } + + // Marshal the current node + const marshalled = await n.marshal() + + // Encrypt the manifest chunk + const encryptedChunk = await makeEncryptedContentAddressedChunk(marshalled) + + // Upload the encrypted chunk data via WebSocket + await manifestStream.uploadChunk(encryptedChunk.data) + + // Create 64-byte reference: address + encryption key + const encryptedRef = new Uint8Array(64) + encryptedRef.set(encryptedChunk.address.toUint8Array(), 0) + encryptedRef.set(encryptedChunk.encryptionKey, 32) + + // Set the node's self address to the full 64-byte encrypted reference + n['selfAddress'] = encryptedRef + } + + await uploadNode(node) + + return { + reference: new Reference(node['selfAddress'] as Uint8Array), + tagUid: undefined, + historyAddress: Optional.empty(), + } + } finally { + await manifestStream.close() + } +} + +/** + * Streams and encrypts files for upload to Swarm using WebSocket (Node.js version) + */ +export async function streamEncryptedFilesWithWebsocket( + bee: Bee, + dir: string, + postageBatchId: BatchId, + onUploadProgress?: (progress: UploadProgress) => void, + options?: UploadOptions, + requestOptions?: BeeRequestOptions, +): Promise { + // Get all files recursively and create FileSource objects + const filePaths = getAllFiles(dir) + const fileSources: FileSource[] = filePaths.map(filePath => createNodeFileSource(filePath, dir)) + + return processEncryptedFiles(bee, fileSources, postageBatchId, { + onUploadProgress, + uploadOptions: options, + requestOptions, + }) +} + +/** + * Streams and encrypts files for upload to Swarm using HTTP (Node.js version) + */ +export async function streamEncryptedFilesWithHttp( + bee: Bee, + dir: string, + postageBatchId: BatchId, + onUploadProgress?: (progress: UploadProgress) => void, + options?: UploadOptions, + requestOptions?: BeeRequestOptions, +): Promise { + // Get all files recursively and create FileSource objects + const filePaths = getAllFiles(dir) + const fileSources: FileSource[] = filePaths.map(filePath => createNodeFileSource(filePath, dir)) + + return processEncryptedFilesWithHttp(bee, fileSources, postageBatchId, { + onUploadProgress, + uploadOptions: options, + requestOptions, + }) +} + +/** + * Core function to process and upload encrypted files using HTTP - shared between browser and Node + */ +async function processEncryptedFilesWithHttp( + bee: Bee, + fileSources: FileSource[], + postageBatchId: BatchId, + options: EncryptedStreamOptions = {}, +): Promise { + const { onUploadProgress, uploadOptions, requestOptions } = options + postageBatchId = new BatchId(postageBatchId) + + let total = 0 + let processed = 0 + + // Calculate total chunks + for (const file of fileSources) { + total += totalChunks(file.size) + } + + // Create a tag for batch upload optimization + const tag = await bee.createTag(requestOptions) + + // Use HTTP endpoint with tag for batch optimization + const uploadOptionsWithTag = { ...uploadOptions, tag: tag.uid } + + async function onChunkUpload(chunkPayload: Uint8Array) { + // Encrypt the chunk + const encryptedChunk = await makeEncryptedContentAddressedChunk(chunkPayload) + + // Upload the encrypted chunk data via HTTP + await bee.uploadChunk(postageBatchId, encryptedChunk.data, uploadOptionsWithTag, requestOptions) + processed++ + onUploadProgress?.({ total, processed }) + } + + const mantaray = new MantarayNode() + + for (const fileSource of fileSources) { + const encryptedChunks: Array<{ address: Uint8Array; key: Uint8Array }> = [] + + // Read and encrypt file chunks + await fileSource.readChunks(async chunkData => { + // Encrypt chunk + const encryptedChunk = await makeEncryptedContentAddressedChunk(chunkData) + + // Store reference + encryptedChunks.push({ + address: encryptedChunk.address.toUint8Array(), + key: encryptedChunk.encryptionKey, + }) + + // Upload + await onChunkUpload(chunkData) + }) + + // Build encrypted merkle tree for this file + const rootReference = await buildEncryptedMerkleTree(encryptedChunks, async (payload, address) => { + await onChunkUpload(payload) + }) + + const { filename, extension } = Strings.parseFilename(fileSource.name) + + // Add to manifest with the encrypted root reference + mantaray.addFork(makeFilePath({ name: fileSource.relativePath } as File), rootReference.toUint8Array(), { + 'Content-Type': maybeEnrichMime(mimes[extension.toLowerCase()] || 'application/octet-stream'), + Filename: filename, + }) + + if (filename === 'index.html') { + mantaray.addFork('/', NULL_ADDRESS, { + 'website-index-document': 'index.html', + }) + } + } + + // Upload the manifest as encrypted chunks recursively using HTTP + return saveEncryptedManifestWithHttp(mantaray, bee, postageBatchId, uploadOptions, requestOptions) +} + +/** + * Saves the manifest with encryption using HTTP, returning a 64-byte reference + */ +async function saveEncryptedManifestWithHttp( + node: MantarayNode, + bee: Bee, + postageBatchId: BatchId, + uploadOptions?: UploadOptions, + requestOptions?: BeeRequestOptions, +): Promise { + // Create a tag for batch upload optimization + const tag = await bee.createTag(requestOptions) + + // Use HTTP endpoint with tag for batch optimization + const uploadOptionsWithTag = { ...uploadOptions, tag: tag.uid } + + // Helper to upload a manifest node + async function uploadNode(n: MantarayNode): Promise { + // Recursively save child nodes first + for (const fork of n['forks'].values()) { + await uploadNode(fork.node) + } + + // Marshal the current node + const marshalled = await n.marshal() + + // Encrypt the manifest chunk + const encryptedChunk = await makeEncryptedContentAddressedChunk(marshalled) + + // Upload the encrypted chunk data via HTTP + await bee.uploadChunk(postageBatchId, encryptedChunk.data, uploadOptionsWithTag, requestOptions) + + // Create 64-byte reference: address + encryption key + const encryptedRef = new Uint8Array(64) + encryptedRef.set(encryptedChunk.address.toUint8Array(), 0) + encryptedRef.set(encryptedChunk.encryptionKey, 32) + + // Set the node's self address to the full 64-byte encrypted reference + n['selfAddress'] = encryptedRef + } + + await uploadNode(node) + + return { + reference: new Reference(node['selfAddress'] as Uint8Array), + tagUid: undefined, + historyAddress: Optional.empty(), + } +} + +// Backwards compatibility: default to WebSocket +export async function streamEncryptedFiles( + bee: Bee, + dir: string, + postageBatchId: BatchId, + onUploadProgress?: (progress: UploadProgress) => void, + options?: UploadOptions, + requestOptions?: BeeRequestOptions, +): Promise { + return streamEncryptedFilesWithWebsocket(bee, dir, postageBatchId, onUploadProgress, options, requestOptions) +} + +/** + * Creates a FileSource for Node.js file system + */ +function createNodeFileSource(filePath: string, baseDir: string): FileSource { + const stats = fs.statSync(filePath) + const relativePath = path.relative(baseDir, filePath) + + return { + name: path.basename(filePath), + relativePath, + size: stats.size, + async readChunks(onChunk: (data: Uint8Array) => Promise) { + const fileContent = fs.readFileSync(filePath) + for (let offset = 0; offset < fileContent.length; offset += CHUNK_SIZE) { + const chunkData = fileContent.slice(offset, Math.min(offset + CHUNK_SIZE, fileContent.length)) + await onChunk(chunkData) + } + }, + } +} + +/** + * Recursively get all files in a directory + */ +function getAllFiles(dirPath: string, arrayOfFiles: string[] = []): string[] { + const files = fs.readdirSync(dirPath) + + files.forEach(file => { + const filePath = path.join(dirPath, file) + + if (fs.statSync(filePath).isDirectory()) { + arrayOfFiles = getAllFiles(filePath, arrayOfFiles) + } else { + arrayOfFiles.push(filePath) + } + }) + + return arrayOfFiles +} + +function maybeEnrichMime(mime: string) { + if (['text/html', 'text/css'].includes(mime)) { + return `${mime}; charset=utf-8` + } + + return mime +} + +export async function hashDirectory(_dir: string) { + throw new Error('Hashing directories is not yet implemented for encrypted uploads') +} diff --git a/test/coverage/coverage-summary.json b/test/coverage/coverage-summary.json index 3691a8f2..a44abddf 100644 --- a/test/coverage/coverage-summary.json +++ b/test/coverage/coverage-summary.json @@ -1,72 +1,80 @@ -{"total": {"lines":{"total":2478,"covered":2028,"skipped":0,"pct":81.84},"statements":{"total":2521,"covered":2067,"skipped":0,"pct":81.99},"functions":{"total":601,"covered":478,"skipped":0,"pct":79.53},"branches":{"total":563,"covered":356,"skipped":0,"pct":63.23},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/bee-dev.ts": {"lines":{"total":14,"covered":5,"skipped":0,"pct":35.71},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":14,"covered":5,"skipped":0,"pct":35.71},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/bee.ts": {"lines":{"total":445,"covered":370,"skipped":0,"pct":83.14},"functions":{"total":127,"covered":104,"skipped":0,"pct":81.88},"statements":{"total":448,"covered":373,"skipped":0,"pct":83.25},"branches":{"total":115,"covered":67,"skipped":0,"pct":58.26}} -,"/home/runner/work/bee-js/bee-js/src/index.ts": {"lines":{"total":16,"covered":16,"skipped":0,"pct":100},"functions":{"total":10,"covered":7,"skipped":0,"pct":70},"statements":{"total":25,"covered":25,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/chunk/bmt.ts": {"lines":{"total":16,"covered":15,"skipped":0,"pct":93.75},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":17,"covered":16,"skipped":0,"pct":94.11},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}} -,"/home/runner/work/bee-js/bee-js/src/chunk/cac.ts": {"lines":{"total":21,"covered":19,"skipped":0,"pct":90.47},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":21,"covered":19,"skipped":0,"pct":90.47},"branches":{"total":7,"covered":5,"skipped":0,"pct":71.42}} -,"/home/runner/work/bee-js/bee-js/src/chunk/soc.ts": {"lines":{"total":61,"covered":60,"skipped":0,"pct":98.36},"functions":{"total":8,"covered":8,"skipped":0,"pct":100},"statements":{"total":61,"covered":60,"skipped":0,"pct":98.36},"branches":{"total":3,"covered":1,"skipped":0,"pct":33.33}} -,"/home/runner/work/bee-js/bee-js/src/feed/identifier.ts": {"lines":{"total":5,"covered":5,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":5,"covered":5,"skipped":0,"pct":100},"branches":{"total":2,"covered":1,"skipped":0,"pct":50}} -,"/home/runner/work/bee-js/bee-js/src/feed/index.ts": {"lines":{"total":84,"covered":82,"skipped":0,"pct":97.61},"functions":{"total":13,"covered":13,"skipped":0,"pct":100},"statements":{"total":84,"covered":82,"skipped":0,"pct":97.61},"branches":{"total":32,"covered":24,"skipped":0,"pct":75}} -,"/home/runner/work/bee-js/bee-js/src/feed/retrievable.ts": {"lines":{"total":19,"covered":18,"skipped":0,"pct":94.73},"functions":{"total":5,"covered":5,"skipped":0,"pct":100},"statements":{"total":21,"covered":20,"skipped":0,"pct":95.23},"branches":{"total":3,"covered":3,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/manifest/manifest.ts": {"lines":{"total":228,"covered":213,"skipped":0,"pct":93.42},"functions":{"total":24,"covered":24,"skipped":0,"pct":100},"statements":{"total":230,"covered":215,"skipped":0,"pct":93.47},"branches":{"total":80,"covered":69,"skipped":0,"pct":86.25}} -,"/home/runner/work/bee-js/bee-js/src/modules/bytes.ts": {"lines":{"total":25,"covered":20,"skipped":0,"pct":80},"functions":{"total":4,"covered":3,"skipped":0,"pct":75},"statements":{"total":25,"covered":20,"skipped":0,"pct":80},"branches":{"total":6,"covered":3,"skipped":0,"pct":50}} -,"/home/runner/work/bee-js/bee-js/src/modules/bzz.ts": {"lines":{"total":31,"covered":30,"skipped":0,"pct":96.77},"functions":{"total":4,"covered":4,"skipped":0,"pct":100},"statements":{"total":31,"covered":30,"skipped":0,"pct":96.77},"branches":{"total":14,"covered":9,"skipped":0,"pct":64.28}} -,"/home/runner/work/bee-js/bee-js/src/modules/chunk.ts": {"lines":{"total":14,"covered":14,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":14,"covered":14,"skipped":0,"pct":100},"branches":{"total":4,"covered":2,"skipped":0,"pct":50}} -,"/home/runner/work/bee-js/bee-js/src/modules/envelope.ts": {"lines":{"total":7,"covered":7,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":7,"covered":7,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/modules/feed.ts": {"lines":{"total":24,"covered":22,"skipped":0,"pct":91.66},"functions":{"total":4,"covered":4,"skipped":0,"pct":100},"statements":{"total":24,"covered":22,"skipped":0,"pct":91.66},"branches":{"total":2,"covered":0,"skipped":0,"pct":0}} -,"/home/runner/work/bee-js/bee-js/src/modules/grantee.ts": {"lines":{"total":21,"covered":21,"skipped":0,"pct":100},"functions":{"total":7,"covered":7,"skipped":0,"pct":100},"statements":{"total":21,"covered":21,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/modules/gsoc.ts": {"lines":{"total":11,"covered":10,"skipped":0,"pct":90.9},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":11,"covered":10,"skipped":0,"pct":90.9},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}} -,"/home/runner/work/bee-js/bee-js/src/modules/pinning.ts": {"lines":{"total":20,"covered":19,"skipped":0,"pct":95},"functions":{"total":6,"covered":6,"skipped":0,"pct":100},"statements":{"total":21,"covered":20,"skipped":0,"pct":95.23},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}} -,"/home/runner/work/bee-js/bee-js/src/modules/pss.ts": {"lines":{"total":12,"covered":11,"skipped":0,"pct":91.66},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":12,"covered":11,"skipped":0,"pct":91.66},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}} -,"/home/runner/work/bee-js/bee-js/src/modules/rchash.ts": {"lines":{"total":7,"covered":5,"skipped":0,"pct":71.42},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":7,"covered":5,"skipped":0,"pct":71.42},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/modules/soc.ts": {"lines":{"total":10,"covered":10,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":10,"covered":10,"skipped":0,"pct":100},"branches":{"total":4,"covered":2,"skipped":0,"pct":50}} -,"/home/runner/work/bee-js/bee-js/src/modules/status.ts": {"lines":{"total":10,"covered":5,"skipped":0,"pct":50},"functions":{"total":2,"covered":1,"skipped":0,"pct":50},"statements":{"total":10,"covered":5,"skipped":0,"pct":50},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/modules/stewardship.ts": {"lines":{"total":11,"covered":11,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":11,"covered":11,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/modules/tag.ts": {"lines":{"total":20,"covered":19,"skipped":0,"pct":95},"functions":{"total":7,"covered":6,"skipped":0,"pct":85.71},"statements":{"total":22,"covered":21,"skipped":0,"pct":95.45},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/modules/debug/balance.ts": {"lines":{"total":26,"covered":26,"skipped":0,"pct":100},"functions":{"total":8,"covered":8,"skipped":0,"pct":100},"statements":{"total":28,"covered":28,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/modules/debug/chequebook.ts": {"lines":{"total":58,"covered":40,"skipped":0,"pct":68.96},"functions":{"total":18,"covered":9,"skipped":0,"pct":50},"statements":{"total":58,"covered":40,"skipped":0,"pct":68.96},"branches":{"total":2,"covered":0,"skipped":0,"pct":0}} -,"/home/runner/work/bee-js/bee-js/src/modules/debug/connectivity.ts": {"lines":{"total":35,"covered":28,"skipped":0,"pct":80},"functions":{"total":11,"covered":7,"skipped":0,"pct":63.63},"statements":{"total":36,"covered":29,"skipped":0,"pct":80.55},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/modules/debug/settlements.ts": {"lines":{"total":18,"covered":18,"skipped":0,"pct":100},"functions":{"total":4,"covered":4,"skipped":0,"pct":100},"statements":{"total":18,"covered":18,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/modules/debug/stake.ts": {"lines":{"total":32,"covered":21,"skipped":0,"pct":65.62},"functions":{"total":6,"covered":3,"skipped":0,"pct":50},"statements":{"total":32,"covered":21,"skipped":0,"pct":65.62},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/modules/debug/stamps.ts": {"lines":{"total":70,"covered":60,"skipped":0,"pct":85.71},"functions":{"total":17,"covered":13,"skipped":0,"pct":76.47},"statements":{"total":73,"covered":63,"skipped":0,"pct":86.3},"branches":{"total":2,"covered":0,"skipped":0,"pct":0}} -,"/home/runner/work/bee-js/bee-js/src/modules/debug/states.ts": {"lines":{"total":29,"covered":24,"skipped":0,"pct":82.75},"functions":{"total":5,"covered":4,"skipped":0,"pct":80},"statements":{"total":29,"covered":24,"skipped":0,"pct":82.75},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/modules/debug/status.ts": {"lines":{"total":36,"covered":34,"skipped":0,"pct":94.44},"functions":{"total":7,"covered":6,"skipped":0,"pct":85.71},"statements":{"total":36,"covered":34,"skipped":0,"pct":94.44},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/modules/debug/transactions.ts": {"lines":{"total":27,"covered":18,"skipped":0,"pct":66.66},"functions":{"total":5,"covered":3,"skipped":0,"pct":60},"statements":{"total":27,"covered":18,"skipped":0,"pct":66.66},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}} -,"/home/runner/work/bee-js/bee-js/src/stamper/stamper.ts": {"lines":{"total":21,"covered":21,"skipped":0,"pct":100},"functions":{"total":5,"covered":5,"skipped":0,"pct":100},"statements":{"total":21,"covered":21,"skipped":0,"pct":100},"branches":{"total":1,"covered":1,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/types/debug.ts": {"lines":{"total":12,"covered":8,"skipped":0,"pct":66.66},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":12,"covered":8,"skipped":0,"pct":66.66},"branches":{"total":7,"covered":3,"skipped":0,"pct":42.85}} -,"/home/runner/work/bee-js/bee-js/src/types/index.ts": {"lines":{"total":21,"covered":21,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":21,"covered":21,"skipped":0,"pct":100},"branches":{"total":4,"covered":4,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/utils/bytes.ts": {"lines":{"total":53,"covered":41,"skipped":0,"pct":77.35},"functions":{"total":15,"covered":12,"skipped":0,"pct":80},"statements":{"total":53,"covered":41,"skipped":0,"pct":77.35},"branches":{"total":18,"covered":14,"skipped":0,"pct":77.77}} -,"/home/runner/work/bee-js/bee-js/src/utils/chunk-size.ts": {"lines":{"total":9,"covered":9,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":9,"covered":9,"skipped":0,"pct":100},"branches":{"total":1,"covered":1,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/utils/chunk-stream.browser.ts": {"lines":{"total":55,"covered":0,"skipped":0,"pct":0},"functions":{"total":10,"covered":0,"skipped":0,"pct":0},"statements":{"total":55,"covered":0,"skipped":0,"pct":0},"branches":{"total":7,"covered":0,"skipped":0,"pct":0}} -,"/home/runner/work/bee-js/bee-js/src/utils/chunk-stream.ts": {"lines":{"total":63,"covered":57,"skipped":0,"pct":90.47},"functions":{"total":6,"covered":5,"skipped":0,"pct":83.33},"statements":{"total":63,"covered":57,"skipped":0,"pct":90.47},"branches":{"total":16,"covered":9,"skipped":0,"pct":56.25}} -,"/home/runner/work/bee-js/bee-js/src/utils/cid.ts": {"lines":{"total":23,"covered":22,"skipped":0,"pct":95.65},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":23,"covered":22,"skipped":0,"pct":95.65},"branches":{"total":3,"covered":2,"skipped":0,"pct":66.66}} -,"/home/runner/work/bee-js/bee-js/src/utils/collection.browser.ts": {"lines":{"total":4,"covered":0,"skipped":0,"pct":0},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":4,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/utils/collection.node.ts": {"lines":{"total":33,"covered":29,"skipped":0,"pct":87.87},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":33,"covered":29,"skipped":0,"pct":87.87},"branches":{"total":10,"covered":6,"skipped":0,"pct":60}} -,"/home/runner/work/bee-js/bee-js/src/utils/collection.ts": {"lines":{"total":18,"covered":14,"skipped":0,"pct":77.77},"functions":{"total":8,"covered":8,"skipped":0,"pct":100},"statements":{"total":21,"covered":17,"skipped":0,"pct":80.95},"branches":{"total":9,"covered":5,"skipped":0,"pct":55.55}} -,"/home/runner/work/bee-js/bee-js/src/utils/constants.ts": {"lines":{"total":7,"covered":7,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":7,"covered":7,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/utils/data.browser.ts": {"lines":{"total":8,"covered":0,"skipped":0,"pct":0},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":8,"covered":0,"skipped":0,"pct":0},"branches":{"total":3,"covered":0,"skipped":0,"pct":0}} -,"/home/runner/work/bee-js/bee-js/src/utils/data.ts": {"lines":{"total":11,"covered":4,"skipped":0,"pct":36.36},"functions":{"total":3,"covered":1,"skipped":0,"pct":33.33},"statements":{"total":12,"covered":4,"skipped":0,"pct":33.33},"branches":{"total":7,"covered":1,"skipped":0,"pct":14.28}} -,"/home/runner/work/bee-js/bee-js/src/utils/duration.ts": {"lines":{"total":22,"covered":20,"skipped":0,"pct":90.9},"functions":{"total":17,"covered":15,"skipped":0,"pct":88.23},"statements":{"total":22,"covered":20,"skipped":0,"pct":90.9},"branches":{"total":5,"covered":5,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/utils/error.ts": {"lines":{"total":12,"covered":12,"skipped":0,"pct":100},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":12,"covered":12,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/utils/expose.ts": {"lines":{"total":13,"covered":13,"skipped":0,"pct":100},"functions":{"total":15,"covered":14,"skipped":0,"pct":93.33},"statements":{"total":20,"covered":20,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/utils/file.ts": {"lines":{"total":12,"covered":8,"skipped":0,"pct":66.66},"functions":{"total":4,"covered":2,"skipped":0,"pct":50},"statements":{"total":13,"covered":8,"skipped":0,"pct":61.53},"branches":{"total":8,"covered":6,"skipped":0,"pct":75}} -,"/home/runner/work/bee-js/bee-js/src/utils/headers.ts": {"lines":{"total":71,"covered":62,"skipped":0,"pct":87.32},"functions":{"total":5,"covered":5,"skipped":0,"pct":100},"statements":{"total":71,"covered":62,"skipped":0,"pct":87.32},"branches":{"total":39,"covered":31,"skipped":0,"pct":79.48}} -,"/home/runner/work/bee-js/bee-js/src/utils/http.ts": {"lines":{"total":37,"covered":32,"skipped":0,"pct":86.48},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":37,"covered":32,"skipped":0,"pct":86.48},"branches":{"total":23,"covered":15,"skipped":0,"pct":65.21}} -,"/home/runner/work/bee-js/bee-js/src/utils/mime.ts": {"lines":{"total":1,"covered":1,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":1,"covered":1,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/utils/pss.ts": {"lines":{"total":5,"covered":5,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":5,"covered":5,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/utils/redundancy.ts": {"lines":{"total":44,"covered":32,"skipped":0,"pct":72.72},"functions":{"total":4,"covered":4,"skipped":0,"pct":100},"statements":{"total":45,"covered":33,"skipped":0,"pct":73.33},"branches":{"total":26,"covered":12,"skipped":0,"pct":46.15}} -,"/home/runner/work/bee-js/bee-js/src/utils/resource-locator.ts": {"lines":{"total":7,"covered":7,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":7,"covered":7,"skipped":0,"pct":100},"branches":{"total":3,"covered":3,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/utils/size.ts": {"lines":{"total":14,"covered":12,"skipped":0,"pct":85.71},"functions":{"total":10,"covered":9,"skipped":0,"pct":90},"statements":{"total":14,"covered":12,"skipped":0,"pct":85.71},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}} -,"/home/runner/work/bee-js/bee-js/src/utils/stamps.ts": {"lines":{"total":63,"covered":45,"skipped":0,"pct":71.42},"functions":{"total":13,"covered":11,"skipped":0,"pct":84.61},"statements":{"total":65,"covered":46,"skipped":0,"pct":70.76},"branches":{"total":22,"covered":8,"skipped":0,"pct":36.36}} -,"/home/runner/work/bee-js/bee-js/src/utils/tar-uploader.browser.ts": {"lines":{"total":11,"covered":0,"skipped":0,"pct":0},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":11,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/utils/tar-uploader.ts": {"lines":{"total":12,"covered":12,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":12,"covered":12,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/utils/tar-writer.browser.ts": {"lines":{"total":8,"covered":0,"skipped":0,"pct":0},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":8,"covered":0,"skipped":0,"pct":0},"branches":{"total":2,"covered":0,"skipped":0,"pct":0}} -,"/home/runner/work/bee-js/bee-js/src/utils/tar-writer.ts": {"lines":{"total":14,"covered":13,"skipped":0,"pct":92.85},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":14,"covered":13,"skipped":0,"pct":92.85},"branches":{"total":4,"covered":3,"skipped":0,"pct":75}} -,"/home/runner/work/bee-js/bee-js/src/utils/tar.browser.ts": {"lines":{"total":40,"covered":0,"skipped":0,"pct":0},"functions":{"total":10,"covered":0,"skipped":0,"pct":0},"statements":{"total":41,"covered":0,"skipped":0,"pct":0},"branches":{"total":4,"covered":0,"skipped":0,"pct":0}} -,"/home/runner/work/bee-js/bee-js/src/utils/tar.ts": {"lines":{"total":39,"covered":36,"skipped":0,"pct":92.3},"functions":{"total":11,"covered":10,"skipped":0,"pct":90.9},"statements":{"total":40,"covered":37,"skipped":0,"pct":92.5},"branches":{"total":6,"covered":3,"skipped":0,"pct":50}} -,"/home/runner/work/bee-js/bee-js/src/utils/tokens.ts": {"lines":{"total":37,"covered":24,"skipped":0,"pct":64.86},"functions":{"total":32,"covered":19,"skipped":0,"pct":59.37},"statements":{"total":37,"covered":24,"skipped":0,"pct":64.86},"branches":{"total":8,"covered":5,"skipped":0,"pct":62.5}} -,"/home/runner/work/bee-js/bee-js/src/utils/type.ts": {"lines":{"total":100,"covered":78,"skipped":0,"pct":78},"functions":{"total":50,"covered":36,"skipped":0,"pct":72},"statements":{"total":100,"covered":78,"skipped":0,"pct":78},"branches":{"total":22,"covered":17,"skipped":0,"pct":77.27}} -,"/home/runner/work/bee-js/bee-js/src/utils/typed-bytes.ts": {"lines":{"total":86,"covered":85,"skipped":0,"pct":98.83},"functions":{"total":31,"covered":30,"skipped":0,"pct":96.77},"statements":{"total":86,"covered":85,"skipped":0,"pct":98.83},"branches":{"total":11,"covered":11,"skipped":0,"pct":100}} -,"/home/runner/work/bee-js/bee-js/src/utils/url.ts": {"lines":{"total":15,"covered":11,"skipped":0,"pct":73.33},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":15,"covered":11,"skipped":0,"pct":73.33},"branches":{"total":5,"covered":1,"skipped":0,"pct":20}} -,"/home/runner/work/bee-js/bee-js/src/utils/workaround.ts": {"lines":{"total":14,"covered":11,"skipped":0,"pct":78.57},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":14,"covered":11,"skipped":0,"pct":78.57},"branches":{"total":5,"covered":2,"skipped":0,"pct":40}} +{"total": {"lines":{"total":3120,"covered":1066,"skipped":0,"pct":34.16},"statements":{"total":3184,"covered":1092,"skipped":0,"pct":34.29},"functions":{"total":703,"covered":158,"skipped":0,"pct":22.47},"branches":{"total":706,"covered":107,"skipped":0,"pct":15.15},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/bee-dev.ts": {"lines":{"total":14,"covered":5,"skipped":0,"pct":35.71},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":14,"covered":5,"skipped":0,"pct":35.71},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/bee.ts": {"lines":{"total":452,"covered":70,"skipped":0,"pct":15.48},"functions":{"total":129,"covered":8,"skipped":0,"pct":6.2},"statements":{"total":455,"covered":70,"skipped":0,"pct":15.38},"branches":{"total":118,"covered":7,"skipped":0,"pct":5.93}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/index.ts": {"lines":{"total":21,"covered":21,"skipped":0,"pct":100},"functions":{"total":15,"covered":5,"skipped":0,"pct":33.33},"statements":{"total":35,"covered":35,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/chunk/bmt.ts": {"lines":{"total":26,"covered":25,"skipped":0,"pct":96.15},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":27,"covered":26,"skipped":0,"pct":96.29},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/chunk/cac.ts": {"lines":{"total":21,"covered":9,"skipped":0,"pct":42.85},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":21,"covered":9,"skipped":0,"pct":42.85},"branches":{"total":7,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/chunk/encrypted-cac.ts": {"lines":{"total":32,"covered":32,"skipped":0,"pct":100},"functions":{"total":4,"covered":4,"skipped":0,"pct":100},"statements":{"total":32,"covered":32,"skipped":0,"pct":100},"branches":{"total":6,"covered":6,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/chunk/encryption.ts": {"lines":{"total":92,"covered":90,"skipped":0,"pct":97.82},"functions":{"total":16,"covered":15,"skipped":0,"pct":93.75},"statements":{"total":96,"covered":92,"skipped":0,"pct":95.83},"branches":{"total":12,"covered":9,"skipped":0,"pct":75}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/chunk/soc.ts": {"lines":{"total":61,"covered":19,"skipped":0,"pct":31.14},"functions":{"total":8,"covered":1,"skipped":0,"pct":12.5},"statements":{"total":61,"covered":19,"skipped":0,"pct":31.14},"branches":{"total":3,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/feed/identifier.ts": {"lines":{"total":5,"covered":3,"skipped":0,"pct":60},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":5,"covered":3,"skipped":0,"pct":60},"branches":{"total":2,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/feed/index.ts": {"lines":{"total":84,"covered":22,"skipped":0,"pct":26.19},"functions":{"total":13,"covered":0,"skipped":0,"pct":0},"statements":{"total":84,"covered":22,"skipped":0,"pct":26.19},"branches":{"total":32,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/feed/retrievable.ts": {"lines":{"total":19,"covered":4,"skipped":0,"pct":21.05},"functions":{"total":5,"covered":0,"skipped":0,"pct":0},"statements":{"total":21,"covered":4,"skipped":0,"pct":19.04},"branches":{"total":3,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/manifest/manifest.ts": {"lines":{"total":228,"covered":99,"skipped":0,"pct":43.42},"functions":{"total":24,"covered":10,"skipped":0,"pct":41.66},"statements":{"total":230,"covered":99,"skipped":0,"pct":43.04},"branches":{"total":80,"covered":26,"skipped":0,"pct":32.5}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/bytes.ts": {"lines":{"total":25,"covered":11,"skipped":0,"pct":44},"functions":{"total":4,"covered":0,"skipped":0,"pct":0},"statements":{"total":25,"covered":11,"skipped":0,"pct":44},"branches":{"total":6,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/bzz.ts": {"lines":{"total":31,"covered":13,"skipped":0,"pct":41.93},"functions":{"total":4,"covered":0,"skipped":0,"pct":0},"statements":{"total":31,"covered":13,"skipped":0,"pct":41.93},"branches":{"total":14,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/chunk-iframe.ts": {"lines":{"total":43,"covered":0,"skipped":0,"pct":0},"functions":{"total":8,"covered":0,"skipped":0,"pct":0},"statements":{"total":43,"covered":0,"skipped":0,"pct":0},"branches":{"total":9,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/chunk-stream-ws.ts": {"lines":{"total":118,"covered":6,"skipped":0,"pct":5.08},"functions":{"total":16,"covered":0,"skipped":0,"pct":0},"statements":{"total":123,"covered":6,"skipped":0,"pct":4.87},"branches":{"total":51,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/chunk.ts": {"lines":{"total":14,"covered":8,"skipped":0,"pct":57.14},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":14,"covered":8,"skipped":0,"pct":57.14},"branches":{"total":4,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/download-stream.ts": {"lines":{"total":26,"covered":5,"skipped":0,"pct":19.23},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":26,"covered":5,"skipped":0,"pct":19.23},"branches":{"total":11,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/envelope.ts": {"lines":{"total":7,"covered":4,"skipped":0,"pct":57.14},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":7,"covered":4,"skipped":0,"pct":57.14},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/feed.ts": {"lines":{"total":24,"covered":10,"skipped":0,"pct":41.66},"functions":{"total":4,"covered":0,"skipped":0,"pct":0},"statements":{"total":24,"covered":10,"skipped":0,"pct":41.66},"branches":{"total":2,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/grantee.ts": {"lines":{"total":21,"covered":8,"skipped":0,"pct":38.09},"functions":{"total":7,"covered":0,"skipped":0,"pct":0},"statements":{"total":21,"covered":8,"skipped":0,"pct":38.09},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/gsoc.ts": {"lines":{"total":11,"covered":9,"skipped":0,"pct":81.81},"functions":{"total":2,"covered":1,"skipped":0,"pct":50},"statements":{"total":11,"covered":9,"skipped":0,"pct":81.81},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/pinning.ts": {"lines":{"total":20,"covered":8,"skipped":0,"pct":40},"functions":{"total":6,"covered":0,"skipped":0,"pct":0},"statements":{"total":21,"covered":8,"skipped":0,"pct":38.09},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/pss.ts": {"lines":{"total":12,"covered":7,"skipped":0,"pct":58.33},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":12,"covered":7,"skipped":0,"pct":58.33},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/rchash.ts": {"lines":{"total":7,"covered":4,"skipped":0,"pct":57.14},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":7,"covered":4,"skipped":0,"pct":57.14},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/soc.ts": {"lines":{"total":10,"covered":7,"skipped":0,"pct":70},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":10,"covered":7,"skipped":0,"pct":70},"branches":{"total":4,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/status.ts": {"lines":{"total":10,"covered":4,"skipped":0,"pct":40},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":10,"covered":4,"skipped":0,"pct":40},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/stewardship.ts": {"lines":{"total":11,"covered":6,"skipped":0,"pct":54.54},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":11,"covered":6,"skipped":0,"pct":54.54},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/tag.ts": {"lines":{"total":20,"covered":8,"skipped":0,"pct":40},"functions":{"total":7,"covered":0,"skipped":0,"pct":0},"statements":{"total":22,"covered":8,"skipped":0,"pct":36.36},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/debug/balance.ts": {"lines":{"total":26,"covered":10,"skipped":0,"pct":38.46},"functions":{"total":8,"covered":0,"skipped":0,"pct":0},"statements":{"total":28,"covered":10,"skipped":0,"pct":35.71},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/debug/chequebook.ts": {"lines":{"total":58,"covered":15,"skipped":0,"pct":25.86},"functions":{"total":18,"covered":0,"skipped":0,"pct":0},"statements":{"total":58,"covered":15,"skipped":0,"pct":25.86},"branches":{"total":2,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/debug/connectivity.ts": {"lines":{"total":35,"covered":10,"skipped":0,"pct":28.57},"functions":{"total":11,"covered":0,"skipped":0,"pct":0},"statements":{"total":36,"covered":10,"skipped":0,"pct":27.77},"branches":{"total":2,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/debug/settlements.ts": {"lines":{"total":18,"covered":7,"skipped":0,"pct":38.88},"functions":{"total":4,"covered":0,"skipped":0,"pct":0},"statements":{"total":18,"covered":7,"skipped":0,"pct":38.88},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/debug/stake.ts": {"lines":{"total":32,"covered":14,"skipped":0,"pct":43.75},"functions":{"total":6,"covered":0,"skipped":0,"pct":0},"statements":{"total":32,"covered":14,"skipped":0,"pct":43.75},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/debug/stamps.ts": {"lines":{"total":70,"covered":17,"skipped":0,"pct":24.28},"functions":{"total":17,"covered":0,"skipped":0,"pct":0},"statements":{"total":73,"covered":17,"skipped":0,"pct":23.28},"branches":{"total":2,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/debug/states.ts": {"lines":{"total":29,"covered":15,"skipped":0,"pct":51.72},"functions":{"total":5,"covered":1,"skipped":0,"pct":20},"statements":{"total":29,"covered":15,"skipped":0,"pct":51.72},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/debug/status.ts": {"lines":{"total":36,"covered":18,"skipped":0,"pct":50},"functions":{"total":7,"covered":0,"skipped":0,"pct":0},"statements":{"total":36,"covered":18,"skipped":0,"pct":50},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/modules/debug/transactions.ts": {"lines":{"total":27,"covered":9,"skipped":0,"pct":33.33},"functions":{"total":5,"covered":0,"skipped":0,"pct":0},"statements":{"total":27,"covered":9,"skipped":0,"pct":33.33},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/stamper/stamper.ts": {"lines":{"total":21,"covered":3,"skipped":0,"pct":14.28},"functions":{"total":5,"covered":0,"skipped":0,"pct":0},"statements":{"total":21,"covered":3,"skipped":0,"pct":14.28},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/types/debug.ts": {"lines":{"total":12,"covered":6,"skipped":0,"pct":50},"functions":{"total":2,"covered":1,"skipped":0,"pct":50},"statements":{"total":12,"covered":6,"skipped":0,"pct":50},"branches":{"total":7,"covered":2,"skipped":0,"pct":28.57}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/types/index.ts": {"lines":{"total":21,"covered":21,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":21,"covered":21,"skipped":0,"pct":100},"branches":{"total":4,"covered":4,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/bytes.ts": {"lines":{"total":53,"covered":27,"skipped":0,"pct":50.94},"functions":{"total":15,"covered":8,"skipped":0,"pct":53.33},"statements":{"total":53,"covered":27,"skipped":0,"pct":50.94},"branches":{"total":18,"covered":9,"skipped":0,"pct":50}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/chunk-joiner.ts": {"lines":{"total":84,"covered":7,"skipped":0,"pct":8.33},"functions":{"total":12,"covered":0,"skipped":0,"pct":0},"statements":{"total":86,"covered":7,"skipped":0,"pct":8.13},"branches":{"total":22,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/chunk-size.ts": {"lines":{"total":9,"covered":1,"skipped":0,"pct":11.11},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":9,"covered":1,"skipped":0,"pct":11.11},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/chunk-stream.browser.ts": {"lines":{"total":55,"covered":0,"skipped":0,"pct":0},"functions":{"total":10,"covered":0,"skipped":0,"pct":0},"statements":{"total":55,"covered":0,"skipped":0,"pct":0},"branches":{"total":7,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/chunk-stream.ts": {"lines":{"total":107,"covered":14,"skipped":0,"pct":13.08},"functions":{"total":9,"covered":0,"skipped":0,"pct":0},"statements":{"total":107,"covered":14,"skipped":0,"pct":13.08},"branches":{"total":28,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/cid.ts": {"lines":{"total":23,"covered":22,"skipped":0,"pct":95.65},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":23,"covered":22,"skipped":0,"pct":95.65},"branches":{"total":3,"covered":2,"skipped":0,"pct":66.66}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/collection.browser.ts": {"lines":{"total":4,"covered":0,"skipped":0,"pct":0},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":4,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/collection.node.ts": {"lines":{"total":33,"covered":15,"skipped":0,"pct":45.45},"functions":{"total":3,"covered":1,"skipped":0,"pct":33.33},"statements":{"total":33,"covered":15,"skipped":0,"pct":45.45},"branches":{"total":10,"covered":3,"skipped":0,"pct":30}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/collection.ts": {"lines":{"total":18,"covered":7,"skipped":0,"pct":38.88},"functions":{"total":8,"covered":2,"skipped":0,"pct":25},"statements":{"total":21,"covered":8,"skipped":0,"pct":38.09},"branches":{"total":9,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/constants.ts": {"lines":{"total":7,"covered":7,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":7,"covered":7,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/data.browser.ts": {"lines":{"total":8,"covered":0,"skipped":0,"pct":0},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":8,"covered":0,"skipped":0,"pct":0},"branches":{"total":3,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/data.ts": {"lines":{"total":11,"covered":1,"skipped":0,"pct":9.09},"functions":{"total":3,"covered":0,"skipped":0,"pct":0},"statements":{"total":12,"covered":1,"skipped":0,"pct":8.33},"branches":{"total":7,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/duration.ts": {"lines":{"total":22,"covered":16,"skipped":0,"pct":72.72},"functions":{"total":17,"covered":12,"skipped":0,"pct":70.58},"statements":{"total":22,"covered":16,"skipped":0,"pct":72.72},"branches":{"total":5,"covered":2,"skipped":0,"pct":40}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/encrypted-chunk-stream.browser.ts": {"lines":{"total":30,"covered":0,"skipped":0,"pct":0},"functions":{"total":9,"covered":0,"skipped":0,"pct":0},"statements":{"total":31,"covered":0,"skipped":0,"pct":0},"branches":{"total":3,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/encrypted-chunk-stream.ts": {"lines":{"total":151,"covered":0,"skipped":0,"pct":0},"functions":{"total":26,"covered":0,"skipped":0,"pct":0},"statements":{"total":155,"covered":0,"skipped":0,"pct":0},"branches":{"total":14,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/error.ts": {"lines":{"total":12,"covered":10,"skipped":0,"pct":83.33},"functions":{"total":3,"covered":2,"skipped":0,"pct":66.66},"statements":{"total":12,"covered":10,"skipped":0,"pct":83.33},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/expose.ts": {"lines":{"total":13,"covered":13,"skipped":0,"pct":100},"functions":{"total":15,"covered":12,"skipped":0,"pct":80},"statements":{"total":20,"covered":20,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/file.ts": {"lines":{"total":12,"covered":2,"skipped":0,"pct":16.66},"functions":{"total":4,"covered":0,"skipped":0,"pct":0},"statements":{"total":13,"covered":2,"skipped":0,"pct":15.38},"branches":{"total":8,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/headers.ts": {"lines":{"total":71,"covered":6,"skipped":0,"pct":8.45},"functions":{"total":5,"covered":0,"skipped":0,"pct":0},"statements":{"total":71,"covered":6,"skipped":0,"pct":8.45},"branches":{"total":39,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/http.ts": {"lines":{"total":37,"covered":25,"skipped":0,"pct":67.56},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":37,"covered":25,"skipped":0,"pct":67.56},"branches":{"total":23,"covered":7,"skipped":0,"pct":30.43}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/mime.ts": {"lines":{"total":1,"covered":1,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":1,"covered":1,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/pss.ts": {"lines":{"total":5,"covered":5,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":5,"covered":5,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/redundancy.ts": {"lines":{"total":44,"covered":32,"skipped":0,"pct":72.72},"functions":{"total":4,"covered":4,"skipped":0,"pct":100},"statements":{"total":45,"covered":33,"skipped":0,"pct":73.33},"branches":{"total":26,"covered":12,"skipped":0,"pct":46.15}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/resource-locator.ts": {"lines":{"total":7,"covered":3,"skipped":0,"pct":42.85},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":7,"covered":3,"skipped":0,"pct":42.85},"branches":{"total":3,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/size.ts": {"lines":{"total":14,"covered":9,"skipped":0,"pct":64.28},"functions":{"total":10,"covered":6,"skipped":0,"pct":60},"statements":{"total":14,"covered":9,"skipped":0,"pct":64.28},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/stamps.ts": {"lines":{"total":63,"covered":31,"skipped":0,"pct":49.2},"functions":{"total":13,"covered":7,"skipped":0,"pct":53.84},"statements":{"total":65,"covered":31,"skipped":0,"pct":47.69},"branches":{"total":22,"covered":4,"skipped":0,"pct":18.18}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/tar-uploader.browser.ts": {"lines":{"total":11,"covered":0,"skipped":0,"pct":0},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":11,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/tar-uploader.ts": {"lines":{"total":12,"covered":6,"skipped":0,"pct":50},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":12,"covered":6,"skipped":0,"pct":50},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/tar-writer.browser.ts": {"lines":{"total":8,"covered":0,"skipped":0,"pct":0},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":8,"covered":0,"skipped":0,"pct":0},"branches":{"total":2,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/tar-writer.ts": {"lines":{"total":14,"covered":2,"skipped":0,"pct":14.28},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":14,"covered":2,"skipped":0,"pct":14.28},"branches":{"total":4,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/tar.browser.ts": {"lines":{"total":40,"covered":0,"skipped":0,"pct":0},"functions":{"total":10,"covered":0,"skipped":0,"pct":0},"statements":{"total":41,"covered":0,"skipped":0,"pct":0},"branches":{"total":4,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/tar.ts": {"lines":{"total":39,"covered":2,"skipped":0,"pct":5.12},"functions":{"total":11,"covered":0,"skipped":0,"pct":0},"statements":{"total":40,"covered":2,"skipped":0,"pct":5},"branches":{"total":6,"covered":0,"skipped":0,"pct":0}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/tokens.ts": {"lines":{"total":37,"covered":23,"skipped":0,"pct":62.16},"functions":{"total":32,"covered":18,"skipped":0,"pct":56.25},"statements":{"total":37,"covered":23,"skipped":0,"pct":62.16},"branches":{"total":8,"covered":4,"skipped":0,"pct":50}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/type.ts": {"lines":{"total":100,"covered":27,"skipped":0,"pct":27},"functions":{"total":50,"covered":2,"skipped":0,"pct":4},"statements":{"total":100,"covered":27,"skipped":0,"pct":27},"branches":{"total":22,"covered":1,"skipped":0,"pct":4.54}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/typed-bytes.ts": {"lines":{"total":86,"covered":77,"skipped":0,"pct":89.53},"functions":{"total":31,"covered":26,"skipped":0,"pct":83.87},"statements":{"total":86,"covered":77,"skipped":0,"pct":89.53},"branches":{"total":11,"covered":8,"skipped":0,"pct":72.72}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/url.ts": {"lines":{"total":15,"covered":11,"skipped":0,"pct":73.33},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":15,"covered":11,"skipped":0,"pct":73.33},"branches":{"total":5,"covered":1,"skipped":0,"pct":20}} +,"/media/attila/Home/home/attila/Projects/bee-js/src/utils/workaround.ts": {"lines":{"total":14,"covered":2,"skipped":0,"pct":14.28},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":14,"covered":2,"skipped":0,"pct":14.28},"branches":{"total":5,"covered":0,"skipped":0,"pct":0}} } diff --git a/test/unit/encrypted-cac.spec.ts b/test/unit/encrypted-cac.spec.ts new file mode 100644 index 00000000..f34ff92c --- /dev/null +++ b/test/unit/encrypted-cac.spec.ts @@ -0,0 +1,163 @@ +import { + makeEncryptedContentAddressedChunk, + decryptEncryptedChunk, + extractEncryptionKey, + extractChunkAddress, +} from '../../src/chunk/encrypted-cac' +import { Span } from '../../src/utils/typed-bytes' + +describe('encrypted-cac', () => { + describe('makeEncryptedContentAddressedChunk', () => { + test('should create encrypted chunk from string', () => { + const payload = 'Hello, Swarm!' + const chunk = makeEncryptedContentAddressedChunk(payload) + + expect(chunk.encryptionKey.length).toBe(32) + expect(chunk.reference.toUint8Array().length).toBe(64) + expect(chunk.address.toUint8Array().length).toBe(32) + expect(chunk.data.length).toBe(8 + 4096) // encrypted span + padded encrypted data + }) + + test('should create encrypted chunk from Uint8Array', () => { + const payload = new Uint8Array([1, 2, 3, 4, 5]) + const chunk = makeEncryptedContentAddressedChunk(payload) + + expect(chunk.encryptionKey.length).toBe(32) + expect(chunk.reference.toUint8Array().length).toBe(64) + expect(chunk.span.toBigInt()).toBe(5n) + }) + + test('should throw error for empty payload', () => { + expect(() => makeEncryptedContentAddressedChunk(new Uint8Array(0))).toThrow('payload size 0 exceeds limits') + }) + + test('should throw error for oversized payload', () => { + const largePayload = new Uint8Array(4097) + expect(() => makeEncryptedContentAddressedChunk(largePayload)).toThrow('payload size 4097 exceeds limits') + }) + + test('should produce different keys for same payload', () => { + const payload = 'Hello, Swarm!' + const chunk1 = makeEncryptedContentAddressedChunk(payload) + const chunk2 = makeEncryptedContentAddressedChunk(payload) + + expect(chunk1.encryptionKey).not.toEqual(chunk2.encryptionKey) + expect(chunk1.reference).not.toEqual(chunk2.reference) + expect(chunk1.address).not.toEqual(chunk2.address) + }) + + test('should encrypt data correctly', () => { + const payload = new Uint8Array([1, 2, 3, 4, 5]) + const chunk = makeEncryptedContentAddressedChunk(payload) + + // Encrypted data should be different from original + expect(chunk.data).not.toEqual(payload) + }) + }) + + describe('decryptEncryptedChunk', () => { + test('should decrypt chunk data correctly', () => { + const originalPayload = 'Hello, Swarm!' + const chunk = makeEncryptedContentAddressedChunk(originalPayload) + + const decrypted = decryptEncryptedChunk(chunk.data, chunk.encryptionKey) + + // Extract span and payload from decrypted data + const decryptedSpan = Span.fromSlice(decrypted, 0) + const payloadLength = Number(decryptedSpan.toBigInt()) + const decryptedPayload = decrypted.slice(8, 8 + payloadLength) + + const decoder = new TextDecoder() + expect(decoder.decode(decryptedPayload)).toBe(originalPayload) + }) + + test('should decrypt binary data correctly', () => { + const originalPayload = new Uint8Array([10, 20, 30, 40, 50]) + const chunk = makeEncryptedContentAddressedChunk(originalPayload) + + const decrypted = decryptEncryptedChunk(chunk.data, chunk.encryptionKey) + + const decryptedSpan = Span.fromSlice(decrypted, 0) + const payloadLength = Number(decryptedSpan.toBigInt()) + const decryptedPayload = decrypted.slice(8, 8 + payloadLength) + + expect(decryptedPayload).toEqual(originalPayload) + }) + + test('should handle maximum payload size', () => { + const maxPayload = new Uint8Array(4096) + crypto.getRandomValues(maxPayload) + + const chunk = makeEncryptedContentAddressedChunk(maxPayload) + const decrypted = decryptEncryptedChunk(chunk.data, chunk.encryptionKey) + + const decryptedSpan = Span.fromSlice(decrypted, 0) + const payloadLength = Number(decryptedSpan.toBigInt()) + const decryptedPayload = decrypted.slice(8, 8 + payloadLength) + + expect(decryptedPayload).toEqual(maxPayload) + }) + }) + + describe('extractEncryptionKey', () => { + test('should extract encryption key from reference', () => { + const payload = 'Hello, Swarm!' + const chunk = makeEncryptedContentAddressedChunk(payload) + + const extractedKey = extractEncryptionKey(chunk.reference) + + expect(extractedKey).toEqual(chunk.encryptionKey) + }) + + test('should throw error for invalid reference length', () => { + const invalidRef = new Uint8Array(32) // Only 32 bytes instead of 64 + const { Reference } = require('../../src/utils/typed-bytes') + const ref = new Reference(invalidRef) + + expect(() => extractEncryptionKey(ref)).toThrow('Invalid encrypted reference length: 32, expected 64') + }) + }) + + describe('extractChunkAddress', () => { + test('should extract chunk address from reference', () => { + const payload = 'Hello, Swarm!' + const chunk = makeEncryptedContentAddressedChunk(payload) + + const extractedAddress = extractChunkAddress(chunk.reference) + + expect(extractedAddress.toUint8Array()).toEqual(chunk.address.toUint8Array()) + }) + + test('should throw error for invalid reference length', () => { + const invalidRef = new Uint8Array(32) + const { Reference } = require('../../src/utils/typed-bytes') + const ref = new Reference(invalidRef) + + expect(() => extractChunkAddress(ref)).toThrow('Invalid encrypted reference length: 32, expected 64') + }) + }) + + describe('encryption/decryption round trip', () => { + test('should successfully round trip various payloads', () => { + const testPayloads = [ + 'Hello, World!', + 'A', + 'x'.repeat(4096), // maximum size + 'The quick brown fox jumps over the lazy dog', + '\u{1F4A9}\u{1F680}\u{1F525}', // emojis + ] + + testPayloads.forEach(originalPayload => { + const chunk = makeEncryptedContentAddressedChunk(originalPayload) + const decrypted = decryptEncryptedChunk(chunk.data, chunk.encryptionKey) + + const decryptedSpan = Span.fromSlice(decrypted, 0) + const payloadLength = Number(decryptedSpan.toBigInt()) + const decryptedPayload = decrypted.slice(8, 8 + payloadLength) + + const decoder = new TextDecoder() + expect(decoder.decode(decryptedPayload)).toBe(originalPayload) + }) + }) + }) +}) diff --git a/test/unit/encryption.spec.ts b/test/unit/encryption.spec.ts new file mode 100644 index 00000000..d5414f4b --- /dev/null +++ b/test/unit/encryption.spec.ts @@ -0,0 +1,217 @@ +import { + Encryption, + generateRandomKey, + newChunkEncrypter, + newDataEncryption, + newSpanEncryption, + KEY_LENGTH, +} from '../../src/chunk/encryption' + +// Test key matching the Go test +const TEST_KEY_HEX = '8abf1502f557f15026716030fb6384792583daf39608a3cd02ff2f47e9bc6e49' +const testKey = new Uint8Array(TEST_KEY_HEX.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16))) + +// Expected encrypted output from Go test (first 4096 bytes chunk with zero data) +const expectedTransformedHex = + '352187af3a843decc63ceca6cb01ea39dbcf77caf0a8f705f5c30d557044ceec9392b94a79376f1e5c10cd0c0f2a98e5353bf22b3ea4fdac6677ee553dec192e3db64e179d0474e96088fb4abd2babd67de123fb398bdf84d818f7bda2c1ab60b3ea0e0569ae54aa969658eb4844e6960d2ff44d7c087ee3aaffa1c0ee5df7e50b615f7ad90190f022934ad5300c7d1809bfe71a11cc04cece5274eb97a5f20350630522c1dbb7cebaf4f97f84e03f5cfd88f2b48880b25d12f4d5e75c150f704ef6b46c72e07db2b705ac3644569dccd22fd8f964f6ef787fda63c46759af334e6f665f70eac775a7017acea49f3c7696151cb1b9434fa4ac27fb803921ffb5ec58dafa168098d7d5b97e384be3384cf5bc235c3d887fef89fe76c0065f9b8d6ad837b442340d9e797b46ef5709ea3358bc415df11e4830de986ef0f1c418ffdcc80e9a3cda9bea0ab5676c0d4240465c43ba527e3b4ea50b4f6255b510e5d25774a75449b0bd71e56c537ade4fcf0f4d63c99ae1dbb5a844971e2c19941b8facfcfc8ee3056e7cb3c7114c5357e845b52f7103cb6e00d2308c37b12baa5b769e1cc7b00fc06f2d16e70cc27a82cb9c1a4e40cb0d43907f73df2c9db44f1b51a6b0bc6d09f77ac3be14041fae3f9df2da42df43ae110904f9ecee278030185254d7c6e918a5512024d047f77a992088cb3190a6587aa54d0c7231c1cd2e455e0d4c07f74bece68e29cd8ba0190c0bcfb26d24634af5d91a81ef5d4dd3d614836ce942ddbf7bb1399317f4c03faa675f325f18324bf9433844bfe5c4cc04130c8d5c329562b7cd66e72f7355de8f5375a72202971613c32bd7f3fcdcd51080758cd1d0a46dbe8f0374381dbc359f5864250c63dde8131cbd7c98ae2b0147d6ea4bf65d1443d511b18e6d608bbb46ac036353b4c51df306a10a6f6939c38629a5c18aaf89cac04bd3ad5156e6b92011c88341cb08551bab0a89e6a46538f5af33b86121dba17e3a434c273f385cd2e8cb90bdd32747d8425d929ccbd9b0815c73325988855549a8489dfd047daf777aaa3099e54cf997175a5d9e1edfe363e3b68c70e02f6bf4fcde6a0f3f7d0e7e98bde1a72ae8b6cd27b32990680cc4a04fc467f41c5adcaddabfc71928a3f6872c360c1d765260690dd28b269864c8e380d9c92ef6b89b0094c8f9bb22608b4156381b19b920e9583c9616ce5693b4d2a6c689f02e6a91584a8e501e107403d2689dd0045269dd9946c0e969fb656a3b39d84a798831f5f9290f163eb2f97d3ae25071324e95e2256d9c1e56eb83c26397855323edc202d56ad05894333b7f0ed3c1e4734782eb8bd5477242fd80d7a89b12866f85cfae476322f032465d6b1253993033fccd4723530630ab97a1566460af9c90c9da843c229406e65f3fa578bd6bf04dee9b6153807ddadb8ceefc5c601a8ab26023c67b1ab1e8e0f29ce94c78c308005a781853e7a2e0e51738939a657c987b5e611f32f47b5ff461c52e63e0ea390515a8e1f5393dae54ea526934b5f310b76e3fa050e40718cb4c8a20e58946d6ee1879f08c52764422fe542b3240e75eccb7aa75b1f8a651e37a3bc56b0932cdae0e985948468db1f98eb4b77b82081ea25d8a762db00f7898864984bd80e2f3f35f236bf57291dec28f550769943bcfb6f884b7687589b673642ef7fe5d7d5a87d3eca5017f83ccb9a3310520474479464cb3f433440e7e2f1e28c0aef700a45848573409e7ab66e0cfd4fe5d2147ace81bc65fd8891f6245cd69246bbf5c27830e5ab882dd1d02aba34ff6ca9af88df00fd602892f02fedbdc65dedec203faf3f8ff4a97314e0ddb58b9ab756a61a562597f4088b445fcc3b28a708ca7b1485dcd791b779fbf2b3ef1ec5c6205f595fbe45a02105034147e5a146089c200a49dae33ae051a08ea5f974a21540aaeffa7f9d9e3d35478016fb27b871036eb27217a5b834b461f535752fb5f1c8dded3ae14ce3a2ef6639e2fe41939e3509e46e347a95d50b2080f1ba42c804b290ddc912c952d1cec3f2661369f738feacc0dbf1ea27429c644e45f9e26f30c341acd34c7519b2a1663e334621691e810767e9918c2c547b2e23cce915f97d26aac8d0d2fcd3edb7986ad4e2b8a852edebad534cb6c0e9f0797d3563e5409d7e068e48356c67ce519246cd9c560e881453df97cbba562018811e6cf8c327f399d1d1253ab47a19f4a0ccc7c6d86a9603e0551da310ea595d71305c4aad96819120a92cdbaf1f77ec8df9cc7c838c0d4de1e8692dd81da38268d1d71324bcffdafbe5122e4b81828e021e936d83ae8021eac592aa52cd296b5ce392c7173d622f8e07d18f59bb1b08ba15211af6703463b09b593af3c37735296816d9f2e7a369354a5374ea3955e14ca8ac56d5bfe4aef7a21bd825d6ae85530bee5d2aaaa4914981b3dfdb2e92ec2a27c83d74b59e84ff5c056f7d8945745f2efc3dcf28f288c6cd8383700fb2312f7001f24dd40015e436ae23e052fe9070ea9535b9c989898a9bda3d5382cf10e432fae6ccf0c825b3e6436edd3a9f8846e5606f8563931b5f29ba407c5236e5730225dda211a8504ec1817bc935e1fd9a532b648c502df302ed2063aed008fd5676131ac9e95998e9447b02bd29d77e38fcfd2959f2de929b31970335eb2a74348cc6918bc35b9bf749eab0fe304c946cd9e1ca284e6853c42646e60b6b39e0d3fb3c260abfc5c1b4ca3c3770f344118ca7c7f5c1ad1f123f8f369cd60afc3cdb3e9e81968c5c9fa7c8b014ffe0508dd4f0a2a976d5d1ca8fc9ad7a237d92cfe7b41413d934d6e142824b252699397e48e4bac4e91ebc10602720684bd0863773c548f9a2f9724245e47b129ecf65afd7252aac48c8a8d6fd3d888af592a01fb02dc71ed7538a700d3d16243e4621e0fcf9f8ed2b4e11c9fa9a95338bb1dac74a7d9bc4eb8cbf900b634a2a56469c00f5994e4f0934bdb947640e6d67e47d0b621aacd632bfd3c800bd7d93bd329f494a90e06ed51535831bd6e07ac1b4b11434ef3918fa9511813a002913f33f836454798b8d1787fea9a4c4743ba091ed192ed92f4d33e43a226bf9503e1a83a16dd340b3cbbf38af6db0d99201da8de529b4225f3d2fa2aad6621afc6c79ef3537720591edfc681ae6d00ede53ed724fc71b23b90d2e9b7158aaee98d626a4fe029107df2cb5f90147e07ebe423b1519d848af18af365c71bfd0665db46be493bbe99b79a188de0cf3594aef2299f0324075bdce9eb0b87bc29d62401ba4fd6ae48b1ba33261b5b845279becf38ee03e3dc5c45303321c5fac96fd02a3ad8c9e3b02127b320501333c9e6360440d1ad5e64a6239501502dde1a49c9abe33b66098458eee3d611bb06ffcd234a1b9aef4af5021cd61f0de6789f822ee116b5078aae8c129e8391d8987500d322b58edd1595dc570b57341f2df221b94a96ab7fbcf32a8ca9684196455694024623d7ed49f7d66e8dd453c0bae50e0d8b34377b22d0ece059e2c385dfc70b9089fcd27577c51f4d870b5738ee2b68c361a67809c105c7848b68860a829f29930857a9f9d40b14fd2384ac43bafdf43c0661103794c4bd07d1cfdd4681b6aeaefad53d4c1473359bcc5a83b09189352e5bb9a7498dd0effb89c35aad26954551f8b0621374b449bf515630bd3974dca982279733470fdd059aa9c3df403d8f22b38c4709c82d8f12b888e22990350490e16179caf406293cc9e65f116bafcbe96af132f679877061107a2f690a82a8cb46eea57a90abd23798c5937c6fe6b17be3f9bfa01ce117d2c268181b9095bf49f395fea07ca03838de0588c5e2db633e836d64488c1421e653ea52d810d096048c092d0da6e02fa6613890219f51a76148c8588c2487b171a28f17b7a299204874af0131725d793481333be5f08e86ca837a226850b0c1060891603bfecf9e55cddd22c0dbb28d495342d9cc3de8409f72e52a0115141cffe755c74f061c1a770428ccb0ae59536ee6fc074fbfc6cacb51a549d327527e20f8407477e60355863f1153f9ce95641198663c968874e7fdb29407bd771d94fdda8180cbb0358f5874738db705924b8cbe0cd5e1484aeb64542fe8f38667b7c34baf818c63b1e18440e9fba575254d063fd49f24ef26432f4eb323f3836972dca87473e3e9bb26dc3be236c3aae6bc8a6da567442309da0e8450e242fc9db836e2964f2c76a3b80a2c677979882dda7d7ebf62c93664018bcf4ec431fe6b403d49b3b36618b9c07c2d0d4569cb8d52223903debc72ec113955b206c34f1ae5300990ccfc0180f47d91afdb542b6312d12aeff7e19c645dc0b9fe6e3288e9539f6d5870f99882df187bfa6d24d179dfd1dac22212c8b5339f7171a3efc15b760fed8f68538bc5cbd845c2d1ab41f3a6c692820653eaef7930c02fbe6061d93805d73decdbb945572a7c44ed0241982a6e4d2d730898f82b3d9877cb7bca41cc6dcee67aa0c3d6db76f0b0a708ace0031113e48429de5d886c10e9200f68f32263a2fbf44a5992c2459fda7b8796ba796e3a0804fc25992ed2c9a5fe0580a6b809200ecde6caa0364b58be11564dcb9a616766dd7906db5636ee708b0204f38d309466d8d4a162965dd727e29f5a6c133e9b4ed5bafe803e479f9b2a7640c942c4a40b14ac7dc9828546052761a070f6404008f1ec3605836339c3da95a00b4fd81b2cabf88b51d2087d5b83e8c5b69bf96d8c72cbd278dad3bbb42b404b436f84ad688a22948adf60a81090f1e904291503c16e9f54b05fc76c881a5f95f0e732949e95d3f1bae2d3652a14fe0dda2d68879604657171856ef72637def2a96ac47d7b3fe86eb3198f5e0e626f06be86232305f2ae79ffcd2725e48208f9d8d63523f81915acc957563ab627cd6bc68c2a37d59fb0ed77a90aa9d085d6914a8ebada22a2c2d471b5163aeddd799d90fbb10ed6851ace2c4af504b7d572686700a59d6db46d5e42bb83f8e0c0ffe1dfa6582cc0b34c921ff6e85e83188d24906d5c08bb90069639e713051b3102b53e6f703e8210017878add5df68e6f2b108de279c5490e9eef5590185c4a1c744d4e00d244e1245a8805bd30407b1bc488db44870ccfd75a8af104df78efa2fb7ba31f048a263efdb3b63271fff4922bece9a71187108f65744a24f4947dc556b7440cb4fa45d296bb7f724588d1f245125b21ea063500029bd49650237f53899daf1312809552c81c5827341263cc807a29fe84746170cdfa1ff3838399a5645319bcaff674bb70efccdd88b3d3bb2f2d98111413585dc5d5bd5168f43b3f55e58972a5b2b9b3733febf02f931bd436648cb617c3794841aab961fe41277ab07812e1d3bc4ff6f4350a3e615bfba08c3b9480ef57904d3a16f7e916345202e3f93d11f7a7305170cb8c4eb9ac88ace8bbd1f377bdd5855d3162d6723d4435e84ce529b8f276a8927915ac759a0d04e5ca4a9d3da6291f0333b475df527e99fe38f7a4082662e8125936640c26dd1d17cf284ce6e2b17777a05aa0574f7793a6a062cc6f7263f7ab126b4528a17becfdec49ac0f7d8705aa1704af97fb861faa8a466161b2b5c08a5bacc79fe8500b913d65c8d3c52d1fd52d2ab2c9f52196e712455619c1cd3e0f391b274487944240e2ed8858dd0823c801094310024ae3fe4dd1cf5a2b6487b42cc5937bbafb193ee331d87e378258963d49b9da90899bbb4b88e79f78e866b0213f4719f67da7bcc2fce073c01e87c62ea3cdbcd589cfc41281f2f4c757c742d6d1e' + +function hexToBytes(hex: string): Uint8Array { + return new Uint8Array(hex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16))) +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map(b => b.toString(16).padStart(2, '0')) + .join('') +} + +describe('encryption', () => { + describe('Encryption', () => { + test('should throw error when data length is longer than padding', () => { + const enc = new Encryption(testKey, 4095, 0) + const data = new Uint8Array(4096) + + expect(() => enc.encrypt(data)).toThrow('data length 4096 longer than padding 4095') + }) + + test('should encrypt data with zero padding', () => { + const enc = new Encryption(testKey, 0, 0) + const data = new Uint8Array(2048) + + const encrypted = enc.encrypt(data) + + expect(encrypted.length).toBe(2048) + }) + + test('should encrypt data when length equals padding', () => { + const enc = new Encryption(testKey, 4096, 0) + const data = new Uint8Array(4096) + + const encrypted = enc.encrypt(data) + const encryptedHex = bytesToHex(encrypted) + const expectedTransformed = hexToBytes(expectedTransformedHex) + + expect(encrypted).toEqual(expectedTransformed) + expect(encryptedHex).toBe(expectedTransformedHex) + }) + + test('should pad encrypted data when length is smaller than padding', () => { + const enc = new Encryption(testKey, 4096, 0) + const data = new Uint8Array(4080) + + const encrypted = enc.encrypt(data) + + expect(encrypted.length).toBe(4096) + }) + + test('should throw error when decrypt data length does not equal padding', () => { + const enc = new Encryption(testKey, 4096, 0) + const data = new Uint8Array(4097) + + expect(() => enc.decrypt(data)).toThrow('data length 4097 different than padding 4096') + }) + + test('should correctly encrypt and decrypt (identity)', () => { + testEncryptDecryptIsIdentity(0, 2048, 2048, 32) + testEncryptDecryptIsIdentity(0, 4096, 4096, 32) + testEncryptDecryptIsIdentity(0, 4096, 1000, 32) + testEncryptDecryptIsIdentity(10, 32, 32, 32) + }) + + test('should produce same ciphertext regardless of input buffer size', () => { + const data = new Uint8Array(4096) + crypto.getRandomValues(data) + + const key = new Uint8Array(KEY_LENGTH) + crypto.getRandomValues(key) + + const enc = new Encryption(key, 0, 42) + const whole = enc.encrypt(data) + + enc.reset() + for (let i = 0; i < 4096; i += KEY_LENGTH) { + const cipher = enc.encrypt(data.subarray(i, i + KEY_LENGTH)) + const wholeSection = whole.subarray(i, i + KEY_LENGTH) + + expect(cipher).toEqual(wholeSection) + } + }) + + test('should reset counter correctly', () => { + const enc = new Encryption(testKey, 0, 0) + const data = new Uint8Array(64) + crypto.getRandomValues(data) + + const encrypted1 = enc.encrypt(data) + enc.reset() + const encrypted2 = enc.encrypt(data) + + expect(encrypted1).toEqual(encrypted2) + }) + }) + + describe('generateRandomKey', () => { + test('should generate key of default length', () => { + const key = generateRandomKey() + expect(key.length).toBe(KEY_LENGTH) + }) + + test('should generate key of specified length', () => { + const key = generateRandomKey(16) + expect(key.length).toBe(16) + }) + + test('should generate different keys', () => { + const key1 = generateRandomKey() + const key2 = generateRandomKey() + + expect(key1).not.toEqual(key2) + }) + }) + + describe('newSpanEncryption', () => { + test('should create span encryption with correct parameters', () => { + const key = generateRandomKey() + const enc = newSpanEncryption(key) + + expect(enc.key()).toEqual(key) + }) + + test('should encrypt 8-byte span', () => { + const key = generateRandomKey() + const enc = newSpanEncryption(key) + const span = new Uint8Array(8) + + const encrypted = enc.encrypt(span) + + expect(encrypted.length).toBe(8) + }) + }) + + describe('newDataEncryption', () => { + test('should create data encryption with correct parameters', () => { + const key = generateRandomKey() + const enc = newDataEncryption(key) + + expect(enc.key()).toEqual(key) + }) + }) + + describe('ChunkEncrypter', () => { + test('should encrypt chunk data', () => { + const encrypter = newChunkEncrypter() + const chunkData = new Uint8Array(4096) + crypto.getRandomValues(chunkData) + + const result = encrypter.encryptChunk(chunkData) + + expect(result.key.length).toBe(KEY_LENGTH) + expect(result.encryptedSpan.length).toBe(8) + expect(result.encryptedData.length).toBe(4096) // padded to ChunkSize + }) + + test('should produce different keys for different chunks', () => { + const encrypter = newChunkEncrypter() + const chunkData = new Uint8Array(4096) + + const result1 = encrypter.encryptChunk(chunkData) + const result2 = encrypter.encryptChunk(chunkData) + + expect(result1.key).not.toEqual(result2.key) + }) + + test('should encrypt span and data separately', () => { + const encrypter = newChunkEncrypter() + const chunkData = new Uint8Array(4096) + // Set first 8 bytes to specific values + chunkData.set([1, 2, 3, 4, 5, 6, 7, 8]) + + const result = encrypter.encryptChunk(chunkData) + + // Verify that encrypted span is different from original + expect(result.encryptedSpan).not.toEqual(chunkData.subarray(0, 8)) + // Verify that encrypted data is different from original + expect(result.encryptedData).not.toEqual(chunkData.subarray(8)) + }) + }) +}) + +function testEncryptDecryptIsIdentity(initCtr: number, padding: number, dataLength: number, keyLength: number): void { + const key = generateRandomKey(keyLength) + const enc = new Encryption(key, padding, initCtr) + + const data = new Uint8Array(dataLength) + crypto.getRandomValues(data) + + const encrypted = enc.encrypt(data) + + enc.reset() + const decrypted = enc.decrypt(encrypted) + + expect(decrypted.length).toBe(padding) + + // Remove extra padding bytes if data was smaller than padding + const actualDecrypted = dataLength < padding ? decrypted.subarray(0, dataLength) : decrypted + + expect(actualDecrypted).toEqual(data) +}