Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
119 commits
Select commit Hold shift + click to select a range
604c2a0
config: add crypt overlay block for destinations
mbertschler Jun 10, 2026
d923c1c
sync: address crypt destinations through the overlay remote
mbertschler Jun 10, 2026
d0ec6a4
README: document encrypted destinations
mbertschler Jun 10, 2026
d983ad4
sync: scope the rclone version preflight to transfers that use blake3
mbertschler Jun 10, 2026
9e7d6db
config: correct the RcloneSection trailing-newline doc, trim resolveC…
mbertschler Jun 10, 2026
af3fd6a
Merge pull request #96 from mbertschler/config-crypt-destinations
mbertschler Jun 10, 2026
35e2acd
store: split files into contents + path observations (schema v14-v16)
mbertschler Jun 10, 2026
bad6cd7
store: add destination_run_ids and remote_objects accessors
mbertschler Jun 10, 2026
72995e7
store: test the v13 contents-split migration end to end
mbertschler Jun 10, 2026
1b65105
index: pin the indexer's offloaded-row handling
mbertschler Jun 10, 2026
109e7a2
store: enforce content-size agreement and statement-level watermark m…
mbertschler Jun 10, 2026
7b171e3
store: trim negation phrasings from test comments
mbertschler Jun 10, 2026
f5535e6
Merge pull request #97 from mbertschler/schema-contents-split
mbertschler Jun 10, 2026
c46c5a8
config: add the kopia destination type
mbertschler Jun 10, 2026
2a5ca0c
store: add origin-node resolution and content-introduction helpers
mbertschler Jun 10, 2026
cbcb774
store: add AdvanceDestinationVector, the single vector advancement path
mbertschler Jun 10, 2026
e9ba9d1
sync: put bucket and peer transfers behind typed destination handlers
mbertschler Jun 10, 2026
b1f7d2b
syncproto: carry content origin on plan entries; add durability exchange
mbertschler Jun 10, 2026
f301025
agent: record wire origins verbatim and classify by delivery run
mbertschler Jun 10, 2026
1db5fc1
store: upgrade placeholder peer endpoints on first real contact
mbertschler Jun 10, 2026
2c94892
sync: add the kopia destination handler
mbertschler Jun 11, 2026
641a5b9
cmd/squirrel: wire kopia destinations through sync and the agent
mbertschler Jun 11, 2026
76a14fc
README: document the kopia destination type
mbertschler Jun 11, 2026
c00f10a
sync: leave Verification zero on dry runs
mbertschler Jun 11, 2026
e931a92
sync: send content origins; record durability at successful close
mbertschler Jun 11, 2026
b40ac99
cmd/squirrel: add peer-sync pull-durability
mbertschler Jun 11, 2026
8063d32
sync: refuse a kopia snapshot manifest without an id
mbertschler Jun 11, 2026
a8fbfc9
sync: state the DurabilityPullReport bucket invariant positively
mbertschler Jun 11, 2026
c02669d
sync: address review — env dedupe, early kopia formatting, README wor…
mbertschler Jun 11, 2026
266ed86
store: advance the self vector component at the introduction run
mbertschler Jun 11, 2026
f2bbdfa
sync: keep the kopia password out of kopia's credential sidecar
mbertschler Jun 11, 2026
63ceaaa
Merge pull request #99 from mbertschler/origin-propagation
mbertschler Jun 11, 2026
2b29cb0
Merge pull request #98 from mbertschler/destination-handlers
mbertschler Jun 11, 2026
b8083be
sync: advance the durability vector on verified bucket pushes
mbertschler Jun 11, 2026
fa8fefe
sync: gate vector advancement on a finalized runs row; assert verifie…
mbertschler Jun 11, 2026
b545302
sync: document the advance-after-close ordering in-code; tighten the …
mbertschler Jun 11, 2026
c0ef934
Merge pull request #100 from mbertschler/vector-wiring
mbertschler Jun 11, 2026
6b6f1ff
config: add the content-addressed destination layout option
mbertschler Jun 11, 2026
65e3aed
store: let remote_objects record uploads with the fingerprint pending…
mbertschler Jun 11, 2026
1cd0332
store: stamp the run that last changed each row's status (v18)
mbertschler Jun 11, 2026
482e9da
store: add the manifest path-delta read and its sync watermark
mbertschler Jun 11, 2026
ee4e7d8
config: add per-volume offload_requires policy
mbertschler Jun 11, 2026
8ab7d80
store: add MarkOffloaded flip and BeginOffloadRunIfClear
mbertschler Jun 11, 2026
f0ee123
offload: durability-gated deletion of local file bytes
mbertschler Jun 11, 2026
0e888a0
cmd/squirrel: add offload command
mbertschler Jun 11, 2026
2710c67
README: document offloading
mbertschler Jun 11, 2026
0e8e706
sync: add the content-addressed destination handler
mbertschler Jun 11, 2026
0e08a3f
cmd/squirrel: render content-addressed push reports
mbertschler Jun 11, 2026
4e707d1
README: document content-addressed destinations and the manifest format
mbertschler Jun 11, 2026
6b340eb
sync, cmd, store: address golangci-lint findings
mbertschler Jun 11, 2026
fde1d25
offload: fail closed on dot-collapsing selectors and negative age cut…
mbertschler Jun 11, 2026
b97e525
Merge pull request #101 from mbertschler/offload-command
mbertschler Jun 11, 2026
3815e7a
sync: share objects/ across volumes at the destination root
mbertschler Jun 11, 2026
7773947
sync: name the exact segment and object paths in the restore refusal
mbertschler Jun 11, 2026
a630728
ci: retrigger checks
mbertschler Jun 11, 2026
e9007c9
Merge origin/offload-v1 (offload command, #101)
mbertschler Jun 11, 2026
1703c49
Merge pull request #102 from mbertschler/content-addressed-offsite
mbertschler Jun 11, 2026
e4acbdc
config: add per-destination hash_algo and checkers knobs
mbertschler Jun 11, 2026
bc81ab0
store: fingerprint fill, destination listing, and remote-verify runs
mbertschler Jun 11, 2026
adce187
sync: capture scan-back fingerprints after content-addressed uploads
mbertschler Jun 11, 2026
b642cd0
sync: add the remote-object re-verification pass
mbertschler Jun 11, 2026
6a16ed6
cmd/squirrel: add the verify command
mbertschler Jun 11, 2026
51ccfd6
README: document offsite verification and the new destination knobs
mbertschler Jun 11, 2026
454868f
sync: exempt pre-migration snapshots from sync-time rotation
mbertschler Jun 11, 2026
fa8340f
config: document pre-migration exemption on Backups.Keep
mbertschler Jun 11, 2026
c4b08ac
cmd/squirrel: make db restore reversible and stale-WAL-safe
mbertschler Jun 11, 2026
810861d
cmd/squirrel: harden db restore partial-failure paths
mbertschler Jun 11, 2026
47bc17b
sync: distinguish missing-from-listing from no-checksum; honest S3 ET…
mbertschler Jun 11, 2026
7d904c3
Merge pull request #116 from mbertschler/scan-back-fingerprints
mbertschler Jun 11, 2026
83fa76a
cmd/squirrel: note that preserved pre-restore DBs are kept indefinitely
mbertschler Jun 11, 2026
74a4406
Merge pull request #117 from mbertschler/fix-backup-safety
mbertschler Jun 11, 2026
d595b19
store: gate peer endpoint upgrade behind operator trust (#110b)
mbertschler Jun 11, 2026
89bedd9
sync: pass operator-trusted flag to GetOrCreatePeerNode (#110b)
mbertschler Jun 11, 2026
7c2ee56
agent: harden peer-sync receiver against origin poisoning, history lo…
mbertschler Jun 11, 2026
229b639
agent,store: clarify endpoint-agreement, body-cap scope, and pre-stag…
mbertschler Jun 11, 2026
bbca867
syncproto,agent: correct InitiatorEndpoint and pre-stage docs to matc…
mbertschler Jun 11, 2026
c5b57f6
Merge pull request #119 from mbertschler/fix-peer-hardening
mbertschler Jun 11, 2026
d050b0a
store: add v19 migration recording durability verify_method
mbertschler Jun 11, 2026
b6ecb99
store: snapshot-pinned vector advance + verify-method provenance
mbertschler Jun 11, 2026
d861504
store: freshness watermark + index-blocks-sync cross-kind guard
mbertschler Jun 11, 2026
f7f791d
offload: require freshness + content-verified method per target
mbertschler Jun 11, 2026
c6954f4
sync: capture advance snapshot at push start; kopia verify depth
mbertschler Jun 11, 2026
72f534d
sync: carry verify_method over the durability pull wire
mbertschler Jun 11, 2026
b56b21c
offload: document freshness watermark is local-run-space only
mbertschler Jun 11, 2026
49d48e8
store,sync: address Copilot review
mbertschler Jun 11, 2026
381827c
store: add origin-space push-freshness, recorded on every vector advance
mbertschler Jun 11, 2026
068bd7e
syncproto,agent,sync: carry push-freshness over the durability pull wire
mbertschler Jun 11, 2026
96bcfb8
sync: snapshot-pin the peer close-phase durability advance (closes #103)
mbertschler Jun 11, 2026
be7437d
sync: reject zero kopia verify_files_percent for a gating verify (#108)
mbertschler Jun 11, 2026
ab62607
offload: gate relayed-target freshness from pulled evidence (closes #…
mbertschler Jun 11, 2026
37ad1b7
offload: fix stale test reference in TestOffloadPeerPulledEvidenceTar…
mbertschler Jun 11, 2026
4cf9719
Merge pull request #120 from mbertschler/fix-durability-soundness
mbertschler Jun 11, 2026
aa163c8
config: add optional s3 storage_class and sftp host-key validation pa…
mbertschler Jun 11, 2026
4d5fe9d
sync: cover rendering of s3 storage_class and sftp host-key params
mbertschler Jun 11, 2026
294f31a
README: document s3 storage_class and sftp host-key validation params
mbertschler Jun 11, 2026
d1b5a8c
sync: scope durability pull to the volume's accepted destinations
mbertschler Jun 11, 2026
3b66c8a
cmd: report dropped durability entries in pull output
mbertschler Jun 11, 2026
c83b7c9
index: pin row size/mtime to the hashed-handle stat
mbertschler Jun 11, 2026
3bc891b
sync: verify content-addressed uploads by re-hashing the source
mbertschler Jun 11, 2026
336a146
README: genericize the s3 storage_class example (no provider tier names)
mbertschler Jun 11, 2026
e89b10f
sync: test that an unconfigured freshness coordinate is dropped and c…
mbertschler Jun 11, 2026
244369b
sync: harden the durability-pull filter for adversarial peers
mbertschler Jun 11, 2026
17de6d6
index,sync: note the residual hash-EOF/stat window; trim negation phr…
mbertschler Jun 11, 2026
f4274a2
Merge pull request #122 from mbertschler/config-gaps
mbertschler Jun 11, 2026
2e6ebbe
Merge pull request #123 from mbertschler/fix-pull-hygiene
mbertschler Jun 11, 2026
eb4c785
Merge pull request #124 from mbertschler/fix-upload-integrity
mbertschler Jun 11, 2026
0d20d5a
store: add v21 contents immutability triggers (UPDATE/DELETE ABORT)
mbertschler Jun 11, 2026
b947718
store: test v18->v21 chain and contents trigger ABORTs
mbertschler Jun 11, 2026
d8de007
store: include offload in sync/index begin-gate blocking sets
mbertschler Jun 11, 2026
678bd73
store: give FinishHookRun the first-terminal-write-wins guard
mbertschler Jun 11, 2026
985d63a
sync: cross-check config path against DB volume path in push handlers
mbertschler Jun 11, 2026
8ee5ba5
sync: gate kopia repository auto-create behind --init
mbertschler Jun 11, 2026
02ffdc1
sync: downgrade a --checksum run that silently lost its hash
mbertschler Jun 11, 2026
985e7a4
Merge pull request #125 from mbertschler/fix-schema-robustness
mbertschler Jun 11, 2026
49ce115
config: emit blake3sum_command for sftp so `--hash blake3` syncs work
mbertschler Jun 16, 2026
175b4f7
Merge pull request #127 from mbertschler/fix-sftp-blake3sum-command
mbertschler Jun 16, 2026
2ffca42
sync: harden the durability pull against unknown methods and origin f…
claude Jun 19, 2026
6167547
docs: record the durability trust boundary and offsite verification gap
claude Jun 19, 2026
89a0e16
Merge pull request #128 from mbertschler/claude/offload-v1-trust-hard…
mbertschler Jun 20, 2026
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
14 changes: 9 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
Squirrel indexes **content** (BLAKE3 hashes), not paths. A hash ever observed
must stay retrievable. Paths are observations of content; content is the entity.

So `Upsert` never rewrites a row's `blake3` in place: when content at a path
changes it marks the prior row `superseded` and inserts a new one, keeping at
most one live (non-`superseded`) row per path. The schema enforces this on
`files` — the `files_blake3_immutable` trigger and the `uniq_files_live_per_path`
partial unique index (`store/migrations.go`).
The schema makes this literal: `contents` is the append-only content entity
(one row per BLAKE3, with size and origin), and `files` rows are path↔content
observations referencing it. `Upsert` never rewrites a row's `content_id` in
place: when content at a path changes it marks the prior row `superseded` and
inserts a new one, keeping at most one live (non-`superseded`) row per path —
enforced by the `uniq_files_live_per_path` partial unique index
(`store/migrations.go`); the id↔hash binding itself is immutable by
construction (`contents.blake3` is UNIQUE and contents rows are never
updated).

The `runs` table follows the same no-loss spirit by policy, not schema: squirrel
never auto-prunes runs — they're an audit trail, and any retention is explicit
Expand Down
173 changes: 169 additions & 4 deletions README.md

Large diffs are not rendered by default.

137 changes: 137 additions & 0 deletions SAFETY-AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1022,6 +1022,143 @@ when restoreFromNode lands.

---

## Durability evidence & offsite verification (offload-v1)

Findings specific to the offload-v1 feature set — the durability version
vectors that gate `offload`, the peer durability pull that carries
evidence between nodes, and the content-addressed offsite push. These
are framed for the intended deployment: a **single operator** whose
nodes (laptop, NAS) are all machines they control and hold the only
credentials to. Under that model the adversary is overwhelmingly
*entropy and bugs*, not a hostile peer; the findings are sized
accordingly.

### D1: Durability-pull trust boundary (relayed offsite evidence)

**Severity:** Medium (defence-in-depth) • **Likelihood:** N/A
(documented assumption, not a live defect).

**Where**

- `sync/durability.go` — `PullDurability` / `pullDurability`,
`validateComponent`, `validateFreshness`.
- `offload/gate.go` — `check`, `methodVerified`, `freshnessFailure`.
- `store/nodes.go` — `GetOrCreateOriginNode`.

**The boundary**

A durability component is recorded one of two ways, and they differ in
what they trust:

- **Direct, self-verified.** When this node pushes to a target itself —
the NAS via peer sync (`sync/node.go`, tagged `peer-blake3` after the
receiver re-hashes every path) or a bucket via rclone (`sync/sync.go`)
— it writes the component into its **own** store from its **own**
confirmed transfer (`AdvanceDestinationVectorTo`). No peer is trusted.
- **Relayed, peer-asserted.** For a target this node never pushes to (an
offsite only the NAS reaches), the only evidence is what the NAS
reports over the durability pull (`UpsertDestinationRunIDVerified`,
reached only from `pullDurability`). Putting such a target in a
volume's `offload_requires` means the local delete decision trusts the
NAS's recorded `(origin, run, method)` assertion. The pull validates
shape (positive run, valid origin name, recognised method) and is
monotonic, but carries **no proof of possession** — a peer that
asserts an inflated run for a destination in the accepted set would be
believed.

**Decision (intended):** the relayed-evidence trust is **accepted**. The
NAS is in the same trust domain as the laptop; a NAS that lies about
durability is a compromised-or-broken NAS, in which case the archive it
holds is already in question and `offload` is a footnote. The gate fails
*closed* on absent evidence, so a peer can only ever *withhold*
offload-eligibility, and the redundancy decision (gate on **all** copies
via `offload_requires`, not the fewest-trusted subset) is what protects
against data loss — see the offload section of `README.md`.

**Defence-in-depth implemented in this branch** (cheap; turns *bugs*
into loud failures, not a security control):

- **Verify-method allow-list at the pull boundary** — `validateComponent`
refuses a non-empty `verify_method` that isn't one this build defines
(`store.KnownVerifyMethod`). Previously an unknown method was stored
and then silently treated as unverified by the gate; now a peer bug or
version-skew string is rejected at receipt. Empty (legitimately
"unverified") still passes.
- **Origin-node creation cap** — `pullDurability` refuses a pull that
names more than `maxOriginNodesPerPull` (256) distinct origins, so a
runaway peer cannot grow the local `nodes` table without bound via
`GetOrCreateOriginNode`. A real volume references a handful of origins;
the cap only converts a flood into an observable refusal.

**Not done (deliberately):** no proof-of-possession protocol, no
laptop-side independent verification of relayed offsites. Those defend
against a malicious NAS, which is out of model. The random per-file
nonce in the rclone crypt overlay also makes "the NAS proves the stored
ciphertext decrypts to the right content" impractical without either a
content-derived nonce or the laptop downloading and decrypting the
object — neither warranted here.

**Issue:** `durability: document the relayed-evidence trust boundary; add verify-method allow-list and origin-node cap as defence-in-depth` (implemented in this branch)

### D2: Content-addressed offsite push proves presence+size, not decrypt-correctness

**Severity:** Medium • **Likelihood:** Low (requires a transfer-time
corruption that preserves decrypted size, or the documented
re-hash→read TOCTOU window to fire).

**Where**

- `sync/content_addressed.go` — `uploadOneObject` (re-hash → `copyTo` →
`statRemote` size check), `captureFingerprints`.
- `sync/verify_remote.go` — `VerifyRemote` (scan-back re-check).

**What it does / doesn't establish**

At upload the push: (1) re-hashes the **local plaintext** and refuses on
drift, so the encryption input is the right content; (2) confirms the
object is **present** and its **decrypted size** (stat is through the
crypt overlay) matches the index; (3) records the provider's checksum of
the ciphertext as the scan-back baseline. The underlying backend's own
transfer integrity (e.g. S3 Content-MD5 on PUT) covers "the ciphertext
rclone sent is the ciphertext stored."

It does **not** confirm that the stored ciphertext *decrypts back* to the
indexed hash — there is no post-upload decrypt-and-rehash. The
unguarded slivers are the documented fork/exec window between the
re-hash and rclone's open (`uploadOneObject` "Residual:" comment) and a
hypothetical crypt bug that produces a right-decrypted-size, wrong
content object. Ongoing bitrot is caught by the scan-back re-verify; a
*wrong-at-upload* object is the gap.

**Proposed mitigation (opt-in, NAS-local — sketch, not built):**

- Add a `--verify` mode to the content-addressed push that, after an
object lands, downloads it back **through the crypt overlay**,
BLAKE3s the plaintext, and compares to the indexed hash. This is the
only check that closes decrypt-correctness, and it lives entirely on
the pushing node (which holds the plaintext) with no protocol or
laptop change.
- Scope it to the **initial upload** of each content hash (or a sampled
subset), not every run — the object is append-only and immutable, so
one read-back per object is sufficient. Cost is one download per
verified object (egress), so it must be opt-in and never run against
cold-tier targets (e.g. Glacier Deep Archive, where a read needs a
restore).
- Tightening the re-hash→read TOCTOU window further (snapshot/lock the
source) is **not** recommended — the window is one fork/exec, the
indexer and scrub already surface drift, and chasing it is
disproportionate.

**Acceptance**

- With `--verify`, a seeded object whose stored ciphertext decrypts to
the wrong content is caught and the run fails before the durability
vector advances; without it, behaviour is unchanged.

**Issue:** `sync: optional read-back-decrypt-rehash verification for content-addressed uploads`

---

## Cross-cutting recommendations

### Tests we should add now
Expand Down
102 changes: 102 additions & 0 deletions agent/durability.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package agent

import (
"context"
"fmt"
"net/http"

"github.com/mbertschler/squirrel/store"
"github.com/mbertschler/squirrel/syncproto"
)

// handleDurability implements POST /v1/sync/durability: a session-less,
// read-only listing of this node's recorded destination durability
// vectors for one volume. Peers pull it (after a sync, or standalone)
// to hold offline evidence about destinations only this node can see.
// Node identity travels as names — local node ids mean nothing to the
// caller.
func (r *peerSyncRouter) handleDurability(w http.ResponseWriter, req *http.Request) {
var body syncproto.DurabilityRequest
if err := decodeJSON(w, req, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if body.Volume == "" {
writeError(w, http.StatusBadRequest, "volume is required")
return
}
if _, ok := r.volumes[body.Volume]; !ok {
writeError(w, http.StatusNotFound, fmt.Sprintf("volume %q is not declared on this node", body.Volume))
return
}
resp, err := r.durabilityResponse(req.Context(), body.Volume)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, resp)
}

// durabilityResponse assembles the wire components for one volume. A
// declared volume with no store row (never indexed or synced) yields an
// empty component list rather than an error — "no recorded durability"
// is a valid answer.
func (r *peerSyncRouter) durabilityResponse(ctx context.Context, volumeName string) (syncproto.DurabilityResponse, error) {
v, err := r.srv.store.GetVolumeByName(ctx, volumeName)
if store.IsNotFound(err) {
return syncproto.DurabilityResponse{}, nil
}
if err != nil {
return syncproto.DurabilityResponse{}, fmt.Errorf("lookup volume: %w", err)
}
rows, err := r.srv.store.ListVolumeDestinationRunIDs(ctx, v.ID)
if err != nil {
return syncproto.DurabilityResponse{}, fmt.Errorf("list destination vectors: %w", err)
}
fresh, err := r.srv.store.ListVolumeDestinationPushFreshness(ctx, v.ID)
if err != nil {
return syncproto.DurabilityResponse{}, fmt.Errorf("list push freshness: %w", err)
}
names := make(map[int64]string, 4)
resolve := func(nodeID int64) (string, error) {
if name, ok := names[nodeID]; ok {
return name, nil
}
node, err := r.srv.store.GetNodeByID(ctx, nodeID)
if err != nil {
return "", fmt.Errorf("resolve origin node %d: %w", nodeID, err)
}
names[nodeID] = node.Name
return node.Name, nil
}
resp := syncproto.DurabilityResponse{
Components: make([]syncproto.DurabilityComponent, 0, len(rows)),
Freshness: make([]syncproto.DurabilityFreshness, 0, len(fresh)),
}
for _, row := range rows {
name, err := resolve(row.OriginNodeID)
if err != nil {
return syncproto.DurabilityResponse{}, err
}
resp.Components = append(resp.Components, syncproto.DurabilityComponent{
Destination: row.Destination,
OriginNode: name,
OriginRun: row.OriginRunID,
UpdatedAtNs: row.UpdatedAtNs,
VerifyMethod: row.VerifyMethod,
})
}
for _, row := range fresh {
name, err := resolve(row.OriginNodeID)
if err != nil {
return syncproto.DurabilityResponse{}, err
}
resp.Freshness = append(resp.Freshness, syncproto.DurabilityFreshness{
Destination: row.Destination,
OriginNode: name,
OriginRun: row.OriginRunID,
UpdatedAtNs: row.UpdatedAtNs,
})
}
return resp, nil
}
116 changes: 116 additions & 0 deletions agent/durability_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package agent

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/mbertschler/squirrel/config"
"github.com/mbertschler/squirrel/syncproto"
)

// postDurability drives POST /v1/sync/durability against the server's
// handler and decodes the response into out. Returns the HTTP status.
func postDurability(t *testing.T, srv *Server, body syncproto.DurabilityRequest, out any) int {
t.Helper()
encoded, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/sync/durability", bytes.NewReader(encoded))
req.Header.Set("Authorization", "Bearer test-token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if out != nil && rec.Code == http.StatusOK {
if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
t.Fatalf("decode response: %v (%s)", err, rec.Body.String())
}
}
return rec.Code
}

// TestDurabilityEndpointListsComponents: the endpoint returns every
// recorded vector component for the volume with origin nodes resolved
// to names — the cross-node identity the caller can map locally.
func TestDurabilityEndpointListsComponents(t *testing.T) {
ctx := context.Background()
vol := &config.Volume{Name: "pics", Path: t.TempDir()}
srv := newTestServer(t, Config{Volumes: map[string]*config.Volume{vol.Name: vol}})

v, err := srv.store.CreateVolume(ctx, vol.Name, vol.Path)
if err != nil {
t.Fatalf("CreateVolume: %v", err)
}
self, err := srv.store.GetSelfNode(ctx)
if err != nil {
t.Fatalf("GetSelfNode: %v", err)
}
ext, err := srv.store.CreateNode(ctx, "ext", "peer://ext")
if err != nil {
t.Fatalf("CreateNode: %v", err)
}
for _, seed := range []struct {
dest string
nodeID int64
run int64
}{
{"offsite-a", self.ID, 12},
{"offsite-a", ext.ID, 4},
{"mirror", self.ID, 9},
} {
if err := srv.store.UpsertDestinationRunID(ctx, v.ID, seed.dest, seed.nodeID, seed.run, false); err != nil {
t.Fatalf("seed %+v: %v", seed, err)
}
}

var resp syncproto.DurabilityResponse
if code := postDurability(t, srv, syncproto.DurabilityRequest{Volume: "pics"}, &resp); code != http.StatusOK {
t.Fatalf("status = %d, want 200", code)
}
if len(resp.Components) != 3 {
t.Fatalf("components = %d, want 3: %+v", len(resp.Components), resp.Components)
}
got := map[string]int64{}
for _, c := range resp.Components {
if c.UpdatedAtNs == 0 {
t.Fatalf("component %+v has zero updated_at_ns", c)
}
got[c.Destination+"/"+c.OriginNode] = c.OriginRun
}
want := map[string]int64{
"offsite-a/" + self.Name: 12,
"offsite-a/ext": 4,
"mirror/" + self.Name: 9,
}
for k, w := range want {
if got[k] != w {
t.Fatalf("component %s = %d, want %d (full: %+v)", k, got[k], w, got)
}
}
}

// TestDurabilityEndpointGuards: an undeclared volume 404s, a missing
// volume name 400s, and a declared volume with no store row answers
// with an empty component list (a valid "nothing recorded yet").
func TestDurabilityEndpointGuards(t *testing.T) {
vol := &config.Volume{Name: "pics", Path: t.TempDir()}
srv := newTestServer(t, Config{Volumes: map[string]*config.Volume{vol.Name: vol}})

if code := postDurability(t, srv, syncproto.DurabilityRequest{Volume: "ghost"}, nil); code != http.StatusNotFound {
t.Fatalf("undeclared volume status = %d, want 404", code)
}
if code := postDurability(t, srv, syncproto.DurabilityRequest{}, nil); code != http.StatusBadRequest {
t.Fatalf("missing volume status = %d, want 400", code)
}
var resp syncproto.DurabilityResponse
if code := postDurability(t, srv, syncproto.DurabilityRequest{Volume: "pics"}, &resp); code != http.StatusOK {
t.Fatalf("declared-but-unmaterialised volume status = %d, want 200", code)
}
if len(resp.Components) != 0 {
t.Fatalf("components = %+v, want empty", resp.Components)
}
}
Loading
Loading