Skip to content

Commit 82a706a

Browse files
Merge branch 'main' into fix/DEVA11Y-483-serialize-spm-scan
2 parents 7ed6134 + b274cd0 commit 82a706a

1 file changed

Lines changed: 112 additions & 19 deletions

File tree

Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift

Lines changed: 112 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)