@twinsearth/w2m-dsh-plugin 2.0.6 → 2.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +24 -6
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/2.0.7/RELEASE-2070.json +21 -0
- package/docs/acceptance/2.0.8/RELEASE-2080.json +21 -0
- package/docs/plans/IM2A-2.2.2-PLAN.md +293 -0
- package/lib/client.js +1 -1
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/agent/chacha.mjs +93 -0
- package/src/agent/crypto.mjs +64 -29
- package/src/agent/transfer/ferry.mjs +195 -0
- package/src/agent/transfer/index.mjs +4 -0
- package/src/agent/transfer/manifest.mjs +92 -0
- package/src/agent/transfer/object-store.mjs +144 -0
- package/src/agent/transfer/publish.mjs +76 -0
- package/src/agent/transfer/reference.mjs +97 -0
- package/src/agent/transfer/want-block.mjs +89 -0
- package/src/game/xiangqi.mjs +217 -0
- package/src/plugin/chat-v2-gate.mjs +40 -0
- package/src/plugin/fleet-binding.mjs +105 -0
- package/src/plugin/fleet-chat-route.mjs +32 -1
- package/src/plugin/tools.mjs +42 -8
package/src/agent/crypto.mjs
CHANGED
|
@@ -53,6 +53,8 @@ import {
|
|
|
53
53
|
randomBytes,
|
|
54
54
|
timingSafeEqual,
|
|
55
55
|
} from 'node:crypto';
|
|
56
|
+
|
|
57
|
+
import { jsOpen, jsSeal } from './chacha.mjs';
|
|
56
58
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
57
59
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
58
60
|
import { blake2s256 as blake2s256Js } from './blake2s.mjs';
|
|
@@ -758,6 +760,57 @@ export function derive(ikm, label, length = KEY_BYTES) {
|
|
|
758
760
|
*
|
|
759
761
|
* @returns {{ ok: boolean, missing: string[], present: string[], node: string, detail: string }}
|
|
760
762
|
*/
|
|
763
|
+
/** Wrap a cipher construction so an OpenSSL "Unknown cipher" carries WHO/WHAT, not just the text. */
|
|
764
|
+
function cipherContextError(kind, cipherName, error, context = {}) {
|
|
765
|
+
const wrapped = new Error('P2P_CIPHER_UNAVAILABLE: ' + kind + ' ' + cipherName + ' failed: ' + (error?.message ?? String(error)));
|
|
766
|
+
wrapped.code = 'P2P_CIPHER_UNAVAILABLE';
|
|
767
|
+
wrapped.detail = {
|
|
768
|
+
cipher: cipherName,
|
|
769
|
+
suite: typeof context.suite === 'string' ? context.suite : 'w2m-p2p-v1',
|
|
770
|
+
peerKey: typeof context.peerKey === 'string' ? context.peerKey : null,
|
|
771
|
+
keyLength: context.keyLength ?? null,
|
|
772
|
+
ivLength: context.ivLength ?? null,
|
|
773
|
+
openssl: error?.message ?? String(error),
|
|
774
|
+
node: process.version,
|
|
775
|
+
};
|
|
776
|
+
wrapped.cause = error;
|
|
777
|
+
return wrapped;
|
|
778
|
+
}
|
|
779
|
+
function createCipherContext(name, key, iv, options, context = {}) {
|
|
780
|
+
try { return createCipheriv(name, key, iv, options); } catch (error) { throw cipherContextError('encrypt', name, error, { ...context, keyLength: key?.length, ivLength: iv?.length }); }
|
|
781
|
+
}
|
|
782
|
+
function createDecipherContext(name, key, iv, options, context = {}) {
|
|
783
|
+
try { return createDecipheriv(name, key, iv, options); } catch (error) { throw cipherContextError('decrypt', name, error, { ...context, keyLength: key?.length, ivLength: iv?.length }); }
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
/** Native ChaCha20-Poly1305 availability (DSH/Electron's BoringSSL may not expose it). */
|
|
788
|
+
let NATIVE_CHACHA = true;
|
|
789
|
+
try {
|
|
790
|
+
const probeKey = Buffer.alloc(32, 1);
|
|
791
|
+
const probeCipher = createCipheriv('chacha20-poly1305', probeKey, Buffer.alloc(12), { authTagLength: 16 });
|
|
792
|
+
probeCipher.final();
|
|
793
|
+
} catch {
|
|
794
|
+
NATIVE_CHACHA = false;
|
|
795
|
+
}
|
|
796
|
+
export function nativeAeadAvailable() { return NATIVE_CHACHA; }
|
|
797
|
+
function aeadSeal(key, nonce, plaintext, aad = Buffer.alloc(0), context = {}) {
|
|
798
|
+
if (!NATIVE_CHACHA) return jsSeal(key, nonce, plaintext, aad);
|
|
799
|
+
const cipher = createCipherContext('chacha20-poly1305', key, nonce, { authTagLength: TAG_BYTES }, { suite: 'w2m-p2p-v1', ...context });
|
|
800
|
+
if (aad.length > 0) cipher.setAAD(aad);
|
|
801
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
|
802
|
+
}
|
|
803
|
+
function aeadOpen(key, nonce, sealed, aad = Buffer.alloc(0)) {
|
|
804
|
+
if (!NATIVE_CHACHA) return jsOpen(key, nonce, sealed, aad);
|
|
805
|
+
if (!Buffer.isBuffer(sealed) || sealed.length < TAG_BYTES) return null;
|
|
806
|
+
try {
|
|
807
|
+
const decipher = createDecipherContext('chacha20-poly1305', key, nonce, { authTagLength: TAG_BYTES }, { suite: 'w2m-p2p-v1' });
|
|
808
|
+
if (aad.length > 0) decipher.setAAD(aad);
|
|
809
|
+
decipher.setAuthTag(sealed.subarray(sealed.length - TAG_BYTES));
|
|
810
|
+
return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - TAG_BYTES)), decipher.final()]);
|
|
811
|
+
} catch { return null; }
|
|
812
|
+
}
|
|
813
|
+
|
|
761
814
|
export function cryptoCapabilities() {
|
|
762
815
|
const present = [];
|
|
763
816
|
const missing = [];
|
|
@@ -774,11 +827,11 @@ export function cryptoCapabilities() {
|
|
|
774
827
|
check('blake2s256', () => blake2s256Support().ok);
|
|
775
828
|
check('sha256', () => createHash('sha256').update('').digest().length === 32);
|
|
776
829
|
check('chacha20-poly1305', () => {
|
|
830
|
+
// Count the JS fallback as available: what the handshake needs is the AEAD, not OpenSSL's copy.
|
|
777
831
|
const key = randomBytes(32);
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
return cipher.getAuthTag().length === 16;
|
|
832
|
+
const sealed = aeadSeal(key, Buffer.alloc(12), Buffer.from('probe'));
|
|
833
|
+
const opened = aeadOpen(key, Buffer.alloc(12), sealed);
|
|
834
|
+
return sealed.length === 21 && opened !== null && opened.toString('utf8') === 'probe';
|
|
782
835
|
});
|
|
783
836
|
check('hkdf-sha256', () => hkdfSync('sha256', randomBytes(32), Buffer.alloc(0), Buffer.from('probe'), 32).byteLength === 32);
|
|
784
837
|
check('x25519+diffieHellman', () => {
|
|
@@ -993,10 +1046,8 @@ export function sessionKeys({ ephemeralShared, initiatorStaticShared, responderS
|
|
|
993
1046
|
* @param {Buffer} message
|
|
994
1047
|
* @returns {Buffer} Ciphertext followed by the tag: 48 bytes for a 32-byte message.
|
|
995
1048
|
*/
|
|
996
|
-
export function sealMac(key, message) {
|
|
997
|
-
|
|
998
|
-
const ciphertext = Buffer.concat([cipher.update(message), cipher.final()]);
|
|
999
|
-
return Buffer.concat([ciphertext, cipher.getAuthTag()]);
|
|
1049
|
+
export function sealMac(key, message, context = {}) {
|
|
1050
|
+
return aeadSeal(key, Buffer.alloc(12), message, Buffer.alloc(0), context);
|
|
1000
1051
|
}
|
|
1001
1052
|
|
|
1002
1053
|
/**
|
|
@@ -1008,13 +1059,7 @@ export function sealMac(key, message) {
|
|
|
1008
1059
|
*/
|
|
1009
1060
|
export function openMac(key, sealed) {
|
|
1010
1061
|
if (!Buffer.isBuffer(sealed) || sealed.length <= TAG_BYTES) return null;
|
|
1011
|
-
|
|
1012
|
-
const decipher = createDecipheriv('chacha20-poly1305', key, Buffer.alloc(12), { authTagLength: TAG_BYTES });
|
|
1013
|
-
decipher.setAuthTag(sealed.subarray(sealed.length - TAG_BYTES));
|
|
1014
|
-
return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - TAG_BYTES)), decipher.final()]);
|
|
1015
|
-
} catch {
|
|
1016
|
-
return null;
|
|
1017
|
-
}
|
|
1062
|
+
return aeadOpen(key, Buffer.alloc(12), sealed);
|
|
1018
1063
|
}
|
|
1019
1064
|
|
|
1020
1065
|
/** @param {Buffer} a @param {Buffer} b @returns {boolean} Constant-time comparison. */
|
|
@@ -1600,13 +1645,11 @@ export class CryptoSession {
|
|
|
1600
1645
|
cipherHeader.writeUInt8(this.sendEpoch & 0xff, 1);
|
|
1601
1646
|
cipherHeader.writeBigUInt64LE(counter, 2);
|
|
1602
1647
|
|
|
1603
|
-
const cipher = createCipheriv('chacha20-poly1305', key, nonceFor(counter), { authTagLength: TAG_BYTES });
|
|
1604
1648
|
// The frame's own header —kind and session —rides as additional authenticated data. That is
|
|
1605
1649
|
// what stops an attacker retagging a DATA frame as a BYE, or moving a frame into another session:
|
|
1606
1650
|
// the tag covers the fields the receiving channel routes on.
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
return Buffer.concat([header, cipherHeader, ciphertext, cipher.getAuthTag()]);
|
|
1651
|
+
const sealed = aeadSeal(key, nonceFor(counter), frame.subarray(FRAME_HEADER_BYTES), header);
|
|
1652
|
+
return Buffer.concat([header, cipherHeader, sealed]);
|
|
1610
1653
|
}
|
|
1611
1654
|
|
|
1612
1655
|
/**
|
|
@@ -1653,16 +1696,8 @@ export class CryptoSession {
|
|
|
1653
1696
|
const key = this.receiveKey(isInitiator, epoch);
|
|
1654
1697
|
if (key === null) return fail(CRYPTO_REASON.EPOCH_TOO_FAR);
|
|
1655
1698
|
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
const decipher = createDecipheriv('chacha20-poly1305', key, nonceFor(counter), { authTagLength: TAG_BYTES });
|
|
1659
|
-
decipher.setAAD(header);
|
|
1660
|
-
decipher.setAuthTag(body.subarray(body.length - TAG_BYTES));
|
|
1661
|
-
plaintext = Buffer.concat([
|
|
1662
|
-
decipher.update(body.subarray(CIPHER_HEADER_BYTES, body.length - TAG_BYTES)),
|
|
1663
|
-
decipher.final(),
|
|
1664
|
-
]);
|
|
1665
|
-
} catch {
|
|
1699
|
+
const plaintext = aeadOpen(key, nonceFor(counter), body.subarray(CIPHER_HEADER_BYTES), header);
|
|
1700
|
+
if (plaintext === null) {
|
|
1666
1701
|
// Authentication failed. The frame is dropped and never processed, and the tag is *not* retried
|
|
1667
1702
|
// against the other epochs: an attacker who could make us try keys until one verified would
|
|
1668
1703
|
// have turned a 128-bit tag into a search.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact Ferry v1(本地最小闭环):chunkFile → ArtifactRef → sink 接收端。
|
|
3
|
+
*
|
|
4
|
+
* 不变式:
|
|
5
|
+
* · 控制面只持引用(ref/rootCid/size/chunkCount),字节永远在本机或 sink;
|
|
6
|
+
* · 发送端流式分块,内存与文件大小无关;每块 SHA-256;
|
|
7
|
+
* · 接收端逐块校验、位图持久化、可断点续传;finalize 重算根才原子落盘;
|
|
8
|
+
* · 路径只允许 sink 根下的相对路径(拒绝绝对路径/.. /盘符/UNC)。
|
|
9
|
+
* 本阶段不做远端 P2P 拉取:`pull` 明确返回 P2P_UNAVAILABLE,绝不回落 relay blob。
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import {
|
|
13
|
+
closeSync, existsSync, fsyncSync, ftruncateSync, mkdirSync, openSync, readdirSync, readFileSync,
|
|
14
|
+
readSync, renameSync, statSync, writeFileSync, writeSync,
|
|
15
|
+
} from 'node:fs';
|
|
16
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { createManifest, chunkLengths, chunkRange } from './manifest.mjs';
|
|
19
|
+
|
|
20
|
+
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
21
|
+
const ROOT = /^sha256:[0-9a-f]{64}$/;
|
|
22
|
+
const readJson = (file) => JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
|
23
|
+
|
|
24
|
+
export function safeRelative(input) {
|
|
25
|
+
if (typeof input !== 'string' || input.trim() === '') return { ok: false, code: 'PATH_REQUIRED' };
|
|
26
|
+
const raw = input.replace(/\\/g, '/');
|
|
27
|
+
if (raw.includes('\0')) return { ok: false, code: 'PATH_NUL' };
|
|
28
|
+
if (isAbsolute(input) || /^[A-Za-z]:/.test(raw) || raw.startsWith('//')) return { ok: false, code: 'PATH_ABSOLUTE' };
|
|
29
|
+
const parts = raw.split('/').filter((part) => part !== '' && part !== '.');
|
|
30
|
+
if (parts.length === 0 || parts.some((part) => part === '..')) return { ok: false, code: 'PATH_TRAVERSAL' };
|
|
31
|
+
return { ok: true, relative: parts.join('/') };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 流式分块:只读一块、算一块的哈希;返回 rootCid 与块清单。 */
|
|
35
|
+
export function chunkFile(filePath, { chunkBytes = 1024 * 1024 } = {}) {
|
|
36
|
+
const stat = statSync(filePath);
|
|
37
|
+
const plan = chunkLengths(stat.size, chunkBytes);
|
|
38
|
+
if (plan.ok !== true) return plan;
|
|
39
|
+
const fd = openSync(filePath, 'r');
|
|
40
|
+
const chunks = [];
|
|
41
|
+
try {
|
|
42
|
+
for (let index = 0; index < plan.count; index += 1) {
|
|
43
|
+
const length = index === plan.count - 1 ? plan.lastChunkBytes : plan.chunkBytes;
|
|
44
|
+
const buf = Buffer.alloc(length);
|
|
45
|
+
const read = readSync(fd, buf, 0, length, index * plan.chunkBytes);
|
|
46
|
+
if (read !== length) return { ok: false, code: 'XFER_READ_SHORT' };
|
|
47
|
+
const digest = sha256(buf);
|
|
48
|
+
chunks.push({ index, cid: 'sha256:' + digest, sha256: digest, size: length });
|
|
49
|
+
}
|
|
50
|
+
} finally {
|
|
51
|
+
closeSync(fd);
|
|
52
|
+
}
|
|
53
|
+
const built = createManifest({
|
|
54
|
+
sizeBytes: stat.size,
|
|
55
|
+
chunkBytes: plan.chunkBytes,
|
|
56
|
+
chunkHashes: chunks.map((chunk) => chunk.sha256),
|
|
57
|
+
objectId: sha256(`${stat.size}:${plan.chunkBytes}:${chunks.map((c) => c.sha256).join('')}`).slice(0, 32),
|
|
58
|
+
kind: 'file',
|
|
59
|
+
sinkKind: 'cache',
|
|
60
|
+
relPath: basename(filePath),
|
|
61
|
+
});
|
|
62
|
+
if (built.ok !== true) return built;
|
|
63
|
+
return {
|
|
64
|
+
ok: true,
|
|
65
|
+
rootCid: 'sha256:' + built.manifest.root_sha256,
|
|
66
|
+
rootHex: built.manifest.root_sha256,
|
|
67
|
+
size: stat.size,
|
|
68
|
+
chunkBytes: plan.chunkBytes,
|
|
69
|
+
chunkCount: plan.count,
|
|
70
|
+
chunks,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 控制面引用:只含元数据,不含字节、块清单或路径。 */
|
|
75
|
+
export function createRefV2({ rootCid, size, chunkCount, chunkBytes, originPeer, sinkHint = 'workspace://artifacts', mediaType = 'application/octet-stream', refId = null }) {
|
|
76
|
+
if (typeof rootCid !== 'string' || !ROOT.test(rootCid)) return { ok: false, code: 'ARTIFACT_ROOT_INVALID' };
|
|
77
|
+
if (!Number.isSafeInteger(size) || size < 0) return { ok: false, code: 'ARTIFACT_SIZE_INVALID' };
|
|
78
|
+
if (!Number.isSafeInteger(chunkCount) || chunkCount < 0) return { ok: false, code: 'ARTIFACT_CHUNKS_INVALID' };
|
|
79
|
+
if (!Number.isSafeInteger(chunkBytes) || chunkBytes <= 0) return { ok: false, code: 'ARTIFACT_CHUNK_SIZE_INVALID' };
|
|
80
|
+
if (typeof originPeer !== 'string' || originPeer === '') return { ok: false, code: 'ARTIFACT_ORIGIN_REQUIRED' };
|
|
81
|
+
return { ok: true, ref: { v: 2, ref: refId ?? rootCid, rootCid, size, chunkCount, chunkBytes, mediaType, originPeer, sinkHint } };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function verifyRefV2(ref) {
|
|
85
|
+
if (ref === null || typeof ref !== 'object' || ref.v !== 2) return { ok: false, code: 'ARTIFACT_REF_INVALID' };
|
|
86
|
+
if (typeof ref.ref !== 'string' || ref.ref === '') return { ok: false, code: 'ARTIFACT_REF_INVALID' };
|
|
87
|
+
if (typeof ref.rootCid !== 'string' || !ROOT.test(ref.rootCid)) return { ok: false, code: 'ARTIFACT_ROOT_INVALID' };
|
|
88
|
+
if (!Number.isSafeInteger(ref.size) || ref.size < 0 || !Number.isSafeInteger(ref.chunkCount) || ref.chunkCount < 0) return { ok: false, code: 'ARTIFACT_REF_INVALID' };
|
|
89
|
+
if (typeof ref.originPeer !== 'string' || ref.originPeer === '') return { ok: false, code: 'ARTIFACT_ORIGIN_REQUIRED' };
|
|
90
|
+
return { ok: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 本地引用索引(控制面的本机替身;relay 发布是下一阶段接线)。 */
|
|
94
|
+
export function createRefIndex({ dir }) {
|
|
95
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
96
|
+
const fileOf = (refId) => join(dir, sha256(String(refId)) + '.json');
|
|
97
|
+
return {
|
|
98
|
+
publish(ref) {
|
|
99
|
+
const checked = verifyRefV2(ref);
|
|
100
|
+
if (checked.ok !== true) return checked;
|
|
101
|
+
const file = fileOf(ref.ref);
|
|
102
|
+
writeFileSync(file, JSON.stringify(ref), { encoding: 'utf8', mode: 0o600 });
|
|
103
|
+
return { ok: true, ref, path: file };
|
|
104
|
+
},
|
|
105
|
+
get(refId) {
|
|
106
|
+
try {
|
|
107
|
+
const ref = readJson(fileOf(refId));
|
|
108
|
+
const checked = verifyRefV2(ref);
|
|
109
|
+
return checked.ok === true ? { ok: true, ref } : checked;
|
|
110
|
+
} catch { return { ok: false, code: 'ARTIFACT_REF_NOT_FOUND' }; }
|
|
111
|
+
},
|
|
112
|
+
list() {
|
|
113
|
+
return { ok: true, refs: readdirSync(dir).filter((n) => n.endsWith('.json')).map((n) => { try { return readJson(join(dir, n)); } catch { return null; } }).filter(Boolean) };
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 接收端:位图持久化 + 逐块校验 + finalize 根校验后原子落盘。
|
|
120
|
+
* @param {{stateDir:string, sinkRoot:string, ref:object, destination:string, chunkBytes?:number}} input
|
|
121
|
+
*/
|
|
122
|
+
export function createReceiver({ stateDir, sinkRoot, ref, destination, chunkBytes = null }) {
|
|
123
|
+
const checked = verifyRefV2(ref);
|
|
124
|
+
if (checked.ok !== true) return checked;
|
|
125
|
+
const safe = safeRelative(destination);
|
|
126
|
+
if (safe.ok !== true) return safe;
|
|
127
|
+
const root = resolve(sinkRoot);
|
|
128
|
+
const target = resolve(root, safe.relative);
|
|
129
|
+
const rel = relative(root, target);
|
|
130
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel) || rel.split(sep).includes('..')) return { ok: false, code: 'PATH_TRAVERSAL' };
|
|
131
|
+
const transferId = sha256(ref.ref + '|' + ref.rootCid + '|' + ref.size).slice(0, 32);
|
|
132
|
+
const dir = join(stateDir, transferId);
|
|
133
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
134
|
+
const bitmapPath = join(dir, 'bitmap.json');
|
|
135
|
+
const partPath = join(dir, 'data.part');
|
|
136
|
+
const chunk = Number.isInteger(chunkBytes) ? chunkBytes : ref.chunkBytes;
|
|
137
|
+
let bitmap = null;
|
|
138
|
+
if (existsSync(bitmapPath)) {
|
|
139
|
+
try { bitmap = readJson(bitmapPath); } catch { bitmap = null; }
|
|
140
|
+
if (bitmap !== null && (bitmap.rootCid !== ref.rootCid || bitmap.size !== ref.size || bitmap.chunkBytes !== chunk)) return { ok: false, code: 'XFER_SESSION_CONFLICT' };
|
|
141
|
+
}
|
|
142
|
+
if (bitmap === null) {
|
|
143
|
+
const fd = openSync(partPath, 'w+', 0o600);
|
|
144
|
+
try { ftruncateSync(fd, ref.size); } finally { closeSync(fd); }
|
|
145
|
+
bitmap = { v: 1, transferId, rootCid: ref.rootCid, size: ref.size, chunkBytes: chunk, chunkCount: ref.chunkCount, chunks: ref.chunkCount, chunk_bytes: chunk, last_chunk_bytes: ref.chunkCount === 0 ? 0 : ref.size - (ref.chunkCount - 1) * chunk, hashes: Array(ref.chunkCount).fill(null), destination: safe.relative, updatedAt: new Date().toISOString() };
|
|
146
|
+
writeFileSync(bitmapPath, JSON.stringify(bitmap), { encoding: 'utf8', mode: 0o600 });
|
|
147
|
+
}
|
|
148
|
+
const save = () => { bitmap.updatedAt = new Date().toISOString(); const tmp = bitmapPath + '.tmp-' + process.pid; writeFileSync(tmp, JSON.stringify(bitmap), { encoding: 'utf8', mode: 0o600 }); renameSync(tmp, bitmapPath); };
|
|
149
|
+
let finalized = false;
|
|
150
|
+
return {
|
|
151
|
+
ok: true,
|
|
152
|
+
transferId,
|
|
153
|
+
have() {
|
|
154
|
+
return new Set(bitmap.hashes.map((h, i) => (h === null ? null : i)).filter((i) => i !== null));
|
|
155
|
+
},
|
|
156
|
+
progress() {
|
|
157
|
+
const received = bitmap.hashes.map((h, i) => (h === null ? null : i)).filter((i) => i !== null);
|
|
158
|
+
return { total: bitmap.chunkCount, received: received.length, complete: received.length === bitmap.chunkCount, bytes: bitmap.hashes.reduce((sum, h, i) => sum + (h === null ? 0 : i === bitmap.chunkCount - 1 ? bitmap.size - i * chunk : chunk), 0) };
|
|
159
|
+
},
|
|
160
|
+
writeChunk(index, data, expectedSha256) {
|
|
161
|
+
if (finalized) return { ok: false, code: 'XFER_ALREADY_FINALIZED' };
|
|
162
|
+
const range = chunkRange(bitmap, index);
|
|
163
|
+
if (range === null) return { ok: false, code: 'XFER_CHUNK_INDEX_INVALID' };
|
|
164
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data ?? '');
|
|
165
|
+
if (buf.length !== range.length) return { ok: false, code: 'XFER_CHUNK_LENGTH_INVALID', detail: { expected: range.length, got: buf.length } };
|
|
166
|
+
const digest = sha256(buf);
|
|
167
|
+
if (typeof expectedSha256 !== 'string' || expectedSha256 !== digest) return { ok: false, code: 'XFER_CHUNK_HASH_MISMATCH', retry: index };
|
|
168
|
+
if (bitmap.hashes[index] !== null && bitmap.hashes[index] !== digest) return { ok: false, code: 'XFER_CHUNK_CONFLICT' };
|
|
169
|
+
if (bitmap.hashes[index] === digest) return { ok: true, duplicate: true, index };
|
|
170
|
+
const fd = openSync(partPath, 'r+');
|
|
171
|
+
try { writeSync(fd, buf, 0, buf.length, range.offset); } finally { closeSync(fd); }
|
|
172
|
+
bitmap.hashes[index] = digest;
|
|
173
|
+
save();
|
|
174
|
+
return { ok: true, index, sha256: digest, writtenBytes: buf.length };
|
|
175
|
+
},
|
|
176
|
+
finalizeTransfer(rootCid = ref.rootCid) {
|
|
177
|
+
if (rootCid !== ref.rootCid) return { ok: false, code: 'XFER_ROOT_MISMATCH' };
|
|
178
|
+
if (bitmap.hashes.some((h) => h === null)) return { ok: false, code: 'XFER_CHUNKS_MISSING', detail: { missing: bitmap.hashes.filter((h) => h === null).length } };
|
|
179
|
+
const built = createManifest({ sizeBytes: bitmap.size, chunkBytes: bitmap.chunkBytes, chunkHashes: bitmap.hashes, objectId: transferId, kind: 'file', sinkKind: 'cache', relPath: safe.relative });
|
|
180
|
+
if (built.ok !== true) return built;
|
|
181
|
+
if ('sha256:' + built.manifest.root_sha256 !== rootCid) return { ok: false, code: 'XFER_ROOT_MISMATCH' };
|
|
182
|
+
mkdirSync(resolve(target, '..'), { recursive: true, mode: 0o700 });
|
|
183
|
+
const fd = openSync(partPath, 'r+');
|
|
184
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
185
|
+
renameSync(partPath, target);
|
|
186
|
+
finalized = true;
|
|
187
|
+
try { writeFileSync(join(dir, 'done.json'), JSON.stringify({ transferId, rootCid, size: bitmap.size, path: target, at: new Date().toISOString() }), { encoding: 'utf8', mode: 0o600 }); } catch { /* receipt is best effort */ }
|
|
188
|
+
return { ok: true, path: target, verified: true, bytes: bitmap.size, transferId };
|
|
189
|
+
},
|
|
190
|
+
/** 远端 P2P 拉取尚未接线:显式失败,不回落 relay。 */
|
|
191
|
+
async pullFrom(originPeer) {
|
|
192
|
+
return { ok: false, code: 'P2P_UNAVAILABLE', detail: { originPeer, why: 'want-block transport is a later slice; relay fallback is forbidden' } };
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件分块清单(C1 的最小可信底座)。
|
|
3
|
+
*
|
|
4
|
+
* 纯函数,无 I/O:给定文件大小与块大小,产出一份可独立校验的 manifest。
|
|
5
|
+
* root = SHA256("w2m-xfer-root-v1" || size || chunkBytes || 逐块 SHA256),域分离避免
|
|
6
|
+
* 叶哈希/根哈希/不同切法互相混淆;manifest 本身不再包含明文内容。
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
export const XFER_VERSION = 1;
|
|
11
|
+
export const DEFAULT_CHUNK_BYTES = 256 * 1024;
|
|
12
|
+
export const MIN_CHUNK_BYTES = 16 * 1024;
|
|
13
|
+
export const MAX_CHUNK_BYTES = 2 * 1024 * 1024;
|
|
14
|
+
export const MAX_CHUNKS = 65536;
|
|
15
|
+
|
|
16
|
+
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
17
|
+
|
|
18
|
+
export function chunkLengths(sizeBytes, chunkBytes = DEFAULT_CHUNK_BYTES) {
|
|
19
|
+
if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 0) return { ok: false, code: 'XFER_SIZE_INVALID' };
|
|
20
|
+
if (!Number.isSafeInteger(chunkBytes) || chunkBytes < MIN_CHUNK_BYTES || chunkBytes > MAX_CHUNK_BYTES) return { ok: false, code: 'XFER_CHUNK_SIZE_INVALID' };
|
|
21
|
+
if (sizeBytes === 0) return { ok: true, count: 0, chunkBytes, lastChunkBytes: 0 };
|
|
22
|
+
const count = Math.ceil(sizeBytes / chunkBytes);
|
|
23
|
+
if (count > MAX_CHUNKS) return { ok: false, code: 'XFER_TOO_MANY_CHUNKS', detail: { count, max: MAX_CHUNKS } };
|
|
24
|
+
return { ok: true, count, chunkBytes, lastChunkBytes: sizeBytes % chunkBytes === 0 ? chunkBytes : sizeBytes % chunkBytes };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function chunkSha256(bytes) {
|
|
28
|
+
return sha256(bytes);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {{sizeBytes:number, chunkBytes?:number, chunkHashes:string[], objectId:string, taskId?:string|null,
|
|
33
|
+
* machineId?:string|null, kind?:string, sinkKind?:string, relPath:string, cipherBytes?:number}} input
|
|
34
|
+
*/
|
|
35
|
+
export function createManifest(input) {
|
|
36
|
+
const plan = chunkLengths(input.sizeBytes, input.chunkBytes ?? DEFAULT_CHUNK_BYTES);
|
|
37
|
+
if (plan.ok !== true) return plan;
|
|
38
|
+
const hashes = Array.isArray(input.chunkHashes) ? input.chunkHashes : [];
|
|
39
|
+
if (hashes.length !== plan.count) return { ok: false, code: 'XFER_CHUNK_COUNT_MISMATCH', detail: { expected: plan.count, got: hashes.length } };
|
|
40
|
+
for (const hash of hashes) if (typeof hash !== 'string' || !/^[0-9a-f]{64}$/.test(hash)) return { ok: false, code: 'XFER_CHUNK_HASH_INVALID' };
|
|
41
|
+
const root = createHash('sha256')
|
|
42
|
+
.update('w2m-xfer-root-v1\0')
|
|
43
|
+
.update(String(input.sizeBytes) + '\0' + String(plan.chunkBytes) + '\0')
|
|
44
|
+
.update(hashes.join('\0'))
|
|
45
|
+
.digest('hex');
|
|
46
|
+
return {
|
|
47
|
+
ok: true,
|
|
48
|
+
manifest: {
|
|
49
|
+
v: XFER_VERSION,
|
|
50
|
+
object_id: input.objectId,
|
|
51
|
+
task_id: input.taskId ?? null,
|
|
52
|
+
machine_id: input.machineId ?? null,
|
|
53
|
+
kind: typeof input.kind === 'string' ? input.kind : 'stdout',
|
|
54
|
+
sink: { kind: typeof input.sinkKind === 'string' ? input.sinkKind : 'cache', rel_path: input.relPath },
|
|
55
|
+
size_bytes: input.sizeBytes,
|
|
56
|
+
chunk_bytes: plan.chunkBytes,
|
|
57
|
+
chunks: plan.count,
|
|
58
|
+
last_chunk_bytes: plan.lastChunkBytes,
|
|
59
|
+
chunk_hashes: hashes.slice(),
|
|
60
|
+
root_sha256: root,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function verifyManifest(manifest) {
|
|
66
|
+
if (manifest === null || typeof manifest !== 'object') return { ok: false, code: 'XFER_MANIFEST_INVALID' };
|
|
67
|
+
if (manifest.v !== XFER_VERSION) return { ok: false, code: 'XFER_MANIFEST_VERSION' };
|
|
68
|
+
if (typeof manifest.object_id !== 'string' || !/^[0-9a-f]{32,64}$/.test(manifest.object_id)) return { ok: false, code: 'XFER_OBJECT_ID_INVALID' };
|
|
69
|
+
if (!Number.isSafeInteger(manifest.size_bytes) || manifest.size_bytes < 0) return { ok: false, code: 'XFER_SIZE_INVALID' };
|
|
70
|
+
const rebuilt = createManifest({
|
|
71
|
+
sizeBytes: manifest.size_bytes,
|
|
72
|
+
chunkBytes: manifest.chunk_bytes,
|
|
73
|
+
chunkHashes: manifest.chunk_hashes,
|
|
74
|
+
objectId: manifest.object_id,
|
|
75
|
+
taskId: manifest.task_id,
|
|
76
|
+
machineId: manifest.machine_id,
|
|
77
|
+
kind: manifest.kind,
|
|
78
|
+
sinkKind: manifest.sink?.kind,
|
|
79
|
+
relPath: manifest.sink?.rel_path,
|
|
80
|
+
});
|
|
81
|
+
if (rebuilt.ok !== true) return rebuilt;
|
|
82
|
+
if (rebuilt.manifest.root_sha256 !== manifest.root_sha256) return { ok: false, code: 'XFER_ROOT_MISMATCH' };
|
|
83
|
+
return { ok: true };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 某一块在文件中的字节偏移与长度(最后一块较短)。 */
|
|
87
|
+
export function chunkRange(manifest, index) {
|
|
88
|
+
if (!Number.isInteger(index) || index < 0 || index >= manifest.chunks) return null;
|
|
89
|
+
const offset = index * manifest.chunk_bytes;
|
|
90
|
+
const length = index === manifest.chunks - 1 ? manifest.last_chunk_bytes : manifest.chunk_bytes;
|
|
91
|
+
return { offset, length };
|
|
92
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 分块落盘 / 读取端的最小实现(C1 + C3 共用)。
|
|
3
|
+
*
|
|
4
|
+
* 一个对象一个目录:data.part(稀疏、按 offset 写)、meta.json(原子更新)。
|
|
5
|
+
* 提交前必须:全部块到齐、逐块哈希匹配、根哈希重算一致 → fsync → rename 为 <object_id>.bin。
|
|
6
|
+
* 失败保留 data.part 以便续传;根哈希不符则隔离,不产生“半份成品”。
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
9
|
+
import {
|
|
10
|
+
closeSync, existsSync, fsyncSync, ftruncateSync, mkdirSync, openSync, readFileSync, readSync,
|
|
11
|
+
renameSync, unlinkSync, writeFileSync, writeSync,
|
|
12
|
+
} from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { createManifest, chunkLengths, chunkRange, XFER_VERSION } from './manifest.mjs';
|
|
16
|
+
|
|
17
|
+
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
18
|
+
const readJson = (file) => JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
|
19
|
+
|
|
20
|
+
export function createObjectStore({ root }) {
|
|
21
|
+
if (typeof root !== 'string' || root === '') throw new TypeError('XFER_STORE_ROOT_REQUIRED');
|
|
22
|
+
const dirOf = (objectId) => join(root, objectId);
|
|
23
|
+
const metaPath = (objectId) => join(dirOf(objectId), 'meta.json');
|
|
24
|
+
const partPath = (objectId) => join(dirOf(objectId), 'data.part');
|
|
25
|
+
const finalPath = (objectId) => join(root, objectId + '.bin');
|
|
26
|
+
|
|
27
|
+
const saveMeta = (meta) => {
|
|
28
|
+
const file = metaPath(meta.object_id);
|
|
29
|
+
const tmp = file + '.tmp-' + process.pid + '-' + randomBytes(4).toString('hex');
|
|
30
|
+
writeFileSync(tmp, JSON.stringify(meta), { encoding: 'utf8', mode: 0o600 });
|
|
31
|
+
renameSync(tmp, file);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
root,
|
|
36
|
+
|
|
37
|
+
/** 开始/恢复一个入站对象。同一 objectId 且根一致 = 续传;否则拒绝。 */
|
|
38
|
+
begin({ objectId, totalBytes, chunkBytes, rootSha256, taskId = null, machineId = null, kind = 'stdout', sinkKind = 'cache', relPath = null }) {
|
|
39
|
+
if (typeof objectId !== 'string' || !/^[0-9a-f]{32,64}$/.test(objectId)) return { ok: false, code: 'XFER_OBJECT_ID_INVALID' };
|
|
40
|
+
if (typeof rootSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(rootSha256)) return { ok: false, code: 'XFER_ROOT_INVALID' };
|
|
41
|
+
const dir = dirOf(objectId);
|
|
42
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
43
|
+
// A completed object is immutable and needs no new session.
|
|
44
|
+
if (existsSync(finalPath(objectId))) return { ok: true, resumed: false, complete: true, objectId };
|
|
45
|
+
if (existsSync(metaPath(objectId))) {
|
|
46
|
+
const meta = readJson(metaPath(objectId));
|
|
47
|
+
if (meta.root_sha256 !== rootSha256 || meta.size_bytes !== totalBytes) return { ok: false, code: 'XFER_OBJECT_CONFLICT' };
|
|
48
|
+
meta.resumed = true;
|
|
49
|
+
saveMeta(meta);
|
|
50
|
+
return { ok: true, resumed: true, complete: meta.chunk_hashes.every((h) => h !== null), objectId };
|
|
51
|
+
}
|
|
52
|
+
const plan = chunkLengths(totalBytes, chunkBytes);
|
|
53
|
+
if (plan.ok !== true) return plan;
|
|
54
|
+
const fd = openSync(partPath(objectId), 'w+', 0o600);
|
|
55
|
+
try { ftruncateSync(fd, totalBytes); } finally { closeSync(fd); }
|
|
56
|
+
const emptyRoot = createManifest({ sizeBytes: 0, chunkBytes, chunkHashes: [], objectId, taskId, machineId, kind, sinkKind, relPath: relPath ?? objectId + '.bin' }).manifest.root_sha256;
|
|
57
|
+
const meta = {
|
|
58
|
+
v: XFER_VERSION,
|
|
59
|
+
object_id: objectId,
|
|
60
|
+
task_id: taskId,
|
|
61
|
+
machine_id: machineId,
|
|
62
|
+
kind,
|
|
63
|
+
sink: { kind: sinkKind, rel_path: relPath ?? objectId + '.bin' },
|
|
64
|
+
size_bytes: totalBytes,
|
|
65
|
+
chunk_bytes: plan.chunkBytes,
|
|
66
|
+
chunks: plan.count,
|
|
67
|
+
last_chunk_bytes: plan.lastChunkBytes,
|
|
68
|
+
root_sha256: totalBytes === 0 ? emptyRoot : rootSha256,
|
|
69
|
+
root_expected: rootSha256,
|
|
70
|
+
chunk_hashes: Array(plan.count).fill(null),
|
|
71
|
+
created_at: new Date().toISOString(),
|
|
72
|
+
};
|
|
73
|
+
// 空对象直接可提交:根必须与调用方声明的根一致。
|
|
74
|
+
if (totalBytes === 0 && rootSha256 !== emptyRoot) return { ok: false, code: 'XFER_ROOT_MISMATCH' };
|
|
75
|
+
saveMeta(meta);
|
|
76
|
+
return { ok: true, resumed: false, complete: totalBytes === 0, objectId };
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/** 写一块。乱序允许;同 index 同哈希幂等;冲突即终态错误。 */
|
|
80
|
+
writeChunk({ objectId, index, bytes, chunkSha256 = null }) {
|
|
81
|
+
const meta = readJson(metaPath(objectId));
|
|
82
|
+
const range = chunkRange(meta, index);
|
|
83
|
+
if (range === null) return { ok: false, code: 'XFER_CHUNK_INDEX_INVALID' };
|
|
84
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes ?? '');
|
|
85
|
+
if (buf.length !== range.length) return { ok: false, code: 'XFER_CHUNK_LENGTH_INVALID', detail: { expected: range.length, got: buf.length } };
|
|
86
|
+
const digest = sha256(buf);
|
|
87
|
+
if (chunkSha256 !== null && chunkSha256 !== digest) return { ok: false, code: 'XFER_CHUNK_HASH_MISMATCH' };
|
|
88
|
+
if (meta.chunk_hashes[index] !== null && meta.chunk_hashes[index] !== digest) return { ok: false, code: 'XFER_CHUNK_CONFLICT' };
|
|
89
|
+
if (meta.chunk_hashes[index] === digest) return { ok: true, duplicate: true, index, sha256: digest };
|
|
90
|
+
const fd = openSync(partPath(objectId), 'r+');
|
|
91
|
+
try { writeSync(fd, buf, 0, buf.length, range.offset); } finally { closeSync(fd); }
|
|
92
|
+
meta.chunk_hashes[index] = digest;
|
|
93
|
+
saveMeta(meta);
|
|
94
|
+
return { ok: true, duplicate: false, index, sha256: digest };
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
/** 已完成的块索引;续传握手用。 */
|
|
98
|
+
have(objectId) {
|
|
99
|
+
if (!existsSync(metaPath(objectId))) return { ok: false, code: 'XFER_SESSION_NOT_FOUND' };
|
|
100
|
+
const meta = readJson(metaPath(objectId));
|
|
101
|
+
const have = [];
|
|
102
|
+
meta.chunk_hashes.forEach((hash, index) => { if (hash !== null) have.push(index); });
|
|
103
|
+
return { ok: true, objectId, have, complete: meta.chunk_hashes.every((h) => h !== null), chunks: meta.chunks, root_sha256: meta.root_expected };
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
/** 全量校验 + 提交。根不符 → 移到 quarantine 并报错(不产生成品)。 */
|
|
107
|
+
commit(objectId) {
|
|
108
|
+
const meta = readJson(metaPath(objectId));
|
|
109
|
+
if (meta.chunk_hashes.some((hash) => hash === null)) return { ok: false, code: 'XFER_CHUNKS_MISSING', detail: { missing: meta.chunk_hashes.filter((h) => h === null).length } };
|
|
110
|
+
const rebuilt = createManifest({ sizeBytes: meta.size_bytes, chunkBytes: meta.chunk_bytes, chunkHashes: meta.chunk_hashes, objectId: meta.object_id, taskId: meta.task_id, machineId: meta.machine_id, kind: meta.kind, sinkKind: meta.sink.kind, relPath: meta.sink.rel_path });
|
|
111
|
+
if (rebuilt.ok !== true) return rebuilt;
|
|
112
|
+
if (rebuilt.manifest.root_sha256 !== meta.root_expected) return { ok: false, code: 'XFER_ROOT_MISMATCH', detail: { expected: meta.root_expected, actual: rebuilt.manifest.root_sha256 } };
|
|
113
|
+
const fd = openSync(partPath(objectId), 'r+');
|
|
114
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
115
|
+
renameSync(partPath(objectId), finalPath(objectId));
|
|
116
|
+
const manifest = { ...rebuilt.manifest, committed_at: new Date().toISOString() };
|
|
117
|
+
const file = join(root, objectId + '.ref.json');
|
|
118
|
+
writeFileSync(file, JSON.stringify(manifest), { encoding: 'utf8', mode: 0o600 });
|
|
119
|
+
try { unlinkSync(metaPath(objectId)); } catch { /* already gone */ }
|
|
120
|
+
try { unlinkSync(dirOf(objectId)); } catch { /* directory removal is best effort */ }
|
|
121
|
+
return { ok: true, objectId, bytes: meta.size_bytes, sha256: rebuilt.manifest.root_sha256, path: finalPath(objectId), manifest };
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
/** 读一块并校验;损坏返回 XFER_CHUNK_HASH_MISMATCH,绝不返回坏字节。 */
|
|
125
|
+
readChunk({ objectId, index }) {
|
|
126
|
+
const manifestPath = join(root, objectId + '.ref.json');
|
|
127
|
+
if (!existsSync(manifestPath)) return { ok: false, code: 'XFER_OBJECT_NOT_FOUND' };
|
|
128
|
+
const manifest = readJson(manifestPath);
|
|
129
|
+
const range = chunkRange(manifest, index);
|
|
130
|
+
if (range === null) return { ok: false, code: 'XFER_CHUNK_INDEX_INVALID' };
|
|
131
|
+
const buf = Buffer.alloc(range.length);
|
|
132
|
+
const fd = openSync(finalPath(objectId), 'r');
|
|
133
|
+
try { const read = readSync(fd, buf, 0, range.length, range.offset); if (read !== range.length) return { ok: false, code: 'XFER_READ_SHORT' }; } finally { closeSync(fd); }
|
|
134
|
+
if (sha256(buf) !== manifest.chunk_hashes[index]) return { ok: false, code: 'XFER_CHUNK_HASH_MISMATCH' };
|
|
135
|
+
return { ok: true, bytes: buf, index, sha256: manifest.chunk_hashes[index] };
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
has(objectId) {
|
|
139
|
+
if (existsSync(join(root, objectId + '.ref.json'))) return { ok: true, complete: true, bytes: readJson(join(root, objectId + '.ref.json')).size_bytes };
|
|
140
|
+
if (existsSync(metaPath(objectId))) { const have = this.have(objectId); return { ok: true, complete: false, received: have.have ?? [], chunks: have.chunks ?? null }; }
|
|
141
|
+
return { ok: false, code: 'XFER_OBJECT_NOT_FOUND' };
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C2:ArtifactRef 的控制面发布(复用 relay 现有项目决策通道,不新增存储、不传字节)。
|
|
3
|
+
*
|
|
4
|
+
* 发布体 = { title:"artifact.available", kind:"artifact_available", text: JSON(ref) };
|
|
5
|
+
* relay 只记录这条元数据,字节永远在本机对象库/sink。读取端按 title 过滤并校验 ref。
|
|
6
|
+
* 该通道在 relay 2.0.5 已存在(POST/GET /v1/projects/:id/decisions),无需新路由即可先跑通。
|
|
7
|
+
*/
|
|
8
|
+
import { verifyRefV2 } from './ferry.mjs';
|
|
9
|
+
|
|
10
|
+
const TITLE = 'artifact.available';
|
|
11
|
+
const KIND = 'artifact_available';
|
|
12
|
+
|
|
13
|
+
function baseOf(url) {
|
|
14
|
+
if (typeof url !== 'string' || url.trim() === '') return null;
|
|
15
|
+
return url.trim().replace(/\/+$/, '');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{relayUrl:string, token:string, projectId?:string, ref:object, fetchImpl?:Function, signal?:AbortSignal}} input
|
|
20
|
+
*/
|
|
21
|
+
export async function publishRef(input) {
|
|
22
|
+
const base = baseOf(input?.relayUrl);
|
|
23
|
+
if (base === null) return { ok: false, code: 'RELAY_URL_REQUIRED' };
|
|
24
|
+
if (typeof input?.token !== 'string' || input.token === '') return { ok: false, code: 'OPERATOR_TOKEN_REQUIRED' };
|
|
25
|
+
const checked = verifyRefV2(input?.ref);
|
|
26
|
+
if (checked.ok !== true) return checked;
|
|
27
|
+
const projectId = typeof input.projectId === 'string' && input.projectId !== '' ? input.projectId : 'default';
|
|
28
|
+
const fetchImpl = typeof input.fetchImpl === 'function' ? input.fetchImpl : globalThis.fetch;
|
|
29
|
+
const body = { title: TITLE, kind: KIND, text: JSON.stringify(checked.ok ? input.ref : null), refs: { rootCid: input.ref.rootCid, originPeer: input.ref.originPeer } };
|
|
30
|
+
let response = null;
|
|
31
|
+
try {
|
|
32
|
+
response = await fetchImpl(base + '/v1/projects/' + encodeURIComponent(projectId) + '/decisions', {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { authorization: 'Bearer ' + input.token, 'content-type': 'application/json', accept: 'application/json' },
|
|
35
|
+
body: JSON.stringify(body),
|
|
36
|
+
signal: input.signal,
|
|
37
|
+
});
|
|
38
|
+
} catch {
|
|
39
|
+
return { ok: false, code: 'RELAY_UNREACHABLE' };
|
|
40
|
+
}
|
|
41
|
+
let json = null;
|
|
42
|
+
try { json = await response.json(); } catch { json = null; }
|
|
43
|
+
if (response.ok !== true) return { ok: false, code: 'RELAY_REFUSED', detail: { status: response.status, error: json?.error?.code ?? null } };
|
|
44
|
+
if (json?.ok !== true) return { ok: false, code: 'RELAY_REFUSED', detail: { status: response.status } };
|
|
45
|
+
return { ok: true, decision: json.decision ?? null, ref: input.ref };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {{relayUrl:string, token:string, projectId?:string, fetchImpl?:Function, signal?:AbortSignal}} input
|
|
50
|
+
*/
|
|
51
|
+
export async function listPublishedRefs(input) {
|
|
52
|
+
const base = baseOf(input?.relayUrl);
|
|
53
|
+
if (base === null) return { ok: false, code: 'RELAY_URL_REQUIRED' };
|
|
54
|
+
if (typeof input?.token !== 'string' || input.token === '') return { ok: false, code: 'DEVICE_TOKEN_REQUIRED' };
|
|
55
|
+
const projectId = typeof input.projectId === 'string' && input.projectId !== '' ? input.projectId : 'default';
|
|
56
|
+
const fetchImpl = typeof input.fetchImpl === 'function' ? input.fetchImpl : globalThis.fetch;
|
|
57
|
+
let response = null;
|
|
58
|
+
try {
|
|
59
|
+
response = await fetchImpl(base + '/v1/projects/' + encodeURIComponent(projectId) + '/decisions', { headers: { authorization: 'Bearer ' + input.token, accept: 'application/json' }, signal: input.signal });
|
|
60
|
+
} catch {
|
|
61
|
+
return { ok: false, code: 'RELAY_UNREACHABLE' };
|
|
62
|
+
}
|
|
63
|
+
let json = null;
|
|
64
|
+
try { json = await response.json(); } catch { json = null; }
|
|
65
|
+
if (response.ok !== true) return { ok: false, code: 'RELAY_REFUSED', detail: { status: response.status } };
|
|
66
|
+
const decisions = Array.isArray(json?.decisions) ? json.decisions : [];
|
|
67
|
+
const refs = [];
|
|
68
|
+
for (const decision of decisions) {
|
|
69
|
+
if (decision?.title !== TITLE || decision?.kind !== KIND) continue;
|
|
70
|
+
let ref = null;
|
|
71
|
+
try { ref = JSON.parse(String(decision.text ?? '')); } catch { continue; }
|
|
72
|
+
if (verifyRefV2(ref).ok !== true) continue;
|
|
73
|
+
refs.push(ref);
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, refs };
|
|
76
|
+
}
|