dig-urn-resolver 0.5.4

Resolve a DIG URN to its data through the protocol (node-first ladder, verified + decrypted). Rust + wasm; first consumer: Sage wallet NFT images.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
// Native-only: the mock-transport suite runs on a tokio runtime (not wasm).
#![cfg(not(target_arch = "wasm32"))]
//! End-to-end resolver behaviour over the in-memory mock transport (no network),
//! exercising the genuine `digstore_core` read-crypto via real fixtures.
//!
//! Covers: URN parse; the §5.3 ladder (node-when-healthy, rpc-else, override wins);
//! the node + rpc transports; the three distinct outcomes — `Success`,
//! `IntegrityFailure` (fail-CLOSED on tampered/verify/decrypt failure, never the
//! bytes), and `Unreachable` (both tiers down, friendly page) — and the render/
//! object-url invariant that unverified bytes are never surfaced.

mod common;

use common::*;
use dig_urn_resolver::{ResolveError, ResolveOptions, ResolveOutcome, ResolvedData, Resolver};

const IMG_KEY: &str = "img/logo.png";

/// A dummy pinned root for tests that exercise reachability/protocol paths where the
/// content bytes are irrelevant (the rpc tier requires a pinned root — HOLE B).
const DUMMY_ROOT: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";

fn rootless_urn(key: &str) -> String {
    format!("urn:dig:chia:{STORE_HEX}/{key}")
}

fn root_pinned_urn(key: &str, root_hex: &str) -> String {
    format!("urn:dig:chia:{STORE_HEX}:{root_hex}/{key}")
}

/// A root-pinned URN with the dummy root, for rpc reachability/error tests.
fn pinned(key: &str) -> String {
    root_pinned_urn(key, DUMMY_ROOT)
}

/// Unwrap a `Success`, failing loudly on any other outcome.
fn success(outcome: ResolveOutcome) -> ResolvedData {
    match outcome {
        ResolveOutcome::Success(d) => d,
        other => panic!("expected Success, got {other:?}"),
    }
}

/// A mock whose node tier is healthy and serves the resource plaintext directly.
fn node_serving(plaintext: Vec<u8>, content_type: &'static str) -> MockTransport {
    let pt = plaintext.clone();
    MockTransport::new(
        Box::new(move |url: &str| {
            if url.ends_with("/health") {
                Ok(status(200))
            } else if url.contains("/s/") {
                Ok(node_response(pt.clone(), Some(content_type), true))
            } else {
                Err(transport_err())
            }
        }),
        Box::new(|_url, _body| Err(transport_err())),
    )
}

/// A mock with NO node (health probes fail) but a serving rpc gateway. The gateway
/// serves ONLY `dig.getContent` — the resolver must never ask it for a trust root.
fn rpc_serving(fx: RpcFixture) -> MockTransport {
    MockTransport::new(
        Box::new(|_url: &str| Err(transport_err())), // no node: every probe/GET fails
        Box::new(move |_url: &str, body: &str| {
            let req: serde_json::Value = serde_json::from_str(body).unwrap();
            match req["method"].as_str().unwrap() {
                "dig.getContent" => Ok(rpc_ok(get_content_result(&fx))),
                other => panic!(
                    "gateway must not be asked for {other} (trust-root must not come from it)"
                ),
            }
        }),
    )
}

// --- URN parsing -----------------------------------------------------------

#[tokio::test]
async fn rejects_non_urn() {
    let t = node_serving(vec![], "text/plain");
    let err = Resolver::new(t).resolve("not-a-urn").await.unwrap_err();
    assert!(matches!(err, ResolveError::Parse(_)));
}

#[tokio::test]
async fn bare_store_urn_defaults_to_index_html() {
    // §8.5: a bare store URN (no resource path) resolves to the default view
    // index.html — the node is asked for `/s/<store>/index.html`.
    let t = MockTransport::new(
        Box::new(|url: &str| {
            if url.ends_with("/health") {
                Ok(status(200))
            } else {
                assert!(url.ends_with("/index.html"), "default view: {url}");
                Ok(node_response(b"landing".to_vec(), Some("text/html"), true))
            }
        }),
        Box::new(|_u, _b| Err(transport_err())),
    );
    let data = success(
        Resolver::new(t)
            .resolve(&format!("urn:dig:chia:{STORE_HEX}"))
            .await
            .unwrap(),
    );
    assert_eq!(data.bytes, b"landing");
}

