@knolo/cli 5.2.2 → 5.5.0
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/README.md +16 -15
- package/bin/knolo.mjs +639 -156
- package/bin/registry/commands.mjs +96 -32
- package/bin/registry/http.mjs +61 -0
- package/package.json +2 -3
- package/templates/icp-knowledge-canister/canisters/knolo-core-rust/Cargo.toml +1 -1
- package/templates/icp-knowledge-canister/canisters/knolo-core-rust/src/lib.rs +294 -3
- package/templates/icp-knowledge-canister/canisters/knolo-icp-canister/Cargo.lock +2 -2
- package/templates/icp-knowledge-canister/canisters/knolo-icp-canister/Cargo.toml +2 -2
|
@@ -11,7 +11,6 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
11
11
|
import { isIP } from 'node:net';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
|
-
import { put as putBlob } from '@vercel/blob';
|
|
15
14
|
import {
|
|
16
15
|
normalizeRegistryUrl,
|
|
17
16
|
registryApiUrl,
|
|
@@ -24,7 +23,7 @@ import {
|
|
|
24
23
|
stageCachedArtifact,
|
|
25
24
|
} from './artifacts.mjs';
|
|
26
25
|
import { RegistryError } from './errors.mjs';
|
|
27
|
-
import { getJson, postJson } from './http.mjs';
|
|
26
|
+
import { getJson, postJson, putBytes } from './http.mjs';
|
|
28
27
|
import {
|
|
29
28
|
cachePath,
|
|
30
29
|
stageLockfileUpdate,
|
|
@@ -291,7 +290,6 @@ export async function runHubPublish(
|
|
|
291
290
|
homeDir,
|
|
292
291
|
print = console.log,
|
|
293
292
|
sleep = wait,
|
|
294
|
-
putImpl = putBlob,
|
|
295
293
|
pollIntervalMs = 1000,
|
|
296
294
|
maxPollAttempts = 120,
|
|
297
295
|
} = {}
|
|
@@ -329,28 +327,35 @@ export async function runHubPublish(
|
|
|
329
327
|
const sha256 = createSha256(bytes);
|
|
330
328
|
const pathname = `sha256/${sha256}.knolo`;
|
|
331
329
|
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
330
|
+
const grant = await postJson(
|
|
331
|
+
hubApiUrl(registry, ['upload', 'token']),
|
|
332
|
+
{ sha256, contentLength: bytes.length },
|
|
333
|
+
{ fetchImpl, headers: authHeaders }
|
|
334
|
+
);
|
|
335
|
+
const upload = resolveUploadGrant(grant, sha256);
|
|
336
|
+
let blobUrl;
|
|
337
|
+
if (upload.reused) {
|
|
338
|
+
blobUrl = assertPublicPackBlobUrl(upload.url, 'reused artifact').toString();
|
|
339
|
+
} else {
|
|
340
|
+
assertGrantPutUrl(upload.putUrl, 'upload grant');
|
|
341
|
+
const uploaded = await putBytes(upload.putUrl, bytes, {
|
|
342
|
+
fetchImpl,
|
|
343
|
+
headers: {
|
|
344
|
+
'content-type': 'application/octet-stream',
|
|
345
|
+
...(upload.putHeaders || {}),
|
|
346
|
+
'content-length': String(bytes.length),
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
if (uploaded.pathname !== undefined && uploaded.pathname !== pathname) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
`Public Blob upload pathname is not locked to ${pathname}.`
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
blobUrl = assertPublicPackBlobUrl(
|
|
355
|
+
uploaded.url,
|
|
356
|
+
'uploaded artifact'
|
|
357
|
+
).toString();
|
|
349
358
|
}
|
|
350
|
-
const blobUrl = assertPublicBlobUrl(
|
|
351
|
-
uploaded.url,
|
|
352
|
-
'uploaded artifact'
|
|
353
|
-
).toString();
|
|
354
359
|
|
|
355
360
|
await postJson(
|
|
356
361
|
hubApiUrl(registry, ['upload', 'complete']),
|
|
@@ -593,16 +598,59 @@ function createSha256(bytes) {
|
|
|
593
598
|
return createHash('sha256').update(bytes).digest('hex');
|
|
594
599
|
}
|
|
595
600
|
|
|
596
|
-
function
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
601
|
+
function resolveUploadGrant(body, sha256) {
|
|
602
|
+
const pathname = body?.pathname || body?.upload?.pathname;
|
|
603
|
+
const expectedPathname = `sha256/${sha256}.knolo`;
|
|
604
|
+
if (typeof pathname !== 'string' || pathname !== expectedPathname) {
|
|
605
|
+
throw new Error(`Hub upload pathname is not locked to ${expectedPathname}.`);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const reusedUrl =
|
|
609
|
+
(body?.reused && (body?.artifact?.url || body?.url)) ||
|
|
610
|
+
body?.artifact?.url;
|
|
611
|
+
if (typeof reusedUrl === 'string' && reusedUrl) {
|
|
612
|
+
return { reused: true, pathname, url: reusedUrl };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const upload = body?.upload && typeof body.upload === 'object' ? body.upload : body;
|
|
616
|
+
if (!upload || typeof upload.url !== 'string' || !upload.url) {
|
|
617
|
+
if (body?.clientToken || upload?.clientToken) {
|
|
618
|
+
throw new Error(
|
|
619
|
+
'Hub returned a Blob client token without upload.url. This CLI PUTs to upload.url; it does not use PACKS_READ_WRITE_TOKEN.'
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
throw new Error('Hub upload token response is missing upload.url.');
|
|
623
|
+
}
|
|
624
|
+
return {
|
|
625
|
+
reused: false,
|
|
626
|
+
pathname,
|
|
627
|
+
putUrl: upload.url,
|
|
628
|
+
putHeaders:
|
|
629
|
+
upload.headers && typeof upload.headers === 'object' ? upload.headers : {},
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function assertGrantPutUrl(value, label) {
|
|
634
|
+
let url;
|
|
635
|
+
try {
|
|
636
|
+
url = new URL(value);
|
|
637
|
+
} catch {
|
|
638
|
+
throw new Error(`Hub ${label} URL is invalid.`);
|
|
639
|
+
}
|
|
640
|
+
if (url.protocol !== 'https:')
|
|
641
|
+
throw new Error(`Hub ${label} URL must use HTTPS.`);
|
|
642
|
+
const hostname = url.hostname.toLowerCase();
|
|
643
|
+
if (
|
|
644
|
+
hostname.includes('.private.') ||
|
|
645
|
+
hostname.startsWith('private.') ||
|
|
646
|
+
isPrivateOrLocalIp(hostname) ||
|
|
647
|
+
isLocalOnlyHostname(hostname)
|
|
648
|
+
) {
|
|
602
649
|
throw new Error(
|
|
603
|
-
|
|
650
|
+
`Refusing private Blob URL for ${label}; Hub verification requires a public Blob URL.`
|
|
604
651
|
);
|
|
605
|
-
|
|
652
|
+
}
|
|
653
|
+
return url;
|
|
606
654
|
}
|
|
607
655
|
|
|
608
656
|
function extractPublisherHandle(account) {
|
|
@@ -611,8 +659,10 @@ function extractPublisherHandle(account) {
|
|
|
611
659
|
account?.publisher?.handle,
|
|
612
660
|
account?.publisher?.slug,
|
|
613
661
|
account?.handle,
|
|
662
|
+
account?.account?.handle,
|
|
614
663
|
account?.account?.publisherHandle,
|
|
615
664
|
account?.account?.publisher?.handle,
|
|
665
|
+
account?.user?.handle,
|
|
616
666
|
account?.user?.publisherHandle,
|
|
617
667
|
account?.publishers?.[0]?.handle,
|
|
618
668
|
account?.publishers?.[0]?.slug,
|
|
@@ -655,6 +705,20 @@ function assertPublicBlobUrl(value, label) {
|
|
|
655
705
|
return url;
|
|
656
706
|
}
|
|
657
707
|
|
|
708
|
+
function assertPublicPackBlobUrl(value, label) {
|
|
709
|
+
const url = assertPublicBlobUrl(value, label);
|
|
710
|
+
const hostname = url.hostname.toLowerCase();
|
|
711
|
+
const blobHost =
|
|
712
|
+
hostname === 'blob.vercel-storage.com' ||
|
|
713
|
+
hostname.endsWith('.blob.vercel-storage.com');
|
|
714
|
+
if (!blobHost || hostname.includes('.private.')) {
|
|
715
|
+
throw new Error(
|
|
716
|
+
`Hub ${label} URL must be a public Vercel Blob pack URL (not a signed grant URL).`
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
return url;
|
|
720
|
+
}
|
|
721
|
+
|
|
658
722
|
function isLocalOnlyHostname(hostname) {
|
|
659
723
|
const normalized = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
660
724
|
return (
|
package/bin/registry/http.mjs
CHANGED
|
@@ -19,6 +19,67 @@ export async function postJson(
|
|
|
19
19
|
return requestJson(url, { fetchImpl, headers, method: 'POST', body });
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export async function putBytes(
|
|
23
|
+
url,
|
|
24
|
+
bytes,
|
|
25
|
+
{ fetchImpl = globalThis.fetch, headers = {} } = {}
|
|
26
|
+
) {
|
|
27
|
+
if (typeof fetchImpl !== 'function')
|
|
28
|
+
throw new Error('This Node runtime does not provide fetch.');
|
|
29
|
+
|
|
30
|
+
let response;
|
|
31
|
+
try {
|
|
32
|
+
response = await fetchImpl(url, {
|
|
33
|
+
method: 'PUT',
|
|
34
|
+
redirect: 'follow',
|
|
35
|
+
headers: { 'content-type': 'application/octet-stream', ...headers },
|
|
36
|
+
body: bytes,
|
|
37
|
+
});
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw new RegistryNetworkError(
|
|
40
|
+
`Could not upload artifact to ${new URL(url).origin}.`,
|
|
41
|
+
{ url: String(url), cause: error }
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const text = await readResponseText(response, url);
|
|
46
|
+
let parsed;
|
|
47
|
+
if (text) {
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(text);
|
|
50
|
+
} catch {
|
|
51
|
+
parsed = undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
const detail =
|
|
57
|
+
(parsed && typeof parsed.error === 'object' && parsed.error?.message) ||
|
|
58
|
+
(typeof parsed?.error === 'string' && parsed.error) ||
|
|
59
|
+
(typeof parsed?.message === 'string' && parsed.message) ||
|
|
60
|
+
(text ? text.slice(0, 240) : '');
|
|
61
|
+
throw new RegistryError(
|
|
62
|
+
`Blob upload failed with HTTP ${response.status}${detail ? `: ${detail}` : '.'}`,
|
|
63
|
+
{
|
|
64
|
+
status: response.status,
|
|
65
|
+
url: String(url),
|
|
66
|
+
body: parsed ?? text,
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const publicUrl =
|
|
72
|
+
(parsed && typeof parsed.url === 'string' && parsed.url) ||
|
|
73
|
+
response.url ||
|
|
74
|
+
String(url);
|
|
75
|
+
return {
|
|
76
|
+
status: response.status,
|
|
77
|
+
url: publicUrl,
|
|
78
|
+
pathname: parsed && typeof parsed.pathname === 'string' ? parsed.pathname : undefined,
|
|
79
|
+
body: parsed,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
22
83
|
async function requestJson(
|
|
23
84
|
url,
|
|
24
85
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knolo/cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"knolo": "bin/knolo.mjs"
|
|
@@ -32,8 +32,7 @@
|
|
|
32
32
|
"test:hub": "node --test test/hub.integration.test.mjs"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@knolo/core": "^5.
|
|
36
|
-
"@vercel/blob": "2.8.0"
|
|
35
|
+
"@knolo/core": "^5.5.0"
|
|
37
36
|
},
|
|
38
37
|
"private": false
|
|
39
38
|
}
|
|
@@ -503,6 +503,263 @@ fn read_slice<'a>(bytes: &'a [u8], cursor: &mut usize, len: usize) -> Result<&'a
|
|
|
503
503
|
Ok(slice)
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
+
#[allow(dead_code)]
|
|
507
|
+
struct VqfReader<'a> {
|
|
508
|
+
bytes: &'a [u8],
|
|
509
|
+
cursor: usize,
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#[allow(dead_code)]
|
|
513
|
+
impl<'a> VqfReader<'a> {
|
|
514
|
+
fn new(bytes: &'a [u8]) -> Self { Self { bytes, cursor: 0 } }
|
|
515
|
+
fn remaining(&self) -> usize { self.bytes.len().saturating_sub(self.cursor) }
|
|
516
|
+
fn byte(&mut self) -> Result<u8, KnoloError> {
|
|
517
|
+
let value = *self.bytes.get(self.cursor).ok_or_else(|| KnoloError::InvalidPack("truncated VQF body".into()))?;
|
|
518
|
+
self.cursor += 1;
|
|
519
|
+
Ok(value)
|
|
520
|
+
}
|
|
521
|
+
fn bytes(&mut self, length: usize) -> Result<&'a [u8], KnoloError> {
|
|
522
|
+
let end = self.cursor.checked_add(length).ok_or_else(|| KnoloError::InvalidPack("VQF length overflow".into()))?;
|
|
523
|
+
if end > self.bytes.len() { return Err(KnoloError::InvalidPack("truncated VQF body".into())); }
|
|
524
|
+
let value = &self.bytes[self.cursor..end];
|
|
525
|
+
self.cursor = end;
|
|
526
|
+
Ok(value)
|
|
527
|
+
}
|
|
528
|
+
fn uvarint(&mut self) -> Result<u64, KnoloError> {
|
|
529
|
+
let mut value = 0u64;
|
|
530
|
+
for index in 0..10 {
|
|
531
|
+
let byte = self.byte()?;
|
|
532
|
+
let payload = (byte & 0x7f) as u64;
|
|
533
|
+
if index == 9 && (byte & 0x7f) > 1 { return Err(KnoloError::InvalidPack("VQF varint overflow".into())); }
|
|
534
|
+
value |= payload << (index * 7);
|
|
535
|
+
if byte & 0x80 == 0 {
|
|
536
|
+
if index > 0 && payload == 0 { return Err(KnoloError::InvalidPack("noncanonical VQF varint".into())); }
|
|
537
|
+
return Ok(value);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
Err(KnoloError::InvalidPack("VQF varint overflow".into()))
|
|
541
|
+
}
|
|
542
|
+
fn finish(&self) -> Result<(), KnoloError> {
|
|
543
|
+
if self.remaining() != 0 { return Err(KnoloError::InvalidPack("trailing VQF bytes".into())); }
|
|
544
|
+
Ok(())
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
#[allow(dead_code)]
|
|
549
|
+
fn decode_vqf_digest_table(bytes: &[u8], max_entries: usize) -> Result<Vec<[u8; 32]>, KnoloError> {
|
|
550
|
+
let mut reader = VqfReader::new(bytes);
|
|
551
|
+
let count = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF digest count overflow".into()))?;
|
|
552
|
+
if count > max_entries || count.checked_mul(32).unwrap_or(usize::MAX) != reader.remaining() { return Err(KnoloError::InvalidPack("invalid VQF digest table length".into())); }
|
|
553
|
+
let mut values = Vec::with_capacity(count);
|
|
554
|
+
for _ in 0..count { values.push(reader.bytes(32)?.try_into().unwrap()); }
|
|
555
|
+
for pair in values.windows(2) { if pair[0] >= pair[1] { return Err(KnoloError::InvalidPack("VQF digest table is not strictly sorted".into())); } }
|
|
556
|
+
reader.finish()?;
|
|
557
|
+
Ok(values)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
#[allow(dead_code)]
|
|
561
|
+
fn decode_vqf_string_table(bytes: &[u8], max_entries: usize, max_bytes: usize) -> Result<Vec<String>, KnoloError> {
|
|
562
|
+
let mut reader = VqfReader::new(bytes);
|
|
563
|
+
let count = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF string count overflow".into()))?;
|
|
564
|
+
if count > max_entries { return Err(KnoloError::InvalidPack("VQF string table exceeds entry limit".into())); }
|
|
565
|
+
let mut values = Vec::with_capacity(count);
|
|
566
|
+
let mut previous: Option<Vec<u8>> = None;
|
|
567
|
+
for _ in 0..count {
|
|
568
|
+
let length = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF string length overflow".into()))?;
|
|
569
|
+
if length > max_bytes { return Err(KnoloError::InvalidPack("VQF string exceeds byte limit".into())); }
|
|
570
|
+
let raw = reader.bytes(length)?.to_vec();
|
|
571
|
+
if previous.as_ref().is_some_and(|item| item.as_slice() >= raw.as_slice()) { return Err(KnoloError::InvalidPack("VQF string table is not strictly sorted".into())); }
|
|
572
|
+
let value = String::from_utf8(raw.clone()).map_err(|_| KnoloError::InvalidPack("VQF string is not UTF-8".into()))?;
|
|
573
|
+
previous = Some(raw);
|
|
574
|
+
values.push(value);
|
|
575
|
+
}
|
|
576
|
+
reader.finish()?;
|
|
577
|
+
Ok(values)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
#[allow(dead_code)]
|
|
581
|
+
fn decode_vqf_blob_table(reader: &mut VqfReader<'_>, max_entries: usize, max_bytes: usize) -> Result<Vec<Vec<u8>>, KnoloError> {
|
|
582
|
+
let count = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF blob count overflow".into()))?;
|
|
583
|
+
if count > max_entries || count > reader.remaining() { return Err(KnoloError::InvalidPack("invalid VQF blob table count".into())); }
|
|
584
|
+
let mut blobs = Vec::with_capacity(count);
|
|
585
|
+
let mut total = 0usize;
|
|
586
|
+
for _ in 0..count {
|
|
587
|
+
let length = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF blob length overflow".into()))?;
|
|
588
|
+
total = total.checked_add(length).ok_or_else(|| KnoloError::InvalidPack("VQF blob bytes overflow".into()))?;
|
|
589
|
+
if total > max_bytes { return Err(KnoloError::InvalidPack("VQF blob table exceeds byte limit".into())); }
|
|
590
|
+
let blob = reader.bytes(length)?.to_vec();
|
|
591
|
+
if blobs.last().is_some_and(|previous: &Vec<u8>| previous.as_slice() >= blob.as_slice()) { return Err(KnoloError::InvalidPack("VQF blob table is not strictly sorted".into())); }
|
|
592
|
+
blobs.push(blob);
|
|
593
|
+
}
|
|
594
|
+
Ok(blobs)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
#[allow(dead_code)]
|
|
598
|
+
fn decode_vqf_object_bytes(reader: &mut VqfReader<'_>, blobs: &[Vec<u8>], source_spans: bool) -> Result<Vec<u8>, KnoloError> {
|
|
599
|
+
let mode = reader.byte()?;
|
|
600
|
+
let ordinal = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF object blob ordinal overflow".into()))?;
|
|
601
|
+
let source = blobs.get(ordinal).ok_or_else(|| KnoloError::InvalidPack("invalid VQF object blob ordinal".into()))?;
|
|
602
|
+
match mode {
|
|
603
|
+
0 => Ok(source.clone()),
|
|
604
|
+
1 if source_spans => {
|
|
605
|
+
let offset = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF source span offset overflow".into()))?;
|
|
606
|
+
let length = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF source span length overflow".into()))?;
|
|
607
|
+
let end = offset.checked_add(length).ok_or_else(|| KnoloError::InvalidPack("VQF source span overflow".into()))?;
|
|
608
|
+
if end > source.len() { return Err(KnoloError::InvalidPack("VQF source span exceeds blob".into())); }
|
|
609
|
+
Ok(source[offset..end].to_vec())
|
|
610
|
+
}
|
|
611
|
+
_ => Err(KnoloError::InvalidPack("invalid VQF object byte mode".into())),
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
#[allow(dead_code)]
|
|
616
|
+
fn decode_vqf_cbor_field(reader: &mut VqfReader<'_>, max_bytes: usize) -> Result<CborValue, KnoloError> {
|
|
617
|
+
let length = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF CBOR field length overflow".into()))?;
|
|
618
|
+
if length > max_bytes { return Err(KnoloError::InvalidPack("VQF CBOR field exceeds byte limit".into())); }
|
|
619
|
+
let raw = reader.bytes(length)?;
|
|
620
|
+
let value = decode_cbor_exact(raw)?;
|
|
621
|
+
if encode_cbor(&value) != raw { return Err(KnoloError::InvalidPack("noncanonical VQF CBOR field".into())); }
|
|
622
|
+
Ok(value)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
#[allow(dead_code)]
|
|
626
|
+
fn verify_vqf_object_identity(id: &str, kind: &str, bytes: &[u8], meta: CborValue) -> Result<(), KnoloError> {
|
|
627
|
+
if !id.starts_with("sha256-") || id.len() != 71 { return Err(KnoloError::InvalidPack("invalid VQF object digest".into())); }
|
|
628
|
+
let body = CborValue::Map(vec![
|
|
629
|
+
("bytes".into(), CborValue::Bytes(bytes.to_vec())),
|
|
630
|
+
("kind".into(), CborValue::Text(kind.into())),
|
|
631
|
+
("meta".into(), meta),
|
|
632
|
+
]);
|
|
633
|
+
if digest_domain("object", &encode_cbor(&body)) != id { return Err(KnoloError::InvalidPack("VQF object identity mismatch".into())); }
|
|
634
|
+
Ok(())
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
#[allow(dead_code)]
|
|
638
|
+
fn vqf_digest_string(raw: &[u8; 32]) -> String {
|
|
639
|
+
let mut value = String::from("sha256-");
|
|
640
|
+
for byte in raw { value.push_str(&format!("{byte:02x}")); }
|
|
641
|
+
value
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
#[allow(dead_code)]
|
|
645
|
+
fn decode_vqf_string_value(reader: &mut VqfReader<'_>, strings: &[String]) -> Result<String, KnoloError> {
|
|
646
|
+
match reader.byte()? {
|
|
647
|
+
0 => {
|
|
648
|
+
let length = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF string length overflow".into()))?;
|
|
649
|
+
let raw = reader.bytes(length)?;
|
|
650
|
+
String::from_utf8(raw.to_vec()).map_err(|_| KnoloError::InvalidPack("VQF string is not UTF-8".into()))
|
|
651
|
+
}
|
|
652
|
+
1 => {
|
|
653
|
+
let ordinal = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF string ordinal overflow".into()))?;
|
|
654
|
+
strings.get(ordinal).cloned().ok_or_else(|| KnoloError::InvalidPack("invalid VQF string ordinal".into()))
|
|
655
|
+
}
|
|
656
|
+
_ => Err(KnoloError::InvalidPack("unknown VQF string tag".into())),
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
#[allow(dead_code)]
|
|
661
|
+
fn decode_vqf_object_records(
|
|
662
|
+
reader: &mut VqfReader<'_>,
|
|
663
|
+
digests: &[[u8; 32]],
|
|
664
|
+
strings: &[String],
|
|
665
|
+
blobs: &[Vec<u8>],
|
|
666
|
+
source_spans: bool,
|
|
667
|
+
max_objects: usize,
|
|
668
|
+
) -> Result<Vec<u8>, KnoloError> {
|
|
669
|
+
let count = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF object count overflow".into()))?;
|
|
670
|
+
if count > max_objects { return Err(KnoloError::InvalidPack("VQF object count exceeds limit".into())); }
|
|
671
|
+
let mut records = Vec::with_capacity(count);
|
|
672
|
+
for _ in 0..count {
|
|
673
|
+
let id_ordinal = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF digest ordinal overflow".into()))?;
|
|
674
|
+
let id = vqf_digest_string(digests.get(id_ordinal).ok_or_else(|| KnoloError::InvalidPack("invalid VQF digest ordinal".into()))?);
|
|
675
|
+
let kind = decode_vqf_string_value(reader, strings)?;
|
|
676
|
+
let bytes = decode_vqf_object_bytes(reader, blobs, source_spans)?;
|
|
677
|
+
let meta = decode_vqf_cbor_field(reader, 512 * 1024 * 1024)?;
|
|
678
|
+
let extra = decode_vqf_cbor_field(reader, 512 * 1024 * 1024)?;
|
|
679
|
+
if let CborValue::Map(entries) = &extra {
|
|
680
|
+
if entries.iter().any(|(key, _)| matches!(key.as_str(), "id" | "kind" | "bytes" | "meta")) { return Err(KnoloError::InvalidPack("VQF object extensions contain a reserved key".into())); }
|
|
681
|
+
} else { return Err(KnoloError::InvalidPack("invalid VQF object extensions".into())); }
|
|
682
|
+
verify_vqf_object_identity(&id, &kind, &bytes, meta.clone())?;
|
|
683
|
+
let mut record = match extra { CborValue::Map(entries) => entries, _ => unreachable!() };
|
|
684
|
+
record.push(("bytes".into(), CborValue::Bytes(bytes)));
|
|
685
|
+
record.push(("id".into(), CborValue::Text(id)));
|
|
686
|
+
record.push(("kind".into(), CborValue::Text(kind)));
|
|
687
|
+
record.push(("meta".into(), meta));
|
|
688
|
+
records.push(CborValue::Map(record));
|
|
689
|
+
}
|
|
690
|
+
reader.finish()?;
|
|
691
|
+
Ok(encode_cbor(&CborValue::Array(records)))
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
#[allow(dead_code)]
|
|
695
|
+
fn decode_vqf_object_body(body: &[u8]) -> Result<Vec<u8>, KnoloError> {
|
|
696
|
+
let mut reader = VqfReader::new(body);
|
|
697
|
+
if reader.byte()? != 1 { return Err(KnoloError::InvalidPack("unsupported VQF object codec version".into())); }
|
|
698
|
+
let flags = reader.byte()?;
|
|
699
|
+
if flags & !1 != 0 { return Err(KnoloError::InvalidPack("unsupported VQF object codec flags".into())); }
|
|
700
|
+
let digest_len = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF digest table length overflow".into()))?;
|
|
701
|
+
let digest_table = decode_vqf_digest_table(reader.bytes(digest_len)?, 1_000_000)?;
|
|
702
|
+
let string_len = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF string table length overflow".into()))?;
|
|
703
|
+
let string_table = decode_vqf_string_table(reader.bytes(string_len)?, 1_000_000, 512 * 1024 * 1024)?;
|
|
704
|
+
let blobs = decode_vqf_blob_table(&mut reader, 1_000_000, 512 * 1024 * 1024)?;
|
|
705
|
+
decode_vqf_object_records(&mut reader, &digest_table, &string_table, &blobs, flags & 1 != 0, 1_000_000)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
#[allow(dead_code)]
|
|
709
|
+
fn decode_vqf_event_body(body: &[u8]) -> Result<Vec<u8>, KnoloError> {
|
|
710
|
+
let mut reader = VqfReader::new(body);
|
|
711
|
+
if reader.byte()? != 1 { return Err(KnoloError::InvalidPack("unsupported VQF event codec version".into())); }
|
|
712
|
+
if reader.byte()? != 0 { return Err(KnoloError::InvalidPack("unsupported VQF event codec flags".into())); }
|
|
713
|
+
let digest_len = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF digest table length overflow".into()))?;
|
|
714
|
+
let digests = decode_vqf_digest_table(reader.bytes(digest_len)?, 1_000_000)?;
|
|
715
|
+
let string_len = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF string table length overflow".into()))?;
|
|
716
|
+
let strings = decode_vqf_string_table(reader.bytes(string_len)?, 1_000_000, 512 * 1024 * 1024)?;
|
|
717
|
+
let count = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF event count overflow".into()))?;
|
|
718
|
+
if count > 1_000_000 || count > reader.remaining() { return Err(KnoloError::InvalidPack("invalid VQF event count".into())); }
|
|
719
|
+
let mut records = Vec::with_capacity(count);
|
|
720
|
+
for _ in 0..count {
|
|
721
|
+
let version = reader.uvarint()?;
|
|
722
|
+
let digest = |reader: &mut VqfReader<'_>| -> Result<String, KnoloError> {
|
|
723
|
+
let ordinal = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF digest ordinal overflow".into()))?;
|
|
724
|
+
Ok(vqf_digest_string(digests.get(ordinal).ok_or_else(|| KnoloError::InvalidPack("invalid VQF digest ordinal".into()))?))
|
|
725
|
+
};
|
|
726
|
+
let id = digest(&mut reader)?;
|
|
727
|
+
let transaction_id = digest(&mut reader)?;
|
|
728
|
+
let parent_count = usize::try_from(reader.uvarint()?).map_err(|_| KnoloError::InvalidPack("VQF parent count overflow".into()))?;
|
|
729
|
+
if parent_count > reader.remaining() { return Err(KnoloError::InvalidPack("truncated VQF event parents".into())); }
|
|
730
|
+
let mut parents = Vec::with_capacity(parent_count);
|
|
731
|
+
for _ in 0..parent_count { parents.push(digest(&mut reader)?); }
|
|
732
|
+
let actor = decode_vqf_string_value(&mut reader, &strings)?;
|
|
733
|
+
let actor_counter = reader.uvarint()?;
|
|
734
|
+
let kind = decode_vqf_string_value(&mut reader, &strings)?;
|
|
735
|
+
let target = digest(&mut reader)?;
|
|
736
|
+
let payload = digest(&mut reader)?;
|
|
737
|
+
let provenance = decode_vqf_cbor_field(&mut reader, 512 * 1024 * 1024)?;
|
|
738
|
+
let extra = decode_vqf_cbor_field(&mut reader, 512 * 1024 * 1024)?;
|
|
739
|
+
if version != 1 || actor.is_empty() || actor_counter < 1 { return Err(KnoloError::InvalidPack("invalid VQF event identity fields".into())); }
|
|
740
|
+
let provenance_map = match provenance { CborValue::Map(_) => provenance, _ => return Err(KnoloError::InvalidPack("invalid VQF event provenance".into())) };
|
|
741
|
+
let mut identity = vec![
|
|
742
|
+
("actor".into(), CborValue::Text(actor.clone())),
|
|
743
|
+
("actorCounter".into(), CborValue::UInt(actor_counter)),
|
|
744
|
+
("kind".into(), CborValue::Text(kind.clone())),
|
|
745
|
+
("parents".into(), CborValue::Array(parents.clone().into_iter().map(CborValue::Text).collect())),
|
|
746
|
+
("payload".into(), CborValue::Text(payload.clone())),
|
|
747
|
+
("provenance".into(), provenance_map.clone()),
|
|
748
|
+
("target".into(), CborValue::Text(target.clone())),
|
|
749
|
+
("transactionId".into(), CborValue::Text(transaction_id.clone())),
|
|
750
|
+
("version".into(), CborValue::UInt(version)),
|
|
751
|
+
];
|
|
752
|
+
if digest_domain("event", &encode_cbor(&CborValue::Map(identity.clone()))) != id { return Err(KnoloError::InvalidPack("VQF event identity mismatch".into())); }
|
|
753
|
+
let mut record = match extra { CborValue::Map(entries) => entries, _ => return Err(KnoloError::InvalidPack("invalid VQF event extensions".into())) };
|
|
754
|
+
if record.iter().any(|(key, _)| matches!(key.as_str(), "version" | "id" | "transactionId" | "parents" | "actor" | "actorCounter" | "kind" | "target" | "payload" | "provenance")) { return Err(KnoloError::InvalidPack("VQF event extensions contain a reserved key".into())); }
|
|
755
|
+
record.append(&mut identity);
|
|
756
|
+
record.push(("id".into(), CborValue::Text(id)));
|
|
757
|
+
records.push(CborValue::Map(record));
|
|
758
|
+
}
|
|
759
|
+
reader.finish()?;
|
|
760
|
+
Ok(encode_cbor(&CborValue::Array(records)))
|
|
761
|
+
}
|
|
762
|
+
|
|
506
763
|
// V5 read-only Knowledge Image support. The implementation intentionally has
|
|
507
764
|
// no external dependencies so the format verifier can be used in offline and
|
|
508
765
|
// embedded environments.
|
|
@@ -662,6 +919,20 @@ pub fn inspect_knowledge_image(bytes: &[u8]) -> Result<KnowledgeImageVerificatio
|
|
|
662
919
|
})
|
|
663
920
|
}
|
|
664
921
|
|
|
922
|
+
/// Validates the VQF-1 envelope boundary before a codec-specific body reader.
|
|
923
|
+
pub fn inspect_vqf_envelope(bytes: &[u8], expected_codec_kind: u8) -> Result<(u64, u64), KnoloError> {
|
|
924
|
+
const HEADER: usize = 56;
|
|
925
|
+
if bytes.len() < HEADER || &bytes[0..4] != b"VQF1" { return Err(KnoloError::InvalidPack("invalid VQF envelope".into())); }
|
|
926
|
+
if bytes[4] != 1 || bytes[5] != expected_codec_kind { return Err(KnoloError::InvalidPack("unsupported VQF envelope version or codec".into())); }
|
|
927
|
+
if bytes[6] != 0 || bytes[7] != 0 { return Err(KnoloError::InvalidPack("unsupported VQF envelope flags".into())); }
|
|
928
|
+
let logical = u64::from_le_bytes(bytes[8..16].try_into().unwrap());
|
|
929
|
+
let body_len = u64::from_le_bytes(bytes[16..24].try_into().unwrap());
|
|
930
|
+
if body_len != (bytes.len() - HEADER) as u64 { return Err(KnoloError::InvalidPack("invalid VQF envelope length".into())); }
|
|
931
|
+
let body = &bytes[HEADER..];
|
|
932
|
+
if digest_raw(&digest_domain("vqf-physical", body)) != bytes[24..56] { return Err(KnoloError::InvalidPack("VQF physical body digest mismatch".into())); }
|
|
933
|
+
Ok((logical, body_len))
|
|
934
|
+
}
|
|
935
|
+
|
|
665
936
|
pub fn verify_knowledge_image(bytes: &[u8]) -> Result<KnowledgeImageVerification, KnoloError> {
|
|
666
937
|
inspect_knowledge_image(bytes)
|
|
667
938
|
}
|
|
@@ -1599,6 +1870,9 @@ fn parse_v5_image(bytes: &[u8]) -> Result<KnowledgeImage, KnoloError> {
|
|
|
1599
1870
|
while offset < bytes.len() {
|
|
1600
1871
|
let segment = read_v5_segment(bytes, offset)?;
|
|
1601
1872
|
if !matches!(segment.kind, 1..=3) && segment.kind < 128 { return Err(KnoloError::InvalidPack("unknown non-optional V5 segment".into())); }
|
|
1873
|
+
if segment.kind <= 3 && segment.flags != 0 && !(matches!(segment.kind, 1 | 2) && segment.flags == 1) {
|
|
1874
|
+
return Err(KnoloError::InvalidPack("unsupported required V5 segment flags".into()));
|
|
1875
|
+
}
|
|
1602
1876
|
if segment.kind <= 3 && segment.schema != 1 { return Err(KnoloError::InvalidPack("unsupported required V5 segment schema".into())); }
|
|
1603
1877
|
if (1..=3).contains(&segment.kind) {
|
|
1604
1878
|
if required_seen[segment.kind as usize - 1] { return Err(KnoloError::InvalidPack("duplicate required V5 segment".into())); }
|
|
@@ -1624,8 +1898,25 @@ fn parse_v5_image(bytes: &[u8]) -> Result<KnowledgeImage, KnoloError> {
|
|
|
1624
1898
|
commit.commit_digest = commit_digest.clone();
|
|
1625
1899
|
if commit_field(&commit_value, "objectSegmentDigest")? != object_segment.digest || commit_field(&commit_value, "eventSegmentDigest")? != event_segment.digest { return Err(KnoloError::InvalidPack("V5 segment digest mismatch".into())); }
|
|
1626
1900
|
|
|
1627
|
-
let
|
|
1628
|
-
let
|
|
1901
|
+
let object_payload = &bytes[object_segment.offset + V5_SEGMENT_HEADER_SIZE..object_segment.offset + object_segment.length];
|
|
1902
|
+
let objects = if object_segment.flags & 1 != 0 {
|
|
1903
|
+
let (_, _) = inspect_vqf_envelope(object_payload, 1)?;
|
|
1904
|
+
let body = &object_payload[56..];
|
|
1905
|
+
let logical = decode_vqf_object_body(body)?;
|
|
1906
|
+
if digest_domain("segment", &logical) != object_segment.digest { return Err(KnoloError::InvalidPack("VQF object logical segment digest mismatch".into())); }
|
|
1907
|
+
parse_objects(&logical)?
|
|
1908
|
+
} else {
|
|
1909
|
+
parse_objects(object_payload)?
|
|
1910
|
+
};
|
|
1911
|
+
let event_payload = &bytes[event_segment.offset + V5_SEGMENT_HEADER_SIZE..event_segment.offset + event_segment.length];
|
|
1912
|
+
let event_logical = if event_segment.flags & 1 != 0 {
|
|
1913
|
+
let (_, _) = inspect_vqf_envelope(event_payload, 2)?;
|
|
1914
|
+
let body = &event_payload[56..];
|
|
1915
|
+
let logical = decode_vqf_event_body(body)?;
|
|
1916
|
+
if digest_domain("segment", &logical) != event_segment.digest { return Err(KnoloError::InvalidPack("VQF event logical segment digest mismatch".into())); }
|
|
1917
|
+
logical
|
|
1918
|
+
} else { event_payload.to_vec() };
|
|
1919
|
+
let events = parse_events(&event_logical)?;
|
|
1629
1920
|
let object_ids = CborValue::Array(objects.iter().map(|object| CborValue::Text(object.id.clone())).collect());
|
|
1630
1921
|
let event_ids = CborValue::Array(events.iter().map(|event| CborValue::Text(event.id.clone())).collect());
|
|
1631
1922
|
if digest_domain("object-root", &encode_cbor(&object_ids)) != commit.object_root || digest_domain("event-root", &encode_cbor(&event_ids)) != commit.event_root { return Err(KnoloError::InvalidPack("V5 object/event root mismatch".into())); }
|
|
@@ -1658,7 +1949,7 @@ fn read_v5_segment(bytes: &[u8], offset: usize) -> Result<KnowledgeImageSegment,
|
|
|
1658
1949
|
let length = V5_SEGMENT_HEADER_SIZE.checked_add(payload_length).ok_or_else(|| KnoloError::InvalidPack("V5 segment length overflow".into()))?;
|
|
1659
1950
|
let payload = bytes.get(offset + V5_SEGMENT_HEADER_SIZE..offset + length).ok_or_else(|| KnoloError::InvalidPack("V5 segment exceeds file".into()))?;
|
|
1660
1951
|
let digest = digest_from_raw(&header[16..48]);
|
|
1661
|
-
if digest != digest_domain("segment", payload) { return Err(KnoloError::InvalidPack("V5 segment digest mismatch".into())); }
|
|
1952
|
+
if flags == 0 && digest != digest_domain("segment", payload) { return Err(KnoloError::InvalidPack("V5 segment digest mismatch".into())); }
|
|
1662
1953
|
Ok(KnowledgeImageSegment { kind, schema, flags, offset, length, payload_length, digest })
|
|
1663
1954
|
}
|
|
1664
1955
|
|
|
@@ -245,11 +245,11 @@ dependencies = [
|
|
|
245
245
|
|
|
246
246
|
[[package]]
|
|
247
247
|
name = "knolo-core-rust"
|
|
248
|
-
version = "5.
|
|
248
|
+
version = "5.5.0"
|
|
249
249
|
|
|
250
250
|
[[package]]
|
|
251
251
|
name = "knolo-icp-canister"
|
|
252
|
-
version = "5.
|
|
252
|
+
version = "5.5.0"
|
|
253
253
|
dependencies = [
|
|
254
254
|
"candid",
|
|
255
255
|
"ic-cdk",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "knolo-icp-canister"
|
|
3
|
-
version = "5.
|
|
3
|
+
version = "5.5.0"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
license = "Apache-2.0"
|
|
6
6
|
description = "ICP canister adapter for Knolo retrieval over Candid"
|
|
@@ -16,5 +16,5 @@ path = "src/lib.rs"
|
|
|
16
16
|
candid = "0.10"
|
|
17
17
|
ic-cdk = "0.17"
|
|
18
18
|
ic-cdk-macros = "0.17"
|
|
19
|
-
knolo-core-rust = { path = "../knolo-core-rust", version = "5.
|
|
19
|
+
knolo-core-rust = { path = "../knolo-core-rust", version = "5.5.0" }
|
|
20
20
|
serde = { version = "1", features = ["derive"] }
|