initial commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# FIX CRLF always when developer has not set
|
||||
# Linux/Mac: git config --global core.autocrlf input
|
||||
# Windows: git config --global core.autocrlf true
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
* eol=lf
|
||||
@@ -0,0 +1,31 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
**/node_modules/**
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.pnp.cjs
|
||||
.pnp.loader.mjs
|
||||
.yarn
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.vscode
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
/junit.xml
|
||||
/.nyc_output/
|
||||
/coverage/
|
||||
/dist/
|
||||
.idea
|
||||
build
|
||||
.pnpm-store/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Francisco Hodge
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,175 @@
|
||||
# Meta-Hash
|
||||
|
||||
Meta-Hash is a TypeScript library designed to monitor a specified folder and compute hashes for any new files detected within it. This library leverages an index system to avoid recomputing hashes if the program is restarted, ensuring efficiency and continuity in hash computation. The hashes are computed in the Content Identifier (CID) format, making them compatible with systems like IPFS (InterPlanetary File System).
|
||||
|
||||
## Features
|
||||
|
||||
- **Platform Agnostic**: Dual builds for Node.js and browser environments
|
||||
- **Efficient Hash Computation**: Computes hashes using multiple algorithms (SHA2-256, SHA1, MD5, SHA3-256, SHA3-384, CRC32)
|
||||
- **Persistent Indexing**: CSV-based index system tracks file hashes, sizes, and modification times
|
||||
- **CID Format**: Uses IPFS Content Identifiers (multiformat, multicodec, multihash)
|
||||
- **Worker Thread Support**: Node.js build can use worker threads for parallel hashing
|
||||
- **Incremental Hashing**: Only rehashes files when size or mtime changes
|
||||
- **Multiple Hash Types**: Supports all major cryptographic and checksum algorithms
|
||||
|
||||
## CID Format and Inspection
|
||||
|
||||
The Content Identifier (CID) is a label used to point to material in IPFS. It doesn't indicate where the content is stored, but it forms a kind of address based on the content itself. CIDs are used as a standard way of pointing to pieces of information. A single CID can represent a piece of content that is distributed across multiple systems.
|
||||
|
||||
The list of CID formats used in this project can be found at the following link:
|
||||
[Multicodecs Table](https://ipfs.io/ipfs/QmXec1jjwzxWJoNbxQF5KffL8q6hFXm9QwUGaa3wKGk6dT/#title=Multicodecs&src=https://raw.githubusercontent.com/multiformats/multicodec/master/table.csv)
|
||||
|
||||
To inspect the details of a CID, including its version, codec, multihash and more, you can use the CID Inspector tool. This tool allows you to enter a CID and it will break it down into its component parts and provide a detailed explanation of each part.
|
||||
|
||||
[CID Inspector Tool](https://cid.ipfs.tech/)
|
||||
|
||||
## Installation
|
||||
|
||||
This package is part of the Meta Orbit monorepo and uses pnpm workspaces.
|
||||
|
||||
From the monorepo root:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
When using as a dependency in other packages:
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@metazla/meta-hash": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage of ComputeHashIndexCache
|
||||
|
||||
To integrate Meta-Hash into your application, follow these steps:
|
||||
|
||||
1. **Import the Library**: Import the necessary components from the library.
|
||||
|
||||
```typescript
|
||||
import { ComputeHashIndexCache, CID_ALGORITHM_NAMES } from "./folder-watch-hasher";
|
||||
```
|
||||
|
||||
2. **Initialize the Watcher**: Create an instance of `ComputeHashIndexCache`, specifying the path to the folder you want to monitor and the hash algorithms you wish to use.
|
||||
|
||||
```typescript
|
||||
//note that the csv file will be derived from the path provided into multiple files (one for each algorithm)
|
||||
const computer = new ComputeHashIndexCache("path/to/your/indexfile/index.csv", [CID_ALGORITHM_NAMES.sha256, CID_ALGORITHM_NAMES.sha1]);
|
||||
```
|
||||
|
||||
3. **Compute Hashes**: Invoke the `computeMissingHash` method for each file you wish to hash. This method will automatically check the index for existing hashes and compute missing ones.
|
||||
|
||||
```typescript
|
||||
let hash:MultiHashData = {};
|
||||
await computer.computeMissingHash("path/to/your/file.txt", hash);
|
||||
console.log(hash[CID_ALGORITHM_NAMES.sha256]);
|
||||
```
|
||||
|
||||
## Example of Index File
|
||||
|
||||
An example index file (`index-cid_sha2-256.csv`) is shown below, demonstrating how file paths, sizes, modification times, and computed hashes are stored:
|
||||
|
||||
```
|
||||
path,size,mtime,cid_sha2-256
|
||||
file.mkv,230265591,2023-01-17T18:53:37.692Z,baejbeif45glg263244x7cbjzyd3ssp5edzae7bxurzxjhnogazcwmj32wa
|
||||
...
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions to Meta-Hash are welcome! Please read our contributing guidelines for more information on how to participate.
|
||||
|
||||
## License
|
||||
|
||||
Meta-Hash is released under the [MIT License](LICENSE.md).
|
||||
|
||||
|
||||
## Platform-Specific Builds
|
||||
|
||||
### Node.js Environment
|
||||
|
||||
```typescript
|
||||
import { HashComputerIndexCache, CID_ALGORITHM_NAMES } from '@metazla/meta-hash';
|
||||
import path from 'path';
|
||||
|
||||
// With worker threads for parallel processing
|
||||
const packageEntryPath = path.dirname(import.meta.resolve("@metazla/meta-hash")) + "/worker.js";
|
||||
const hashComputer = new HashComputerIndexCache(
|
||||
'/path/to/index/folder',
|
||||
[CID_ALGORITHM_NAMES.sha256, CID_ALGORITHM_NAMES.sha1],
|
||||
packageEntryPath
|
||||
);
|
||||
|
||||
// Hash a file
|
||||
const hashes = {};
|
||||
await hashComputer.computeMissingHash('/path/to/file.mkv', hashes);
|
||||
console.log(hashes[CID_ALGORITHM_NAMES.sha256]);
|
||||
```
|
||||
|
||||
### Browser Environment
|
||||
|
||||
```typescript
|
||||
import { HashComputerIndexCache, CID_ALGORITHM_NAMES } from '@metazla/meta-hash/index-browser';
|
||||
|
||||
// Browser build (no worker threads, uses Web Crypto API where available)
|
||||
const hashComputer = new HashComputerIndexCache(
|
||||
'/virtual/index/path',
|
||||
[CID_ALGORITHM_NAMES.sha256]
|
||||
);
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Build Configuration
|
||||
|
||||
Built with `tsup` for dual output:
|
||||
- **Node.js**: `dist/index.js` (ESM) and `dist/index.cjs` (CommonJS)
|
||||
- **Browser**: `dist/index-browser.js` (ESM) and `dist/index-browser.cjs` (CommonJS)
|
||||
|
||||
### Worker Thread Support (Node.js)
|
||||
|
||||
The Node.js build includes `worker.js` for parallel hash computation:
|
||||
```typescript
|
||||
// Automatic worker path resolution
|
||||
const workerPath = path.join(
|
||||
path.dirname(import.meta.resolve('@metazla/meta-hash')),
|
||||
'worker.js'
|
||||
);
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# From monorepo root
|
||||
pnpm run build
|
||||
|
||||
# From package directory
|
||||
cd packages/meta-hash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
cd packages/meta-hash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Uses Vitest for unit tests.
|
||||
|
||||
## Integration
|
||||
|
||||
Used by:
|
||||
- **meta-mesh**: File content hashing for deduplication
|
||||
- **meta-orbit**: Content addressing for distributed storage
|
||||
- **meta-ui**: Browser-based file verification
|
||||
|
||||
## Performance
|
||||
|
||||
- **Index Cache**: Avoids rehashing unchanged files (checks size + mtime)
|
||||
- **Worker Threads**: Parallel hashing on multi-core systems (Node.js only)
|
||||
- **Streaming**: Hashes large files without loading into memory
|
||||
- **CSV Index**: Fast lookup and incremental updates
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@metazla/meta-hash",
|
||||
"version": "1.0.0",
|
||||
"description": "module that hash (cid) files and uses cache",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"dist/index.d.ts": [
|
||||
"dist/index.d.ts"
|
||||
],
|
||||
"*": [
|
||||
"dist/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./index-interface": {
|
||||
"import": "./dist/index-interface.js",
|
||||
"require": "./dist/index-interface.cjs"
|
||||
},
|
||||
"./index-browser": {
|
||||
"import": "./dist/index-browser.js",
|
||||
"require": "./dist/index-browser.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"test": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@libp2p/crypto": "^4.0.5",
|
||||
"@types/node": "^20.12.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.3",
|
||||
"vitest": "^1.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"bencodec": "^2.0.1",
|
||||
"chokidar": "^3.6.0",
|
||||
"crc-32": "^1.2.2",
|
||||
"csv-parse": "^5.5.5",
|
||||
"csv-stringify": "^6.4.6",
|
||||
"debounce": "^2.0.0",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"multiformats": "^13.1.0",
|
||||
"p-queue": "^8.0.1",
|
||||
"piscina": "^4.4.0"
|
||||
}
|
||||
}
|
||||
Generated
+2341
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
// Browser-compatible hash implementation
|
||||
import {SimpleHash} from "@root/file-id/SimpleHash";
|
||||
import {createMD5, createSHA1, createSHA256, createSHA3} from "hash-wasm";
|
||||
|
||||
export class BrowserHasher implements SimpleHash {
|
||||
private algorithm: string;
|
||||
private wasmHasher: any;
|
||||
private isWasm: boolean;
|
||||
|
||||
constructor(algorithm: string) {
|
||||
this.algorithm = algorithm;
|
||||
this.isWasm = true;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isWasm) {
|
||||
switch (this.algorithm) {
|
||||
case 'md5':
|
||||
this.wasmHasher = await createMD5();
|
||||
break;
|
||||
case 'sha-1':
|
||||
this.wasmHasher = await createSHA1();
|
||||
break;
|
||||
case 'sha-256':
|
||||
this.wasmHasher = await createSHA256();
|
||||
break;
|
||||
case 'sha3-256':
|
||||
this.wasmHasher = await createSHA3(256);
|
||||
break;
|
||||
case 'sha3-384':
|
||||
this.wasmHasher = await createSHA3(384);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported algorithm: ${this.algorithm}`);
|
||||
}
|
||||
this.wasmHasher.init();
|
||||
}
|
||||
}
|
||||
|
||||
update(data: Uint8Array): SimpleHash {
|
||||
this.wasmHasher.update(data);
|
||||
return this;
|
||||
}
|
||||
|
||||
async digest(): Promise<Uint8Array> {
|
||||
const hash = await this.wasmHasher.digest('binary');
|
||||
return new Uint8Array(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import {createHash} from 'crypto';
|
||||
import {SimpleHash} from './SimpleHash';
|
||||
import {encode} from 'bencodec';
|
||||
import {basename} from 'path';
|
||||
|
||||
/**
|
||||
* BitTorrent v2 Info Hash Hasher (BEP 52)
|
||||
*
|
||||
* Implements BitTorrent v2 file hashing with:
|
||||
* - Fixed 16KB block size
|
||||
* - SHA-256 merkle tree
|
||||
* - Info hash computation
|
||||
*
|
||||
* Architecture:
|
||||
* 1. Streaming: Accumulates file data into 16KB blocks
|
||||
* 2. Block hashing: SHA-256 hash each complete block
|
||||
* 3. Merkle tree: Build balanced tree from block hashes
|
||||
* 4. Info hash: SHA-256(bencode(info_dict))
|
||||
*/
|
||||
export class BtihV2Hasher implements SimpleHash {
|
||||
private static readonly BLOCK_SIZE = 16 * 1024; // 16KB fixed by BEP 52
|
||||
|
||||
private blockHashes: Buffer[] = [];
|
||||
private currentBlock: Buffer[] = [];
|
||||
private currentBlockSize: number = 0;
|
||||
private fileSize: number = 0;
|
||||
private fileName: string = 'unknown';
|
||||
|
||||
/**
|
||||
* Optional: Set filename for info dictionary
|
||||
* If not set, defaults to 'unknown'
|
||||
*/
|
||||
setFileName(fileName: string): void {
|
||||
this.fileName = basename(fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hasher with new data chunk
|
||||
* Accumulates chunks into 16KB blocks and hashes each complete block
|
||||
*/
|
||||
update(data: Uint8Array): SimpleHash {
|
||||
const buffer = Buffer.from(data);
|
||||
this.fileSize += buffer.length;
|
||||
|
||||
let offset = 0;
|
||||
while (offset < buffer.length) {
|
||||
const remaining = BtihV2Hasher.BLOCK_SIZE - this.currentBlockSize;
|
||||
const toRead = Math.min(remaining, buffer.length - offset);
|
||||
|
||||
// Accumulate chunk into current block
|
||||
this.currentBlock.push(buffer.subarray(offset, offset + toRead));
|
||||
this.currentBlockSize += toRead;
|
||||
offset += toRead;
|
||||
|
||||
// When block is complete, hash it
|
||||
if (this.currentBlockSize === BtihV2Hasher.BLOCK_SIZE) {
|
||||
this.finalizeBlock();
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize current block by hashing it and storing the hash
|
||||
*/
|
||||
private finalizeBlock(): void {
|
||||
if (this.currentBlock.length === 0) return;
|
||||
|
||||
const blockData = Buffer.concat(this.currentBlock);
|
||||
const blockHash = createHash('sha256').update(blockData).digest();
|
||||
this.blockHashes.push(blockHash);
|
||||
|
||||
// Reset current block
|
||||
this.currentBlock = [];
|
||||
this.currentBlockSize = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute final info hash
|
||||
*
|
||||
* Steps:
|
||||
* 1. Finalize any partial block
|
||||
* 2. Build merkle tree from block hashes
|
||||
* 3. Construct info dictionary (name, length, pieces_root)
|
||||
* 4. Bencode info dict
|
||||
* 5. SHA-256 of bencoded data = info hash
|
||||
*/
|
||||
digest(): Buffer {
|
||||
// Finalize any remaining partial block
|
||||
if (this.currentBlockSize > 0) {
|
||||
this.finalizeBlock();
|
||||
}
|
||||
|
||||
// Handle empty file edge case
|
||||
if (this.blockHashes.length === 0) {
|
||||
// Empty file: hash empty block
|
||||
const emptyHash = createHash('sha256').update(Buffer.alloc(0)).digest();
|
||||
this.blockHashes.push(emptyHash);
|
||||
}
|
||||
|
||||
// Build merkle tree to get pieces root
|
||||
const piecesRoot = this.buildMerkleTree(this.blockHashes);
|
||||
|
||||
// Construct info dictionary (BEP 52 v2 format)
|
||||
const infoDict = {
|
||||
name: this.fileName,
|
||||
length: this.fileSize,
|
||||
'piece length': this.computePieceLength(this.fileSize),
|
||||
'pieces root': piecesRoot,
|
||||
'meta version': 2 // BitTorrent v2
|
||||
};
|
||||
|
||||
// Bencode the info dictionary
|
||||
const bencodedInfo = encode(infoDict);
|
||||
|
||||
// Info hash = SHA-256 of bencoded info dict
|
||||
const infoHash = createHash('sha256').update(bencodedInfo).digest();
|
||||
|
||||
return infoHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build balanced merkle tree from leaf hashes
|
||||
*
|
||||
* Algorithm (from BEP 52 reference implementation):
|
||||
* 1. Pad to power of 2 with zero hashes
|
||||
* 2. Recursively pair adjacent hashes
|
||||
* 3. Hash each pair: SHA-256(left || right)
|
||||
* 4. Repeat until single root hash remains
|
||||
*/
|
||||
private buildMerkleTree(hashes: Buffer[]): Buffer {
|
||||
if (hashes.length === 0) {
|
||||
throw new Error('Cannot build merkle tree from empty hash list');
|
||||
}
|
||||
|
||||
// Pad to next power of 2 with zero hashes
|
||||
let paddedHashes = [...hashes];
|
||||
const targetSize = this.nextPowerOfTwo(paddedHashes.length);
|
||||
const zeroHash = Buffer.alloc(32); // 32 bytes of zeros for SHA-256
|
||||
while (paddedHashes.length < targetSize) {
|
||||
paddedHashes.push(zeroHash);
|
||||
}
|
||||
|
||||
// Build tree by repeatedly pairing and hashing
|
||||
while (paddedHashes.length > 1) {
|
||||
const parentHashes: Buffer[] = [];
|
||||
|
||||
for (let i = 0; i < paddedHashes.length; i += 2) {
|
||||
const left = paddedHashes[i];
|
||||
const right = paddedHashes[i + 1];
|
||||
const combined = Buffer.concat([left, right]);
|
||||
const parentHash = createHash('sha256').update(combined).digest();
|
||||
parentHashes.push(parentHash);
|
||||
}
|
||||
|
||||
paddedHashes = parentHashes;
|
||||
}
|
||||
|
||||
return paddedHashes[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute piece length using BEP 52 formula
|
||||
*
|
||||
* Formula: piece_length = 16KB * 2^n
|
||||
* where: file_size / piece_length < 2048
|
||||
*
|
||||
* This ensures each file has between 1024 and 2047 pieces
|
||||
*/
|
||||
private computePieceLength(fileSize: number): number {
|
||||
if (fileSize === 0) {
|
||||
return BtihV2Hasher.BLOCK_SIZE; // 16KB for empty files
|
||||
}
|
||||
|
||||
let pieceLength = BtihV2Hasher.BLOCK_SIZE; // Start at 16KB
|
||||
const maxPieces = 2048;
|
||||
|
||||
// Increase piece length until we have < 2048 pieces
|
||||
while (Math.ceil(fileSize / pieceLength) >= maxPieces) {
|
||||
pieceLength *= 2;
|
||||
}
|
||||
|
||||
return pieceLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find next power of 2 >= n
|
||||
*/
|
||||
private nextPowerOfTwo(n: number): number {
|
||||
if (n === 0) return 1;
|
||||
n--;
|
||||
n |= n >> 1;
|
||||
n |= n >> 2;
|
||||
n |= n >> 4;
|
||||
n |= n >> 8;
|
||||
n |= n >> 16;
|
||||
return n + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {CID_ALGORITHM_CODES, CID_ALGORITHM_NAMES} from "@root/hash-compute/MultiHashData";
|
||||
|
||||
export const codeTable = {
|
||||
[CID_ALGORITHM_NAMES.crc32]: CID_ALGORITHM_CODES.crc32,
|
||||
[CID_ALGORITHM_NAMES.md5]: CID_ALGORITHM_CODES.md5,
|
||||
[CID_ALGORITHM_NAMES.sha1]: CID_ALGORITHM_CODES.sha1,
|
||||
[CID_ALGORITHM_NAMES.sha256]: CID_ALGORITHM_CODES.sha256,
|
||||
[CID_ALGORITHM_NAMES.sha3_256]: CID_ALGORITHM_CODES.sha3_256,
|
||||
[CID_ALGORITHM_NAMES.sha3_384]: CID_ALGORITHM_CODES.sha3_384,
|
||||
[CID_ALGORITHM_NAMES.midhash256]: CID_ALGORITHM_CODES.midhash256,
|
||||
[CID_ALGORITHM_NAMES.btih_v2]: CID_ALGORITHM_CODES.btih_v2
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {CID_ALGORITHM_CODES, CID_ALGORITHM_NAMES} from "../hash-compute/MultiHashData";
|
||||
import {create} from "multiformats/hashes/digest";
|
||||
import {CID} from 'multiformats/cid';
|
||||
import {SimpleHash} from "./SimpleHash";
|
||||
import {codeTable} from "@root/file-id/CodeTable";
|
||||
|
||||
/**
|
||||
* Compute the CIDs of a file using specific algorithms
|
||||
* @param stream The Readable stream of the file
|
||||
* @param algorithms Array of algorithms ('sha256', 'sha1', etc.)
|
||||
* @returns Array of CIDs (in the order of the algorithms)
|
||||
*/
|
||||
export async function computeCIDs({stream, algorithms,createHasher}: {
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
algorithms: CID_ALGORITHM_NAMES[];
|
||||
createHasher : (algo: CID_ALGORITHM_NAMES) => Promise<SimpleHash>
|
||||
}): Promise<string[]> {
|
||||
const hashers = await hasherDefiner(algorithms,createHasher);
|
||||
const reader = stream.getReader();
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
// Assuming the hasher can handle Uint8Array directly
|
||||
for (const item of hashers) {
|
||||
await item.hasher.update(value);
|
||||
}
|
||||
}
|
||||
return cidFinalize(hashers);
|
||||
}
|
||||
|
||||
async function hasherDefiner(algorithms: CID_ALGORITHM_NAMES[],
|
||||
createHasher : (algo: CID_ALGORITHM_NAMES) => Promise<SimpleHash>): Promise<{
|
||||
hasher: SimpleHash,
|
||||
code: CID_ALGORITHM_CODES
|
||||
}[]> {
|
||||
const hashers = algorithms.filter(algo => Object.values(CID_ALGORITHM_NAMES).includes(algo))
|
||||
.map(async algo => ({
|
||||
hasher: await createHasher(algo),
|
||||
code: codeTable[algo]
|
||||
}));
|
||||
return await Promise.all(hashers);
|
||||
}
|
||||
|
||||
async function cidFinalize(hashers: { hasher: SimpleHash, code: number }[]): Promise<string[]> {
|
||||
return await Promise.all(hashers.map(async ({code, hasher}) => {
|
||||
const hashBuffer = await hasher.digest();
|
||||
const digest = create(code, hashBuffer);
|
||||
return CID.createV1(code, digest).toString();
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import crc32 from "crc-32";
|
||||
import {SimpleHash} from "@root/file-id/SimpleHash";
|
||||
|
||||
export class Crc32Hash implements SimpleHash {
|
||||
private _crc: number = undefined;
|
||||
|
||||
update(data: Buffer): SimpleHash {
|
||||
// Update the CRC-32 checksum with the new chunk of data
|
||||
this._crc = crc32.buf(data, this._crc);
|
||||
return this;
|
||||
}
|
||||
|
||||
digest(): Buffer {
|
||||
const buffer = Buffer.alloc(4); // Create a buffer of 4 bytes (32 bits)
|
||||
buffer.writeInt32BE(this._crc, 0); // Write the unsigned integer to the buffer in big-endian format
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {CID_ALGORITHM_NAMES} from "../hash-compute/MultiHashData";
|
||||
import {SimpleHash} from "./SimpleHash";
|
||||
import {createHash} from "crypto";
|
||||
import {Crc32Hash} from "./Crc32Hash";
|
||||
import {BtihV2Hasher} from "./BtihV2Hasher";
|
||||
|
||||
/**
|
||||
* Compute the CIDs of a file using specific algorithms
|
||||
* @returns Array of CIDs (in the order of the algorithms)
|
||||
* @param algo
|
||||
*/
|
||||
export async function createHasher(algo: CID_ALGORITHM_NAMES): Promise<SimpleHash> {
|
||||
switch (algo) {
|
||||
case CID_ALGORITHM_NAMES.sha256:
|
||||
return createHash("sha256");
|
||||
case CID_ALGORITHM_NAMES.sha1:
|
||||
return createHash("sha1");
|
||||
case CID_ALGORITHM_NAMES.md5:
|
||||
return createHash("md5");
|
||||
case CID_ALGORITHM_NAMES.sha3_256:
|
||||
return createHash("sha3-256");
|
||||
case CID_ALGORITHM_NAMES.sha3_384:
|
||||
return createHash("sha3-384");
|
||||
case CID_ALGORITHM_NAMES.crc32:
|
||||
return new Crc32Hash();
|
||||
case CID_ALGORITHM_NAMES.btih_v2:
|
||||
return new BtihV2Hasher();
|
||||
default:
|
||||
throw new Error(`Unsupported algorithm: ${algo}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {CID_ALGORITHM_NAMES} from "../hash-compute/MultiHashData";
|
||||
import {SimpleHash} from "./SimpleHash";
|
||||
import {Crc32Hash} from "./Crc32Hash";
|
||||
import {BrowserHasher} from "@root/file-id/BrowserHasher";
|
||||
|
||||
export async function createHasher(algo: CID_ALGORITHM_NAMES): Promise<SimpleHash> {
|
||||
const hasher = (() => {
|
||||
switch (algo) {
|
||||
case CID_ALGORITHM_NAMES.sha256:
|
||||
return new BrowserHasher('sha-256');
|
||||
case CID_ALGORITHM_NAMES.sha1:
|
||||
return new BrowserHasher('sha-1');
|
||||
case CID_ALGORITHM_NAMES.md5:
|
||||
return new BrowserHasher('md5');
|
||||
case CID_ALGORITHM_NAMES.sha3_256:
|
||||
return new BrowserHasher('sha3-256');
|
||||
case CID_ALGORITHM_NAMES.sha3_384:
|
||||
return new BrowserHasher('sha3-384');
|
||||
case CID_ALGORITHM_NAMES.crc32:
|
||||
return new Crc32Hash();
|
||||
default:
|
||||
throw new Error(`Unsupported algorithm: ${algo}`);
|
||||
}
|
||||
})();
|
||||
|
||||
if (hasher instanceof BrowserHasher) {
|
||||
await hasher.initialize();
|
||||
}
|
||||
|
||||
return hasher;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { open, stat } from 'fs/promises';
|
||||
import { create } from 'multiformats/hashes/digest';
|
||||
import { CID } from 'multiformats/cid';
|
||||
import { CID_ALGORITHM_CODES } from '../hash-compute/MultiHashData.js';
|
||||
|
||||
/**
|
||||
* Computes a midhash256 based on sampling the middle 1MB of a file
|
||||
* plus the file size as a 64-bit integer prefix.
|
||||
*
|
||||
* Algorithm:
|
||||
* - For files <= 1MB: Hashes entire file content + size prefix
|
||||
* - For files > 1MB: Hashes middle 1MB + size prefix
|
||||
*
|
||||
* The size prefix ensures that files with identical middle content
|
||||
* but different sizes produce different hashes.
|
||||
*
|
||||
* Performance: ~20ms regardless of file size (O(1) complexity)
|
||||
*
|
||||
* @param filePath - Path to file
|
||||
* @returns CID string in proper CID v1 format with multicodec 0x1000
|
||||
*/
|
||||
export async function computeMidHash256(filePath: string): Promise<string> {
|
||||
const SAMPLE_SIZE = 1024 * 1024; // 1MB
|
||||
|
||||
// Get file size (instant - no file read)
|
||||
const stats = await stat(filePath);
|
||||
const fileSize = stats.size;
|
||||
|
||||
// Create size buffer (64-bit big-endian)
|
||||
const sizeBuffer = Buffer.allocUnsafe(8);
|
||||
sizeBuffer.writeBigUInt64BE(BigInt(fileSize), 0);
|
||||
|
||||
// Extract sample data
|
||||
let sampleData: Buffer;
|
||||
if (fileSize <= SAMPLE_SIZE) {
|
||||
// Small file: read entire content
|
||||
const fd = await open(filePath, 'r');
|
||||
sampleData = Buffer.allocUnsafe(fileSize);
|
||||
await fd.read(sampleData, 0, fileSize, 0);
|
||||
await fd.close();
|
||||
} else {
|
||||
// Large file: read middle 1MB
|
||||
const middleOffset = Math.floor((fileSize - SAMPLE_SIZE) / 2);
|
||||
const fd = await open(filePath, 'r');
|
||||
sampleData = Buffer.allocUnsafe(SAMPLE_SIZE);
|
||||
await fd.read(sampleData, 0, SAMPLE_SIZE, middleOffset);
|
||||
await fd.close();
|
||||
}
|
||||
|
||||
// Compute SHA-256 hash of [size + sample]
|
||||
const hashInput = Buffer.concat([sizeBuffer, sampleData]);
|
||||
const hashBuffer = createHash('sha256')
|
||||
.update(hashInput)
|
||||
.digest();
|
||||
|
||||
// Wrap in CID format with custom multicodec 0x1000
|
||||
const digest = create(CID_ALGORITHM_CODES.midhash256, hashBuffer);
|
||||
return CID.createV1(CID_ALGORITHM_CODES.midhash256, digest).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous version of computeMidHash256 for use in worker threads
|
||||
* Uses synchronous fs operations for compatibility with Piscina workers
|
||||
*/
|
||||
export function computeMidHash256Sync(filePath: string): string {
|
||||
const fs = require('fs');
|
||||
const SAMPLE_SIZE = 1024 * 1024; // 1MB
|
||||
|
||||
// Get file size
|
||||
const stats = fs.statSync(filePath);
|
||||
const fileSize = stats.size;
|
||||
|
||||
// Create size buffer (64-bit big-endian)
|
||||
const sizeBuffer = Buffer.allocUnsafe(8);
|
||||
sizeBuffer.writeBigUInt64BE(BigInt(fileSize), 0);
|
||||
|
||||
// Extract sample data
|
||||
let sampleData: Buffer;
|
||||
if (fileSize <= SAMPLE_SIZE) {
|
||||
// Small file: read entire content
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
sampleData = Buffer.allocUnsafe(fileSize);
|
||||
fs.readSync(fd, sampleData, 0, fileSize, 0);
|
||||
fs.closeSync(fd);
|
||||
} else {
|
||||
// Large file: read middle 1MB
|
||||
const middleOffset = Math.floor((fileSize - SAMPLE_SIZE) / 2);
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
sampleData = Buffer.allocUnsafe(SAMPLE_SIZE);
|
||||
fs.readSync(fd, sampleData, 0, SAMPLE_SIZE, middleOffset);
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
|
||||
// Compute SHA-256 hash of [size + sample]
|
||||
const hashInput = Buffer.concat([sizeBuffer, sampleData]);
|
||||
const hashBuffer = createHash('sha256')
|
||||
.update(hashInput)
|
||||
.digest();
|
||||
|
||||
// Wrap in CID format with custom multicodec 0x1000
|
||||
const digest = create(CID_ALGORITHM_CODES.midhash256, hashBuffer);
|
||||
return CID.createV1(CID_ALGORITHM_CODES.midhash256, digest).toString();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {CID_ALGORITHM_NAMES} from "@root/hash-compute/MultiHashData";
|
||||
|
||||
export interface FileIDComputerInterface{
|
||||
computeCIDs(filePath: ReadableStream<Uint8Array>, algorithms: CID_ALGORITHM_NAMES[]): Promise<string[]>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {Piscina} from "piscina";
|
||||
import {CID_ALGORITHM_NAMES} from "../hash-compute/MultiHashData";
|
||||
|
||||
export class FileIDComputerWorker{
|
||||
private piscina: Piscina;
|
||||
|
||||
constructor(workerPath?:string) {
|
||||
if(!workerPath) {
|
||||
// Construct the URL for the current module
|
||||
let distFolder = import.meta.dirname;
|
||||
distFolder = distFolder.replace('src', 'dist');
|
||||
distFolder = distFolder + "/worker.js";
|
||||
workerPath = new URL(distFolder,"file://").href;
|
||||
}
|
||||
this.piscina = new Piscina({
|
||||
maxThreads: 4,
|
||||
//filename: new URL('./ShaComputeWorker.ts', import.meta.url).href
|
||||
filename: workerPath
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the CIDs of a file using specific algorithms
|
||||
* @param filePath The path to the file
|
||||
* @param algorithms Array of algorithms ('sha256', 'sha1')
|
||||
* @returns Array of CIDs (in the order of the algorithms)
|
||||
*/
|
||||
public async computeCIDs(filePath: string, algorithms: CID_ALGORITHM_NAMES[]): Promise<string[]> {
|
||||
return this.piscina.run({filePath, algorithms});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import {MultiHashData} from '../hash-compute/MultiHashData';
|
||||
import {CID} from 'multiformats/cid';
|
||||
|
||||
/**
|
||||
* Magnet Link Generator for BitTorrent v2
|
||||
*
|
||||
* Generates BitTorrent v2 magnet links from file metadata.
|
||||
*
|
||||
* Format: magnet:?xt=urn:btmh:1220<info-hash-hex>&dn=<filename>&xl=<filesize>
|
||||
*
|
||||
* Components:
|
||||
* - xt: eXact Topic (torrent identifier)
|
||||
* - btmh: BitTorrent Merkle Hash (v2 URN scheme)
|
||||
* - 1220: Multihash prefix (0x12 = SHA-256, 0x20 = 32 bytes)
|
||||
* - info-hash-hex: 64-character hex string (32 bytes SHA-256)
|
||||
* - dn: Display Name (filename)
|
||||
* - xl: eXact Length (file size in bytes)
|
||||
*
|
||||
* Reference: BEP 52, BEP 9 (magnet URIs)
|
||||
*/
|
||||
export class MagnetLinkGenerator {
|
||||
/**
|
||||
* Generate BitTorrent v2 magnet link from metadata
|
||||
*
|
||||
* @param metadata - File metadata containing cid_btih_v2
|
||||
* @param fileName - Original filename for display name
|
||||
* @param fileSize - File size in bytes
|
||||
* @returns Magnet link string, or null if btih_v2 not available
|
||||
*
|
||||
* @example
|
||||
* const metadata = {cid_btih_v2: 'bafyb...'};
|
||||
* const magnet = MagnetLinkGenerator.generate(metadata, 'video.mp4', 1024000);
|
||||
* // Returns: magnet:?xt=urn:btmh:1220abc...&dn=video.mp4&xl=1024000
|
||||
*/
|
||||
static generate(
|
||||
metadata: MultiHashData,
|
||||
fileName: string,
|
||||
fileSize: number
|
||||
): string | null {
|
||||
const cidString = metadata.cid_btih_v2;
|
||||
if (!cidString) {
|
||||
return null; // BitTorrent v2 hash not computed
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode CID to get raw info hash
|
||||
const cid = CID.parse(cidString);
|
||||
const infoHash = cid.multihash.digest; // Raw 32-byte SHA-256 hash
|
||||
|
||||
// Convert to hex for magnet link
|
||||
const infoHashHex = Buffer.from(infoHash).toString('hex');
|
||||
|
||||
// Build magnet link components
|
||||
const components = [
|
||||
`xt=urn:btmh:1220${infoHashHex}`, // 1220 = multihash prefix (SHA-256, 32 bytes)
|
||||
`dn=${encodeURIComponent(fileName)}`, // Display name (URL encoded)
|
||||
`xl=${fileSize}` // Exact length in bytes
|
||||
];
|
||||
|
||||
return `magnet:?${components.join('&')}`;
|
||||
} catch (error) {
|
||||
console.error('Failed to generate magnet link from CID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract info hash from magnet link
|
||||
*
|
||||
* @param magnetLink - Magnet link string
|
||||
* @returns Info hash as hex string, or null if invalid
|
||||
*
|
||||
* @example
|
||||
* const hash = MagnetLinkGenerator.extractInfoHash('magnet:?xt=urn:btmh:1220abc...');
|
||||
* // Returns: 'abc...'
|
||||
*/
|
||||
static extractInfoHash(magnetLink: string): string | null {
|
||||
try {
|
||||
const match = magnetLink.match(/xt=urn:btmh:1220([0-9a-f]{64})/i);
|
||||
return match ? match[1] : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from magnet link
|
||||
*
|
||||
* @param magnetLink - Magnet link string
|
||||
* @returns Decoded filename, or null if not present
|
||||
*/
|
||||
static extractFileName(magnetLink: string): string | null {
|
||||
try {
|
||||
const match = magnetLink.match(/dn=([^&]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file size from magnet link
|
||||
*
|
||||
* @param magnetLink - Magnet link string
|
||||
* @returns File size in bytes, or null if not present
|
||||
*/
|
||||
static extractFileSize(magnetLink: string): number | null {
|
||||
try {
|
||||
const match = magnetLink.match(/xl=(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate BitTorrent v2 magnet link format
|
||||
*
|
||||
* @param magnetLink - Magnet link string
|
||||
* @returns True if valid BitTorrent v2 magnet link
|
||||
*/
|
||||
static isValid(magnetLink: string): boolean {
|
||||
if (!magnetLink.startsWith('magnet:?')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have btmh (BitTorrent Merkle Hash) identifier
|
||||
if (!magnetLink.includes('xt=urn:btmh:1220')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have 64-character hex hash (32 bytes SHA-256)
|
||||
const hashMatch = magnetLink.match(/1220([0-9a-f]{64})/i);
|
||||
if (!hashMatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse magnet link into components
|
||||
*
|
||||
* @param magnetLink - Magnet link string
|
||||
* @returns Object with parsed components, or null if invalid
|
||||
*/
|
||||
static parse(magnetLink: string): {
|
||||
infoHash: string;
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
} | null {
|
||||
if (!this.isValid(magnetLink)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const infoHash = this.extractInfoHash(magnetLink);
|
||||
if (!infoHash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
infoHash,
|
||||
fileName: this.extractFileName(magnetLink) || undefined,
|
||||
fileSize: this.extractFileSize(magnetLink) || undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate magnet link from CID string directly
|
||||
*
|
||||
* @param cidString - CID string (e.g., 'bafyb...')
|
||||
* @param fileName - Original filename
|
||||
* @param fileSize - File size in bytes
|
||||
* @returns Magnet link string, or null if CID invalid
|
||||
*/
|
||||
static fromCID(
|
||||
cidString: string,
|
||||
fileName: string,
|
||||
fileSize: number
|
||||
): string | null {
|
||||
return this.generate({cid_btih_v2: cidString}, fileName, fileSize);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {CID_ALGORITHM_NAMES} from "@root/hash-compute/MultiHashData";
|
||||
import fs from "fs";
|
||||
import {Readable} from 'stream';
|
||||
import {computeCIDs as computeCIDsInternal} from "./ComputeHash";
|
||||
import {createHasher} from "@root/file-id/CreateHasher";
|
||||
|
||||
/**
|
||||
* Compute the CIDs of a file using specific algorithms
|
||||
* @param filePath The path to the file
|
||||
* @param algorithms Array of algorithms ('sha256', 'sha1')
|
||||
* @returns Array of CIDs (in the order of the algorithms)
|
||||
*/
|
||||
export default async function computeCIDs({filePath, algorithms}: {
|
||||
filePath: string;
|
||||
algorithms: CID_ALGORITHM_NAMES[]
|
||||
}): Promise<string[]> {
|
||||
const stream = Readable.toWeb(fs.createReadStream(filePath)) as ReadableStream<Uint8Array>;
|
||||
return computeCIDsInternal({stream, algorithms, createHasher});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface SimpleHash {
|
||||
update(data: Uint8Array): SimpleHash | Promise<SimpleHash>;
|
||||
|
||||
digest(): Uint8Array | Promise<Uint8Array>;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Interface for file processing implementations used by FolderWatcher.
|
||||
*
|
||||
* Provides hooks for different stages of the file processing pipeline:
|
||||
* 1. markPending() - File discovered, waiting in queue
|
||||
* 2. queueFile() - File starts processing (optional pre-processing phase)
|
||||
* 3. processFile() - Main processing phase
|
||||
* 4. finalize() - After all files processed (optional)
|
||||
*/
|
||||
export interface FileProcessorInterface {
|
||||
/** Optional initialization before processing starts */
|
||||
initialize?(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Main file processing method.
|
||||
* @param current - Current file index in the queue
|
||||
* @param queueSize - Total number of files to process
|
||||
* @param nfoFilePath - Path to the file being processed
|
||||
*/
|
||||
processFile(current: number, queueSize: number, nfoFilePath: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Determines if a file should be processed.
|
||||
* @param filePath - Path to the file to check
|
||||
* @returns true if the file should be processed
|
||||
*/
|
||||
canProcessFile(filePath: string): Promise<boolean>;
|
||||
|
||||
/** Optional callback when a file is deleted */
|
||||
deleteFile?(filePath: string): Promise<void>;
|
||||
|
||||
/** Optional callback after all files have been processed */
|
||||
finalize?(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Optional callback when a file is discovered and marked as pending.
|
||||
* Called BEFORE the file enters the processing queue.
|
||||
* Useful for tracking/monitoring pending files.
|
||||
* @param filePath - Path to the file being marked as pending
|
||||
*/
|
||||
markPending?(filePath: string): void;
|
||||
|
||||
/**
|
||||
* Optional callback when a file begins processing.
|
||||
* Called WITHIN the queue (respects concurrency limits).
|
||||
* Can be used for quick/light pre-processing before main processing.
|
||||
* @param filePath - Path to the file being queued
|
||||
*/
|
||||
queueFile?(filePath: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import * as path from 'path';
|
||||
import chokidar from 'chokidar';
|
||||
import {readdir} from "fs/promises";
|
||||
|
||||
/**
|
||||
* Generic file discovery utility
|
||||
* Discovers files in directory trees and emits paths as async generator
|
||||
* No filtering logic - just pure file discovery
|
||||
*/
|
||||
export class FolderWatcher {
|
||||
/**
|
||||
* Discover all files in given directories
|
||||
* Returns async generator that yields file paths as they're discovered
|
||||
*
|
||||
* @param directories - Array of directory paths to scan
|
||||
* @returns AsyncGenerator yielding file paths
|
||||
*/
|
||||
async *discoverFiles(directories: string[]): AsyncGenerator<string> {
|
||||
for (const dir of directories) {
|
||||
const normalizedDir = path.normalize(dir);
|
||||
yield* this.walkDirectory(normalizedDir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk directory tree
|
||||
* Yields file paths as they're discovered
|
||||
*
|
||||
* @param directory - Directory to walk
|
||||
* @returns AsyncGenerator yielding file paths
|
||||
*/
|
||||
private async *walkDirectory(directory: string): AsyncGenerator<string> {
|
||||
try {
|
||||
const entries = await readdir(directory, {withFileTypes: true});
|
||||
|
||||
// Collect subdirectories for parallel processing
|
||||
const subdirs: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
subdirs.push(fullPath);
|
||||
} else {
|
||||
// Yield file path immediately
|
||||
yield fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Process subdirectories (yields naturally to event loop)
|
||||
for (const subdir of subdirs) {
|
||||
yield* this.walkDirectory(subdir);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Handle permission errors gracefully
|
||||
if (error.code === 'EACCES' || error.code === 'EPERM') {
|
||||
console.warn(`[FolderWatcher] Permission denied: ${directory} - skipping`);
|
||||
} else if (error.code === 'ENOENT') {
|
||||
console.warn(`[FolderWatcher] Directory not found: ${directory} - skipping`);
|
||||
} else if (error.code === 'ENOTDIR') {
|
||||
console.warn(`[FolderWatcher] Not a directory: ${directory} - skipping`);
|
||||
} else {
|
||||
console.error(`[FolderWatcher] Error reading directory ${directory}:`, error.message);
|
||||
}
|
||||
// Continue processing - don't let one bad directory stop the scan
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch directories for file changes using chokidar
|
||||
*
|
||||
* @param directories - Directories to watch
|
||||
* @param callbacks - Event callbacks
|
||||
* @param options - Chokidar options
|
||||
*/
|
||||
watch(directories: string[], callbacks: {
|
||||
onAdd?: (filePath: string) => void | Promise<void>;
|
||||
onChange?: (filePath: string) => void | Promise<void>;
|
||||
onUnlink?: (filePath: string) => void | Promise<void>;
|
||||
onReady?: () => void | Promise<void>;
|
||||
onError?: (error: Error) => void | Promise<void>;
|
||||
}, options?: {
|
||||
interval?: number;
|
||||
stabilityThreshold?: number;
|
||||
pollInterval?: number;
|
||||
}): chokidar.FSWatcher {
|
||||
const chokidarConfig: chokidar.WatchOptions = {
|
||||
ignoreInitial: true, // Don't emit events for initial scan
|
||||
persistent: true,
|
||||
depth: Infinity,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: options?.stabilityThreshold || 30000,
|
||||
pollInterval: options?.pollInterval || 5000
|
||||
}
|
||||
};
|
||||
|
||||
let watcher: chokidar.FSWatcher;
|
||||
if ((options?.interval || 0) <= 0) {
|
||||
watcher = chokidar.watch(directories, chokidarConfig);
|
||||
} else {
|
||||
watcher = chokidar.watch(directories, {
|
||||
...chokidarConfig,
|
||||
usePolling: true,
|
||||
interval: options.interval
|
||||
});
|
||||
}
|
||||
|
||||
if (callbacks.onAdd) {
|
||||
watcher.on('add', async (filePath) => {
|
||||
try {
|
||||
await callbacks.onAdd!(filePath);
|
||||
} catch (error) {
|
||||
console.error(`[FolderWatcher] Error in onAdd callback for ${filePath}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (callbacks.onChange) {
|
||||
watcher.on('change', async (filePath) => {
|
||||
try {
|
||||
await callbacks.onChange!(filePath);
|
||||
} catch (error) {
|
||||
console.error(`[FolderWatcher] Error in onChange callback for ${filePath}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (callbacks.onUnlink) {
|
||||
watcher.on('unlink', async (filePath) => {
|
||||
try {
|
||||
await callbacks.onUnlink!(filePath);
|
||||
} catch (error) {
|
||||
console.error(`[FolderWatcher] Error in onUnlink callback for ${filePath}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watcher.on('error', (error: Error) => {
|
||||
if (callbacks.onError) {
|
||||
callbacks.onError(error);
|
||||
} else {
|
||||
// Default error handling
|
||||
const err = error as any;
|
||||
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
||||
console.warn(`[FolderWatcher] Permission denied for file watcher:`, error.message);
|
||||
} else if (err.code === 'ENOENT') {
|
||||
console.warn(`[FolderWatcher] Watched path no longer exists:`, error.message);
|
||||
} else if (err.code === 'ENOSPC') {
|
||||
console.error(`[FolderWatcher] CRITICAL: No space left on device or inotify watch limit reached:`, error.message);
|
||||
console.error(`[FolderWatcher] Try increasing fs.inotify.max_user_watches: sysctl fs.inotify.max_user_watches=524288`);
|
||||
} else {
|
||||
console.error(`[FolderWatcher] Watcher error:`, error.message || error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (callbacks.onReady) {
|
||||
watcher.on('ready', async () => {
|
||||
try {
|
||||
console.log(`[FolderWatcher] Watching for file changes on ${directories.join(', ')}`);
|
||||
await callbacks.onReady!();
|
||||
} catch (error) {
|
||||
console.error(`[FolderWatcher] Error in onReady callback:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return watcher;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {readdir} from "fs/promises";
|
||||
import * as path from 'path';
|
||||
import {FileProcessorInterface} from "./FileProcessorInterface.js";
|
||||
|
||||
export interface PollingPathConfig {
|
||||
path: string;
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PollingWatcher - Manual polling-based file watcher (no chokidar)
|
||||
*
|
||||
* Polls specific directories at configured intervals using simple setInterval + readdir.
|
||||
* Perfect for network drives (SMB/NFS) where filesystem events don't work reliably.
|
||||
*
|
||||
* Usage:
|
||||
* - Configure with array of {path, intervalMs} objects
|
||||
* - Each path can have its own polling interval
|
||||
* - Runs in parallel with FolderWatcher (chokidar)
|
||||
*/
|
||||
export class PollingWatcher {
|
||||
private fileProcessor: FileProcessorInterface;
|
||||
private pollingConfigs: PollingPathConfig[];
|
||||
private intervals: NodeJS.Timeout[] = [];
|
||||
private seenFiles: Set<string> = new Set();
|
||||
private initialized = false;
|
||||
|
||||
constructor(fileProcessor: FileProcessorInterface, pollingConfigs: PollingPathConfig[]) {
|
||||
this.fileProcessor = fileProcessor;
|
||||
this.pollingConfigs = pollingConfigs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan directory and find all files
|
||||
*/
|
||||
private async scanDirectory(directory: string, allFiles: string[]): Promise<void> {
|
||||
try {
|
||||
const entries = await readdir(directory, {withFileTypes: true});
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await this.scanDirectory(fullPath, allFiles);
|
||||
} else {
|
||||
allFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Ignore permission errors and other scan errors
|
||||
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
|
||||
console.error(`Error scanning directory ${directory}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single file (check if new, then queue for processing)
|
||||
*/
|
||||
private async processFile(filePath: string): Promise<void> {
|
||||
// Skip if already seen
|
||||
if (this.seenFiles.has(filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if file type is supported
|
||||
if (!await this.fileProcessor.canProcessFile(filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as seen
|
||||
this.seenFiles.add(filePath);
|
||||
console.log(`[PollingWatcher] New file detected: ${filePath}`);
|
||||
|
||||
// Queue for processing (same pipeline as chokidar)
|
||||
if (this.fileProcessor.markPending) {
|
||||
this.fileProcessor.markPending(filePath);
|
||||
}
|
||||
|
||||
if (this.fileProcessor.queueFile) {
|
||||
await this.fileProcessor.queueFile(filePath);
|
||||
}
|
||||
|
||||
if (this.fileProcessor.processFile) {
|
||||
this.fileProcessor.processFile(0, 0, filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a specific directory once
|
||||
*/
|
||||
private async pollDirectory(dirPath: string): Promise<void> {
|
||||
console.log(`[PollingWatcher] Polling ${dirPath}...`);
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const allFiles: string[] = [];
|
||||
await this.scanDirectory(dirPath, allFiles);
|
||||
|
||||
console.log(`[PollingWatcher] Found ${allFiles.length} files in ${dirPath} (${Date.now() - startTime}ms)`);
|
||||
|
||||
// Process new files
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const filePath of allFiles) {
|
||||
promises.push(this.processFile(filePath));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
console.log(`[PollingWatcher] Poll completed for ${dirPath} (${Date.now() - startTime}ms)`);
|
||||
} catch (error: any) {
|
||||
console.error(`[PollingWatcher] Error polling ${dirPath}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling all configured paths
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.pollingConfigs.length === 0) {
|
||||
console.log('[PollingWatcher] No polling paths configured, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[PollingWatcher] Starting polling for ${this.pollingConfigs.length} paths`);
|
||||
|
||||
// Initialize file processor if needed
|
||||
if (!this.initialized && this.fileProcessor.initialize) {
|
||||
await this.fileProcessor.initialize();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
// Do initial scan for all paths
|
||||
for (const config of this.pollingConfigs) {
|
||||
console.log(`[PollingWatcher] Initial scan: ${config.path} (interval: ${config.intervalMs}ms)`);
|
||||
await this.pollDirectory(config.path);
|
||||
}
|
||||
|
||||
// Start interval polling for each path
|
||||
for (const config of this.pollingConfigs) {
|
||||
const interval = setInterval(async () => {
|
||||
await this.pollDirectory(config.path);
|
||||
}, config.intervalMs);
|
||||
|
||||
this.intervals.push(interval);
|
||||
console.log(`[PollingWatcher] Polling started for ${config.path} every ${config.intervalMs}ms`);
|
||||
}
|
||||
|
||||
console.log('[PollingWatcher] All polling intervals started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all polling
|
||||
*/
|
||||
stop(): void {
|
||||
console.log('[PollingWatcher] Stopping all polling intervals');
|
||||
for (const interval of this.intervals) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
this.intervals = [];
|
||||
console.log('[PollingWatcher] All polling intervals stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the seen files cache (useful for testing/debugging)
|
||||
*/
|
||||
clearCache(): void {
|
||||
console.log(`[PollingWatcher] Clearing seen files cache (${this.seenFiles.size} files)`);
|
||||
this.seenFiles.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {CID_ALGORITHM_NAMES, MultiHashData} from "./MultiHashData";
|
||||
|
||||
import {computeCIDs} from "@root/file-id/ComputeHash";
|
||||
import {SimpleHash} from "@root/file-id/SimpleHash";
|
||||
|
||||
export class HashComputer{
|
||||
|
||||
constructor(private targetHash: CID_ALGORITHM_NAMES[],
|
||||
private createHasher : (algo: CID_ALGORITHM_NAMES) => Promise<SimpleHash>) {
|
||||
}
|
||||
|
||||
async computeMissingHash(stream: ReadableStream<Uint8Array>, metadata: MultiHashData): Promise<void> {
|
||||
// Dynamically determine which hashes are needed
|
||||
const neededHashes = this.targetHash.filter(hashName => !metadata[hashName]);
|
||||
|
||||
// If all hashes are already computed, skip the processing
|
||||
if (neededHashes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute only the needed CIDs
|
||||
const cids = await computeCIDs({stream, algorithms:neededHashes,createHasher:this.createHasher});
|
||||
|
||||
// Map the computed CIDs back to their respective metadata properties
|
||||
for (const [index, cid] of cids.entries()) {
|
||||
const hashType = neededHashes[index];
|
||||
metadata[hashType] = cid;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {CID_ALGORITHM_NAMES, ComputeInterface, MultiHashData} from "./MultiHashData";
|
||||
import {Readable} from "stream";
|
||||
|
||||
import {computeCIDs} from "@root/file-id/ComputeHash";
|
||||
import fs from "fs";
|
||||
import {createHasher} from "@root/file-id/CreateHasher";
|
||||
|
||||
export class HashComputerFile implements ComputeInterface{
|
||||
|
||||
constructor(private targetHash: CID_ALGORITHM_NAMES[]) {
|
||||
}
|
||||
|
||||
public async computeMissingHash(filePath: string, metadata: MultiHashData): Promise<void> {
|
||||
// Dynamically determine which hashes are needed
|
||||
const neededHashes = this.targetHash.filter(hashName => !metadata[hashName]);
|
||||
|
||||
// If all hashes are already computed, skip the processing
|
||||
if (neededHashes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute only the needed CIDs
|
||||
const stream = Readable.toWeb(fs.createReadStream(filePath)) as ReadableStream<Uint8Array>;
|
||||
const cids = await computeCIDs({stream, algorithms:neededHashes,createHasher});
|
||||
|
||||
// Map the computed CIDs back to their respective metadata properties
|
||||
for (const [index, cid] of cids.entries()) {
|
||||
const hashType = neededHashes[index];
|
||||
metadata[hashType] = cid;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {HashComputerWorker} from "./HashComputerWorker";
|
||||
import {CID_ALGORITHM_NAMES, ComputeInterface, MultiHashData} from "./MultiHashData";
|
||||
import {HashIndexManager} from "./HashIndexManager";
|
||||
import {stat} from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export class HashComputerIndexCache implements ComputeInterface {
|
||||
private hashIndexManager: HashIndexManager;
|
||||
private hashComputer: HashComputerWorker;
|
||||
|
||||
constructor(indexFolderPath: string,private targetHash: CID_ALGORITHM_NAMES[] = [CID_ALGORITHM_NAMES.sha1, CID_ALGORITHM_NAMES.sha256],workerPath?:string) {
|
||||
this.hashComputer = new HashComputerWorker(targetHash,workerPath);
|
||||
this.hashIndexManager = new HashIndexManager(indexFolderPath,targetHash);
|
||||
}
|
||||
|
||||
public async computeMissingHash(filePath: string, metadata: MultiHashData): Promise<void> {
|
||||
await this.hashIndexManager.init();
|
||||
let stats = await stat(filePath);
|
||||
if (this.hashIndexManager.hasFileInCache(path.basename(filePath),stats)) {
|
||||
const indexLine = this.hashIndexManager.getCidForFile(filePath, stats.size, stats.mtime.toISOString());
|
||||
if(indexLine) {
|
||||
for (const hash of this.targetHash) {
|
||||
if (!metadata[hash] && indexLine[hash]) {
|
||||
metadata[hash] = indexLine[hash];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.hashComputer.computeMissingHash(filePath, metadata);
|
||||
this.hashIndexManager.addFileCid(filePath, stats.size, stats.mtime.toISOString(), metadata);
|
||||
}
|
||||
|
||||
async getHashIndexManager(): Promise<HashIndexManager> {
|
||||
await this.hashIndexManager.init();
|
||||
return this.hashIndexManager;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {FileIDComputerWorker} from "../file-id/FileIDComputerWorker";
|
||||
import {CID_ALGORITHM_NAMES, ComputeInterface, MultiHashData} from "./MultiHashData";
|
||||
|
||||
export class HashComputerWorker implements ComputeInterface {
|
||||
private fileIDComputer: FileIDComputerWorker;
|
||||
|
||||
constructor(private targetHash: CID_ALGORITHM_NAMES[], workerPath?: string) {
|
||||
this.fileIDComputer = new FileIDComputerWorker(workerPath);
|
||||
}
|
||||
|
||||
async computeMissingHash(filePath: string, metadata: MultiHashData): Promise<void> {
|
||||
// Dynamically determine which hashes are needed
|
||||
const neededHashes = this.targetHash.filter(hashName => !metadata[hashName]);
|
||||
|
||||
// If all hashes are already computed, skip the processing
|
||||
if (neededHashes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute only the needed CIDs
|
||||
const cids = await this.fileIDComputer.computeCIDs(filePath, neededHashes);
|
||||
|
||||
// Map the computed CIDs back to their respective metadata properties
|
||||
for (const [index, cid] of cids.entries()) {
|
||||
const hashType = neededHashes[index];
|
||||
metadata[hashType] = cid;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import {createReadStream, promises as fs} from 'fs';
|
||||
import {parse} from 'csv-parse';
|
||||
import {parse as parseSync} from 'csv-parse/sync';
|
||||
import {stringify} from 'csv-stringify/sync';
|
||||
import {existsAsync} from "../utils/ExistsAsync";
|
||||
import path from "path";
|
||||
import {clearInterval} from "node:timers";
|
||||
import {CID_ALGORITHM_NAMES} from "./MultiHashData";
|
||||
import {stat} from "fs/promises";
|
||||
import {Stats} from "node:fs";
|
||||
|
||||
interface IndexLine extends Partial<Record<CID_ALGORITHM_NAMES, string>> {
|
||||
path: string;
|
||||
size: string;
|
||||
mtime: string;
|
||||
}
|
||||
|
||||
export const INDEX_HEADERS = ['path', 'size', 'mtime'];
|
||||
|
||||
export class HashIndexManager {
|
||||
private cache: Map<string, IndexLine> = new Map<string, IndexLine>();//the key is the file `${file_name}-${filesize}-${mtime_ISOstr}`
|
||||
private intervalId: any;
|
||||
private intervalTime: number = 30000;
|
||||
private lastIndexFileSize: { [key in CID_ALGORITHM_NAMES]?: number } = {}; //size of the index file last time it was read
|
||||
private lastCacheFile: { [key in CID_ALGORITHM_NAMES]?: IndexLine[] } = {}; //state of the file last time it was read
|
||||
private indexOpsInProgress: boolean = false;
|
||||
private hasChanged: boolean = false;
|
||||
private initialLoad: Promise<void>;
|
||||
private filePaths: { [key in CID_ALGORITHM_NAMES]?: string } = {};
|
||||
|
||||
constructor(private indexFolderPath: string,
|
||||
private targetHash: CID_ALGORITHM_NAMES[]) {
|
||||
}
|
||||
|
||||
hasFileInCache(filePath: string,stats:Stats): boolean {
|
||||
return this.cache.has(`${path.basename(filePath)}-${stats.size}-${stats.mtime.toISOString()}`);
|
||||
}
|
||||
|
||||
getCacheSize(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* After init consecutively calls to this method will not reload the index
|
||||
* readThe index for the first time and enable autosave
|
||||
* @param autosave
|
||||
*/
|
||||
public async init(autosave = true) {
|
||||
if (!this.initialLoad) {
|
||||
this.initialLoad = new Promise<void>(async (resolve, reject) => {
|
||||
// Create the index folder if it doesn't exist
|
||||
await fs.mkdir(this.indexFolderPath, { recursive: true });
|
||||
const stat = await fs.stat(this.indexFolderPath);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`Invalid index folder path ${this.indexFolderPath}`);
|
||||
}
|
||||
for (const hash of this.targetHash) {
|
||||
this.filePaths[hash] = path.join(this.indexFolderPath, `index-${hash}.csv`);
|
||||
console.log(`Index file path for ${hash} is ${this.filePaths[hash]}`);
|
||||
if (!this.filePaths[hash]) {
|
||||
throw new Error(`Invalid index file path for ${hash}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
for (const hash of this.targetHash) {
|
||||
if (!this.checkCSVHeaders(this.filePaths[hash], hash)) {
|
||||
throw new Error(`Invalid index file headers for ${hash}`);
|
||||
}
|
||||
await this.loadIndex(hash);
|
||||
}
|
||||
if (autosave) {
|
||||
this.startAutoSave()
|
||||
}
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.initialLoad;
|
||||
}
|
||||
|
||||
// Function to check CSV headers
|
||||
private checkCSVHeaders(csvContent: string, hash: CID_ALGORITHM_NAMES): boolean {
|
||||
const records = parseSync(csvContent, {
|
||||
bom: true,
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
});
|
||||
|
||||
if (records.length === 0) {
|
||||
//we will write the headers
|
||||
return true;
|
||||
}
|
||||
|
||||
// Extract headers
|
||||
const headers = Object.keys(records[0]);
|
||||
|
||||
// Define required headers
|
||||
//from the IndexLine interface
|
||||
const requiredHeaders = [...INDEX_HEADERS, hash];
|
||||
|
||||
// Check if all required headers are present
|
||||
return requiredHeaders.every(header => headers.includes(header));
|
||||
};
|
||||
|
||||
public stopAutoSave() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
public startAutoSave(time: number = this.intervalTime) {
|
||||
this.stopAutoSave();
|
||||
this.intervalTime = time;
|
||||
this.intervalId = setInterval(() => this.saveCacheToFile(), time);
|
||||
}
|
||||
|
||||
public async loadIndex(hash: CID_ALGORITHM_NAMES): Promise<IndexLine[]> {
|
||||
if (await existsAsync(this.filePaths[hash])) {
|
||||
// check the file size and if it did not change, do not read the file
|
||||
const stats = await fs.stat(this.filePaths[hash]);
|
||||
if (this.lastIndexFileSize[hash] !== stats.size) {
|
||||
// Read existing file content and parse it
|
||||
const records: IndexLine[] = await this.readCsv(hash);
|
||||
for (const record of records) {
|
||||
const cacheKey = `${record.path}-${record.size}-${record.mtime}`;
|
||||
let indexLine = this.cache.get(cacheKey);
|
||||
if (!indexLine) {
|
||||
this.cache.set(cacheKey, record);
|
||||
} else if(record[hash]){
|
||||
//update the cache with the latest data
|
||||
indexLine[hash] = record[hash];
|
||||
}
|
||||
}
|
||||
this.lastIndexFileSize[hash] = stats.size;
|
||||
this.lastCacheFile[hash] = records;
|
||||
return records;
|
||||
} else {
|
||||
return this.lastCacheFile[hash];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private loadIndexFromCache(): IndexLine[] {
|
||||
return Array.from(this.cache.values());
|
||||
}
|
||||
|
||||
private async readCsv(hash: CID_ALGORITHM_NAMES): Promise<IndexLine[]> {
|
||||
if (!(await existsAsync(this.filePaths[hash]))) {
|
||||
return [];
|
||||
}
|
||||
const start = performance.now();
|
||||
|
||||
const parser = parse({
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
});
|
||||
|
||||
const records: IndexLine[] = [];
|
||||
|
||||
return new Promise<IndexLine[]>((resolve, reject) => {
|
||||
createReadStream(this.filePaths[hash])
|
||||
.pipe(parser)
|
||||
.on('data', (record) => {
|
||||
records.push(record);
|
||||
})
|
||||
.on('end', () => {
|
||||
resolve(records);
|
||||
console.log(`Index read ${hash} time ${performance.now() - start}ms`);
|
||||
})
|
||||
.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async saveCacheToFile(): Promise<void> {
|
||||
if (this.indexOpsInProgress || !this.hasChanged) {
|
||||
return;
|
||||
}
|
||||
this.hasChanged = false;
|
||||
this.indexOpsInProgress = true;
|
||||
const start = performance.now();
|
||||
|
||||
let cacheRows: IndexLine[];
|
||||
if (this.cache.size !== 0) {
|
||||
cacheRows = this.loadIndexFromCache();
|
||||
}
|
||||
let didWrite=false;
|
||||
for (const hash of this.targetHash) {
|
||||
let existingRows: IndexLine[] = await this.loadIndex(hash);
|
||||
let existingRowsAsMap: Map<string, IndexLine> = new Map(existingRows.map(row => [row.path + '-' + row.size + '-' + row.mtime, row]));
|
||||
if (this.cache.size !== 0) {
|
||||
// Filter out cacheRows that are already in the file
|
||||
const newRows = cacheRows.filter(row => {
|
||||
//to be added a row must not exist in the file and must exist in the cache (with a hash)
|
||||
const newRow = !existingRowsAsMap.has(row.path + '-' + row.size + '-' + row.mtime);
|
||||
return newRow && !!row[hash];
|
||||
});
|
||||
|
||||
if (newRows.length !== 0) {
|
||||
// Serialize new cacheRows to CSV string
|
||||
const csvString = stringify(newRows, {
|
||||
header: existingRows.length === 0, // Only add header if the file was empty
|
||||
columns: [
|
||||
{key: 'path', header: 'path'},
|
||||
{key: 'size', header: 'size'},
|
||||
{key: 'mtime', header: 'mtime'},
|
||||
{key: hash, header: hash},
|
||||
],
|
||||
});
|
||||
|
||||
// Append new cacheRows to the file
|
||||
await fs.appendFile(this.filePaths[hash], csvString);
|
||||
didWrite = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(didWrite) {
|
||||
const totalTime = performance.now() - start;
|
||||
console.log(`Index saved in ${totalTime}ms`);
|
||||
// Check if the time to save the index is greater than the interval time. increase the interval time if needed
|
||||
if (totalTime * 10 > this.intervalTime) {
|
||||
this.startAutoSave(totalTime * 10);
|
||||
console.log(`Index save interval increased to ${totalTime * 10}ms`);
|
||||
}
|
||||
}
|
||||
this.indexOpsInProgress = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* this.getCidForFile(fileName, stats.size, stats.mtime.toISOString())
|
||||
* @param filePath
|
||||
*/
|
||||
public async getCidForFileAsync(filePath: string): Promise<IndexLine> {
|
||||
const fileName = path.basename(filePath);
|
||||
const stats = await stat(filePath);
|
||||
return this.getCidForFile(fileName, stats.size, stats.mtime.toISOString());
|
||||
}
|
||||
|
||||
public getCidForFile(filePath: string, fileSize: number, mtime: string): IndexLine {
|
||||
const fileName = path.basename(filePath);
|
||||
let fileNameIndex = this.cache.get(`${fileName}-${fileSize}-${mtime}`);
|
||||
if (fileNameIndex) {
|
||||
for (const hash of this.targetHash) {
|
||||
if (!fileNameIndex[hash]) {
|
||||
delete fileNameIndex[hash];
|
||||
}
|
||||
}
|
||||
if (fileNameIndex.mtime) {
|
||||
//if we have a mtime, we need to check it
|
||||
if (fileNameIndex.size === (fileSize + "") && fileNameIndex.mtime === mtime) {
|
||||
return fileNameIndex;
|
||||
}
|
||||
} else {
|
||||
//mtime is optional
|
||||
if (fileNameIndex.size === (fileSize + "")) {
|
||||
return fileNameIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3 - if not found, delete the entry (keeps the index clean)
|
||||
/*if (fileNameIndex && fileNameIndex.size !== (fileSize + "") && pathIndex && pathIndex.size !== (fileSize + "")) {
|
||||
this.cache.delete(fileName);
|
||||
this.cache.delete(filePath);
|
||||
}*/
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public addFileCid(filePath: string, fileSize: number, mtime: string, hashs: Partial<Record<CID_ALGORITHM_NAMES, string>>): void {
|
||||
if (!filePath || !fileSize || !mtime || !hashs) {
|
||||
throw new Error('Invalid parameters');
|
||||
}
|
||||
for (const hash of this.targetHash) {
|
||||
if (!hashs[hash]) {
|
||||
throw new Error(`Missing hash ${hash}`);
|
||||
}
|
||||
}
|
||||
const size = fileSize + "";
|
||||
const baseName = path.basename(filePath);
|
||||
const cacheKey = `${baseName}-${size}-${mtime}`;
|
||||
let indexLine = this.cache.get(cacheKey);
|
||||
if (!indexLine) {
|
||||
//filter only the hashes we need
|
||||
let filteredHash = {};
|
||||
for (const hash of this.targetHash) {
|
||||
filteredHash[hash] = hashs[hash];
|
||||
}
|
||||
const data = {path: baseName, size: size, mtime: mtime, ...filteredHash};
|
||||
this.cache.set(cacheKey, data);
|
||||
} else {
|
||||
//update the cache with the latest data
|
||||
for (const hash of this.targetHash) {
|
||||
indexLine[hash] = hashs[hash];
|
||||
}
|
||||
}
|
||||
this.hasChanged = true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface MultiHashData {
|
||||
cid_crc32?: string; //0x0132 CRC32 (SFV)
|
||||
cid_md5?: string; // 0xd5 md5
|
||||
cid_sha1?: string; // 0x11 sha1
|
||||
"cid_sha2-256"?: string;//0x12 sha2-256
|
||||
"cid_sha3-256"?: string; // 0x16 sha3-256
|
||||
"cid_sha3_384"?: string; // 0x16 sha3-256
|
||||
cid_midhash256?: string; // 0x1000 midhash256 (SHA-256 of middle 1MB + file size) - CID v1 format
|
||||
cid_btih_v2?: string; // 0x10B7 BitTorrent v2 info hash (BEP 52) - CID v1 format
|
||||
}
|
||||
|
||||
export interface ComputeInterface {
|
||||
computeMissingHash(filePath: string, metadata: MultiHashData): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* according to
|
||||
* https://ipfs.io/ipfs/QmXec1jjwzxWJoNbxQF5KffL8q6hFXm9QwUGaa3wKGk6dT/#title=Multicodecs&src=https://raw.githubusercontent.com/multiformats/multicodec/master/table.csv
|
||||
*/
|
||||
export enum CID_ALGORITHM_CODES {
|
||||
crc32 = 0x0132,
|
||||
md5 = 0xd5,
|
||||
sha1 = 0x11,
|
||||
sha256 = 0x12,
|
||||
sha3_256 = 0x16,
|
||||
sha3_384 = 0x15,
|
||||
midhash256 = 0x1000, // Custom code for midhash256
|
||||
btih_v2 = 0x10B7, // Custom code for BitTorrent v2 info hash
|
||||
}
|
||||
|
||||
export enum CID_ALGORITHM_NAMES {
|
||||
crc32 = 'cid_crc32',
|
||||
md5 = 'cid_md5',
|
||||
sha1 = 'cid_sha1',
|
||||
sha256 = 'cid_sha2-256',
|
||||
sha3_256 = 'cid_sha3-256',
|
||||
sha3_384 = 'cid_sha3-384',
|
||||
midhash256 = 'cid_midhash256',
|
||||
btih_v2 = 'cid_btih_v2',
|
||||
}
|
||||
|
||||
export enum CID_ALGORITHM {
|
||||
crc32 = 'crc32',
|
||||
md5 = 'md5',
|
||||
sha1 = 'sha1',
|
||||
sha256 = 'sha2-256',
|
||||
sha3_256 = 'sha3-256',
|
||||
sha3_384 = 'sha3-384',
|
||||
midhash256 = 'midhash256',
|
||||
btih_v2 = 'btih-v2',
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
export * from './index-interface.js';
|
||||
export * from './file-id/CreateHasherBrowser.js';
|
||||
export * from './hash-compute/HashComputer.js';
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './hash-compute/MultiHashData.js';
|
||||
export * from './folder-watcher/FileProcessorInterface.js';
|
||||
export * from './file-id/FileIDComputerInterface.js';
|
||||
@@ -0,0 +1,20 @@
|
||||
//fileID
|
||||
export * from './file-id/FileIDComputerWorker.js';
|
||||
export * from './file-id/ShaComputeWorker.js';//worker
|
||||
export * from './file-id/FastHash.js'; // Fast-hash computation
|
||||
export * from './file-id/BtihV2Hasher.js'; // BitTorrent v2 info hash
|
||||
export * from './file-id/MagnetLinkGenerator.js'; // Magnet link generation
|
||||
|
||||
//hash-compute
|
||||
export * from './hash-compute/HashComputerIndexCache.js';
|
||||
export * from './hash-compute/HashComputerWorker.js';
|
||||
export * from './hash-compute/HashIndexManager.js';
|
||||
export * from './hash-compute/HashComputer.js';
|
||||
export * from './hash-compute/HashComputerFile.js';
|
||||
|
||||
//folder-watcher
|
||||
export * from './folder-watcher/FolderWatcher.js';
|
||||
export * from './folder-watcher/PollingWatcher.js';
|
||||
export * from './index-interface.js';
|
||||
|
||||
export * from './utils/ExistsAsync.js';
|
||||
@@ -0,0 +1,12 @@
|
||||
// Example of checking if a file exists asynchronously
|
||||
// Note: `existsSync` does not have a direct async equivalent, so we use `access` instead.
|
||||
import {access,constants} from "fs/promises";
|
||||
|
||||
export async function existsAsync(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath, constants.F_OK);
|
||||
return true; // File exists
|
||||
} catch {
|
||||
return false; // File does not exist
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {BtihV2Hasher} from '../lib/file-id/BtihV2Hasher';
|
||||
import {CID_ALGORITHM_NAMES, HashComputerWorker} from "../lib";
|
||||
import {readFile} from 'fs/promises';
|
||||
import {createHash} from 'crypto';
|
||||
import * as process from "process";
|
||||
|
||||
process.env.WORKER_URL = "./dist/worker.js";
|
||||
|
||||
/**
|
||||
* BitTorrent v2 Info Hash Tests (BEP 52)
|
||||
*
|
||||
* Tests verify:
|
||||
* - 16KB block hashing
|
||||
* - Merkle tree construction
|
||||
* - Info hash computation
|
||||
* - CID encoding format
|
||||
* - Edge cases (empty files, partial blocks)
|
||||
*/
|
||||
describe('BitTorrent v2 Info Hash (BEP 52)', () => {
|
||||
it('should compute info hash for small file', async () => {
|
||||
const hasher = new BtihV2Hasher();
|
||||
hasher.setFileName('test.txt');
|
||||
|
||||
// Read test file and feed to hasher
|
||||
const testData = await readFile('./src/tests/test.txt');
|
||||
hasher.update(testData);
|
||||
|
||||
// Get info hash
|
||||
const infoHash = hasher.digest();
|
||||
|
||||
// Verify hash format
|
||||
expect(infoHash).toBeInstanceOf(Buffer);
|
||||
expect(infoHash.length).toBe(32); // SHA-256 produces 32 bytes
|
||||
|
||||
// Verify it's non-zero
|
||||
const isNonZero = infoHash.some(byte => byte !== 0);
|
||||
expect(isNonZero).toBe(true);
|
||||
|
||||
console.log('Info hash (hex):', infoHash.toString('hex'));
|
||||
});
|
||||
|
||||
it('should handle empty file', () => {
|
||||
const hasher = new BtihV2Hasher();
|
||||
hasher.setFileName('empty.txt');
|
||||
|
||||
// Don't update with any data (empty file)
|
||||
const infoHash = hasher.digest();
|
||||
|
||||
// Verify hash format
|
||||
expect(infoHash).toBeInstanceOf(Buffer);
|
||||
expect(infoHash.length).toBe(32);
|
||||
|
||||
// Should produce valid hash even for empty file
|
||||
const isNonZero = infoHash.some(byte => byte !== 0);
|
||||
expect(isNonZero).toBe(true);
|
||||
|
||||
console.log('Empty file info hash (hex):', infoHash.toString('hex'));
|
||||
});
|
||||
|
||||
it('should handle file exactly one block (16KB)', () => {
|
||||
const hasher = new BtihV2Hasher();
|
||||
hasher.setFileName('exactly-16kb.bin');
|
||||
|
||||
// Create exactly 16KB of data
|
||||
const oneBlock = Buffer.alloc(16 * 1024);
|
||||
for (let i = 0; i < oneBlock.length; i++) {
|
||||
oneBlock[i] = i % 256;
|
||||
}
|
||||
|
||||
hasher.update(oneBlock);
|
||||
const infoHash = hasher.digest();
|
||||
|
||||
expect(infoHash).toBeInstanceOf(Buffer);
|
||||
expect(infoHash.length).toBe(32);
|
||||
|
||||
console.log('16KB file info hash (hex):', infoHash.toString('hex'));
|
||||
});
|
||||
|
||||
it('should handle streaming updates (partial blocks)', () => {
|
||||
const hasher = new BtihV2Hasher();
|
||||
hasher.setFileName('streamed.bin');
|
||||
|
||||
// Simulate streaming: feed data in small chunks
|
||||
const chunkSize = 1024; // 1KB chunks
|
||||
const totalSize = 50 * 1024; // 50KB total
|
||||
const data = Buffer.alloc(totalSize);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
data[i] = i % 256;
|
||||
}
|
||||
|
||||
// Stream in chunks
|
||||
for (let offset = 0; offset < data.length; offset += chunkSize) {
|
||||
const end = Math.min(offset + chunkSize, data.length);
|
||||
hasher.update(data.subarray(offset, end));
|
||||
}
|
||||
|
||||
const infoHash = hasher.digest();
|
||||
|
||||
expect(infoHash).toBeInstanceOf(Buffer);
|
||||
expect(infoHash.length).toBe(32);
|
||||
|
||||
console.log('Streamed 50KB info hash (hex):', infoHash.toString('hex'));
|
||||
});
|
||||
|
||||
it('should compute consistent hash for same data', () => {
|
||||
const testData = Buffer.from('Hello BitTorrent v2!');
|
||||
|
||||
// Compute hash twice
|
||||
const hasher1 = new BtihV2Hasher();
|
||||
hasher1.setFileName('hello.txt');
|
||||
hasher1.update(testData);
|
||||
const hash1 = hasher1.digest();
|
||||
|
||||
const hasher2 = new BtihV2Hasher();
|
||||
hasher2.setFileName('hello.txt');
|
||||
hasher2.update(testData);
|
||||
const hash2 = hasher2.digest();
|
||||
|
||||
// Should produce identical hashes
|
||||
expect(hash1.equals(hash2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should produce different hashes for different data', () => {
|
||||
const data1 = Buffer.from('File A content');
|
||||
const data2 = Buffer.from('File B content');
|
||||
|
||||
const hasher1 = new BtihV2Hasher();
|
||||
hasher1.setFileName('fileA.txt');
|
||||
hasher1.update(data1);
|
||||
const hash1 = hasher1.digest();
|
||||
|
||||
const hasher2 = new BtihV2Hasher();
|
||||
hasher2.setFileName('fileB.txt');
|
||||
hasher2.update(data2);
|
||||
const hash2 = hasher2.digest();
|
||||
|
||||
// Should produce different hashes
|
||||
expect(hash1.equals(hash2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should produce different hashes for different filenames', () => {
|
||||
const data = Buffer.from('Same content');
|
||||
|
||||
const hasher1 = new BtihV2Hasher();
|
||||
hasher1.setFileName('nameA.txt');
|
||||
hasher1.update(data);
|
||||
const hash1 = hasher1.digest();
|
||||
|
||||
const hasher2 = new BtihV2Hasher();
|
||||
hasher2.setFileName('nameB.txt');
|
||||
hasher2.update(data);
|
||||
const hash2 = hasher2.digest();
|
||||
|
||||
// Different filenames should produce different info hashes
|
||||
expect(hash1.equals(hash2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should integrate with HashComputerWorker', async () => {
|
||||
const hashComputer = new HashComputerWorker([
|
||||
CID_ALGORITHM_NAMES.btih_v2,
|
||||
CID_ALGORITHM_NAMES.sha256
|
||||
]);
|
||||
|
||||
const metadata: any = {};
|
||||
await hashComputer.computeMissingHash('./src/tests/test.txt', metadata);
|
||||
|
||||
console.log('Computed metadata:', metadata);
|
||||
|
||||
// Verify btih_v2 CID was computed
|
||||
expect(metadata).toHaveProperty('cid_btih_v2');
|
||||
expect(typeof metadata.cid_btih_v2).toBe('string');
|
||||
expect(metadata.cid_btih_v2.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify CID format (should start with 'b' for base32 CIDv1)
|
||||
expect(metadata.cid_btih_v2.startsWith('b')).toBe(true);
|
||||
|
||||
// Should also have SHA-256 for comparison
|
||||
expect(metadata).toHaveProperty('cid_sha2-256');
|
||||
});
|
||||
|
||||
it('should compute piece length correctly', () => {
|
||||
// Test piece length formula: 16KB * 2^n where pieces < 2048
|
||||
const testCases = [
|
||||
{fileSize: 0, expectedMin: 16 * 1024, expectedMax: 16 * 1024}, // Empty file
|
||||
{fileSize: 1024, expectedMin: 16 * 1024, expectedMax: 16 * 1024}, // 1KB
|
||||
{fileSize: 16 * 1024, expectedMin: 16 * 1024, expectedMax: 16 * 1024}, // 16KB
|
||||
{fileSize: 1024 * 1024, expectedMin: 16 * 1024, expectedMax: 16 * 1024}, // 1MB
|
||||
{fileSize: 100 * 1024 * 1024, expectedMin: 64 * 1024, expectedMax: 128 * 1024}, // 100MB
|
||||
];
|
||||
|
||||
for (const {fileSize, expectedMin, expectedMax} of testCases) {
|
||||
const hasher = new BtihV2Hasher();
|
||||
hasher.setFileName('size-test.bin');
|
||||
|
||||
// Feed dummy data of specified size
|
||||
const chunkSize = 16 * 1024;
|
||||
let remaining = fileSize;
|
||||
while (remaining > 0) {
|
||||
const toWrite = Math.min(chunkSize, remaining);
|
||||
hasher.update(Buffer.alloc(toWrite));
|
||||
remaining -= toWrite;
|
||||
}
|
||||
|
||||
// Digest triggers piece length computation
|
||||
const infoHash = hasher.digest();
|
||||
|
||||
// We can't directly access piece length from outside,
|
||||
// but we can verify the hash was computed successfully
|
||||
expect(infoHash).toBeInstanceOf(Buffer);
|
||||
expect(infoHash.length).toBe(32);
|
||||
}
|
||||
});
|
||||
|
||||
it('should produce valid CID format with codec 0x10B7', async () => {
|
||||
const hashComputer = new HashComputerWorker([
|
||||
CID_ALGORITHM_NAMES.btih_v2
|
||||
]);
|
||||
|
||||
const metadata: any = {};
|
||||
await hashComputer.computeMissingHash('./src/tests/test.txt', metadata);
|
||||
|
||||
const cid = metadata.cid_btih_v2;
|
||||
console.log('BitTorrent v2 CID:', cid);
|
||||
|
||||
// Verify CID format
|
||||
expect(cid).toBeDefined();
|
||||
expect(typeof cid).toBe('string');
|
||||
|
||||
// CIDv1 base32 starts with 'b'
|
||||
expect(cid.startsWith('b')).toBe(true);
|
||||
|
||||
// Should be longer than minimum CID length
|
||||
expect(cid.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {BtihV2Hasher, MagnetLinkGenerator} from '../lib';
|
||||
import {readFile, stat} from 'fs/promises';
|
||||
|
||||
/**
|
||||
* End-to-End Magnet Link Generation Tests
|
||||
*
|
||||
* Tests the complete workflow:
|
||||
* 1. Compute BitTorrent v2 info hash from file
|
||||
* 2. Generate magnet link
|
||||
* 3. Parse and validate magnet link
|
||||
*/
|
||||
describe('Magnet Link Generation (End-to-End)', () => {
|
||||
it('should generate valid magnet link from file', async () => {
|
||||
const filePath = './src/tests/test.txt';
|
||||
const fileName = 'test.txt';
|
||||
|
||||
// Step 1: Read file and compute info hash
|
||||
const fileData = await readFile(filePath);
|
||||
const fileStats = await stat(filePath);
|
||||
const fileSize = fileStats.size;
|
||||
|
||||
const hasher = new BtihV2Hasher();
|
||||
hasher.setFileName(fileName);
|
||||
hasher.update(fileData);
|
||||
const infoHash = hasher.digest();
|
||||
|
||||
// Verify info hash computed
|
||||
expect(infoHash).toBeInstanceOf(Buffer);
|
||||
expect(infoHash.length).toBe(32);
|
||||
|
||||
console.log('File:', fileName);
|
||||
console.log('Size:', fileSize, 'bytes');
|
||||
console.log('Info hash (hex):', infoHash.toString('hex'));
|
||||
|
||||
// Note: We can't test magnet link generation end-to-end without
|
||||
// the CID encoding working properly through HashComputerWorker.
|
||||
// This would require the worker integration to be fixed.
|
||||
});
|
||||
|
||||
it('should generate magnet link from mock CID', () => {
|
||||
// Mock CID format (this is a valid CIDv1 with our custom codec 0x10B7)
|
||||
// In real usage, this would come from HashComputerWorker
|
||||
const fileName = 'test-video.mp4';
|
||||
const fileSize = 1024 * 1024 * 100; // 100MB
|
||||
|
||||
// Create a mock info hash (32 bytes of test data)
|
||||
const mockInfoHash = Buffer.from(
|
||||
'b04c198dd3148719db3e32fecc54ec41ec355d7f866a1c3f71bf980c3cde01bf',
|
||||
'hex'
|
||||
);
|
||||
|
||||
// For this test, we'll construct the magnet link manually
|
||||
// since we can't get a real CID without the worker
|
||||
const infoHashHex = mockInfoHash.toString('hex');
|
||||
const magnetLink = `magnet:?xt=urn:btmh:1220${infoHashHex}&dn=${encodeURIComponent(fileName)}&xl=${fileSize}`;
|
||||
|
||||
console.log('\nGenerated magnet link:');
|
||||
console.log(magnetLink);
|
||||
|
||||
// Verify magnet link format
|
||||
expect(magnetLink.startsWith('magnet:?')).toBe(true);
|
||||
expect(magnetLink.includes('xt=urn:btmh:1220')).toBe(true);
|
||||
expect(magnetLink.includes(infoHashHex)).toBe(true);
|
||||
expect(magnetLink.includes(`dn=${encodeURIComponent(fileName)}`)).toBe(true);
|
||||
expect(magnetLink.includes(`xl=${fileSize}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate magnet link format', () => {
|
||||
const validLink = 'magnet:?xt=urn:btmh:1220' + 'a'.repeat(64) + '&dn=file.mp4&xl=1024';
|
||||
const invalidLink1 = 'http://example.com';
|
||||
const invalidLink2 = 'magnet:?xt=urn:btih:' + 'a'.repeat(40); // v1 format (SHA-1)
|
||||
const invalidLink3 = 'magnet:?xt=urn:btmh:1220' + 'a'.repeat(32); // Too short
|
||||
|
||||
expect(MagnetLinkGenerator.isValid(validLink)).toBe(true);
|
||||
expect(MagnetLinkGenerator.isValid(invalidLink1)).toBe(false);
|
||||
expect(MagnetLinkGenerator.isValid(invalidLink2)).toBe(false);
|
||||
expect(MagnetLinkGenerator.isValid(invalidLink3)).toBe(false);
|
||||
});
|
||||
|
||||
it('should extract info hash from magnet link', () => {
|
||||
const infoHash = 'b04c198dd3148719db3e32fecc54ec41ec355d7f866a1c3f71bf980c3cde01bf';
|
||||
const magnetLink = `magnet:?xt=urn:btmh:1220${infoHash}&dn=file.mp4&xl=1024`;
|
||||
|
||||
const extracted = MagnetLinkGenerator.extractInfoHash(magnetLink);
|
||||
expect(extracted).toBe(infoHash);
|
||||
});
|
||||
|
||||
it('should extract filename from magnet link', () => {
|
||||
const fileName = 'Big Buck Bunny.mp4';
|
||||
const infoHash = 'a'.repeat(64);
|
||||
const magnetLink = `magnet:?xt=urn:btmh:1220${infoHash}&dn=${encodeURIComponent(fileName)}&xl=1024`;
|
||||
|
||||
const extracted = MagnetLinkGenerator.extractFileName(magnetLink);
|
||||
expect(extracted).toBe(fileName);
|
||||
});
|
||||
|
||||
it('should extract file size from magnet link', () => {
|
||||
const fileSize = 1024000;
|
||||
const infoHash = 'a'.repeat(64);
|
||||
const magnetLink = `magnet:?xt=urn:btmh:1220${infoHash}&dn=file.mp4&xl=${fileSize}`;
|
||||
|
||||
const extracted = MagnetLinkGenerator.extractFileSize(magnetLink);
|
||||
expect(extracted).toBe(fileSize);
|
||||
});
|
||||
|
||||
it('should parse complete magnet link', () => {
|
||||
const infoHash = 'b04c198dd3148719db3e32fecc54ec41ec355d7f866a1c3f71bf980c3cde01bf';
|
||||
const fileName = 'test-video.mp4';
|
||||
const fileSize = 1024000;
|
||||
const magnetLink = `magnet:?xt=urn:btmh:1220${infoHash}&dn=${encodeURIComponent(fileName)}&xl=${fileSize}`;
|
||||
|
||||
const parsed = MagnetLinkGenerator.parse(magnetLink);
|
||||
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.infoHash).toBe(infoHash);
|
||||
expect(parsed!.fileName).toBe(fileName);
|
||||
expect(parsed!.fileSize).toBe(fileSize);
|
||||
});
|
||||
|
||||
it('should handle special characters in filename', () => {
|
||||
const fileName = 'My Video [1080p] (2024).mp4';
|
||||
const infoHash = 'a'.repeat(64);
|
||||
const magnetLink = `magnet:?xt=urn:btmh:1220${infoHash}&dn=${encodeURIComponent(fileName)}&xl=1024`;
|
||||
|
||||
const extracted = MagnetLinkGenerator.extractFileName(magnetLink);
|
||||
expect(extracted).toBe(fileName);
|
||||
|
||||
// Verify round-trip
|
||||
const parsed = MagnetLinkGenerator.parse(magnetLink);
|
||||
expect(parsed?.fileName).toBe(fileName);
|
||||
});
|
||||
|
||||
it('should demonstrate complete workflow (conceptual)', async () => {
|
||||
// This test demonstrates the intended workflow once worker integration is fixed
|
||||
const filePath = './src/tests/test.txt';
|
||||
const fileStats = await stat(filePath);
|
||||
|
||||
console.log('\n=== Complete Workflow Demo ===');
|
||||
console.log('1. File:', filePath);
|
||||
console.log('2. Size:', fileStats.size, 'bytes');
|
||||
|
||||
// Step 1: Compute info hash
|
||||
const fileData = await readFile(filePath);
|
||||
const hasher = new BtihV2Hasher();
|
||||
hasher.setFileName('test.txt');
|
||||
hasher.update(fileData);
|
||||
const infoHash = hasher.digest();
|
||||
|
||||
console.log('3. Info hash:', infoHash.toString('hex'));
|
||||
|
||||
// Step 2: In production, this would be the CID from metadata
|
||||
// const cid = metadata.cid_btih_v2;
|
||||
// const magnetLink = MagnetLinkGenerator.generate(metadata, 'test.txt', fileStats.size);
|
||||
|
||||
// Step 3: For demo, construct magnet link manually
|
||||
const magnetLink = `magnet:?xt=urn:btmh:1220${infoHash.toString('hex')}&dn=test.txt&xl=${fileStats.size}`;
|
||||
console.log('4. Magnet link:', magnetLink);
|
||||
|
||||
// Step 4: Verify it's valid
|
||||
const isValid = MagnetLinkGenerator.isValid(magnetLink);
|
||||
console.log('5. Valid:', isValid);
|
||||
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
// Step 5: Parse it back
|
||||
const parsed = MagnetLinkGenerator.parse(magnetLink);
|
||||
console.log('6. Parsed:', parsed);
|
||||
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.infoHash).toBe(infoHash.toString('hex'));
|
||||
expect(parsed!.fileName).toBe('test.txt');
|
||||
expect(parsed!.fileSize).toBe(fileStats.size);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import {CID_ALGORITHM_NAMES, HashComputerIndexCache, HashComputerWorker} from "../lib";
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import * as process from "process";
|
||||
import { mkdir, rm, rmdir } from "fs/promises";
|
||||
|
||||
process.env.WORKER_URL = "./dist/worker.js";
|
||||
|
||||
/**
|
||||
* /!\ WARNING /!\
|
||||
* Remember to build the worker each time you change the worker code.
|
||||
*/
|
||||
|
||||
describe('add', () => {
|
||||
it('meta data compute', async () => {
|
||||
let hashComputer = new HashComputerWorker([
|
||||
CID_ALGORITHM_NAMES.sha1,
|
||||
CID_ALGORITHM_NAMES.sha256,
|
||||
CID_ALGORITHM_NAMES.md5,
|
||||
CID_ALGORITHM_NAMES.sha3_256,
|
||||
CID_ALGORITHM_NAMES.crc32,
|
||||
CID_ALGORITHM_NAMES.sha3_384
|
||||
]);
|
||||
let metadata = {};
|
||||
await hashComputer.computeMissingHash('./src/tests/test.txt', metadata);
|
||||
console.log(metadata);
|
||||
/**
|
||||
* test.txt CF20B62A (CRC32)
|
||||
* 26f1157f4349a6fb5d207ac5f5995140 *test.txt (MD5)
|
||||
* bb242b7b821074f4b9eb9a94d439ab668705aac4 ?SHA1*test.txt
|
||||
* 2aa7a80d2fd1eb96d31afc7cdda9211a97524202c63f04151d6163fba35061a0 ?SHA256*test.txt
|
||||
* 865a1ea4977b25e04b8ea84af93b21f1a031cb48359f3805089cdae1d89842b9a3faf87654989dcae418144a8bbb378c ?SHA3-384*test.txt
|
||||
*/
|
||||
expect(metadata).toEqual({
|
||||
"cid_crc32": "bagzafmqcathsbnrk",
|
||||
"cid_sha1": 'baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra',
|
||||
"cid_sha2-256": 'baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua',
|
||||
"cid_md5": 'bahkqdvibcatpcfl7ine2n625eb5ml5mzkfaa',
|
||||
"cid_sha3-256": 'baelbmidner7x3dd7t2kldh6wiq4w6lsye7qrwo6uoqot2t52zkhqk56fnq',
|
||||
"cid_sha3-384": 'baekrkmeglipkjf33exqexdvijl4twipruay4wsbvt44akce43lq5rgccxgr7v6dwksmj3sxedakevc53g6ga'
|
||||
});
|
||||
});
|
||||
it('meta data compute with index', async () => {
|
||||
await rm("./tmp",{recursive:true});
|
||||
await mkdir("./tmp",{recursive:true});
|
||||
let hashComputer = new HashComputerIndexCache("./tmp/index.csv",[
|
||||
CID_ALGORITHM_NAMES.sha1,
|
||||
CID_ALGORITHM_NAMES.sha256,
|
||||
CID_ALGORITHM_NAMES.md5,
|
||||
CID_ALGORITHM_NAMES.sha3_256,
|
||||
CID_ALGORITHM_NAMES.crc32,
|
||||
CID_ALGORITHM_NAMES.sha3_384
|
||||
]);
|
||||
let metadata = {};
|
||||
await hashComputer.computeMissingHash('./src/tests/test.txt', metadata);
|
||||
console.log(metadata);
|
||||
/**
|
||||
* test.txt CF20B62A (CRC32)
|
||||
* 26f1157f4349a6fb5d207ac5f5995140 *test.txt (MD5)
|
||||
* bb242b7b821074f4b9eb9a94d439ab668705aac4 ?SHA1*test.txt
|
||||
* 2aa7a80d2fd1eb96d31afc7cdda9211a97524202c63f04151d6163fba35061a0 ?SHA256*test.txt
|
||||
* 865a1ea4977b25e04b8ea84af93b21f1a031cb48359f3805089cdae1d89842b9a3faf87654989dcae418144a8bbb378c ?SHA3-384*test.txt
|
||||
*/
|
||||
expect(metadata).toEqual({
|
||||
"cid_crc32": "bagzafmqcathsbnrk",
|
||||
"cid_sha1": 'baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra',
|
||||
"cid_sha2-256": 'baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua',
|
||||
"cid_md5": 'bahkqdvibcatpcfl7ine2n625eb5ml5mzkfaa',
|
||||
"cid_sha3-256": 'baelbmidner7x3dd7t2kldh6wiq4w6lsye7qrwo6uoqot2t52zkhqk56fnq',
|
||||
"cid_sha3-384": 'baekrkmeglipkjf33exqexdvijl4twipruay4wsbvt44akce43lq5rgccxgr7v6dwksmj3sxedakevc53g6ga'
|
||||
});
|
||||
await (await hashComputer.getHashIndexManager()).saveCacheToFile();
|
||||
});
|
||||
it('cache', async () => {
|
||||
await mkdir("./tmp",{recursive:true});
|
||||
let hashComputer = new HashComputerIndexCache("./tmp/index.csv",[
|
||||
CID_ALGORITHM_NAMES.sha1,
|
||||
CID_ALGORITHM_NAMES.sha256,
|
||||
CID_ALGORITHM_NAMES.md5,
|
||||
CID_ALGORITHM_NAMES.sha3_256,
|
||||
CID_ALGORITHM_NAMES.crc32,
|
||||
CID_ALGORITHM_NAMES.sha3_384
|
||||
]);
|
||||
let indexLine = await (await hashComputer.getHashIndexManager()).getCidForFileAsync('./src/tests/test.txt');
|
||||
delete indexLine['mtime'];
|
||||
expect(indexLine).toEqual({
|
||||
"cid_sha1": 'baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra',
|
||||
"cid_sha2-256": 'baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua',
|
||||
"cid_md5": 'bahkqdvibcatpcfl7ine2n625eb5ml5mzkfaa',
|
||||
"cid_sha3-256": 'baelbmidner7x3dd7t2kldh6wiq4w6lsye7qrwo6uoqot2t52zkhqk56fnq',
|
||||
"cid_crc32": "bagzafmqcathsbnrk",
|
||||
"cid_sha3-384": 'baekrkmeglipkjf33exqexdvijl4twipruay4wsbvt44akce43lq5rgccxgr7v6dwksmj3sxedakevc53g6ga',
|
||||
"path": "test.txt",
|
||||
"size": "499",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
this is a file that is just some data to be hashed
|
||||
|
||||
loreum ipsum dolor sit amet
|
||||
consectetur adipiscing elit
|
||||
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua
|
||||
ut enim ad minim veniam
|
||||
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat
|
||||
duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur
|
||||
excepteur sint occaecat cupidatat non proident
|
||||
sunt in culpa qui officia deserunt mollit anim id est laborum
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"noImplicitAny": false,
|
||||
"outDir": "build/",
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"allowJs": true,
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"esModuleInterop":true,
|
||||
"skipLibCheck": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"moduleResolution": "node",
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@root/*": ["src/lib/*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
//used to replace the alias by relative path
|
||||
"transform": "typescript-transform-paths",
|
||||
"afterDeclarations": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/lib"
|
||||
],
|
||||
"exclude": ["node_modules/**"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {defineConfig} from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
'index': 'src/lib/index.ts',
|
||||
'index-interface': 'src/lib/index-interface.ts',
|
||||
'index-browser': 'src/lib/index-browser.ts',
|
||||
'worker': 'src/lib/file-id/ShaComputeWorker.ts',
|
||||
},
|
||||
splitting: true,
|
||||
treeshake: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
dts:true,
|
||||
minify:false,
|
||||
format: ['cjs', 'esm'],
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@root': path.resolve(__dirname, './src/lib')
|
||||
}
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
testTimeout: 300000, // Set global timeout to 30 seconds
|
||||
// Use ts-jest or esbuild to handle TypeScript files
|
||||
// Specify other configurations as needed
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user