// --- ladder: node preferred when healthy -----------------------------------

#[tokio::test]
async fn node_tier_serves_when_healthy() {
    let t = node_serving(b"hello world".to_vec(), "text/plain");
    let data = success(
        Resolver::new(t)
            .resolve(&rootless_urn("index.html"))
            .await
            .unwrap(),
    );
    assert_eq!(data.bytes, b"hello world");
    assert_eq!(data.content_type, "text/plain");
}

#[tokio::test]
async fn node_content_type_falls_back_to_extension_when_header_absent() {
    // Node serves bytes but omits content-type → derived from the .png path.
    let t = MockTransport::new(
        Box::new(|url: &str| {
            if url.ends_with("/health") {
                Ok(status(200))
            } else {
                Ok(node_response(vec![1, 2, 3], None, true))
            }
        }),
        Box::new(|_u, _b| Err(transport_err())),
    );
    let data = success(
        Resolver::new(t)
            .resolve(&rootless_urn(IMG_KEY))
            .await
            .unwrap(),
    );
    assert_eq!(data.content_type, "image/png");
}

#[tokio::test]
async fn node_404_falls_through_to_rpc_which_serves() {
    // #855 PRIMARY: the local node genuinely lacks the content (clean 404) — the
    // stranger's common case. The ladder MUST fall through to the rpc gateway that
    // HAS it, not abort at the local tier. Root-pinned so the rpc tier can verify.
    let fx = build_fixture(IMG_KEY, b"gateway has it", None);
    let root = fx.root_hex.clone();
    let t = MockTransport::new(
        Box::new(|url: &str| {
            if url.ends_with("/health") {
                Ok(status(200)) // a local node IS up …
            } else {
                Ok(status(404)) // … but does not hold this resource
            }
        }),
        Box::new(move |_u: &str, body: &str| {
            let req: serde_json::Value = serde_json::from_str(body).unwrap();
            match req["method"].as_str().unwrap() {
                "dig.getContent" => Ok(rpc_ok(get_content_result(&fx))),
                other => panic!("unexpected method {other}"),
            }
        }),
    );
    let data = success(
        Resolver::new(t)
            .resolve(&root_pinned_urn(IMG_KEY, &root))
            .await
            .unwrap(),
    );
    assert_eq!(data.bytes, b"gateway has it");
}

#[tokio::test]
async fn all_tiers_not_found_is_branded_final_not_found() {
    // #855: local 404 AND the gateway reports absence → ONE branded final NotFound
    // (not Unreachable, not a raw per-tier error). The content genuinely does not
    // exist anywhere reachable.
    let t = MockTransport::new(
        Box::new(|url: &str| {
            if url.ends_with("/health") {
                Ok(status(200))
            } else {
                Ok(status(404)) // node: absent
            }
        }),
        // gateway: total_length 0 ⇒ NotFound
        Box::new(|_u: &str, _b: &str| Ok(rpc_ok(serde_json::json!({ "total_length": 0 })))),
    );
    let err = Resolver::new(t)
        .resolve(&pinned(IMG_KEY))
        .await
        .unwrap_err();
    assert!(matches!(err, ResolveError::NotFound));
}

#[tokio::test]
async fn integrity_failure_at_node_tier_never_queries_rpc() {
    // #855 SECURITY: a tampered/unverified response at tier 1 MUST fail closed
    // IMMEDIATELY — it must NEVER fall through to another tier (that would let an
    // attacker turn a tampered tier-1 response into a silent retry serving
    // attacker-chosen bytes, §5.4). The rpc post MUST never be reached: the gateway
    // closure PANICS if the ladder tries it after the integrity failure.
    let t = MockTransport::new(
        Box::new(|url: &str| {
            if url.ends_with("/health") {
                Ok(status(200))
            } else {
                // 200 bytes with NO X-Dig-Verified ⇒ node did not attest ⇒ VerifyFailed.
                Ok(node_response(
                    b"unverified".to_vec(),
                    Some("text/plain"),
                    false,
                ))
            }
        }),
        Box::new(|url: &str, _b: &str| {
            panic!("integrity failure must NOT fall through to rpc: {url}")
        }),
    );
    let outcome = Resolver::new(t)
        .resolve(&rootless_urn(IMG_KEY))
        .await
        .unwrap();
    assert_eq!(outcome, ResolveOutcome::IntegrityFailure);
}

