feat(cli): add amy napplet fetch for NIP-5D napplets

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
  <event-id>) 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
This commit is contained in:
Claude
2026-06-19 22:14:14 +00:00
parent 2648771429
commit 0eea00543a
5 changed files with 391 additions and 105 deletions
@@ -166,6 +166,10 @@ private suspend fun dispatch(argv: Array<String>): Int {
Commands.nsite(dataDir, tail) Commands.nsite(dataDir, tail)
} }
"napplet" -> {
Commands.napplet(dataDir, tail)
}
"store" -> { "store" -> {
Commands.store(dataDir, tail) Commands.store(dataDir, tail)
} }
@@ -362,13 +366,21 @@ private fun printUsage() {
| [--since TS] [--until TS] | [--since TS] [--until TS]
| [--timeout SECS] | [--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 | 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 | [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin
| [--out FILE] [--timeout SECS] (AUTHOR: npub|nprofile|hex|name@domain; | [--out FILE] [--timeout SECS] (AUTHOR: npub|nprofile|hex|name@domain;
| [--max-inline-bytes N] --d selects a kind:35128 named site, else the | [--max-inline-bytes N] --d selects a kind:35128 named site, else the
| kind:15128 root site; --path defaults to /) | 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): |Contacts (NIP-02 kind:3):
| follow USER [--timeout SECS] add USER to your contact list | follow USER [--timeout SECS] add USER to your contact list
| unfollow USER [--timeout SECS] remove USER from your contact list | unfollow USER [--timeout SECS] remove USER from your contact list
@@ -96,6 +96,11 @@ object Commands {
tail: Array<String>, tail: Array<String>,
): Int = NsiteCommands.dispatch(dataDir, tail) ): Int = NsiteCommands.dispatch(dataDir, tail)
suspend fun napplet(
dataDir: DataDir,
tail: Array<String>,
): Int = NappletCommands.dispatch(dataDir, tail)
suspend fun store( suspend fun store(
dataDir: DataDir, dataDir: DataDir,
tail: Array<String>, tail: Array<String>,
@@ -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 <event-id>`),
* 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<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "napplet <fetch> …")
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<String>,
): 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 <author> [--d ID] | --snapshot <event-id> [--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<NormalizedRelayUrl>,
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<NormalizedRelayUrl>,
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
}
}
@@ -24,32 +24,29 @@ import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output 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.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent 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 com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import java.io.File
/** /**
* `amy nsite fetch` — resolve a single path of a NIP-5A static website / napplet * `amy nsite fetch` — resolve a single path of a NIP-5A static website over
* over Nostr + Blossom, **verifying** the content against the signed manifest. * Nostr + Blossom, **verifying** the content against the signed manifest.
* *
* The manifest (`RootSiteEvent` kind 15128, or `NamedSiteEvent` kind 35128 with * The manifest (`RootSiteEvent` kind 15128, or `NamedSiteEvent` kind 35128 with
* `--d`) pins each path to a sha256. This command fetches the manifest from * `--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 * 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 * 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 * untrusted server that substitutes or corrupts the blob is skipped.
* same trust boundary a napplet shell relies on, exercised end-to-end so the
* resolver can be checked against real-world manifests (interop / agents).
* *
* Thin-assembly only: all resolution + verification lives in quartz * For NIP-5D napplets (kinds 5129/15129/35129, with aggregate-hash + capability
* (`StaticSiteResolver`) and the byte fetch in commons (`BlossomClient.download`). * 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 { object NsiteCommands {
suspend fun dispatch( suspend fun dispatch(
@@ -75,8 +72,8 @@ object NsiteCommands {
val outFile = args.flag("out") val outFile = args.flag("out")
val timeoutSecs = args.longFlag("timeout", 8L) val timeoutSecs = args.longFlag("timeout", 8L)
val maxInlineBytes = args.longFlag("max-inline-bytes", 65_536L) val maxInlineBytes = args.longFlag("max-inline-bytes", 65_536L)
val extraServers = commaList(args.flag("server")) val extraServers = StaticSiteFetch.commaList(args.flag("server"))
val extraRelays = commaList(args.flag("relay")) val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
try { try {
@@ -102,88 +99,25 @@ object NsiteCommands {
) )
} }
val paths = manifest.paths
// Manifest servers first (author intent), then any --server fallbacks; keep order, dedupe. // Manifest servers first (author intent), then any --server fallbacks; keep order, dedupe.
val servers = (manifest.servers + extraServers).distinct() return StaticSiteFetch.resolveAndEmit(
if (servers.isEmpty()) { requestPath = requestPath,
return Output.error("no_servers", "manifest lists no Blossom servers; pass --server URL") paths = manifest.paths,
} servers = (manifest.servers + extraServers).distinct(),
manifestFields =
val blossom = BlossomClient() mapOf(
val resolution = "kind" to manifest.kind,
StaticSiteResolver.resolve( "manifest_event_id" to manifest.id,
requestPath = requestPath, "d" to identifier,
paths = paths, ),
servers = servers, outFile = outFile,
fetch = { url -> blossom.download(url) }, maxInlineBytes = maxInlineBytes,
) )
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
}
}
} finally { } finally {
ctx.close() ctx.close()
} }
} }
private fun emitResolved(
resolved: StaticSiteResolution.Resolved,
requestPath: String,
manifest: SiteManifest,
identifier: String?,
outFile: String?,
maxInlineBytes: Long,
) {
val base =
linkedMapOf<String, Any?>(
"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 * Fetch the latest matching manifest from [relays]: a [NamedSiteEvent] (kind
* 35128) addressed by [identifier] when `--d` is given, otherwise the author's * 35128) addressed by [identifier] when `--d` is given, otherwise the author's
@@ -223,37 +157,25 @@ object NsiteCommands {
when (event) { when (event) {
is NamedSiteEvent -> is NamedSiteEvent ->
if (event.identifier() == identifier) { 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 { } else {
null null
} }
is RootSiteEvent -> is RootSiteEvent ->
if (identifier == null) { if (identifier == null) {
SiteManifest(event.id, event.createdAt, event.paths(), event.servers()) SiteManifest(event.kind, event.id, event.createdAt, event.paths(), event.servers())
} else { } else {
null null
} }
else -> null else -> null
} }
private fun commaList(value: String?): List<String> =
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` / * Flattened view of either manifest event type (`RootSiteEvent` /
* `NamedSiteEvent`) so the rest of the command doesn't branch on Root vs Named. * `NamedSiteEvent`) so the rest of the command doesn't branch on Root vs Named.
*/ */
private class SiteManifest( private class SiteManifest(
val kind: Int,
val id: String, val id: String,
val createdAt: Long, val createdAt: Long,
val paths: List<PathTag>, val paths: List<PathTag>,
@@ -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<String> =
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<PathTag>,
servers: List<String>,
manifestFields: Map<String, Any?>,
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<String, Any?>,
outFile: String?,
maxInlineBytes: Long,
) {
val base =
linkedMapOf<String, Any?>(
"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")
}