protondrive: don't list a file as empty when its recorded size is missing or invalid

A file whose extended attributes had a SHA-1 but a missing, negative or
malformed Size was listed with size 0, so copying it failed with a size
mismatch. Its size is now the sum of its recorded block sizes, if they
are usable, and otherwise unknown, as for a file without extended
attributes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-11 20:27:33 +01:00
1 parent f89be42eaf
commit 18b4f7d1d1
6 files changed
+40 -14

No files matched your search

+15 -3
View File
@@ -750,6 +750,7 @@ type xattrs struct {
BlockSizes []int64
SHA1 string // hex; empty for a folder
Extra map[string]json.RawMessage // top level members other than Common
NoSize bool // set if read attributes record no usable size
}
// xattrsTimeLayout is the layout of ModificationTime, as JavaScript's
@@ -808,6 +809,7 @@ func parseXAttrs(data []byte) *xattrs {
x := &xattrs{}
var members map[string]json.RawMessage
if json.Unmarshal(data, &members) != nil {
x.NoSize = true
return x
}
var common struct {
@@ -831,9 +833,6 @@ func parseXAttrs(data []byte) *xattrs {
}
}
}
if json.Unmarshal(common.Size, &x.Size) != nil || x.Size < 0 {
x.Size = 0
}
var blockSizes []int64
if json.Unmarshal(common.BlockSizes, &blockSizes) == nil && len(blockSizes) > 0 {
x.BlockSizes = blockSizes
@@ -844,6 +843,19 @@ func parseXAttrs(data []byte) *xattrs {
}
}
}
if json.Unmarshal(common.Size, &x.Size) != nil || x.Size < 0 {
// Without a valid size, block sizes no larger than a block
// add up to it.
x.Size = 0
x.NoSize = x.BlockSizes == nil
for _, size := range x.BlockSizes {
if size > maxBlockSize {
x.Size, x.NoSize = 0, true
break
}
x.Size += size
}
}
if common.Digests != nil {
_ = json.Unmarshal(common.Digests.SHA1, &x.SHA1)
}
+15 -7
View File
@@ -746,7 +746,7 @@ func TestXAttrs(t *testing.T) {
got, verifyErr, err := decryptXAttrs(encryptSigned(t, []byte(`{}`), nodeKR, signer, now), nodeKR, publicOf(t, signer), now)
require.NoError(t, err)
require.NoError(t, verifyErr)
assert.Equal(t, &xattrs{}, got)
assert.Equal(t, &xattrs{NoSize: true}, got)
})
t.Run("Empty", func(t *testing.T) {
got, verifyErr, err := decryptXAttrs("", nodeKR, publicOf(t, signer), now)
@@ -759,17 +759,25 @@ func TestXAttrs(t *testing.T) {
in := `{"Common":{"ModificationTime":"yesterday","Size":"big","BlockSizes":[1,0],"Digests":{"SHA1":7}}}`
got, _, err := decryptXAttrs(encryptSigned(t, []byte(in), nodeKR, signer, now), nodeKR, publicOf(t, signer), now)
require.NoError(t, err)
assert.Equal(t, &xattrs{}, got)
assert.Equal(t, &xattrs{NoSize: true}, got)
got, _, err = decryptXAttrs(encryptSigned(t, []byte(`not json`), nodeKR, signer, now), nodeKR, publicOf(t, signer), now)
require.NoError(t, err)
assert.Equal(t, &xattrs{}, got)
assert.Equal(t, &xattrs{NoSize: true}, got)
})
t.Run("NegativeSize", func(t *testing.T) {
in := `{"Common":{"Size":-5,"BlockSizes":[5],"Digests":{"SHA1":"abc"}}}`
got, _, err := decryptXAttrs(encryptSigned(t, []byte(in), nodeKR, signer, now), nodeKR, publicOf(t, signer), now)
require.NoError(t, err)
assert.Equal(t, &xattrs{BlockSizes: []int64{5}, SHA1: "abc"}, got)
// The size is taken from the block sizes, if they are usable.
for in, want := range map[string]*xattrs{
`{"Common":{"Size":-5,"BlockSizes":[5],"Digests":{"SHA1":"abc"}}}`: {Size: 5, BlockSizes: []int64{5}, SHA1: "abc"},
`{"Common":{"BlockSizes":[4194304,3],"Digests":{"SHA1":"abc"}}}`: {Size: 4194307, BlockSizes: []int64{4194304, 3}, SHA1: "abc"},
`{"Common":{"Size":"big","BlockSizes":[4194304,3],"Digests":{"SHA1":"abc"}}}`: {Size: 4194307, BlockSizes: []int64{4194304, 3}, SHA1: "abc"},
`{"Common":{"Size":-5,"Digests":{"SHA1":"abc"}}}`: {SHA1: "abc", NoSize: true},
`{"Common":{"Size":-5,"BlockSizes":[1152921504606846976],"Digests":{"SHA1":"abc"}}}`: {BlockSizes: []int64{1 << 60}, SHA1: "abc", NoSize: true},
} {
got, _, err := decryptXAttrs(encryptSigned(t, []byte(in), nodeKR, signer, now), nodeKR, publicOf(t, signer), now)
require.NoError(t, err)
assert.Equal(t, want, got, in)
}
})
}
@@ -193,6 +193,9 @@ func cloneFile(file *api.File) *api.File {
func checkXAttrs(t *testing.T, x *xattrs) {
t.Helper()
assert.GreaterOrEqual(t, x.Size, int64(0))
if x.NoSize {
assert.Zero(t, x.Size)
}
if x.BlockSizes != nil {
assert.NotEmpty(t, x.BlockSizes)
}
+1 -1
View File
@@ -134,7 +134,7 @@ func (n *node) size(original bool) int64 {
// plaintextSize returns the size of the file's content, and whether
// the extended attributes record it.
func (n *node) plaintextSize() (int64, bool) {
if n.X != nil && (n.X.SHA1 != "" || n.X.Size > 0 || len(n.X.BlockSizes) > 0) {
if n.X != nil && !n.X.NoSize && (n.X.SHA1 != "" || n.X.Size > 0 || len(n.X.BlockSizes) > 0) {
return n.X.Size, true
}
return 0, false
@@ -213,6 +213,12 @@ func TestNodeWithoutXAttrs(t *testing.T) {
assert.Equal(t, "abcdef", n.sha1())
assert.Equal(t, int64(0), n.size(true))
assert.Equal(t, int64(42), n.size(false))
// Attributes which record no usable size leave it unknown.
n.X = &xattrs{SHA1: "ABCDEF", NoSize: true}
_, known := n.plaintextSize()
assert.False(t, known)
assert.Equal(t, int64(42), n.size(true))
}
func TestGetNode(t *testing.T) {
-3
View File
@@ -147,8 +147,5 @@ Small, specific, independently actionable. Remove only when actually done.
existing files in one folder lists it again each time (`sync` is not
affected: it calls `Update`). New files and folders are added to the
listing instead since Task 17.
- [ ] protondrive: extended attributes with a SHA-1 but no `Size` make a
file report size 0; the sum of `BlockSizes`, or the encrypted size,
would be a better fallback. Seen in Task 17.
- [ ] protondrive: spec 9.5 asks for a fuzz target for event decoding;
add it with events (fuzz_internal_test.go).