// --- ladder: rpc fallback when no node -------------------------------------

#[tokio::test]
async fn rootless_urn_over_rpc_is_rejected() {
    // HOLE B: with no node, a rootless URN cannot be chain-verified over the
    // untrusted gateway → hard RootRequired, WITHOUT ever asking the gateway for a
    // root (the mock panics if it is). Never data, never unreachable.
    let fx = build_fixture(IMG_KEY, b"whatever", None);
    let err = Resolver::new(rpc_serving(fx))
        .resolve(&rootless_urn(IMG_KEY))
        .await
        .unwrap_err();
    assert!(matches!(err, ResolveError::RootRequired));
}

#[tokio::test]
async fn rpc_tier_resolves_root_pinned() {
    let fx = build_fixture(IMG_KEY, b"pinned bytes", None);
    let root = fx.root_hex.clone();
    let data = success(
        Resolver::new(rpc_serving(fx))
            .resolve(&root_pinned_urn(IMG_KEY, &root))
            .await
            .unwrap(),
    );
    assert_eq!(data.bytes, b"pinned bytes");
}

#[tokio::test]
async fn rpc_private_store_salt_decrypts_root_pinned() {
    let salt = "cd".repeat(32);
    let fx = build_fixture(IMG_KEY, b"secret art", Some(&salt));
    let root = fx.root_hex.clone();
    let data = success(
        Resolver::new(rpc_serving(fx))
            .resolve(&format!(
                "{}?salt={}",
                root_pinned_urn(IMG_KEY, &root),
                salt
            ))
            .await
            .unwrap(),
    );
    assert_eq!(data.bytes, b"secret art");
}

#[tokio::test]
async fn rpc_not_found_when_content_empty() {
    // Root-pinned URN, gateway reports total_length 0 → hard NotFound.
    let t = MockTransport::new(
        Box::new(|_u: &str| Err(transport_err())),
        Box::new(|_u: &str, _b: &str| Ok(rpc_ok(serde_json::json!({ "total_length": 0 })))),
    );
    let err = Resolver::new(t)
        .resolve(&pinned(IMG_KEY))
        .await
        .unwrap_err();
    assert!(matches!(err, ResolveError::NotFound));
}

// --- IntegrityFailure: fail-CLOSED, distinct, never the bytes --------------

#[tokio::test]
async fn tampered_ciphertext_is_integrity_failure_not_data() {
    let mut tampered = build_fixture(IMG_KEY, b"authentic", None);
    let root = tampered.root_hex.clone();
    // Valid proof + root, but different ciphertext whose leaf won't match.
    tampered.ciphertext_b64 = base64_of(b"tampered ciphertext bytes!!");
    tampered.total_length = 27;
    tampered.chunk_lens = vec![27];
    let outcome = Resolver::new(rpc_serving(tampered))
        .resolve(&root_pinned_urn(IMG_KEY, &root))
        .await
        .unwrap();
    assert_eq!(outcome, ResolveOutcome::IntegrityFailure);
    assert!(
        outcome.data().is_none(),
        "must never carry the unverified bytes"
    );
}

#[tokio::test]
async fn wrong_salt_is_integrity_failure() {
    // Ciphertext committed WITHOUT salt, resolved WITH one → inclusion verifies
    // (authentic bytes) but the decrypt tag fails → integrity failure.
    let fx = build_fixture(IMG_KEY, b"art", None);
    let root = fx.root_hex.clone();
    let wrong_salt = "ef".repeat(32);
    let outcome = Resolver::new(rpc_serving(fx))
        .resolve(&format!(
            "{}?salt={}",
            root_pinned_urn(IMG_KEY, &root),
            wrong_salt
        ))
        .await
        .unwrap();
    assert_eq!(outcome, ResolveOutcome::IntegrityFailure);
}

