@@ -78,6 +78,27 @@ type blob interface {
7878 Uncompressed () (io.ReadCloser , error )
7979}
8080
81+ // layerWithDigest extends blob to include the Digest method
82+ type layerWithDigest interface {
83+ blob
84+ Digest () (v1.Hash , error )
85+ }
86+
87+ // resumableLayer wraps a layer to add resume support
88+ type resumableLayer struct {
89+ v1.Layer
90+ store * LocalStore
91+ }
92+
93+ func (rl * resumableLayer ) Uncompressed () (io.ReadCloser , error ) {
94+ // For resumable downloads, we need to check for incomplete downloads
95+ // and wrap the layer to inject the resume offset into the context.
96+ // However, since the HTTP request happens inside the layer's Compressed() call,
97+ // we can't easily intercept it here without modifying the layer interface.
98+ // For now, we'll rely on the WriteBlob append logic and fetcher Range support.
99+ return rl .Layer .Uncompressed ()
100+ }
101+
81102// writeLayer writes the layer blob to the store.
82103// It returns true when a new blob was created and the blob's DiffID.
83104func (s * LocalStore ) writeLayer (layer blob , updates chan <- v1.Update ) (bool , v1.Hash , error ) {
@@ -94,13 +115,28 @@ func (s *LocalStore) writeLayer(layer blob, updates chan<- v1.Update) (bool, v1.
94115 return false , hash , nil
95116 }
96117
118+ // Check if we're resuming an incomplete download
119+ incompleteSize , err := s .GetIncompleteSize (hash )
120+ if err != nil {
121+ return false , v1.Hash {}, fmt .Errorf ("check incomplete size: %w" , err )
122+ }
123+
97124 lr , err := layer .Uncompressed ()
98125 if err != nil {
99126 return false , v1.Hash {}, fmt .Errorf ("get blob contents: %w" , err )
100127 }
101128 defer lr .Close ()
102- r := progress .NewReader (lr , updates )
103129
130+ // Wrap the reader with progress reporting, accounting for already downloaded bytes
131+ var r io.Reader
132+ if incompleteSize > 0 {
133+ r = progress .NewReaderWithOffset (lr , updates , incompleteSize )
134+ } else {
135+ r = progress .NewReader (lr , updates )
136+ }
137+
138+ // WriteBlob will handle appending to incomplete files
139+ // The HTTP layer will handle resuming via Range headers
104140 if err := s .WriteBlob (hash , r ); err != nil {
105141 return false , hash , err
106142 }
@@ -109,6 +145,7 @@ func (s *LocalStore) writeLayer(layer blob, updates chan<- v1.Update) (bool, v1.
109145
110146// WriteBlob writes the blob to the store, reporting progress to the given channel.
111147// If the blob is already in the store, it is a no-op and the blob is not consumed from the reader.
148+ // If an incomplete download exists, it will be resumed by appending to the existing file.
112149func (s * LocalStore ) WriteBlob (diffID v1.Hash , r io.Reader ) error {
113150 hasBlob , err := s .hasBlob (diffID )
114151 if err != nil {
@@ -122,21 +159,61 @@ func (s *LocalStore) WriteBlob(diffID v1.Hash, r io.Reader) error {
122159 if err != nil {
123160 return fmt .Errorf ("get blob path: %w" , err )
124161 }
125- f , err := createFile (incompletePath (path ))
126- if err != nil {
127- return fmt .Errorf ("create blob file: %w" , err )
162+
163+ incompletePath := incompletePath (path )
164+
165+ // Check if we're resuming a partial download
166+ var f * os.File
167+ var isResume bool
168+ if _ , err := os .Stat (incompletePath ); err == nil {
169+ // Resume: open file in append mode
170+ isResume = true
171+ f , err = os .OpenFile (incompletePath , os .O_WRONLY | os .O_APPEND , 0666 )
172+ if err != nil {
173+ return fmt .Errorf ("open incomplete blob file for resume: %w" , err )
174+ }
175+ } else {
176+ // New download: create file
177+ f , err = createFile (incompletePath )
178+ if err != nil {
179+ return fmt .Errorf ("create blob file: %w" , err )
180+ }
128181 }
129- defer os .Remove (incompletePath (path ))
130182 defer f .Close ()
131183
132184 if _ , err := io .Copy (f , r ); err != nil {
185+ // Don't delete the incomplete file on error - we want to resume later
133186 return fmt .Errorf ("copy blob %q to store: %w" , diffID .String (), err )
134187 }
135188
136189 f .Close () // Rename will fail on Windows if the file is still open.
137- if err := os .Rename (incompletePath (path ), path ); err != nil {
190+
191+ // For resumed downloads, verify the complete file's hash before finalizing
192+ // (For new downloads, the stream was already verified during download)
193+ if isResume {
194+ completeFile , err := os .Open (incompletePath )
195+ if err != nil {
196+ return fmt .Errorf ("open completed file for verification: %w" , err )
197+ }
198+ defer completeFile .Close ()
199+
200+ computedHash , _ , err := v1 .SHA256 (completeFile )
201+ if err != nil {
202+ return fmt .Errorf ("compute hash of completed file: %w" , err )
203+ }
204+
205+ if computedHash .String () != diffID .String () {
206+ return fmt .Errorf ("hash mismatch after download: got %s, want %s" , computedHash , diffID )
207+ }
208+ }
209+
210+ if err := os .Rename (incompletePath , path ); err != nil {
138211 return fmt .Errorf ("rename blob file: %w" , err )
139212 }
213+
214+ // Only remove incomplete file if rename succeeded (though rename should have moved it)
215+ // This is a safety cleanup in case rename didn't remove the source
216+ os .Remove (incompletePath )
140217 return nil
141218}
142219
@@ -160,6 +237,25 @@ func (s *LocalStore) hasBlob(hash v1.Hash) (bool, error) {
160237 return false , nil
161238}
162239
240+ // GetIncompleteSize returns the size of an incomplete blob if it exists, or 0 if it doesn't.
241+ func (s * LocalStore ) GetIncompleteSize (hash v1.Hash ) (int64 , error ) {
242+ path , err := s .blobPath (hash )
243+ if err != nil {
244+ return 0 , fmt .Errorf ("get blob path: %w" , err )
245+ }
246+
247+ incompletePath := incompletePath (path )
248+ stat , err := os .Stat (incompletePath )
249+ if err != nil {
250+ if os .IsNotExist (err ) {
251+ return 0 , nil
252+ }
253+ return 0 , fmt .Errorf ("stat incomplete file: %w" , err )
254+ }
255+
256+ return stat .Size (), nil
257+ }
258+
163259// createFile is a wrapper around os.Create that creates any parent directories as needed.
164260func createFile (path string ) (* os.File , error ) {
165261 if err := os .MkdirAll (filepath .Dir (path ), 0777 ); err != nil {
0 commit comments