From b547b077476ee05746390c7ac010964959438a29 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 20:08:27 +0000 Subject: [PATCH] feat(quartz): add NIP-5A static-site / napplet resolver Adds a platform-agnostic resolver for NIP-5A static-website / napplet (NIP-5D) manifests in quartz commonMain, under nip5aStaticWebsites/resolver/: - StaticSitePathLookup: request-path normalization (query/fragment stripping, leading-slash insensitivity, root/dir -> index.html), slash-insensitive path lookup over a manifest's path tags, and a web-asset Content-Type guess. - StaticSiteResolver: hash verification, Blossom candidate-URL assembly, and a suspend resolve() that downloads each listed server in order and accepts the first blob whose recomputed sha256 matches the manifest pin. HTTP is injected via a BlobFetcher typealias so quartz keeps no HTTP dependency. The trust model is the point: the signed manifest is the authority, the Blossom server is untrusted. A server that substitutes/corrupts a blob fails verification and is skipped -- it can withhold content but never forge it. Tests cover normalization, lookup, MIME guessing, and the security cases (tampered server skipped -> falls through to honest server; all-tampered -> Unresolvable; undeclared path -> PathNotInManifest without fetching). Also adds quartz/plans/2026-06-19-napplet-nip5a-resolver.md documenting the design and the open event-shape alignment questions (35128 vs 35129 manifest kind, capability declaration vs NIP-89, aggregate build hash, server ordering) to raise with the napplet author before the nsite/napplet event shape forks. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C --- .../2026-06-19-napplet-nip5a-resolver.md | 100 +++++++++++ .../resolver/StaticSitePathLookup.kt | 103 +++++++++++ .../resolver/StaticSiteResolver.kt | 151 ++++++++++++++++ .../resolver/StaticSiteResolverTest.kt | 162 ++++++++++++++++++ 4 files changed, 516 insertions(+) create mode 100644 quartz/plans/2026-06-19-napplet-nip5a-resolver.md create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSitePathLookup.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolver.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolverTest.kt diff --git a/quartz/plans/2026-06-19-napplet-nip5a-resolver.md b/quartz/plans/2026-06-19-napplet-nip5a-resolver.md new file mode 100644 index 0000000000..75b3ef1afb --- /dev/null +++ b/quartz/plans/2026-06-19-napplet-nip5a-resolver.md @@ -0,0 +1,100 @@ +# NIP-5A static-site resolver + napplet (NIP-5D) alignment + +Date: 2026-06-19 +Status: resolver landed (quartz commonMain); alignment questions open + +## Context + +[napplet.run](https://napplet.run) proposes "napplets" — small, sandboxed web +apps distributed over Nostr + Blossom, where a **shell** brokers dangerous +capabilities (signing, relay access, storage) and the applet runs as untrusted +code behind a trust boundary. Relevant upstream material: + +- Web packages: +- NAPs track (capability + wire-format specs): +- Runtime packages: +- Playground: + +The important overlap with Amethyst: napplet **distribution** reuses the nsite +static-website event shape that Quartz already implements — +`com.vitorpamplona.quartz.nip5aStaticWebsites` (`RootSiteEvent` kind 15128, +`NamedSiteEvent` kind 35128). Each manifest pins request paths to content-addressed +Blossom blobs via `path` tags (`[path, ]`) plus `server` tags. **NIP-5D** +is the web projection on top of NIP-5A (iframe hosting, `postMessage` transport, +`window.napplet.*` capability surface). + +So Amethyst already owns the bottom half of the stack. The cheap, high-leverage +move (vs. building a full shell) is to be the reference **resolver** for NIP-5A and +help keep the event shape from forking across napplets / nsites / NMP / Tiles. + +## What landed + +A platform-agnostic resolver in `quartz/commonMain`, under +`nip5aStaticWebsites/resolver/`: + +- **`StaticSitePathLookup.kt`** — `normalizeStaticPath()` (strips query/fragment + + leading slash, expands root/dir requests to `index.html`), `List.resolvePath()` + (leading-slash-insensitive match), `guessStaticContentType()` (web asset MIME map; + Blossom serves blobs untyped, so the host must label them). +- **`StaticSiteResolver.kt`** — `verify(blob, hash)`, `candidateUrls(servers, hash)`, + and `suspend resolve(requestPath, paths, servers, fetch): StaticSiteResolution`. + HTTP is injected via a `BlobFetcher` typealias so Quartz keeps no HTTP dependency; + `commons`/`amethyst` supply an OkHttp-backed fetcher. + +**Trust model (the point):** the signed manifest is the authority; the Blossom +server is untrusted. `resolve` downloads the content-addressed blob from each listed +server in order and accepts the **first whose recomputed sha256 matches the pin**. A +server that substitutes/corrupts/truncates a blob fails verification and is silently +skipped — it can withhold content but can never forge it. Tests cover root/dir/query +normalization, slash-insensitive lookup, MIME guessing, and the security cases +(tampered server skipped → falls through to honest server; all-tampered → +`Unresolvable`; undeclared path → `PathNotInManifest` without fetching). + +Deliberately **out of scope** in the protocol layer: SPA "serve index.html for any +unknown route" fallback (weakens the path→hash guarantee — a shell policy decision), +and the author's kind:10063 Blossom-list as a server fallback (caller appends it +before calling `resolve`; `BlossomServerResolver`/BUD-10 already exists in `amethyst`). + +## Open alignment questions for the napplet author + +These are worth resolving before three projects (napplets, nsites, NMP, Tiles) fork +the event shape. Raise upstream on napplet/naps: + +1. **Manifest kind: 35128 vs 35129.** napplet/naps describes NIP-5A distribution as + **kind 35128** (the exact `NamedSiteEvent` Amethyst already renders), while + napplet/web describes the NIP-5D web manifest as **kind 35129**. Confirm the + intent: is a napplet a *plain* NIP-5A nsite (35128) that a NIP-5D-aware shell + simply *recognizes*, or a *distinct* 35129 event? If 35129 is distinct, what does + it add over 35128 — and should it embed/reference a 35128 rather than duplicate the + `path`/`server` tag set? Avoid silently colliding with the nsite 35128 Amethyst + already publishes and resolves. + +2. **Capability declaration vs NIP-89.** napplet manifests carry a `requires` / + capability declaration (which NAP domains the applet needs: identity, relay, + value, …). Amethyst already models "an app handles these event kinds" via NIP-89 + `AppDefinitionEvent` (kind 31990, `k`-tags). Should napplet capability `requires` + reuse NIP-89 semantics (or a documented superset) instead of a parallel tag, so a + single client can reason about both? + +3. **Aggregate build hash.** Both repos mention an aggregate hash over the per-file + set. Is that pinned as a dedicated tag on the manifest (canonical + serialization/ordering defined), or only implied by the set of `path` hashes? The + resolver verifies per-file hashes today; if there is a canonical aggregate, Quartz + should expose `aggregateHash()` and verify it too. + +4. **`server` semantics.** Are `server` tags an *ordered preference* list (our + resolver assumes order = priority) or an unordered set the host load-balances? And + is the author's kind:10063 Blossom list an implicit fallback, or must servers be + exhaustively listed on the manifest? + +## Possible follow-ups (not in this change) + +- Wire an OkHttp `BlobFetcher` in `commons` and point `StaticWebsite.kt` at the + resolver to render verified content (today it only shows manifest metadata + opens + links in an external browser). +- The full shell: Android WebView host (`allow-scripts`, no `allow-same-origin`) + + `postMessage`↔`NostrSigner`/Blossom/relay bridge + a per-applet permission ledger + (reuse the signer-prompt design in + `amethyst/plans/2026-05-25-appfunctions-signer-prompts.md`). The brokers it needs + (three `NostrSigner` types, Blossom upload/download, relay client, NIP-57 zaps) all + already ship — the WebView + consent UI are the only genuinely new pieces. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSitePathLookup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSitePathLookup.kt new file mode 100644 index 0000000000..22909d86be --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSitePathLookup.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip5aStaticWebsites.resolver + +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag + +/** + * Request-path lookup for NIP-5A static-website / napplet manifests. + * + * A manifest (`RootSiteEvent` kind 15128 / `NamedSiteEvent` kind 35128) maps request + * paths to content-addressed blobs through `path` tags ([PathTag] = request path + the + * blob's lowercase sha256). The same event shape backs both nsites and napplets + * (NIP-5D web projection), so the lookup here is deliberately runtime-agnostic. + * + * These helpers apply the usual static-host path conventions before matching: + * - the query string (`?…`) and fragment (`#…`) are dropped, + * - a leading `/` (or `./`) is irrelevant to the match, + * - an empty path or a directory request (trailing `/`) resolves to `index.html`. + * + * The match itself stays strict and content-addressed — there is no SPA "serve + * index.html for any unknown route" fallback here. That is a host/shell policy + * decision (it weakens the path→hash guarantee) and belongs in the shell, not in + * the protocol layer. + */ + +/** The implicit document served for the site root and for directory requests. */ +const val STATIC_SITE_INDEX = "index.html" + +/** + * Normalises a raw request path to the canonical form used for manifest matching: + * strips the query/fragment and any leading `/` or `./`, and expands a root or + * directory request to its `index.html` document. + */ +fun normalizeStaticPath(requestPath: String): String { + val withoutQuery = requestPath.substringBefore('?').substringBefore('#') + val trimmed = withoutQuery.removePrefix("./").removePrefix("/") + return when { + trimmed.isEmpty() -> STATIC_SITE_INDEX + trimmed.endsWith('/') -> trimmed + STATIC_SITE_INDEX + else -> trimmed + } +} + +/** Canonical form of a manifest-declared path, so `/app.js` and `app.js` compare equal. */ +private fun PathTag.canonicalPath() = path.removePrefix("./").removePrefix("/") + +/** + * Finds the [PathTag] that serves [requestPath], applying [normalizeStaticPath] to the + * request and to each declared path before comparing. Returns `null` when the path is + * not declared in the manifest. + */ +fun List.resolvePath(requestPath: String): PathTag? { + val target = normalizeStaticPath(requestPath) + return firstOrNull { it.canonicalPath() == target } +} + +/** + * Best-effort `Content-Type` for a manifest path, derived from its file extension. + * Blossom serves blobs untyped (content-addressed), so the host must label them; this + * covers the common web-runtime asset types and falls back to `application/octet-stream`. + */ +fun guessStaticContentType(path: String): String { + val ext = path.substringAfterLast('.', "").lowercase() + return when (ext) { + "html", "htm" -> "text/html; charset=utf-8" + "js", "mjs" -> "text/javascript; charset=utf-8" + "css" -> "text/css; charset=utf-8" + "json" -> "application/json; charset=utf-8" + "wasm" -> "application/wasm" + "svg" -> "image/svg+xml" + "png" -> "image/png" + "jpg", "jpeg" -> "image/jpeg" + "gif" -> "image/gif" + "webp" -> "image/webp" + "avif" -> "image/avif" + "ico" -> "image/x-icon" + "txt", "md" -> "text/plain; charset=utf-8" + "xml" -> "application/xml" + "woff2" -> "font/woff2" + "woff" -> "font/woff" + "ttf" -> "font/ttf" + "map" -> "application/json" + else -> "application/octet-stream" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolver.kt new file mode 100644 index 0000000000..47d8ba8b2b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolver.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip5aStaticWebsites.resolver + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.coroutines.cancellation.CancellationException + +/** + * Fetches the raw bytes of a Blossom blob from an absolute URL, or returns `null` when + * the server is unreachable / responds with an error. Supplied by the host platform + * (e.g. an OkHttp-backed implementation in `commons`/`amethyst`) so that `quartz` carries + * no HTTP dependency and the resolver stays Kotlin-Multiplatform-pure. + */ +typealias BlobFetcher = suspend (url: String) -> ByteArray? + +/** Outcome of resolving a single request path against a NIP-5A static-website manifest. */ +sealed interface StaticSiteResolution { + /** + * The request path was declared in the manifest and a Blossom server returned a blob + * whose sha256 matched the declared [hash]. [bytes] are safe to render. + */ + data class Resolved( + val path: String, + val hash: HexKey, + val contentType: String, + val bytes: ByteArray, + val server: String, + ) : StaticSiteResolution { + // ByteArray needs structural equals/hashCode. + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Resolved) return false + return path == other.path && + hash == other.hash && + contentType == other.contentType && + server == other.server && + bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int { + var result = path.hashCode() + result = 31 * result + hash.hashCode() + result = 31 * result + contentType.hashCode() + result = 31 * result + server.hashCode() + result = 31 * result + bytes.contentHashCode() + return result + } + } + + /** No `path` tag in the manifest matches the request path. */ + data object PathNotInManifest : StaticSiteResolution + + /** + * The path exists in the manifest but no listed server returned a blob whose hash + * matched [hash] — every candidate was unreachable, errored, or served tampered bytes. + */ + data class Unresolvable( + val hash: HexKey, + ) : StaticSiteResolution +} + +/** + * Resolves request paths for a NIP-5A static-website / napplet manifest into verified + * blob bytes, fetched over Blossom. + * + * The trust model is the whole point: the **manifest is the authority and the Blossom + * server is untrusted**. The signed manifest pins each path to a sha256; this resolver + * downloads the content-addressed blob from each listed server in order and accepts the + * first one whose recomputed sha256 matches the pin. A server that substitutes, corrupts, + * or truncates a blob fails [verify] and is silently skipped — it can withhold content but + * can never forge it. This is what lets a napplet shell run third-party code from an + * untrusted CDN behind a single signed, content-addressed manifest. + */ +object StaticSiteResolver { + /** True iff [blob]'s sha256 equals [expectedHash] (case-insensitive hex). */ + fun verify( + blob: ByteArray, + expectedHash: HexKey, + ): Boolean = sha256(blob).toHexKey().equals(expectedHash, ignoreCase = true) + + /** + * Ordered candidate Blossom URLs for [hash] across [servers]. Blossom addresses blobs + * by bare sha256 (`/`), so the path's extension is irrelevant here. + */ + fun candidateUrls( + servers: List, + hash: HexKey, + ): List = servers.map { "${it.trimEnd('/')}/$hash" } + + /** + * Resolves [requestPath] against the manifest's [paths] and [servers], fetching with + * [fetch] and verifying every downloaded blob's hash before returning it. + * + * @param paths the manifest's `path` tags (`event.paths()`). + * @param servers the manifest's `server` tags (`event.servers()`), tried in order. + * Additional fallbacks (e.g. the author's kind:10063 Blossom list) can + * be appended by the caller before invoking this function. + */ + suspend fun resolve( + requestPath: String, + paths: List, + servers: List, + fetch: BlobFetcher, + ): StaticSiteResolution { + val match = paths.resolvePath(requestPath) ?: return StaticSiteResolution.PathNotInManifest + + for (url in candidateUrls(servers, match.hash)) { + val bytes = + try { + fetch(url) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } ?: continue + + if (verify(bytes, match.hash)) { + return StaticSiteResolution.Resolved( + path = match.path, + hash = match.hash, + contentType = guessStaticContentType(match.path), + bytes = bytes, + server = url.substringBeforeLast('/'), + ) + } + } + + return StaticSiteResolution.Unresolvable(match.hash) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolverTest.kt new file mode 100644 index 0000000000..782115b8b9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolverTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip5aStaticWebsites.resolver + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class StaticSiteResolverTest { + private fun bytes(s: String) = s.encodeToByteArray() + + private fun hashOf(s: String) = sha256(bytes(s)).toHexKey() + + @Test + fun normalizesRootAndDirectoryAndQueryPaths() { + assertEquals("index.html", normalizeStaticPath("")) + assertEquals("index.html", normalizeStaticPath("/")) + assertEquals("app.js", normalizeStaticPath("/app.js")) + assertEquals("app.js", normalizeStaticPath("./app.js")) + assertEquals("assets/app.js", normalizeStaticPath("/assets/app.js?v=2#top")) + assertEquals("docs/index.html", normalizeStaticPath("/docs/")) + } + + @Test + fun lookupMatchesRegardlessOfLeadingSlash() { + val paths = + listOf( + PathTag("/index.html", hashOf("home")), + PathTag("assets/app.js", hashOf("script")), + ) + + assertEquals(hashOf("home"), paths.resolvePath("/")?.hash) + assertEquals(hashOf("home"), paths.resolvePath("/index.html")?.hash) + assertEquals(hashOf("script"), paths.resolvePath("assets/app.js")?.hash) + assertEquals(null, paths.resolvePath("missing.js")) + } + + @Test + fun guessesWebContentTypes() { + assertEquals("text/html; charset=utf-8", guessStaticContentType("index.html")) + assertEquals("text/javascript; charset=utf-8", guessStaticContentType("app.mjs")) + assertEquals("application/wasm", guessStaticContentType("core.wasm")) + assertEquals("application/octet-stream", guessStaticContentType("blob.unknownext")) + } + + @Test + fun verifyAcceptsMatchingAndRejectsTamperedBytes() { + val good = bytes("napplet") + val hash = sha256(good).toHexKey() + + assertTrue(StaticSiteResolver.verify(good, hash)) + assertFalse(StaticSiteResolver.verify(bytes("evil"), hash)) + } + + @Test + fun resolvesFromTheFirstServerThatServesMatchingBytes() = + runTest { + val html = "home" + val paths = listOf(PathTag("/index.html", hashOf(html))) + + val resolution = + StaticSiteResolver.resolve( + requestPath = "/", + paths = paths, + servers = listOf("https://cdn.example.com", "https://backup.example.com"), + fetch = { url -> if (url.startsWith("https://cdn.example.com")) bytes(html) else null }, + ) + + val resolved = assertIs(resolution) + // Resolved.path echoes the manifest's declared path verbatim, not the normalized request. + assertEquals("/index.html", resolved.path) + assertEquals("https://cdn.example.com", resolved.server) + assertEquals("text/html; charset=utf-8", resolved.contentType) + assertEquals(html, resolved.bytes.decodeToString()) + } + + @Test + fun skipsAServerThatTampersWithTheBlobAndFallsThroughToAnHonestOne() = + runTest { + val html = "home" + val paths = listOf(PathTag("/index.html", hashOf(html))) + + var triedMalicious = false + val resolution = + StaticSiteResolver.resolve( + requestPath = "/", + paths = paths, + servers = listOf("https://evil.example.com", "https://honest.example.com"), + fetch = { url -> + if (url.startsWith("https://evil.example.com")) { + triedMalicious = true + bytes("injected malware") + } else { + bytes(html) + } + }, + ) + + val resolved = assertIs(resolution) + // The tampered server was contacted but its bytes were rejected by hash verification... + assertTrue(triedMalicious) + // ...and resolution fell through to the honest server's verified copy. + assertEquals("https://honest.example.com", resolved.server) + assertEquals(html, resolved.bytes.decodeToString()) + } + + @Test + fun reportsUnresolvableWhenEveryServerFailsVerification() = + runTest { + val paths = listOf(PathTag("/index.html", hashOf("real"))) + + val resolution = + StaticSiteResolver.resolve( + requestPath = "/", + paths = paths, + servers = listOf("https://a.example.com", "https://b.example.com"), + fetch = { _ -> bytes("tampered") }, + ) + + assertEquals(StaticSiteResolution.Unresolvable(hashOf("real")), resolution) + } + + @Test + fun reportsPathNotInManifestForUndeclaredPaths() = + runTest { + val paths = listOf(PathTag("/index.html", hashOf("home"))) + + val resolution = + StaticSiteResolver.resolve( + requestPath = "/secret.js", + paths = paths, + servers = listOf("https://a.example.com"), + fetch = { _ -> error("should not fetch for an undeclared path") }, + ) + + assertEquals(StaticSiteResolution.PathNotInManifest, resolution) + } +}