#[tokio::test]
async fn integrity_failure_renders_security_page_never_the_bytes() {
    let mut tampered = build_fixture(IMG_KEY, b"authentic", None);
    let root = tampered.root_hex.clone();
    tampered.ciphertext_b64 = base64_of(b"evil");
    tampered.total_length = 4;
    tampered.chunk_lens = vec![4];
    let rendered = Resolver::new(rpc_serving(tampered))
        .resolve_rendered(&root_pinned_urn(IMG_KEY, &root))
        .await
        .unwrap();
    assert_eq!(rendered.content_type, "text/html");
    let html = String::from_utf8(rendered.bytes).unwrap();
    assert!(html.contains("Integrity Verification Failed"));
    assert!(
        !html.contains("evil"),
        "unverified bytes must never be rendered"
    );
    // The security page is NOT dressed up as the retryable network page.
    assert!(!html.contains("Connect to Node"));
}

#[tokio::test]
async fn rpc_protocol_error_is_hard_error_not_unreachable() {
    // Endpoint IS reachable (200) but returns a JSON-RPC error → hard Rpc error,
    // never the friendly unreachable page nor an integrity failure.
    let t = MockTransport::new(
        Box::new(|_u: &str| Err(transport_err())),
        Box::new(|_u: &str, _b: &str| {
            Ok(ok(
                serde_json::json!({ "jsonrpc": "2.0", "id": 1, "error": { "code": -32000, "message": "boom" } })
                    .to_string()
                    .into_bytes(),
                Some("application/json"),
            ))
        }),
    );
    let err = Resolver::new(t)
        .resolve(&pinned(IMG_KEY))
        .await
        .unwrap_err();
    assert!(matches!(err, ResolveError::Rpc(_)));
}

// --- Unreachable → friendly branded HTML (distinct from integrity failure) --

#[tokio::test]
async fn both_tiers_unreachable_is_unreachable_outcome() {
    let t = MockTransport::new(
        Box::new(|_u: &str| Err(transport_err())),
        Box::new(|_u: &str, _b: &str| Err(transport_err())),
    );
    let outcome = Resolver::new(t).resolve(&pinned(IMG_KEY)).await.unwrap();
    assert_eq!(outcome, ResolveOutcome::Unreachable);

    let rendered = outcome.render("https://dig.net");
    assert_eq!(rendered.content_type, "text/html");
    let html = String::from_utf8(rendered.bytes).unwrap();
    assert!(html.contains("DIG Network unreachable"));
    assert!(html.contains("Connect to Node"));
    assert!(html.contains("https://dig.net"));
    // Unmistakably NOT the security page.
    assert!(!html.contains("Integrity Verification Failed"));
}

#[tokio::test]
async fn unreachable_connect_url_is_overridable() {
    let t = MockTransport::new(
        Box::new(|_u: &str| Err(transport_err())),
        Box::new(|_u: &str, _b: &str| Err(transport_err())),
    );
    let opts = ResolveOptions {
        connect_url: Some("dig://home".into()),
        ..Default::default()
    };
    let rendered = Resolver::with_options(t, opts)
        .resolve_rendered(&pinned(IMG_KEY))
        .await
        .unwrap();
    let html = String::from_utf8(rendered.bytes).unwrap();
    assert!(html.contains("dig://home"));
}

// --- HOLE A: node trust is loopback-only -----------------------------------

#[tokio::test]
async fn node_without_x_dig_verified_is_fail_closed() {
    // A loopback node serves 200 bytes but omits X-Dig-Verified → the node did not
    // attest verification → fail closed (IntegrityFailure), never returned as data.
    let t = MockTransport::new(
        Box::new(|url: &str| {
            if url.ends_with("/health") {
                Ok(status(200))
            } else {
                Ok(node_response(
                    b"unverified".to_vec(),
                    Some("text/plain"),
                    false,
                ))
            }
        }),
        Box::new(|_u, _b| Err(transport_err())),
    );
    let outcome = Resolver::new(t)
        .resolve(&rootless_urn(IMG_KEY))
        .await
        .unwrap();
    assert_eq!(outcome, ResolveOutcome::IntegrityFailure);
}

