mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
This commit is contained in:
@@ -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: <https://github.com/napplet/web>
|
||||
- NAPs track (capability + wire-format specs): <https://github.com/napplet/naps>
|
||||
- Runtime packages: <https://github.com/kehto/web>
|
||||
- Playground: <https://kehto.github.io/web/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, <sha256>]`) 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<PathTag>.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.
|
||||
+103
@@ -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<PathTag>.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"
|
||||
}
|
||||
}
|
||||
+151
@@ -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 (`<server>/<sha256>`), so the path's extension is irrelevant here.
|
||||
*/
|
||||
fun candidateUrls(
|
||||
servers: List<String>,
|
||||
hash: HexKey,
|
||||
): List<String> = 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<PathTag>,
|
||||
servers: List<String>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
+162
@@ -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("<html>napplet</html>")
|
||||
val hash = sha256(good).toHexKey()
|
||||
|
||||
assertTrue(StaticSiteResolver.verify(good, hash))
|
||||
assertFalse(StaticSiteResolver.verify(bytes("<html>evil</html>"), hash))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvesFromTheFirstServerThatServesMatchingBytes() =
|
||||
runTest {
|
||||
val html = "<html>home</html>"
|
||||
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<StaticSiteResolution.Resolved>(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 = "<html>home</html>"
|
||||
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("<html>injected malware</html>")
|
||||
} else {
|
||||
bytes(html)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val resolved = assertIs<StaticSiteResolution.Resolved>(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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user