@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.
@@ -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 uploaded = await putImpl(pathname, bytes, {
333
- access: 'public',
334
- addRandomSuffix: false,
335
- // The pathname is content-addressed by the already-computed SHA-256.
336
- // Allowing overwrite makes a retry safe after a partial Hub publish.
337
- allowOverwrite: true,
338
- contentType: 'application/octet-stream',
339
- cacheControlMaxAge: 31536000,
340
- token: packsReadWriteToken(env),
341
- });
342
- if (!uploaded || typeof uploaded.url !== 'string' || !uploaded.url) {
343
- throw new Error('Public Blob upload did not return a URL.');
344
- }
345
- if (uploaded.pathname !== undefined && uploaded.pathname !== pathname) {
346
- throw new Error(
347
- `Public Blob upload pathname is not locked to ${pathname}.`
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 packsReadWriteToken(env) {
597
- const token =
598
- typeof env?.PACKS_READ_WRITE_TOKEN === 'string'
599
- ? env.PACKS_READ_WRITE_TOKEN.trim()
600
- : '';
601
- if (!token)
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
- 'PACKS_READ_WRITE_TOKEN is required for public Blob uploads.'
650
+ `Refusing private Blob URL for ${label}; Hub verification requires a public Blob URL.`
604
651
  );
605
- return token;
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 (
@@ -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.2.2",
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.1.0",
36
- "@vercel/blob": "2.8.0"
35
+ "@knolo/core": "^5.5.0"
37
36
  },
38
37
  "private": false
39
38
  }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "knolo-core-rust"
3
- version = "5.1.0"
3
+ version = "5.5.0"
4
4
  edition = "2021"
5
5
  license = "Apache-2.0"
6
6
  description = "Rust runtime support for reading and querying .knolo packs"
@@ -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 objects = parse_objects(&bytes[object_segment.offset + V5_SEGMENT_HEADER_SIZE..object_segment.offset + object_segment.length])?;
1628
- let events = parse_events(&bytes[event_segment.offset + V5_SEGMENT_HEADER_SIZE..event_segment.offset + event_segment.length])?;
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.1.0"
248
+ version = "5.5.0"
249
249
 
250
250
  [[package]]
251
251
  name = "knolo-icp-canister"
252
- version = "5.1.0"
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.1.0"
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.1.0" }
19
+ knolo-core-rust = { path = "../knolo-core-rust", version = "5.5.0" }
20
20
  serde = { version = "1", features = ["derive"] }