#[tokio::test]
async fn override_remote_host_uses_verified_rpc_not_node() {
    // HOLE A: an override at a REMOTE host must NOT be trusted as a node. It is
    // routed to the client-verified rpc path — the node `/s/` + `/health` GETs are
    // never called; only the verified rpc post is.
    let fx = build_fixture(IMG_KEY, b"verified art", None);
    let root = fx.root_hex.clone();
    let t = MockTransport::new(
        Box::new(|url: &str| panic!("remote override must not use the node path: {url}")),
        Box::new(move |url: &str, body: &str| {
            assert!(
                url.starts_with("http://evil.example.com"),
                "override base: {url}"
            );
            let req: serde_json::Value = serde_json::from_str(body).unwrap();
            match req["method"].as_str().unwrap() {
                "dig.getContent" => Ok(rpc_ok(get_content_result(&fx))),
                other => panic!("unexpected method {other}"),
            }
        }),
    );
    let opts = ResolveOptions {
        endpoint: Some("http://evil.example.com".into()),
        ..Default::default()
    };
    let data = success(
        Resolver::with_options(t, opts)
            .resolve(&root_pinned_urn(IMG_KEY, &root))
            .await
            .unwrap(),
    );
    // The bytes came back ONLY because they verified against the pinned root.
    assert_eq!(data.bytes, b"verified art");
}

#[tokio::test]
async fn override_loopback_host_may_use_node() {
    // A loopback override IS eligible for the node path (the user's own machine).
    let t = MockTransport::new(
        Box::new(|url: &str| {
            assert!(
                url.starts_with("http://127.0.0.1:9778"),
                "loopback base: {url}"
            );
            Ok(node_response(b"local".to_vec(), Some("text/plain"), true))
        }),
        Box::new(|_u, _b| Err(transport_err())),
    );
    let opts = ResolveOptions {
        endpoint: Some("http://127.0.0.1:9778".into()),
        ..Default::default()
    };
    let data = success(
        Resolver::with_options(t, opts)
            .resolve(&rootless_urn("index.html"))
            .await
            .unwrap(),
    );
    assert_eq!(data.bytes, b"local");
}

#[tokio::test]
async fn override_remote_unreachable_does_not_leak_to_public_rpc() {
    // A remote override that is fully down → Unreachable, never a silent fallback to
    // rpc.dig.net (the override is authoritative). Root-pinned so it reaches the rpc
    // transport (the remote override IS an rpc endpoint) before failing.
    let t = MockTransport::new(
        Box::new(|_u: &str| Err(transport_err())),
        Box::new(|url: &str, _b: &str| {
            assert!(!url.contains("rpc.dig.net"), "must not leak to public rpc");
            Err(transport_err())
        }),
    );
    let opts = ResolveOptions {
        endpoint: Some("http://my-node.example.com:1234".into()),
        ..Default::default()
    };
    let outcome = Resolver::with_options(t, opts)
        .resolve(&pinned(IMG_KEY))
        .await
        .unwrap();
    assert_eq!(outcome, ResolveOutcome::Unreachable);
}

// --- outcome accessors -----------------------------------------------------

#[test]
fn outcome_accessors() {
    let ok = ResolveOutcome::Success(ResolvedData::new(vec![1, 2], "image/png".into()));
    assert!(ok.is_success());
    assert_eq!(ok.kind(), "success");
    assert_eq!(ok.data().unwrap().bytes, vec![1, 2]);

    assert_eq!(ResolveOutcome::IntegrityFailure.kind(), "integrity_failure");
    assert!(!ResolveOutcome::IntegrityFailure.is_success());
    assert_eq!(ResolveOutcome::Unreachable.kind(), "unreachable");

    // Success renders its verified content verbatim.
    let rendered = ok.render("https://dig.net");
    assert_eq!(rendered.bytes, vec![1, 2]);
    assert_eq!(rendered.content_type, "image/png");
}

// --- node CIPHERTEXT path (client verify+decrypt, salt on both tiers) ------

