Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,13 @@ A pin is only worth the channel you got it over — a digest read off the
same cleartext mirror buys nothing. Surface `plaintext_transport` to the
user rather than deciding for them; if they have an out-of-band digest,
pass it through `--sha256` (or the MCP `snapshot_download` `sha256` arg).
If a download exits non-zero with `VERIFICATION_UNAVAILABLE`, the mirror's
`.md5sum` sidecar could not be fetched, so trond refused to extract a chain
database it cannot check — nothing was written to the destination. Retry,
or pick another backup/mirror. Only pass `--no-verify` (MCP:
`no_verify: true`) when the user has explicitly accepted an unverified
chain database; a completed download reports
`"verification_skipped": true` in that case.

When the download finishes, its manifest + log stay under
`~/.trond/snapshots/<id>.{json,log}` so you can audit later. Stale
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,14 @@ The agent-ergonomics arc lands across four sequenced PRs:
download. Existing MD5 behaviour, verification ordering, and disk
headroom are unchanged. Schema `1.12.2` → `1.12.3` (additive optional
fields on one schema).
- `snapshot download` fails closed on integrity: the `.md5sum` sidecar is
always fetched (a preflight HEAD no longer decides whether to verify),
and a 404 / transport failure aborts with `VERIFICATION_UNAVAILABLE`
(exit 1) instead of silently extracting an unverified chain database.
`--no-verify` (MCP `snapshot_download` gains `no_verify`, previously
absent) is the only way to skip the check; results carry
`verification_skipped` so `md5_verified: false` can no longer be read
as "the mirror had no sidecar". Schema 1.12.2 → 1.12.3

## [0.1.0-alpha] — 2026-XX-XX

Expand Down
8 changes: 8 additions & 0 deletions cmd/snapshot/detach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ func TestStripDetach(t *testing.T) {
in: []string{"trond", "--state-dir", "/tmp", "snapshot", "download", "--detach", "--to", "/data"},
want: []string{"trond", "--state-dir", "/tmp", "snapshot", "download", "--to", "/data"},
},
{
// The detached child is the process that actually fetches the
// sidecar and refuses to extract without it, so the operator's
// deliberate opt-out has to survive the re-exec.
name: "preserves --no-verify so the opt-out reaches the detached child",
in: []string{"trond", "snapshot", "download", "--detach", "--no-verify", "--network", "nile"},
want: []string{"trond", "snapshot", "download", "--no-verify", "--network", "nile"},
},
}

