From 0b1f1662c92a6ab0011d7954730044078c080b41 Mon Sep 17 00:00:00 2001 From: Pierre Date: Sun, 26 Apr 2026 22:01:15 -0300 Subject: [PATCH 1/2] fix: always write root index in v4 format The server now rejects root docSchema writes that use v3 format, returning 400 {"message":"invalid hash"} regardless of whether the hash algorithm matches. Only v4 (SHA-256 of the text content) is accepted for new writes. The previous commit (f5c7b9c) correctly detected the server's schema version from Mirror(), but then applied that same version to writes, reintroducing v3 writes for accounts whose root was still v3. Fix: IndexReader() always produces v4 output. Rehash() is simplified to always SHA-256 the IndexReader() output (v4). SchemaVersion on HashTree is still populated by Mirror() for informational purposes but no longer drives write behaviour. The RMAPI_FORCE_SCHEMA_VERSION env var still overrides both, useful for debugging. Co-Authored-By: Claude Sonnet 4.6 --- api/sync15/tree.go | 49 ++++++++++++---------------------------------- 1 file changed, 12 insertions(+), 37 deletions(-) diff --git a/api/sync15/tree.go b/api/sync15/tree.go index ff603358..d36c50fd 100644 --- a/api/sync15/tree.go +++ b/api/sync15/tree.go @@ -153,10 +153,9 @@ func parseIndex(f io.Reader) ([]*Entry, string, error) { func (t *HashTree) IndexReader() (io.Reader, error) { var w bytes.Buffer - schemaVersion := t.SchemaVersion - if schemaVersion == "" { - schemaVersion = SchemaVersionV3 - } + // Always write v4 format: the server now requires SHA256-based hashes (v4) + // for new root writes, even when the existing root is v3. + schemaVersion := SchemaVersionV4 if envSchema := os.Getenv("RMAPI_FORCE_SCHEMA_VERSION"); envSchema != "" { log.Info.Printf("forcing schema version to %s via RMAPI_FORCE_SCHEMA_VERSION", envSchema) @@ -228,42 +227,19 @@ func (t *HashTree) Remove(id string) error { } func (t *HashTree) Rehash() error { - schemaVersion := t.SchemaVersion - if schemaVersion == "" { - schemaVersion = SchemaVersionV3 + reader, err := t.IndexReader() + if err != nil { + return err } - if envSchema := os.Getenv("RMAPI_FORCE_SCHEMA_VERSION"); envSchema != "" { - schemaVersion = envSchema + schemaBytes, err := io.ReadAll(reader) + if err != nil { + return err } - var hash string - var err error - - if schemaVersion == SchemaVersionV3 { - entries := []*Entry{} - for _, e := range t.Docs { - entries = append(entries, &e.Entry) - } - hash, err = HashEntries(entries) - if err != nil { - return err - } - } else { - reader, err := t.IndexReader() - if err != nil { - return err - } - - schemaBytes, err := io.ReadAll(reader) - if err != nil { - return err - } - - hasher := sha256.New() - hasher.Write(schemaBytes) - hash = hex.EncodeToString(hasher.Sum(nil)) - } + hasher := sha256.New() + hasher.Write(schemaBytes) + hash := hex.EncodeToString(hasher.Sum(nil)) log.Info.Println("New root hash: ", hash) t.Hash = hash @@ -300,7 +276,6 @@ func (t *HashTree) Mirror(r RemoteStorage, maxconcurrent int) error { return fmt.Errorf("cannot get root hash %v", err) } defer rootIndexReader.Close() - entries, schema, err := parseIndex(rootIndexReader) if err != nil { return fmt.Errorf("cannot parse rootIndex, %v", err) From 365bcbce2210758a94dc06a96f866bbc5016332a Mon Sep 17 00:00:00 2001 From: Pierre Date: Sun, 26 Apr 2026 22:06:42 -0300 Subject: [PATCH 2/2] test: add regression test for v3-mirrored tree writing v4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestRootIndexWritesV4WhenMirroredV3 directly captures the regression: a HashTree with SchemaVersion=v3 (as mirrored from an old root) must still emit a v4-format root docSchema from IndexReader(), and Rehash() must produce SHA-256 of that v4 body. Also expand the IndexReader() comment to clarify that RMAPI_FORCE_SCHEMA_VERSION only overrides the serialized schema format, not the hash algorithm — forcing v3 is for inspecting failure modes, not a supported write path. Co-Authored-By: Claude Sonnet 4.6 --- api/sync15/tree.go | 8 ++++++-- api/sync15/tree_test.go | 44 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/api/sync15/tree.go b/api/sync15/tree.go index d36c50fd..3e58c302 100644 --- a/api/sync15/tree.go +++ b/api/sync15/tree.go @@ -153,8 +153,12 @@ func parseIndex(f io.Reader) ([]*Entry, string, error) { func (t *HashTree) IndexReader() (io.Reader, error) { var w bytes.Buffer - // Always write v4 format: the server now requires SHA256-based hashes (v4) - // for new root writes, even when the existing root is v3. + // t.SchemaVersion records the format read from the server during Mirror(). + // For writes, always emit v4 root indexes: current servers reject new + // v3-format root docSchema uploads even when the v3 HashEntries hash + // matches the body. Rehash() always SHA-256s the serialized output, so + // RMAPI_FORCE_SCHEMA_VERSION=3 changes the body schema but not the hash + // algorithm — use it only to inspect failure modes, not as a working mode. schemaVersion := SchemaVersionV4 if envSchema := os.Getenv("RMAPI_FORCE_SCHEMA_VERSION"); envSchema != "" { diff --git a/api/sync15/tree_test.go b/api/sync15/tree_test.go index ea3eba2b..c77a19d4 100644 --- a/api/sync15/tree_test.go +++ b/api/sync15/tree_test.go @@ -1,6 +1,8 @@ package sync15 import ( + "crypto/sha256" + "encoding/hex" "io" "strings" "testing" @@ -97,6 +99,48 @@ blah:0:someid:0:10 } } +// TestRootIndexWritesV4WhenMirroredV3 is the regression test for the bug +// introduced in f5c7b9c: a tree whose SchemaVersion was read as v3 from the +// server must still produce a v4 root docSchema on write. +func TestRootIndexWritesV4WhenMirroredV3(t *testing.T) { + tree := HashTree{ + SchemaVersion: SchemaVersionV3, // simulates a tree mirrored from a v3 root + } + doc := &BlobDoc{ + Entry: Entry{Hash: "somehash", DocumentID: "someid"}, + } + doc.AddFile(&Entry{Hash: "filehash", DocumentID: "someid.pdf", Size: 100}) + tree.Add(doc) + + reader, err := tree.IndexReader() + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + + _, schema, err := parseIndex(strings.NewReader(string(body))) + if err != nil { + t.Fatal(err) + } + if schema != SchemaVersionV4 { + t.Errorf("IndexReader() produced schema %s for v3-mirrored tree; want v4", schema) + } + + if err := tree.Rehash(); err != nil { + t.Fatal(err) + } + reader2, _ := tree.IndexReader() + body2, _ := io.ReadAll(reader2) + h := sha256.Sum256(body2) + expectedHash := hex.EncodeToString(h[:]) + if tree.Hash != expectedHash { + t.Errorf("Rehash() = %s; want sha256(IndexReader) = %s", tree.Hash, expectedHash) + } +} + func TestCreateRootIndex(t *testing.T) { tree := HashTree{ SchemaVersion: SchemaVersionV4,