#[tokio::test]
async fn node_ciphertext_response_is_verified_and_decrypted() {
    // A loopback node that returns CIPHERTEXT (not plaintext) must be client-side
    // verified + decrypted, NOT trusted blindly.
    let fx = build_fixture(IMG_KEY, b"\x89PNG node ciphertext", None);
    let root = fx.root_hex.clone();
    let t = MockTransport::new(
        Box::new(move |url: &str| {
            if url.ends_with("/health") {
                Ok(status(200))
            } else {
                Ok(node_ciphertext_response(&fx))
            }
        }),
        Box::new(|_u, _b| Err(transport_err())),
    );
    let data = success(
        Resolver::new(t)
            .resolve(&root_pinned_urn(IMG_KEY, &root))
            .await
            .unwrap(),
    );
    assert_eq!(data.bytes, b"\x89PNG node ciphertext");
}

#[tokio::test]
async fn salted_urn_decrypts_on_node_ciphertext_path() {
    let salt = "cd".repeat(32);
    let fx = build_fixture(IMG_KEY, b"salted node art", Some(&salt));
    let root = fx.root_hex.clone();
    let t = MockTransport::new(
        Box::new(move |url: &str| {
            if url.ends_with("/health") {
                Ok(status(200))
            } else {
                Ok(node_ciphertext_response(&fx))
            }
        }),
        Box::new(|_u, _b| Err(transport_err())),
    );
    let data = success(
        Resolver::new(t)
            .resolve(&format!(
                "{}?salt={}",
                root_pinned_urn(IMG_KEY, &root),
                salt
            ))
            .await
            .unwrap(),
    );
    assert_eq!(data.bytes, b"salted node art");
}

#[tokio::test]
async fn wrong_salt_on_node_ciphertext_path_is_integrity_failure() {
    // Ciphertext committed WITHOUT salt; resolved WITH a salt over the node
    // ciphertext path → decrypt tag fails → fail-closed IntegrityFailure.
    let fx = build_fixture(IMG_KEY, b"art", None);
    let root = fx.root_hex.clone();
    let t = MockTransport::new(
        Box::new(move |url: &str| {
            if url.ends_with("/health") {
                Ok(status(200))
            } else {
                Ok(node_ciphertext_response(&fx))
            }
        }),
        Box::new(|_u, _b| Err(transport_err())),
    );
    let wrong = "ef".repeat(32);
    let outcome = Resolver::new(t)
        .resolve(&format!(
            "{}?salt={}",
            root_pinned_urn(IMG_KEY, &root),
            wrong
        ))
        .await
        .unwrap();
    assert_eq!(outcome, ResolveOutcome::IntegrityFailure);
}

#[tokio::test]
async fn rootless_urn_over_node_ciphertext_derives_root_from_header() {
    // #1432: a ROOTLESS URN resolved over a healthy loopback node that answers with
    // CIPHERTEXT (not attested plaintext) must still succeed — the client derives its
    // trust root from the node's `X-Dig-Root` header (`node.rs::fetch`, `node_root`),
    // then verifies+decrypts against THAT root exactly as the root-pinned path does.
    // This locks the rootless-over-loopback contract for the ciphertext branch; the
    // plaintext branch is already covered by `node_tier_serves_when_healthy`.
    let fx = build_fixture(IMG_KEY, b"rootless node ciphertext art", None);
    let t = MockTransport::new(
        Box::new(move |url: &str| {
            if url.ends_with("/health") {
                Ok(status(200))
            } else {
                Ok(node_ciphertext_response(&fx))
            }
        }),
        Box::new(|_u, _b| Err(transport_err())),
    );
    let data = success(
        Resolver::new(t)
            .resolve(&rootless_urn(IMG_KEY))
            .await
            .unwrap(),
    );
    assert_eq!(data.bytes, b"rootless node ciphertext art");
}

