Skip to content

Commit 545da66

Browse files
MagicalTuxclaude
andcommitted
Add range invalidation and checksum verification support
Adds InvalidateRange to clear downloaded blocks so they get re-downloaded, plus Verify/VerifyRange for SHA-256 integrity checks with auto-invalidation on mismatch. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 05af373 commit 545da66

3 files changed

Lines changed: 757 additions & 2 deletions

File tree

README.md

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ if err != nil {
193193
- `Stat() (os.FileInfo, error)` - Get file info
194194
- `Complete() error` - Download entire file
195195
- `SavePart() error` - Manually save download progress
196+
- `InvalidateRange(start, end int64) error` - Mark blocks in range as not downloaded
197+
- `Verify(expected [32]byte) error` - Verify full file SHA-256, invalidate on mismatch
198+
- `VerifyRange(start, end int64, expected [32]byte) error` - Verify range SHA-256, invalidate on mismatch
196199

197200
## Resume Behavior
198201

@@ -212,6 +215,39 @@ On close:
212215
- Go 1.18 or later
213216
- Server must support HTTP Range requests for partial downloads (falls back to full download otherwise)
214217

215-
## TODO
218+
## Range Invalidation & Checksum Verification
216219

217-
- Add support for range invalidation (bad checksum causes re-download of affected area)
220+
SmartRemote supports invalidating downloaded ranges and verifying data integrity with SHA-256 checksums. When a checksum mismatch is detected, the affected blocks are automatically invalidated and will be re-downloaded on next access.
221+
222+
### Invalidate a Range
223+
224+
Force specific blocks to be re-downloaded:
225+
226+
```go
227+
// Invalidate bytes [start, end) — blocks will be re-downloaded on next read
228+
err := f.InvalidateRange(0, 65536)
229+
```
230+
231+
### Verify Entire File
232+
233+
Verify the complete file against a known SHA-256 hash:
234+
235+
```go
236+
expected := sha256.Sum256(knownGoodData)
237+
err := f.Verify(expected)
238+
if errors.Is(err, smartremote.ErrChecksumMismatch) {
239+
// All blocks invalidated, will re-download on next read
240+
}
241+
```
242+
243+
### Verify a Range
244+
245+
Verify a specific byte range:
246+
247+
```go
248+
expected := sha256.Sum256(knownGoodData[start:end])
249+
err := f.VerifyRange(start, end, expected)
250+
if errors.Is(err, smartremote.ErrChecksumMismatch) {
251+
// Only affected blocks invalidated
252+
}
253+
```

invalidate.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package smartremote
2+
3+
import (
4+
"crypto/sha256"
5+
"errors"
6+
"io"
7+
)
8+
9+
// ErrChecksumMismatch is returned when a checksum verification fails.
10+
// The affected blocks are automatically invalidated so they will be
11+
// re-downloaded on next access.
12+
var ErrChecksumMismatch = errors.New("checksum mismatch")
13+
14+
// InvalidateRange marks all blocks overlapping the byte range [start, end)
15+
// as not downloaded, causing them to be re-downloaded on next read access
16+
// or by the idle background downloader. This is useful when a checksum
17+
// verification detects corrupted data.
18+
func (f *File) InvalidateRange(start, end int64) error {
19+
f.lk.Lock()
20+
defer f.lk.Unlock()
21+
22+
return f.invalidateRange(start, end)
23+
}
24+
25+
// invalidateRange is the internal implementation that assumes the write lock
26+
// is already held.
27+
func (f *File) invalidateRange(start, end int64) error {
28+
if start < 0 {
29+
return errors.New("invalid range: negative start")
30+
}
31+
if end < start {
32+
return errors.New("invalid range: end before start")
33+
}
34+
if start == end {
35+
return nil // empty range, nothing to do
36+
}
37+
38+
if err := f.getSize(); err != nil {
39+
return err
40+
}
41+
42+
if end > f.size {
43+
end = f.size
44+
}
45+
if start >= f.size {
46+
return nil // range is beyond file
47+
}
48+
49+
firstBlock := uint64(start / f.blkSize)
50+
lastBlock := uint64((end - 1) / f.blkSize)
51+
52+
f.status.RemoveRange(firstBlock, lastBlock+1)
53+
f.complete = false
54+
55+
return f.savePart()
56+
}
57+
58+
// Verify downloads the entire file if needed, then computes its SHA-256 hash
59+
// and compares it to expected. If the hashes do not match, all blocks are
60+
// invalidated and ErrChecksumMismatch is returned.
61+
func (f *File) Verify(expected [32]byte) error {
62+
// Ensure the entire file is downloaded
63+
if err := f.Complete(); err != nil {
64+
return err
65+
}
66+
67+
// Read size (file is complete so this is safe without lock)
68+
f.lk.RLock()
69+
size := f.size
70+
f.lk.RUnlock()
71+
72+
// Compute SHA-256 of the local file (no lock needed - file is complete
73+
// and os.File.ReadAt is goroutine-safe)
74+
actual, err := f.hashRange(0, size)
75+
if err != nil {
76+
return err
77+
}
78+
79+
if actual == expected {
80+
return nil
81+
}
82+
83+
// Mismatch: invalidate everything
84+
f.lk.Lock()
85+
f.invalidateRange(0, f.size)
86+
f.lk.Unlock()
87+
88+
return ErrChecksumMismatch
89+
}
90+
91+
// VerifyRange downloads the blocks covering [start, end) if needed, then
92+
// computes the SHA-256 hash of that byte range and compares it to expected.
93+
// If the hashes do not match, the blocks in the range are invalidated and
94+
// ErrChecksumMismatch is returned.
95+
func (f *File) VerifyRange(start, end int64, expected [32]byte) error {
96+
if start < 0 {
97+
return errors.New("invalid range: negative start")
98+
}
99+
if end <= start {
100+
return errors.New("invalid range: end must be after start")
101+
}
102+
103+
// Ensure blocks are downloaded
104+
f.lk.Lock()
105+
if err := f.getSize(); err != nil {
106+
f.lk.Unlock()
107+
return err
108+
}
109+
if end > f.size {
110+
end = f.size
111+
}
112+
if start >= f.size {
113+
f.lk.Unlock()
114+
return errors.New("invalid range: start beyond file size")
115+
}
116+
firstBlock := uint32(start / f.blkSize)
117+
lastBlock := uint32((end - 1) / f.blkSize)
118+
err := f.needBlocks(firstBlock, lastBlock)
119+
f.lk.Unlock()
120+
if err != nil {
121+
return err
122+
}
123+
124+
// Compute SHA-256 of the range (no lock needed - blocks are downloaded
125+
// and os.File.ReadAt is goroutine-safe)
126+
actual, err := f.hashRange(start, end)
127+
if err != nil {
128+
return err
129+
}
130+
131+
if actual == expected {
132+
return nil
133+
}
134+
135+
// Mismatch: invalidate the range
136+
f.lk.Lock()
137+
f.invalidateRange(start, end)
138+
f.lk.Unlock()
139+
140+
return ErrChecksumMismatch
141+
}
142+
143+
// hashRange computes SHA-256 of bytes [start, end) from the local file.
144+
func (f *File) hashRange(start, end int64) ([32]byte, error) {
145+
h := sha256.New()
146+
buf := make([]byte, f.blkSize)
147+
pos := start
148+
149+
for pos < end {
150+
n := int64(len(buf))
151+
if pos+n > end {
152+
n = end - pos
153+
}
154+
rn, err := f.local.ReadAt(buf[:n], pos)
155+
if rn > 0 {
156+
h.Write(buf[:rn])
157+
}
158+
if err != nil && err != io.EOF {
159+
var zero [32]byte
160+
return zero, err
161+
}
162+
pos += int64(rn)
163+
if rn == 0 {
164+
break
165+
}
166+
}
167+
168+
var result [32]byte
169+
copy(result[:], h.Sum(nil))
170+
return result, nil
171+
}

0 commit comments

Comments
 (0)