@@ -192,8 +192,34 @@ private struct BrowserStackCLIDownloader {
192192 return cacheRoot
193193 }
194194
195+ /// Best-effort removal of stale staging artifacts (`.tmp.*` files and directories) left
196+ /// behind when a previous extraction was interrupted. The extract helpers call
197+ /// forwardExit()/exit() on failure and SIGKILL can hit at any point, both of which
198+ /// bypass the `defer` cleanup in prepareArtifact. Only entries older than one hour are
199+ /// removed, so a concurrent build's in-flight staging directory is never deleted
200+ /// mid-extraction.
201+ private func sweepStaleStaging( in cacheRoot: URL ) {
202+ let staleStagingAge : TimeInterval = 3600
203+ let now = Date ( )
204+ guard let entries = try ? fileManager. contentsOfDirectory (
205+ at: cacheRoot,
206+ includingPropertiesForKeys: [ . contentModificationDateKey] ,
207+ options: [ ]
208+ ) else {
209+ return
210+ }
211+ for entry in entries where entry. lastPathComponent. hasPrefix ( " .tmp. " ) {
212+ let modified = ( try ? entry. resourceValues ( forKeys: [ . contentModificationDateKey] ) ) ? . contentModificationDate
213+ guard let modified, now. timeIntervalSince ( modified) > staleStagingAge else {
214+ continue
215+ }
216+ try ? fileManager. removeItem ( at: entry)
217+ }
218+ }
219+
195220 private func prepareArtifact( using info: ArtifactInfo ) async throws -> BrowserStackCLIArtifact {
196221 let cacheRoot = try ensureCacheRootExists ( )
222+ sweepStaleStaging ( in: cacheRoot)
197223 let versionDirectory = cacheRoot. appendingPathComponent ( info. version, isDirectory: true )
198224 let executableName = info. executableName
199225 let expectedExecutableURL = versionDirectory. appendingPathComponent ( executableName, isDirectory: false )
@@ -202,37 +228,104 @@ private struct BrowserStackCLIDownloader {
202228 return BrowserStackCLIArtifact ( version: info. version, executableURL: expectedExecutableURL)
203229 }
204230
205- if fileManager. fileExists ( atPath: versionDirectory. path) {
206- try fileManager. removeItem ( at: versionDirectory)
207- }
208- try fileManager. createDirectory ( at: versionDirectory, withIntermediateDirectories: true )
231+ // Extract into a unique staging directory and atomically publish it to the final
232+ // version directory (DEVA11Y-482). The previous check-delete-recreate sequence was
233+ // a TOCTOU: two concurrent builds sharing ~/.cache could both fall through the
234+ // isExecutableFile check, then one instance's removeItem/createDirectory would wipe
235+ // the other's in-progress extraction, corrupting the binary or leaving a partial
236+ // file that locateExecutable's fallback would happily run. Staging + rename means a
237+ // version directory only ever becomes visible fully-formed, and a loser of the
238+ // publish race reuses the winner's binary instead of clobbering it.
239+ let stagingDirectory = cacheRoot. appendingPathComponent (
240+ " .tmp. \( info. version) . \( UUID ( ) . uuidString) " ,
241+ isDirectory: true
242+ )
243+ defer { try ? fileManager. removeItem ( at: stagingDirectory) }
244+ try fileManager. createDirectory ( at: stagingDirectory, withIntermediateDirectories: true )
209245
210246 Diagnostics . remark ( " BrowserStackAccessibilityLint: Downloading CLI \( info. version) ... " )
211247
212248 #if os(Windows)
213- let archiveURL = versionDirectory. appendingPathComponent ( " browserstack-cli.zip " )
249+ // Download the archive to a sibling temp file *outside* the staging directory so a
250+ // failed cleanup (e.g. an AV scanner or indexer holding a handle on Windows) can
251+ // never bake the .zip into the published version directory. A leftover is a `.tmp.*`
252+ // sibling that sweepStaleStaging reclaims later.
253+ let archiveURL = cacheRoot. appendingPathComponent ( " .tmp. \( info. version) . \( UUID ( ) . uuidString) .zip " )
254+ defer { try ? fileManager. removeItem ( at: archiveURL) }
214255 try await download ( from: info. resolvedURL, to: archiveURL)
215256 Diagnostics . remark ( " BrowserStackAccessibilityLint: Extracting CLI \( info. version) ... " )
216- try unzip ( archive: archiveURL, into: versionDirectory)
217- try ? fileManager. removeItem ( at: archiveURL)
257+ try unzip ( archive: archiveURL, into: stagingDirectory)
218258 #else
219- try extractWithBsdtar ( from: info. resolvedURL, into: versionDirectory )
259+ try extractWithBsdtar ( from: info. resolvedURL, into: stagingDirectory )
220260 #endif
221261
222- let locatedBinary = try locateExecutable ( in: versionDirectory, preferredName: executableName)
223- let finalBinaryURL : URL
224- if locatedBinary. lastPathComponent == executableName {
225- finalBinaryURL = locatedBinary
226- } else {
227- finalBinaryURL = expectedExecutableURL
228- if fileManager. fileExists ( atPath: finalBinaryURL. path) {
229- try fileManager. removeItem ( at: finalBinaryURL)
262+ // Normalise the binary to the expected name *inside* the staging directory so the
263+ // published version directory is always structurally complete before it is renamed.
264+ // Compare full paths, not just the last component: locateExecutable recurses, so a
265+ // binary that already has the right name can still sit in a nested subdirectory
266+ // (e.g. a versioned tarball folder). Relocating it to the top-level staged path
267+ // unless it is already exactly there guarantees stagedExecutableURL exists before
268+ // we set permissions and publish.
269+ let locatedBinary = try locateExecutable ( in: stagingDirectory, preferredName: executableName)
270+ let stagedExecutableURL = stagingDirectory. appendingPathComponent ( executableName, isDirectory: false )
271+ if locatedBinary. standardizedFileURL != stagedExecutableURL. standardizedFileURL {
272+ if fileManager. fileExists ( atPath: stagedExecutableURL. path) {
273+ try fileManager. removeItem ( at: stagedExecutableURL)
230274 }
231- try fileManager. moveItem ( at: locatedBinary, to: finalBinaryURL)
275+ try fileManager. moveItem ( at: locatedBinary, to: stagedExecutableURL)
276+ }
277+ try ensureExecutablePermissions ( at: stagedExecutableURL)
278+
279+ try publishVersionDirectory ( from: stagingDirectory, to: versionDirectory, expectedExecutableURL: expectedExecutableURL)
280+ return BrowserStackCLIArtifact ( version: info. version, executableURL: expectedExecutableURL)
281+ }
282+
283+ /// Publishes a fully-prepared staging directory to its final version directory.
284+ ///
285+ /// Correctness does not depend on `moveItem`'s throw-on-existing-destination behaviour,
286+ /// which differs across Foundation platforms (Darwin throws `fileWriteFileExists`; a
287+ /// bare POSIX `rename(2)` silently replaces an empty destination). We check for the
288+ /// destination explicitly: when it is absent the publish is a single atomic rename on
289+ /// the shared cache filesystem (staging and version dir are both children of cacheRoot),
290+ /// so concurrent builds never observe a half-formed version directory; when it is
291+ /// present — another build won the race, or `forceDownload` is refreshing a stale copy —
292+ /// a valid published binary is reused, otherwise the stale directory is replaced and the
293+ /// rename retried once. The replace path is deliberate last-writer-wins and is *not*
294+ /// atomic; it tolerates a peer removing or republishing the directory concurrently
295+ /// rather than failing the build.
296+ private func publishVersionDirectory( from stagingDirectory: URL , to versionDirectory: URL , expectedExecutableURL: URL ) throws {
297+ // Fast path: destination absent -> single atomic rename. A create race that briefly
298+ // loses (destination appears between the check and the move) falls through to the
299+ // shared "destination present" handling below rather than failing.
300+ if !fileManager. fileExists ( atPath: versionDirectory. path) {
301+ do {
302+ try fileManager. moveItem ( at: stagingDirectory, to: versionDirectory)
303+ return
304+ } catch {
305+ // Fall through.
306+ }
307+ }
308+
309+ // Destination present: reuse a valid binary unless a forced refresh was requested.
310+ if !forceDownload, fileManager. isExecutableFile ( atPath: expectedExecutableURL. path) {
311+ return
232312 }
233313
234- try ensureExecutablePermissions ( at: finalBinaryURL)
235- return BrowserStackCLIArtifact ( version: info. version, executableURL: finalBinaryURL)
314+ // Stale/incomplete destination, or a forced refresh: replace and retry once.
315+ // removeItem is best-effort so a peer deleting the directory first cannot turn into
316+ // an ENOENT crash mid-race.
317+ try ? fileManager. removeItem ( at: versionDirectory)
318+ do {
319+ try fileManager. moveItem ( at: stagingDirectory, to: versionDirectory)
320+ } catch {
321+ // A peer republished the version directory between our remove and move. If it
322+ // now holds a valid binary, treat that as success rather than failing a build
323+ // that already has the artifact it needs.
324+ if fileManager. isExecutableFile ( atPath: expectedExecutableURL. path) {
325+ return
326+ }
327+ throw error
328+ }
236329 }
237330
238331#if !os(Windows)
0 commit comments