#[tokio::test]
async fn rootless_urn_falls_to_rpc_root_required_when_node_unhealthy() {
    // #1432: the rootless-over-loopback trust anchor is the NODE — with no healthy
    // loopback node, the ladder falls to the untrusted rpc gateway, which can never
    // be trusted with a chain-anchored root for a rootless URN. The wall must still
    // hold: hard `RootRequired`, never falling back to an unverifiable resolve.
    let fx = build_fixture(IMG_KEY, b"unused", None);
    let err = Resolver::new(rpc_serving(fx))
        .resolve(&rootless_urn(IMG_KEY))
        .await
        .unwrap_err();
    assert!(matches!(err, ResolveError::RootRequired));
}

// --- caching ---------------------------------------------------------------

#[tokio::test]
async fn second_resolve_is_a_memory_cache_hit_no_network() {
    use std::cell::Cell;
    use std::rc::Rc;
    let fx = build_fixture(IMG_KEY, b"cached bytes", None);
    let root = fx.root_hex.clone();
    let calls = Rc::new(Cell::new(0u32));
    let c = calls.clone();
    let t = MockTransport::new(
        Box::new(|_u: &str| Err(transport_err())), // no node
        Box::new(move |_u: &str, _b: &str| {
            let n = c.get() + 1;
            c.set(n);
            assert!(n == 1, "2nd network call — expected a cache hit");
            Ok(rpc_ok(get_content_result(&fx)))
        }),
    );
    let r = Resolver::new(t);
    let urn = root_pinned_urn(IMG_KEY, &root);
    assert_eq!(
        success(r.resolve(&urn).await.unwrap()).bytes,
        b"cached bytes"
    );
    assert_eq!(
        success(r.resolve(&urn).await.unwrap()).bytes,
        b"cached bytes"
    );
    assert_eq!(calls.get(), 1, "exactly one network fetch");
}

#[tokio::test]
async fn failures_are_not_cached_and_recovery_retries() {
    use std::cell::Cell;
    use std::rc::Rc;
    let fx = build_fixture(IMG_KEY, b"recovered", None);
    let root = fx.root_hex.clone();
    let up = Rc::new(Cell::new(false));
    let up2 = up.clone();
    let t = MockTransport::new(
        Box::new(|_u: &str| Err(transport_err())),
        Box::new(move |_u: &str, _b: &str| {
            if up2.get() {
                Ok(rpc_ok(get_content_result(&fx)))
            } else {
                Err(transport_err())
            }
        }),
    );
    let r = Resolver::new(t);
    let urn = root_pinned_urn(IMG_KEY, &root);
    // Network down → Unreachable, and it MUST NOT be cached.
    assert_eq!(r.resolve(&urn).await.unwrap(), ResolveOutcome::Unreachable);
    // Network recovers → a real retry (not a cached failure) → Success.
    up.set(true);
    assert_eq!(success(r.resolve(&urn).await.unwrap()).bytes, b"recovered");
}

#[tokio::test]
async fn tampered_disk_cache_entry_fails_closed() {
    use std::time::{SystemTime, UNIX_EPOCH};
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let dir = std::env::temp_dir().join(format!("dig-urn-resolver-disk-{nanos}"));
    let dir_str = dir.to_string_lossy().to_string();

    let fx = build_fixture(IMG_KEY, b"disk bytes", None);
    let root = fx.root_hex.clone();
    let urn = root_pinned_urn(IMG_KEY, &root);

    // (1) Populate the disk cache with the verifiable artifacts.
    {
        let opts = ResolveOptions {
            cache_path: Some(dir_str.clone()),
            ..Default::default()
        };
        let d = success(
            Resolver::with_options(rpc_serving(fx), opts)
                .resolve(&urn)
                .await
                .unwrap(),
        );
        assert_eq!(d.bytes, b"disk bytes");
    }

    // (2) TAMPER the on-disk ciphertext (leave the proof intact).
    let file = std::fs::read_dir(&dir)
        .unwrap()
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .find(|p| p.extension().and_then(|x| x.to_str()) == Some("json"))
        .expect("a disk cache entry was written");
    let mut v: serde_json::Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap();
    v["ciphertext"] = serde_json::json!([9, 9, 9, 9, 9, 9]);
    std::fs::write(&file, serde_json::to_vec(&v).unwrap()).unwrap();

    // (3) A FRESH resolver (empty memory) whose transport PANICS on any network —
    // it must serve from disk, RE-VERIFY, catch the tamper, and fail closed.
    let t = MockTransport::new(
        Box::new(|u: &str| panic!("must not hit the network (disk hit): {u}")),
        Box::new(|u: &str, _b: &str| panic!("must not hit the network (disk hit): {u}")),
    );
    let opts = ResolveOptions {
        cache_path: Some(dir_str),
        ..Default::default()
    };
    let outcome = Resolver::with_options(t, opts).resolve(&urn).await.unwrap();
    assert_eq!(
        outcome,
        ResolveOutcome::IntegrityFailure,
        "tampered disk bytes must fail closed, never be served"
    );

    let _ = std::fs::remove_dir_all(&dir);
}

