Skip to content

Commit 40448e7

Browse files
author
keeper
committed
fix(pipeline): verify reported success over a document it cannot read
1 parent be1c899 commit 40448e7

3 files changed

Lines changed: 72 additions & 1 deletion

File tree

SPEC.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,19 @@ here. `internal/pipeline/manifest_test.go` pins the current field set.
227227
v2 records the immutability observed at write time, since gitdr now writes to non-WORM
228228
destinations too (§4). `destination.wormImmutable` and `wormDetails` capture it. The
229229
manifest is signed, so this is a tamper-evident answer to "was this backup on WORM
230-
storage?". `verify` doesn't check the schema string, so older manifests still verify.
230+
storage?". `verify` does not compare the schema *version*, so older manifests still verify — but from
231+
2026-09 it does refuse a document that is not a manifest at all.
232+
233+
Before that, `verify -manifest` on a drill report printed `signature valid: true` and
234+
`0 of 0 artifacts ok`, and exited zero. The signature check is schema-agnostic and correct,
235+
because a signature is over bytes; the problem was what came after it. A drill report unmarshals
236+
into a `Manifest` without error, having no artifacts, so the command reported success over a
237+
document it cannot read — and would have gone on reporting success if that document were swapped
238+
for anything else signed by the same key.
239+
240+
That is a behaviour change from exit 0 to non-zero for one input, and it is a fix rather than a
241+
break: the previous answer was wrong. A drill report is signed evidence and deserves a real
242+
check; `verify -manifest` is not it.
231243

232244
**v3 adds `repos[].refs` and `repos[].copiedAt`, and it is additive: every v2 field is
233245
unchanged, both new fields are `omitempty`, and a v2 manifest still canonicalises to the bytes

internal/pipeline/pipeline_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"bytes"
55
"context"
66
"crypto/rand"
7+
"encoding/base64"
8+
"encoding/json"
79
"fmt"
810
"io"
911
"os"
@@ -452,3 +454,47 @@ func TestEncryptedBackupRestore(t *testing.T) {
452454
t.Errorf("want an 'encrypted' hint in the error, got: %v", err)
453455
}
454456
}
457+
458+
// `verify` refuses a document that is not a manifest, rather than passing over it.
459+
//
460+
// Verify is schema-agnostic up to the signature, which is correct: a signature is over bytes.
461+
// Then it unmarshals into a Manifest, and a drill report unmarshals cleanly - it simply has no
462+
// artifacts. So `verify -manifest {ts}.drill.json` reported "signature valid, 0 of 0 ok" and
463+
// exited zero, on the evidence surface, for a document the command cannot read. That check
464+
// would have gone on passing if the report were swapped for anything else signed by the same
465+
// key, which is the definition of a check that cannot fail.
466+
func TestVerifyRefusesADocumentThatIsNotAManifest(t *testing.T) {
467+
ctx := context.Background()
468+
md := newMemDest(true)
469+
pubPEM, privPEM, _ := crypto.GenerateKeyPair()
470+
signer, _ := crypto.ParsePrivateKey(privPEM)
471+
pub, _ := crypto.ParsePublicKey(pubPEM)
472+
473+
// A drill report, signed exactly as the engine signs one, so the only thing under test is
474+
// what verify does after the signature holds.
475+
report := pipeline.DrillReport{Schema: pipeline.DrillSchema, DrillID: "d1", Status: pipeline.StatusSuccess}
476+
canon, err := json.Marshal(report)
477+
if err != nil {
478+
t.Fatal(err)
479+
}
480+
const key = "github.com/octo/drills/20260903T000000Z.drill.json"
481+
if _, err := md.PutImmutable(ctx, key, strings.NewReader(string(canon)), int64(len(canon)), dest.Retention{}); err != nil {
482+
t.Fatal(err)
483+
}
484+
sig := base64.StdEncoding.EncodeToString(crypto.Sign(signer, canon))
485+
if _, err := md.PutImmutable(ctx, key+".sig", strings.NewReader(sig), int64(len(sig)), dest.Retention{}); err != nil {
486+
t.Fatal(err)
487+
}
488+
489+
res, err := pipeline.Verify(ctx, pipeline.VerifyDeps{Dest: md, PublicKey: pub}, key)
490+
if err == nil {
491+
t.Fatal("verify passed over a drill report; it counted zero artifacts as a success")
492+
}
493+
if !strings.Contains(err.Error(), pipeline.DrillSchema) {
494+
t.Errorf("the refusal does not say what the document was: %v", err)
495+
}
496+
// The signature still held, and saying so is not the same as saying the document verified.
497+
if res == nil || !res.SignatureValid {
498+
t.Error("the signature check itself should still have run and passed")
499+
}
500+
}

internal/pipeline/verify.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ func Verify(ctx context.Context, d VerifyDeps, manifestKey string) (*VerifyResul
5858
if err := json.Unmarshal(canon, &m); err != nil {
5959
return res, fmt.Errorf("parse manifest: %w", err)
6060
}
61+
// Refused rather than counted, and this is not pedantry about a field.
62+
//
63+
// Verify is schema-agnostic up to this point: it fetches the object and its .sig and checks
64+
// the signature, which is right, because the signature is over bytes. Then it unmarshals into
65+
// a Manifest, and a drill report unmarshals into a Manifest without error - it simply has no
66+
// artifacts. So `verify -manifest {ts}.drill.json` printed "signature valid, 0 of 0 artifacts
67+
// ok" and exited zero: a check that passes on a document it does not understand, and that
68+
// would go on passing if the report were swapped for anything else signed by the same key.
69+
//
70+
// A drill report is signed evidence and deserves a real check; it is just not this one.
71+
if m.Schema != "" && m.Schema != ManifestSchema && !strings.HasPrefix(m.Schema, "gitdr.manifest/") {
72+
return res, fmt.Errorf("%s is a %s document, not a manifest: verify checks manifests, and counting its zero artifacts as a pass would be a green over a document this command cannot read", manifestKey, m.Schema)
73+
}
6174

6275
for _, repo := range m.Repos {
6376
for _, a := range repo.Artifacts {

0 commit comments

Comments
 (0)