Skip to content

Commit 2e28a2a

Browse files
kitbellewclaude
andcommitted
Add createFileAtomically and owner-only staging
A caller that publishes a file for others to read may need to create it only when nobody else has, and to stage a secret where nobody else can read it on the way. Neither was available. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 10d359f commit 2e28a2a

2 files changed

Lines changed: 141 additions & 5 deletions

File tree

io/src/main/scala/sbt/io/IO.scala

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@ package sbt.io
1414
import java.io._
1515
import java.net.{ URI, URISyntaxException, URL }
1616
import java.nio.charset.Charset
17-
import java.nio.file.attribute.PosixFilePermissions
17+
import java.nio.file.attribute.{
18+
AclEntry,
19+
AclEntryPermission,
20+
AclEntryType,
21+
PosixFilePermissions,
22+
UserPrincipal
23+
}
1824
import java.nio.file.{ Path => NioPath, _ }
1925
import java.util.{ Locale, Properties, UUID }
2026
import java.util.concurrent.ForkJoinPool
@@ -475,7 +481,35 @@ object IO {
475481
* `write` returns successfully. If `write` throws, `to` is left untouched and the
476482
* staging file is removed.
477483
*/
478-
def writeFileAtomically[T](to: File)(write: File => T): T = {
484+
def writeFileAtomically[T](to: File)(write: File => T): T =
485+
writeFileAtomically(to, ownerOnly = false)(write)
486+
487+
/**
488+
* Stages a write to a sibling temp file and atomically replaces `to` only after
489+
* `write` returns successfully. If `write` throws, `to` is left untouched and the
490+
* staging file is removed.
491+
*
492+
* @param ownerOnly
493+
* if true, no content written out could be read by anyone other than its owner
494+
*/
495+
def writeFileAtomically[T](to: File, ownerOnly: Boolean)(write: File => T): T =
496+
writeStaged(to, ownerOnly, replace = true)(write)
497+
498+
/**
499+
* Like `writeFileAtomically`, except that it refuses a `to` that already exists and
500+
* throws `FileAlreadyExistsException`.
501+
*
502+
* @param ownerOnly
503+
* if true, no content written out could be read by anyone other than its owner
504+
*/
505+
def createFileAtomically[T](to: File, ownerOnly: Boolean)(write: File => T): T =
506+
writeStaged(to, ownerOnly, replace = false)(write)
507+
508+
private def writeStaged[T](
509+
to: File,
510+
ownerOnly: Boolean,
511+
replace: Boolean
512+
)(write: File => T): T = {
479513
val parent = Option(to.getAbsoluteFile.getParentFile).getOrElse(new File("."))
480514
createDirectory(parent)
481515

@@ -488,7 +522,7 @@ object IO {
488522

489523
val toPath = to.toPath
490524
val staging = stagingFile.toPath
491-
touch(stagingFile)
525+
if (ownerOnly) createForOwner(staging) else touch(stagingFile)
492526

493527
def retry(func: => NioPath): NioPath = Retry(
494528
func,
@@ -498,19 +532,50 @@ object IO {
498532
)
499533
def move(options: CopyOption*): NioPath = retry(Files.move(staging, toPath, options*))
500534
def replaceFile(): NioPath =
535+
// ATOMIC_MOVE uses POSIX rename, so it can only be used with `replace`
501536
try move(StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
502537
catch { case _: AtomicMoveNotSupportedException => move(StandardCopyOption.REPLACE_EXISTING) }
538+
def createLink(): NioPath =
539+
// Files.move with ATOMIC_MOVE possibly replaces and without has a race condition
540+
try retry(Files.createLink(toPath, staging)) // try this first
541+
catch {
542+
case e @ (_: UnsupportedOperationException | _: IOException)
543+
if !e.isInstanceOf[FileAlreadyExistsException] =>
544+
move()
545+
}
503546

504547
try {
505548
val result = write(stagingFile)
506-
replaceFile()
549+
if (replace)
550+
replaceFile()
551+
else
552+
createLink()
507553
result
508554
} finally {
509555
Files.deleteIfExists(staging)
510556
()
511557
}
512558
}
513559

560+
/** Creates `path` such that only its owner can read and write it. */
561+
private def createForOwner(path: NioPath): Unit = {
562+
if (isPosix) {
563+
val ownerOnly = PosixFilePermissions.fromString("rw-------")
564+
Files.createFile(path, PosixFilePermissions.asFileAttribute(ownerOnly))
565+
} else {
566+
Files.createFile(path)
567+
if (hasAclFileAttributeView) {
568+
val view = Path(path.toFile).aclFileAttributeView
569+
val acl = AclEntry.newBuilder
570+
acl.setPrincipal(view.getOwner)
571+
acl.setPermissions(AclEntryPermission.values()*)
572+
acl.setType(AclEntryType.ALLOW)
573+
view.setAcl(java.util.Collections.singletonList(acl.build))
574+
}
575+
}
576+
()
577+
}
578+
514579
/**
515580
* Copies all bytes from the given input stream to the given output stream.
516581
* Neither stream is closed.

io/src/test/scala/sbt/io/IOSpec.scala

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,83 @@
1212
package sbt.io
1313

1414
import java.io.File
15-
import java.nio.file.Files
15+
import java.nio.file.{ FileAlreadyExistsException, Files }
16+
import java.nio.file.attribute.PosixFilePermission.{ OWNER_READ, OWNER_WRITE }
17+
import scala.collection.JavaConverters._
1618
import org.scalatest.funsuite.AnyFunSuite
1719
import sbt.io.syntax._
1820

1921
class IOSpec extends AnyFunSuite {
2022

23+
test("createFileAtomically should write a file that is not there") {
24+
IO.withTemporaryDirectory { dir =>
25+
val target = new File(dir, "out.json")
26+
IO.createFileAtomically(target, ownerOnly = false)(staging => IO.write(staging, "content"))
27+
assert(IO.read(target) === "content")
28+
assert(dir.listFiles.map(_.getName).toList === List("out.json"))
29+
}
30+
}
31+
32+
test("createFileAtomically should refuse a file that is there") {
33+
IO.withTemporaryDirectory { dir =>
34+
val target = new File(dir, "out.json")
35+
IO.write(target, "first")
36+
assertThrows[FileAlreadyExistsException] {
37+
IO.createFileAtomically(target, ownerOnly = false)(staging => IO.write(staging, "second"))
38+
}
39+
assert(IO.read(target) === "first")
40+
assert(dir.listFiles.map(_.getName).toList === List("out.json"))
41+
}
42+
}
43+
44+
test("createFileAtomically should let one writer of many create the file") {
45+
IO.withTemporaryDirectory { dir =>
46+
val writers = 8
47+
val pool = java.util.concurrent.Executors.newFixedThreadPool(writers)
48+
try {
49+
val winners = (0 until 20).map { round =>
50+
val target = new File(dir, s"target$round")
51+
val go = new java.util.concurrent.CountDownLatch(1)
52+
val won = new java.util.concurrent.atomic.AtomicInteger
53+
val running = (0 until writers).map { writer =>
54+
pool.submit(new Runnable {
55+
def run(): Unit = {
56+
go.await()
57+
try {
58+
IO.createFileAtomically(target, ownerOnly = false)(staging =>
59+
IO.write(staging, s"writer $writer")
60+
)
61+
won.incrementAndGet()
62+
()
63+
} catch { case _: FileAlreadyExistsException => () }
64+
}
65+
})
66+
}
67+
go.countDown()
68+
running.foreach(_.get())
69+
assert(IO.read(target).startsWith("writer "))
70+
won.get
71+
}
72+
assert(winners.toList === List.fill(20)(1))
73+
} finally {
74+
pool.shutdown()
75+
()
76+
}
77+
}
78+
}
79+
80+
test("writeFileAtomically should keep a file to its owner when asked") {
81+
if (IO.isPosix) {
82+
IO.withTemporaryDirectory { dir =>
83+
val target = new File(dir, "secret.json")
84+
IO.writeFileAtomically(target, ownerOnly = true)(staging => IO.write(staging, "hush"))
85+
val permissions = Files.getPosixFilePermissions(target.toPath).asScala.toSet
86+
assert(permissions === Set(OWNER_READ, OWNER_WRITE))
87+
assert(IO.read(target) === "hush")
88+
}
89+
}
90+
}
91+
2192
test("IO should relativize") {
2293
// Given:
2394
// io-relativize/

0 commit comments

Comments
 (0)