Skip to content

Commit ca63adc

Browse files
committed
fix(mirror): eliminate race conditions by using fresh factory inside task closures
Affected endpoints: apiMirrorsDrop, apiMirrorsUpdate. Both endpoints shared the same architectural flaw as the previously fixed publish, repos, and snapshot endpoints: operations were performed outside the task lock, with stale DB state used inside the lock. Issues Fixed: 1. apiMirrorsDrop - Collections created before task lock Problem: mirrorCollection and snapshotCollection created before task lock. Snapshot dependency check done with stale factory. Concurrent drops both load pre-task state, both see same snapshot dependencies. If snapshots created after pre-task check, can delete mirror used by snapshots. Fix: Create fresh taskCollectionFactory inside task, fresh load of mirror after lock acquired, fresh snapshot check with current factory, drop using fresh collections. 2. apiMirrorsUpdate - Mirror loaded before task lock Problem: remote loaded outside task, rename duplicate check with stale factory. Concurrent updates both load pre-task state, long-running update uses stale mirror reference. TOCTOU race: rename check passes, another creates mirror with same name, update saves with stale data. Fix: Create fresh taskCollectionFactory inside task, fresh load of mirror after lock acquired, pre-task rename validation, fresh rename check inside lock, use fresh mirror and collections for all operations. Root cause analysis: The fundamental issue is the split between pre-task work and task-protected work. Collections and objects were being loaded before lock acquisition, then stale copies used inside the lock. Correct pattern (from fixed publish.go, repos.go, and snapshot.go): 1. HTTP Handler (before task lock): - Shallow load for 404 check only - Extract resource keys - Submit task with resources 2. Task Closure (after lock acquired): - Create fresh collectionFactory - Fresh load of all objects - LoadComplete on fresh copies - All mutations on fresh state - All checks atomic inside lock - Save using fresh collections This ensures: - Concurrent operations are serialized by task queue - No stale DB state used for mutations - No lost updates from concurrent modifications - No TOCTOU races on duplicate checks - No loss of mirrors used by snapshots - No stale data in long-running updates
1 parent 70c2787 commit ca63adc

1 file changed

Lines changed: 38 additions & 7 deletions

File tree

api/mirror.go

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -216,9 +216,9 @@ func apiMirrorsDrop(c *gin.Context) {
216216
name := c.Params.ByName("name")
217217
force := c.Request.URL.Query().Get("force") == "1"
218218

219+
// Phase 1: Pre-task validation (shallow load for 404 check only)
219220
collectionFactory := context.NewCollectionFactory()
220221
mirrorCollection := collectionFactory.RemoteRepoCollection()
221-
snapshotCollection := collectionFactory.SnapshotCollection()
222222

223223
repo, err := mirrorCollection.ByName(name)
224224
if err != nil {
@@ -228,21 +228,34 @@ func apiMirrorsDrop(c *gin.Context) {
228228

229229
resources := []string{string(repo.Key())}
230230
taskName := fmt.Sprintf("Delete mirror %s", name)
231+
231232
maybeRunTaskInBackground(c, taskName, resources, func(_ aptly.Progress, _ *task.Detail) (*task.ProcessReturnValue, error) {
232-
err := repo.CheckLock()
233+
// Phase 2: Inside task lock - create fresh collections
234+
taskCollectionFactory := context.NewCollectionFactory()
235+
taskMirrorCollection := taskCollectionFactory.RemoteRepoCollection()
236+
taskSnapshotCollection := taskCollectionFactory.SnapshotCollection()
237+
238+
// Fresh load after lock acquired
239+
repo, err := taskMirrorCollection.ByName(name)
240+
if err != nil {
241+
return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, fmt.Errorf("unable to drop: %v", err)
242+
}
243+
244+
err = repo.CheckLock()
233245
if err != nil {
234246
return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, fmt.Errorf("unable to drop: %v", err)
235247
}
236248

237249
if !force {
238-
snapshots := snapshotCollection.ByRemoteRepoSource(repo)
250+
// Fresh checks with current collections
251+
snapshots := taskSnapshotCollection.ByRemoteRepoSource(repo)
239252

240253
if len(snapshots) > 0 {
241254
return &task.ProcessReturnValue{Code: http.StatusForbidden, Value: nil}, fmt.Errorf("won't delete mirror with snapshots, use 'force=1' to override")
242255
}
243256
}
244257

245-
err = mirrorCollection.Drop(repo)
258+
err = taskMirrorCollection.Drop(repo)
246259
if err != nil {
247260
return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, fmt.Errorf("unable to drop: %v", err)
248261
}
@@ -550,6 +563,7 @@ func apiMirrorsUpdate(c *gin.Context) {
550563
return
551564
}
552565

566+
// Pre-task validation of new name if provided
553567
if b.Name != remote.Name {
554568
_, err = collection.ByName(b.Name)
555569
if err == nil {
@@ -566,9 +580,26 @@ func apiMirrorsUpdate(c *gin.Context) {
566580

567581
resources := []string{string(remote.Key())}
568582
maybeRunTaskInBackground(c, "Update mirror "+b.Name, resources, func(out aptly.Progress, detail *task.Detail) (*task.ProcessReturnValue, error) {
583+
// Phase 2: Inside task lock - create fresh factory
584+
taskCollectionFactory := context.NewCollectionFactory()
585+
taskCollection := taskCollectionFactory.RemoteRepoCollection()
586+
587+
// Fresh load after lock acquired
588+
remote, err := taskCollection.ByName(c.Params.ByName("name"))
589+
if err != nil {
590+
return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, fmt.Errorf("unable to update: %s", err)
591+
}
592+
593+
// Fresh rename check inside lock (if renaming)
594+
if b.Name != remote.Name {
595+
_, err := taskCollection.ByName(b.Name)
596+
if err == nil {
597+
return &task.ProcessReturnValue{Code: http.StatusConflict, Value: nil}, fmt.Errorf("unable to rename: mirror %s already exists", b.Name)
598+
}
599+
}
569600

570601
downloader := context.NewDownloader(out)
571-
err := remote.Fetch(downloader, verifier, b.IgnoreSignatures)
602+
err = remote.Fetch(downloader, verifier, b.IgnoreSignatures)
572603
if err != nil {
573604
return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, fmt.Errorf("unable to update: %s", err)
574605
}
@@ -780,8 +811,8 @@ func apiMirrorsUpdate(c *gin.Context) {
780811
}
781812

782813
log.Info().Msgf("%s: Finalizing download...", b.Name)
783-
_ = remote.FinalizeDownload(collectionFactory, out)
784-
err = collectionFactory.RemoteRepoCollection().Update(remote)
814+
_ = remote.FinalizeDownload(taskCollectionFactory, out)
815+
err = taskCollection.Update(remote)
785816
if err != nil {
786817
return &task.ProcessReturnValue{Code: http.StatusInternalServerError, Value: nil}, fmt.Errorf("unable to update: %s", err)
787818
}

0 commit comments

Comments
 (0)