From 0eea00543ac711cc5a25dc35118890ef87a86d3d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 22:14:14 +0000 Subject: [PATCH] feat(cli): add `amy napplet fetch` for NIP-5D napplets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the CLI to fetch and verify NIP-5D napplet kinds, mirroring `amy nsite` but adding the napplet-specific runtime checks. - NappletCommands: `amy napplet fetch AUTHOR [--d ID] | --snapshot EVENT-ID [--path P] [--server …] [--relay …] [--out FILE] [--timeout SECS]`. Fetches a root (15129), named (35129, via --d), or snapshot (5129, via --snapshot ) manifest; recomputes the NIP-5A aggregate hash and refuses a manifest whose `x` tag doesn't match its path tags (`aggregate_mismatch`) before touching any blob; then resolves the path with per-blob sha256 verification. Output adds `requires` (NAP capabilities), `aggregate_sha256`, and `aggregate_verified`. - StaticSiteFetch: new shared helper holding the Blossom download + resolve + emit logic, so `nsite` and `napplet` don't duplicate it. NsiteCommands is slimmed down to use it (also now reports the manifest `kind`). Smoke-tested offline: bad-args, help, and dead-relay runs resolving cleanly to not_found with the correct kind for all three napplet variants (15129/35129/5129) plus a no-regression check on `nsite fetch`. The aggregate/per-blob verification logic itself is covered by the quartz unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C --- .../com/vitorpamplona/amethyst/cli/Main.kt | 14 +- .../amethyst/cli/commands/Commands.kt | 5 + .../amethyst/cli/commands/NappletCommands.kt | 216 ++++++++++++++++++ .../amethyst/cli/commands/NsiteCommands.kt | 130 +++-------- .../amethyst/cli/commands/StaticSiteFetch.kt | 131 +++++++++++ 5 files changed, 391 insertions(+), 105 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSiteFetch.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index fcbd727bc7..5ddad30bd8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -166,6 +166,10 @@ private suspend fun dispatch(argv: Array): Int { Commands.nsite(dataDir, tail) } + "napplet" -> { + Commands.napplet(dataDir, tail) + } + "store" -> { Commands.store(dataDir, tail) } @@ -362,13 +366,21 @@ private fun printUsage() { | [--since TS] [--until TS] | [--timeout SECS] | - |Static websites / napplets (NIP-5A kind:15128/35128): + |Static websites (NIP-5A kind:15128/35128): | nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and | [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin | [--out FILE] [--timeout SECS] (AUTHOR: npub|nprofile|hex|name@domain; | [--max-inline-bytes N] --d selects a kind:35128 named site, else the | kind:15128 root site; --path defaults to /) | + |Napplets (NIP-5D kind:5129/15129/35129): + | napplet fetch AUTHOR [--d ID] [--path P] like `nsite fetch`, plus NIP-5D verification: + | [--server URL[,URL]] [--relay URL[,URL]] recompute + check the `x` aggregate hash and + | [--out FILE] [--timeout SECS] report the napplet's `requires` capabilities + | [--max-inline-bytes N] (--d selects a kind:35129 named napplet, else + | napplet fetch --snapshot EVENT-ID the kind:15129 root; --snapshot pins a kind:5129 + | [--path P] … immutable snapshot by event id) + | |Contacts (NIP-02 kind:3): | follow USER [--timeout SECS] add USER to your contact list | unfollow USER [--timeout SECS] remove USER from your contact list diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 151af2fafd..0859b4dc8f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -96,6 +96,11 @@ object Commands { tail: Array, ): Int = NsiteCommands.dispatch(dataDir, tail) + suspend fun napplet( + dataDir: DataDir, + tail: Array, + ): Int = NappletCommands.dispatch(dataDir, tail) + suspend fun store( dataDir: DataDir, tail: Array, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt new file mode 100644 index 0000000000..d80c6c32c3 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt @@ -0,0 +1,216 @@ +/* + * 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.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest +import com.vitorpamplona.quartz.nip5dNapplets.NappletSnapshotEvent +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent + +/** + * `amy napplet fetch` — resolve a single path of a NIP-5D napplet over + * Nostr + Blossom with the full runtime verification contract: + * + * 1. fetch + signature-verify the manifest (kind 15129 root, 35129 named with + * `--d`, or 5129 snapshot with `--snapshot `), + * 2. recompute the NIP-5A aggregate hash and check it against the manifest's `x` + * tag — a mismatch is refused up front (`aggregate_mismatch`), + * 3. download the path's blob from Blossom and accept only a copy whose sha256 + * matches the per-path pin (an untrusted server cannot substitute a blob). + * + * It also reports the napplet's `requires` capabilities, so a shell can see which + * NAP domains it would have to broker. Steps 2–3 live in quartz + * (`NappletManifest.verifyAggregate`, `StaticSiteResolver`); this is thin glue, + * shared with `amy nsite` via [StaticSiteFetch]. + */ +object NappletCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "napplet …") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "fetch" -> fetch(dataDir, rest) + else -> Output.error("bad_args", "napplet ${tail[0]}") + } + } + + private suspend fun fetch( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val snapshotId = args.flag("snapshot") + val author = args.positionalOrNull(0) + if (snapshotId == null && author == null) { + return Output.error("bad_args", "napplet fetch [--d ID] | --snapshot [--path P]") + } + val identifier = args.flag("d") + val requestPath = args.flag("path", "/")!! + val outFile = args.flag("out") + val timeoutSecs = args.longFlag("timeout", 8L) + val maxInlineBytes = args.longFlag("max-inline-bytes", 65_536L) + val extraServers = StaticSiteFetch.commaList(args.flag("server")) + val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val relays = + extraRelays + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + .ifEmpty { ctx.bootstrapRelays() } + + val event = + if (snapshotId != null) { + fetchSnapshot(ctx, snapshotId, relays, timeoutSecs * 1000) + } else { + val authorHex = ctx.requireUserHex(author!!) + fetchByAuthor(ctx, authorHex, identifier, relays, timeoutSecs * 1000) + } + + if (event == null) { + return Output.error( + "not_found", + "no napplet manifest found", + mapOf( + "kind" to expectedKind(snapshotId, identifier), + "snapshot" to snapshotId, + "d" to identifier, + ), + ) + } + + val manifest = event as NappletManifest + + // NIP-5D step 3: the signed aggregate MUST match the path tags. Refuse a + // tampered/inconsistent manifest before fetching any third-party blob. + if (!manifest.verifyAggregate()) { + return Output.error( + "aggregate_mismatch", + "manifest x aggregate does not match its path tags", + mapOf( + "kind" to event.kind, + "manifest_event_id" to event.id, + "declared" to manifest.declaredAggregateHash(), + "computed" to manifest.computeAggregateHash(), + ), + ) + } + + val declaredAggregate = manifest.declaredAggregateHash() + return StaticSiteFetch.resolveAndEmit( + requestPath = requestPath, + paths = manifest.paths(), + servers = (manifest.servers() + extraServers).distinct(), + manifestFields = + mapOf( + "kind" to event.kind, + "manifest_event_id" to event.id, + "d" to identifier, + "requires" to manifest.requires(), + "aggregate_sha256" to declaredAggregate, + "aggregate_verified" to (declaredAggregate != null), + ), + outFile = outFile, + maxInlineBytes = maxInlineBytes, + ) + } finally { + ctx.close() + } + } + + private fun expectedKind( + snapshotId: String?, + identifier: String?, + ): Int = + when { + snapshotId != null -> NappletSnapshotEvent.KIND + identifier != null -> NamedNappletEvent.KIND + else -> RootNappletEvent.KIND + } + + /** + * Fetch the latest napplet manifest for [authorHex]: a [NamedNappletEvent] (kind + * 35129) addressed by [identifier] when `--d` is given, otherwise the author's + * root [RootNappletEvent] (kind 15129). + */ + private suspend fun fetchByAuthor( + ctx: Context, + authorHex: String, + identifier: String?, + relays: Set, + timeoutMs: Long, + ): Event? { + if (relays.isEmpty()) return null + val filter = + if (identifier != null) { + Filter( + kinds = listOf(NamedNappletEvent.KIND), + authors = listOf(authorHex), + tags = mapOf("d" to listOf(identifier)), + limit = 1, + ) + } else { + Filter(kinds = listOf(RootNappletEvent.KIND), authors = listOf(authorHex), limit = 1) + } + return ctx + .drain(relays.associateWith { listOf(filter) }, timeoutMs) + .map { (_, ev) -> ev } + .filter { it.pubKey == authorHex && matchesIdentifier(it, identifier) } + .maxByOrNull { it.createdAt } + } + + /** Fetch a specific immutable snapshot ([NappletSnapshotEvent] / kind 5129) by event id. */ + private suspend fun fetchSnapshot( + ctx: Context, + eventId: String, + relays: Set, + timeoutMs: Long, + ): Event? { + if (relays.isEmpty()) return null + val filter = Filter(ids = listOf(eventId), kinds = listOf(NappletSnapshotEvent.KIND), limit = 1) + return ctx + .drain(relays.associateWith { listOf(filter) }, timeoutMs) + .map { (_, ev) -> ev } + .firstOrNull { it is NappletSnapshotEvent && it.id == eventId } + } + + private fun matchesIdentifier( + event: Event, + identifier: String?, + ): Boolean = + when (event) { + is NamedNappletEvent -> event.identifier() == identifier + is RootNappletEvent -> identifier == null + else -> false + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt index 38a8b79a61..81cdeb560d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt @@ -24,32 +24,29 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output -import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent -import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution -import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag -import java.io.File /** - * `amy nsite fetch` — resolve a single path of a NIP-5A static website / napplet - * over Nostr + Blossom, **verifying** the content against the signed manifest. + * `amy nsite fetch` — resolve a single path of a NIP-5A static website over + * Nostr + Blossom, **verifying** the content against the signed manifest. * * The manifest (`RootSiteEvent` kind 15128, or `NamedSiteEvent` kind 35128 with * `--d`) pins each path to a sha256. This command fetches the manifest from * relays, then downloads the requested path's blob from the manifest's Blossom * servers and accepts the first copy whose recomputed sha256 matches the pin — an - * untrusted server that substitutes or corrupts the blob is skipped. This is the - * same trust boundary a napplet shell relies on, exercised end-to-end so the - * resolver can be checked against real-world manifests (interop / agents). + * untrusted server that substitutes or corrupts the blob is skipped. * - * Thin-assembly only: all resolution + verification lives in quartz - * (`StaticSiteResolver`) and the byte fetch in commons (`BlossomClient.download`). + * For NIP-5D napplets (kinds 5129/15129/35129, with aggregate-hash + capability + * verification) use `amy napplet fetch`. + * + * Thin-assembly only: resolution + verification live in quartz (`StaticSiteResolver`), + * the byte fetch in commons (`BlossomClient.download`), shared via [StaticSiteFetch]. */ object NsiteCommands { suspend fun dispatch( @@ -75,8 +72,8 @@ object NsiteCommands { val outFile = args.flag("out") val timeoutSecs = args.longFlag("timeout", 8L) val maxInlineBytes = args.longFlag("max-inline-bytes", 65_536L) - val extraServers = commaList(args.flag("server")) - val extraRelays = commaList(args.flag("relay")) + val extraServers = StaticSiteFetch.commaList(args.flag("server")) + val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) val ctx = Context.open(dataDir) try { @@ -102,88 +99,25 @@ object NsiteCommands { ) } - val paths = manifest.paths // Manifest servers first (author intent), then any --server fallbacks; keep order, dedupe. - val servers = (manifest.servers + extraServers).distinct() - if (servers.isEmpty()) { - return Output.error("no_servers", "manifest lists no Blossom servers; pass --server URL") - } - - val blossom = BlossomClient() - val resolution = - StaticSiteResolver.resolve( - requestPath = requestPath, - paths = paths, - servers = servers, - fetch = { url -> blossom.download(url) }, - ) - - return when (resolution) { - is StaticSiteResolution.PathNotInManifest -> - Output.error( - "path_not_found", - "manifest declares no such path", - mapOf( - "path" to requestPath, - "available_paths" to paths.map { it.path }, - ), - ) - - is StaticSiteResolution.Unresolvable -> - Output.error( - "unresolvable", - "no server returned a blob matching the manifest hash", - mapOf( - "path" to requestPath, - "sha256" to resolution.hash, - "servers" to servers, - ), - ) - - is StaticSiteResolution.Resolved -> { - emitResolved(resolution, requestPath, manifest, identifier, outFile, maxInlineBytes) - 0 - } - } + return StaticSiteFetch.resolveAndEmit( + requestPath = requestPath, + paths = manifest.paths, + servers = (manifest.servers + extraServers).distinct(), + manifestFields = + mapOf( + "kind" to manifest.kind, + "manifest_event_id" to manifest.id, + "d" to identifier, + ), + outFile = outFile, + maxInlineBytes = maxInlineBytes, + ) } finally { ctx.close() } } - private fun emitResolved( - resolved: StaticSiteResolution.Resolved, - requestPath: String, - manifest: SiteManifest, - identifier: String?, - outFile: String?, - maxInlineBytes: Long, - ) { - val base = - linkedMapOf( - "found" to true, - "verified" to true, - "request_path" to requestPath, - "manifest_path" to resolved.path, - "sha256" to resolved.hash, - "content_type" to resolved.contentType, - "size" to resolved.bytes.size, - "server" to resolved.server, - "manifest_event_id" to manifest.id, - "d" to identifier, - ) - - if (outFile != null) { - File(outFile).writeBytes(resolved.bytes) - base["out"] = outFile - } else if (isTextual(resolved.contentType) && resolved.bytes.size <= maxInlineBytes) { - base["content"] = resolved.bytes.decodeToString() - } else { - base["note"] = "binary or large blob not inlined; pass --out FILE to save it" - } - - Output.emit(base) - } - /** * Fetch the latest matching manifest from [relays]: a [NamedSiteEvent] (kind * 35128) addressed by [identifier] when `--d` is given, otherwise the author's @@ -223,37 +157,25 @@ object NsiteCommands { when (event) { is NamedSiteEvent -> if (event.identifier() == identifier) { - SiteManifest(event.id, event.createdAt, event.paths(), event.servers()) + SiteManifest(event.kind, event.id, event.createdAt, event.paths(), event.servers()) } else { null } is RootSiteEvent -> if (identifier == null) { - SiteManifest(event.id, event.createdAt, event.paths(), event.servers()) + SiteManifest(event.kind, event.id, event.createdAt, event.paths(), event.servers()) } else { null } else -> null } - private fun commaList(value: String?): List = - value - ?.split(',') - ?.map { it.trim() } - ?.filter { it.isNotEmpty() } - ?: emptyList() - - private fun isTextual(contentType: String): Boolean = - contentType.startsWith("text/") || - contentType.startsWith("application/json") || - contentType.startsWith("application/xml") || - contentType.startsWith("image/svg") - /** * Flattened view of either manifest event type (`RootSiteEvent` / * `NamedSiteEvent`) so the rest of the command doesn't branch on Root vs Named. */ private class SiteManifest( + val kind: Int, val id: String, val createdAt: Long, val paths: List, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSiteFetch.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSiteFetch.kt new file mode 100644 index 0000000000..c47a0d53cd --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSiteFetch.kt @@ -0,0 +1,131 @@ +/* + * 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.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient +import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution +import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import java.io.File + +/** + * Shared Blossom resolve-and-emit used by both `amy nsite` (NIP-5A) and + * `amy napplet` (NIP-5D): given a manifest's `path` + `server` tags, download the + * requested path's blob, verify its sha256 against the pin, and print the result. + * All resolution + verification lives in quartz (`StaticSiteResolver`); this is + * thin glue around it. + */ +internal object StaticSiteFetch { + fun commaList(value: String?): List = + value + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?: emptyList() + + /** + * Resolves [requestPath] against the manifest's [paths]/[servers], emitting the + * verified bytes (inlined for small text, or written to [outFile]) merged with + * the caller's [manifestFields] (kind, ids, requires, aggregate, …), or a + * structured `no_servers` / `path_not_found` / `unresolvable` error. + */ + suspend fun resolveAndEmit( + requestPath: String, + paths: List, + servers: List, + manifestFields: Map, + outFile: String?, + maxInlineBytes: Long, + ): Int { + if (servers.isEmpty()) { + return Output.error("no_servers", "manifest lists no Blossom servers; pass --server URL") + } + + val blossom = BlossomClient() + val resolution = + StaticSiteResolver.resolve( + requestPath = requestPath, + paths = paths, + servers = servers, + fetch = { url -> blossom.download(url) }, + ) + + return when (resolution) { + is StaticSiteResolution.PathNotInManifest -> + Output.error( + "path_not_found", + "manifest declares no such path", + mapOf("path" to requestPath, "available_paths" to paths.map { it.path }), + ) + + is StaticSiteResolution.Unresolvable -> + Output.error( + "unresolvable", + "no server returned a blob matching the manifest hash", + mapOf("path" to requestPath, "sha256" to resolution.hash, "servers" to servers), + ) + + is StaticSiteResolution.Resolved -> { + emitResolved(resolution, requestPath, manifestFields, outFile, maxInlineBytes) + 0 + } + } + } + + private fun emitResolved( + resolved: StaticSiteResolution.Resolved, + requestPath: String, + manifestFields: Map, + outFile: String?, + maxInlineBytes: Long, + ) { + val base = + linkedMapOf( + "found" to true, + "verified" to true, + "request_path" to requestPath, + "manifest_path" to resolved.path, + "sha256" to resolved.hash, + "content_type" to resolved.contentType, + "size" to resolved.bytes.size, + "server" to resolved.server, + ) + base.putAll(manifestFields) + + if (outFile != null) { + File(outFile).writeBytes(resolved.bytes) + base["out"] = outFile + } else if (isTextual(resolved.contentType) && resolved.bytes.size <= maxInlineBytes) { + base["content"] = resolved.bytes.decodeToString() + } else { + base["note"] = "binary or large blob not inlined; pass --out FILE to save it" + } + + Output.emit(base) + } + + private fun isTextual(contentType: String): Boolean = + contentType.startsWith("text/") || + contentType.startsWith("application/json") || + contentType.startsWith("application/xml") || + contentType.startsWith("image/svg") +}