#[tokio::test]
async fn chunk_lens_overflow_is_integrity_failure_not_panic() {
    // The gateway serves VERIFIED ciphertext but crafts chunk_lens whose total wraps
    // usize on wasm32 (`[len+2^31, 2^31]`) to slice out of bounds → panic=abort. The
    // u64-checked total + bounded slicing must fail closed to IntegrityFailure, never
    // panic (integrity already holds — this is the availability guard).
    let mut fx = build_fixture(IMG_KEY, b"authentic bytes here!!", None);
    let root = fx.root_hex.clone();
    let real = fx.total_length as u32;
    fx.chunk_lens = vec![real.wrapping_add(1 << 31), 1 << 31];
    let outcome = Resolver::new(rpc_serving(fx))
        .resolve(&root_pinned_urn(IMG_KEY, &root))
        .await
        .unwrap();
    assert_eq!(outcome, ResolveOutcome::IntegrityFailure);
}

// --- Node vs browser env degradation ---------------------------------------

#[tokio::test]
async fn browser_cors_blocked_local_probe_falls_back_to_verified_rpc() {
    // Simulate a BROWSER where the local-node `/health` + `/s/` probes throw
    // (CORS-blocked) — every GET errors. The ladder must NOT hard-fail: it degrades
    // to the VERIFIED rpc tier (never to unverified bytes).
    let fx = build_fixture(IMG_KEY, b"rpc bytes", None);
    let root = fx.root_hex.clone();
    let t = MockTransport::new(
        Box::new(|_u: &str| Err(transport_err())), // browser: local probe unavailable
        Box::new(move |_u: &str, body: &str| {
            let req: serde_json::Value = serde_json::from_str(body).unwrap();
            match req["method"].as_str().unwrap() {
                "dig.getContent" => Ok(rpc_ok(get_content_result(&fx))),
                other => panic!("unexpected method {other}"),
            }
        }),
    );
    let data = success(
        Resolver::new(t)
            .resolve(&root_pinned_urn(IMG_KEY, &root))
            .await
            .unwrap(),
    );
    assert_eq!(data.bytes, b"rpc bytes");
}

#[tokio::test]
async fn unusable_cache_path_degrades_gracefully_never_throws() {
    // A cache_path that can't be used (here: a path that is a FILE, not a directory —
    // stands in for a no-filesystem/browser environment) must NOT throw: the disk
    // layer is best-effort, so resolve still succeeds via memory + network.
    use std::time::{SystemTime, UNIX_EPOCH};
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let not_a_dir = std::env::temp_dir().join(format!("dig-urn-resolver-notadir-{nanos}"));
    std::fs::write(&not_a_dir, b"x").unwrap();

    let fx = build_fixture(IMG_KEY, b"still resolves", None);
    let root = fx.root_hex.clone();
    let opts = ResolveOptions {
        cache_path: Some(not_a_dir.to_string_lossy().to_string()),
        ..Default::default()
    };
    let data = success(
        Resolver::with_options(rpc_serving(fx), opts)
            .resolve(&root_pinned_urn(IMG_KEY, &root))
            .await
            .unwrap(),
    );
    assert_eq!(data.bytes, b"still resolves");

    let _ = std::fs::remove_file(&not_a_dir);
}

fn base64_of(bytes: &[u8]) -> String {
    use base64::Engine;
    base64::engine::general_purpose::STANDARD.encode(bytes)
}