for _, c := range cases {
Expand Down
41 changes: 31 additions & 10 deletions cmd/snapshot/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ var downloadCmd = &cobra.Command{
Short: "Stream a snapshot tarball into a local directory",
Long: `Download a chain database snapshot, streaming the tarball through
gunzip + tar so the .tgz is never persisted to disk. Verifies the
upstream MD5 sidecar (when published), pre-checks free disk space, and
refuses to overwrite an existing database without --force.
upstream MD5 sidecar — if the sidecar cannot be fetched the download
aborts rather than extracting unchecked data, unless you pass
--no-verify. Also pre-checks free disk space and refuses to overwrite an
existing database without --force.

The default destination is ./output-directory under the current working
directory — same convention as the official tron-docker tooling. Pass
Expand Down Expand Up @@ -77,7 +79,7 @@ func init() {
downloadCmd.Flags().StringVar(&dlDest, "to", "", "Destination directory (default ./output-directory)")
downloadCmd.Flags().StringVar(&dlNode, "node", "", "Managed node name; resolves --to from state")
downloadCmd.Flags().BoolVar(&dlForce, "force", false, "Overwrite existing database in destination")
downloadCmd.Flags().BoolVar(&dlNoVerify, "no-verify", false, "Skip MD5 verification (not recommended)")
downloadCmd.Flags().BoolVar(&dlNoVerify, "no-verify", false, "Extract without checking the MD5 sidecar (UNSAFE; otherwise a missing sidecar aborts the download)")
downloadCmd.Flags().StringVar(&dlSHA256, "sha256", "",
"Expected SHA-256 of the tarball, obtained out of band (64 hex chars). "+
"Unlike the upstream .md5sum — which travels the same channel as the "+
Expand Down Expand Up @@ -181,6 +183,15 @@ func runDownload(cmd *cobra.Command, _ []string) error {
if errors.As(err, &ow) {
return output.NewError("HUMAN_REQUIRED", output.ExitHumanRequired, ow.Error())
}
var vu *snapshot.VerificationUnavailableError
if errors.As(err, &vu) {
return output.NewError("VERIFICATION_UNAVAILABLE", output.ExitGeneralError, vu.Error()).
WithSuggestions(
"Retry — the sidecar may be published a few minutes after the tarball, or the mirror may be briefly unhealthy",
fmt.Sprintf("Pick another backup: trond snapshot list --network %s", src.Network),
"Only if you accept an unauthenticated chain database: re-run with --no-verify",
)
}
return output.NewError("DOWNLOAD_ERROR", output.ExitGeneralError, err.Error())
}

Expand All @@ -193,8 +204,11 @@ func runDownload(cmd *cobra.Command, _ []string) error {
humanGB(uint64(res.BytesDownloaded)), res.Duration.Round(time.Second), res.FilesExtracted, dest)
if res.MD5Verified {
fmt.Fprint(cmd.OutOrStdout(), " (md5 ✓)")
} else if !dlNoVerify {
fmt.Fprint(cmd.OutOrStdout(), " (md5 sidecar absent — not verified)")
} else {
// Only reachable with --no-verify: without it, a sidecar that is
// missing or unfetchable aborts the download rather than landing
// an unchecked chain database here.
fmt.Fprint(cmd.OutOrStdout(), " (NOT VERIFIED — --no-verify was passed; this chain database is unauthenticated)")
}
if res.SHA256Verified {
fmt.Fprint(cmd.OutOrStdout(), " (sha256 pin ✓)")
Expand Down Expand Up @@ -225,11 +239,14 @@ func downloadPayload(src *snapshot.Source, backup, dest string, res *snapshot.Do
"bytes_downloaded": res.BytesDownloaded,
"duration_ms": res.DurationMs,
"md5_verified": res.MD5Verified,
"actual_md5": res.ActualMD5,
"files_extracted": res.FilesExtracted,
"userdata_present": pre.UserdataPresent,
"sha256": res.SHA256,
"sha256_verified": res.SHA256Verified,
// Distinguishes "checked and good" from "deliberately unchecked":
// a missing sidecar is now an error, never a silent skip.
"verification_skipped": res.VerificationSkipped,
"actual_md5": res.ActualMD5,
"files_extracted": res.FilesExtracted,
"userdata_present": pre.UserdataPresent,
"sha256": res.SHA256,
"sha256_verified": res.SHA256Verified,
// The cleartext-transport fact has to reach agents that read
// stdout only and never see the stderr warning.
"plaintext_transport": res.PlaintextTransport,
Expand Down Expand Up @@ -271,6 +288,10 @@ func emitPlan(outputFmt string, src *snapshot.Source, backup, dest string, pre *
if pre.PlaintextTransport {
fmt.Print(snapshot.PlaintextWarning(pre.URL, dlSHA256 != ""))
}
if !pre.HasMD5Sidecar {
fmt.Println(" WARNING: the sidecar did not answer this probe — the download will")
fmt.Println(" refuse to extract unverified data unless you pass --no-verify.")
}
if pre.WouldOverwrite {
fmt.Println(" WARNING: existing database would be overwritten (use --force).")
}
Expand Down
40 changes: 31 additions & 9 deletions internal/knowledge/files/snapshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ Before any tarball bytes hit the wire, trond:

1. Sends an HTTP HEAD to the tarball URL → reads `Content-Length`. The
body is never opened on this probe.
2. Issues a separate HEAD to the `.md5sum` sidecar → records whether
inline verification will be possible.
2. Issues a separate HEAD to the `.md5sum` sidecar → records whether the
mirror advertises one. Informational only: it never decides whether
verification happens (see "MD5 verification").
3. `Statfs(destination)` → reads available bytes (Bavail × Bsize, the
same number `df` shows).
4. Refuses to start the GET if free space < `Content-Length × 2`.
Expand Down Expand Up @@ -118,10 +119,12 @@ If a mirror ever gains HTTPS, switch its `BaseURL` in

## What the MD5 sidecar does and does not buy

Mainnet mirrors publish `<tarball>.tgz.md5sum` sidecars. trond:
Mainnet and Nile mirrors both publish `<tarball>.tgz.md5sum` sidecars.
trond:

1. HEADs the sidecar in preflight (records "has md5 sidecar: true/false").
2. Downloads the sidecar (a few hundred bytes).
1. HEADs the sidecar in preflight (records "has md5 sidecar: true/false"
for `--dry-run`; this answer decides nothing).
2. Downloads the sidecar (a few hundred bytes) before the tarball GET.
3. Hashes the tarball stream while extracting.
4. Compares — mismatch fails the operation with the database in whatever
partial state extraction reached.
Expand All @@ -134,10 +137,21 @@ substitute the sidecar, and the two will agree. MD5 is also collision-prone
on its own merits. So the sidecar check proves the transfer was not
*corrupted*; it proves nothing about where the bytes came from.

Nile and the occasional outage may leave the sidecar absent. trond will
still extract; the result message reads `(md5 sidecar absent — not
verified)`. Pass `--no-verify` to suppress that note when you've made
the choice deliberately.
A mirror outage — or a mirror that stops publishing sidecars — can still
leave the sidecar absent. **trond then refuses to download**: step 2
fails with `VERIFICATION_UNAVAILABLE` (exit 1) and nothing is written to
the destination. The same applies to any transport failure fetching the
sidecar. The preflight HEAD from step 1 is not consulted: it arrives over
the same unauthenticated HTTP channel as the tarball, so a 404 there is
never taken as permission to skip the check.

If you accept an unverified chain database, say so explicitly with
`--no-verify` (MCP: `no_verify: true`); the flag carries through
`--detach` to the background child. The result line then reads
`(NOT VERIFIED — --no-verify was passed; this chain database is
unauthenticated)` and JSON output carries `"verification_skipped": true`.
Treat such a database as untrusted input — it is the state your node will
serve to every dApp, explorer and exchange that queries it.

## `--sha256`: the check that can detect substitution

Expand Down Expand Up @@ -301,6 +315,14 @@ version change.
- Malformed pin, rejected before the transfer starts. SHA-256 digests are
64 hex characters; you may have pasted an MD5 (32).

`VERIFICATION_UNAVAILABLE: cannot verify snapshot integrity: <url> is
unavailable ...`
- The `.md5sum` sidecar 404'd or the fetch failed, so trond has nothing
to check the tarball against and refuses to extract. Retry (sidecars
are sometimes published a few minutes after the tarball), pick another
backup with `trond snapshot list`, or switch `--region` / `--domain`.
`--no-verify` bypasses the check and should be a deliberate choice.

## Local database backup with `db_cp`

`scripts/db_cp.sh` makes a fast, space-efficient local copy of a
Expand Down
22 changes: 16 additions & 6 deletions internal/mcp/tools_snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ type snapshotDownloadArgs struct {
Force bool `json:"force,omitempty" jsonschema:"overwrite an existing chain DB (DESTRUCTIVE)"`
DryRun bool `json:"dry_run,omitempty" jsonschema:"print the plan and exit without downloading"`
SHA256 string `json:"sha256,omitempty" jsonschema:"expected SHA-256 of the tarball (64 hex chars) obtained out of band; a mismatch fails the download. The mainnet mirrors are cleartext HTTP and their .md5sum sidecar rides the same channel, so this pin is the only check that can detect a substituted archive"`
// NoVerify is the deliberate opt-out from integrity checking. Without
// it, a sidecar that cannot be fetched aborts the download instead of
// silently extracting unverified chain data — mirroring `--no-verify`
// on the CLI so an agent isn't stuck when a mirror genuinely stops
// publishing sidecars.
NoVerify bool `json:"no_verify,omitempty" jsonschema:"UNSAFE: extract without checking the MD5 sidecar; otherwise a missing/unfetchable sidecar aborts the download"`
}

func registerSnapshotTools(s *mcp.Server) {
Expand Down Expand Up @@ -59,7 +65,7 @@ func registerSnapshotTools(s *mcp.Server) {
mcp.AddTool(s, &mcp.Tool{
Name: "snapshot_download",
Title: "Download a chain DB snapshot",
Description: `Stream a snapshot tarball into a destination directory, gunzip + tar in one pipeline (no .tgz on disk). Pre-checks free disk space (HEAD probe + Statfs, requires 2× headroom). Refuses overwrite of an existing chain DB unless force=true. Preserves any pre-existing userdata/. MD5-verifies inline against the published sidecar when present.
Description: `Stream a snapshot tarball into a destination directory, gunzip + tar in one pipeline (no .tgz on disk). Pre-checks free disk space (HEAD probe + Statfs, requires 2× headroom). Refuses overwrite of an existing chain DB unless force=true. Preserves any pre-existing userdata/. MD5-verifies inline against the published sidecar; if the sidecar cannot be fetched the download fails instead of extracting unverified chain data — pass no_verify=true only to accept that risk deliberately.

Use dry_run=true to inspect the plan first. The tool emits MCP progress notifications during the actual download so the client can render a live progress bar. NOTE: this MCP tool runs the download in-process and blocks until completion or context cancellation; for fire-and-forget mainnet-full sized downloads (multi-hour) prefer the CLI with --detach.

Expand Down Expand Up @@ -147,6 +153,7 @@ func snapshotDownloadTool(ctx context.Context, req *mcp.CallToolRequest, args sn
DestDir: args.Dest,
Force: args.Force,
ExpectedSHA256: args.SHA256,
NoVerify: args.NoVerify,
}

pre, err := snapshot.Preflight(ctx, opts)
Expand Down Expand Up @@ -188,11 +195,14 @@ func snapshotDownloadTool(ctx context.Context, req *mcp.CallToolRequest, args sn
"bytes_downloaded": res.BytesDownloaded,
"duration_ms": res.DurationMs,
"md5_verified": res.MD5Verified,
"actual_md5": res.ActualMD5,
"files_extracted": res.FilesExtracted,
"userdata_present": pre.UserdataPresent,
"sha256": res.SHA256,
"sha256_verified": res.SHA256Verified,
// A missing sidecar is an error now, so md5_verified=false means
// exactly one thing: the caller passed no_verify.
"verification_skipped": res.VerificationSkipped,
"actual_md5": res.ActualMD5,
"files_extracted": res.FilesExtracted,
"userdata_present": pre.UserdataPresent,
"sha256": res.SHA256,
"sha256_verified": res.SHA256Verified,
// An MCP caller has no stderr to read the warning from, so the
// cleartext-transport fact has to travel in the payload.
"plaintext_transport": res.PlaintextTransport,
Expand Down
8 changes: 7 additions & 1 deletion internal/schema/embed.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,13 @@ import (
// inferring it, and pin an out-of-band digest via `--sha256` /
// the MCP `sha256` arg. One existing schema, additive optional
// fields only — PATCH.
const SchemaVersion = "1.12.4"
// 1.12.5 — snapshot-download output gains `verification_skipped`. A
// missing or unfetchable `.md5sum` sidecar is now a hard error
// (`VERIFICATION_UNAVAILABLE`) rather than a silent skip, so
// `md5_verified: false` on a successful download means one
// thing only: the operator passed `--no-verify` / `no_verify`.
// One existing schema, one additive optional field — PATCH.
const SchemaVersion = "1.12.5"

// JSONSchemaURLBase is the canonical URL prefix for individual output
// schema files. Embedded $id values inside each schema mirror this so
Expand Down
4 changes: 4 additions & 0 deletions internal/schema/files/snapshot-download.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
"actual_md5": { "type": "string", "pattern": "^[0-9a-f]{32}$" },
"expected_md5": { "type": "string", "pattern": "^[0-9a-f]{32}$" },
"md5_verified": { "type": "boolean", "description": "The upstream .md5sum sidecar matched. On a plaintext_transport mirror the sidecar arrives over the same unauthenticated channel as the tarball, so this attests transfer integrity only — not provenance." },
"verification_skipped": {
"type": "boolean",
"description": "true only when --no-verify / no_verify was passed. A missing or unfetchable .md5sum sidecar is an error (VERIFICATION_UNAVAILABLE), never a silent skip, so md5_verified=false always means the operator opted out."
},
"dest": { "type": "string" },
"userdata_present": { "type": "boolean" },
"source": { "type": "object" },
Expand Down
4 changes: 2 additions & 2 deletions internal/schema/version_baseline.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"schema_version": "1.12.4",
"schema_version": "1.12.5",
"entries": [
{
"name": "apply",
Expand Down Expand Up @@ -103,7 +103,7 @@
},
{
"name": "snapshot-download",
"hash": "24767d4d39d7201e46c9f0d23e575283a768bc5867cab81cb9255ce0f5eb3d66"
"hash": "14517cc14171dcf3f615fa2cf69be553bceab3b83fcea2ac7f913260e64e5c2c"
},
{
"name": "snapshot-jobs",
Expand Down
Loading
Loading