Skip to content

Commit e8519c0

Browse files
authored
Instrumented path based operations using hooks defined in Checker (#325)
Instrumented path based operations using hooks defined in `Checker`. ```scala trait Checker { def onRead(path: ReadablePath): Unit def onWrite(path: Path): Unit } ``` ### Exceptions The following operations were not instrumented: - `followLink`, `readLink` - `list`, `walk` - `exists`, `isLink`, `isFile`, `isDir` - read operations for permissions/stats - `watch` ### Future work - A more comprehensive design would add hooks for each core operation. This would eliminate the special check handling in operations like `move` and `symlink`. - As such, the methods of `ReadablePath` represent escape hatches. These cannot be "plugged" without breaking binary compatibility. This resolves part 1 of [mill #3746](com-lihaoyi/mill#3746).
1 parent 7263129 commit e8519c0

23 files changed

+659
-24
lines changed

build.mill

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ trait MiMaChecks extends Mima {
7373
// this is fine, because ProcessLike is sealed (and its subclasses should be final)
7474
ProblemFilter.exclude[ReversedMissingMethodProblem]("os.ProcessLike.joinPumperThreadsHook")
7575
)
76+
override def mimaExcludeAnnotations: T[Seq[String]] = Seq(
77+
"os.experimental"
78+
)
7679
}
7780

7881
trait OsLibModule

os/src-jvm/package.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ package object os {
5959

6060
val sub: SubPath = SubPath.sub
6161

62+
@experimental
63+
val checker: DynamicVariable[Checker] = new DynamicVariable[Checker](Checker.Nop)
64+
6265
/**
6366
* Extractor to let you easily pattern match on [[os.Path]]s. Lets you do
6467
*

os/src-native/package.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ package object os {
5252

5353
val sub: SubPath = SubPath.sub
5454

55+
@experimental
56+
val checker: DynamicVariable[Checker] = new DynamicVariable[Checker](Checker.Nop)
57+
5558
/**
5659
* Extractor to let you easily pattern match on [[os.Path]]s. Lets you do
5760
*

os/src/FileOps.scala

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,12 @@ import scala.util.Try
2222
* ignore the destination if it already exists, using [[os.makeDir.all]]
2323
*/
2424
object makeDir extends Function1[Path, Unit] {
25-
def apply(path: Path): Unit = Files.createDirectory(path.wrapped)
25+
def apply(path: Path): Unit = {
26+
checker.value.onWrite(path)
27+
Files.createDirectory(path.wrapped)
28+
}
2629
def apply(path: Path, perms: PermSet): Unit = {
30+
checker.value.onWrite(path)
2731
Files.createDirectory(
2832
path.wrapped,
2933
PosixFilePermissions.asFileAttribute(perms.toSet())
@@ -38,6 +42,7 @@ object makeDir extends Function1[Path, Unit] {
3842
object all extends Function1[Path, Unit] {
3943
def apply(path: Path): Unit = apply(path, null, true)
4044
def apply(path: Path, perms: PermSet = null, acceptLinkedDirectory: Boolean = true): Unit = {
45+
checker.value.onWrite(path)
4146
// We special case calling makeDir.all on a symlink to a directory;
4247
// normally createDirectories blows up noisily, when really what most
4348
// people would want is for it to succeed since there is a (linked)
@@ -84,6 +89,8 @@ object move {
8489
atomicMove: Boolean = false,
8590
createFolders: Boolean = false
8691
): Unit = {
92+
checker.value.onWrite(from)
93+
checker.value.onWrite(to)
8794
if (createFolders && to.segmentCount != 0) makeDir.all(to / up)
8895
val opts1 =
8996
if (replaceExisting) Array[CopyOption](StandardCopyOption.REPLACE_EXISTING)
@@ -176,6 +183,8 @@ object copy {
176183
createFolders: Boolean = false,
177184
mergeFolders: Boolean = false
178185
): Unit = {
186+
checker.value.onRead(from)
187+
checker.value.onWrite(to)
179188
if (createFolders && to.segmentCount != 0) makeDir.all(to / up)
180189
val opts1 =
181190
if (followLinks) Array[CopyOption]()
@@ -191,18 +200,17 @@ object copy {
191200
s"Can't copy a directory into itself: $to is inside $from"
192201
)
193202

194-
def copyOne(p: Path): file.Path = {
203+
def copyOne(p: Path): Unit = {
195204
val target = to / p.relativeTo(from)
196205
if (mergeFolders && isDir(p, followLinks) && isDir(target, followLinks)) {
197206
// nothing to do
198-
target.wrapped
199207
} else {
200208
Files.copy(p.wrapped, target.wrapped, opts1 ++ opts2 ++ opts3: _*)
201209
}
202210
}
203211

204212
copyOne(from)
205-
if (stat(from, followLinks = followLinks).isDir) walk(from).map(copyOne)
213+
if (stat(from, followLinks = followLinks).isDir) for (p <- walk(from)) copyOne(p)
206214
}
207215

208216
/** This overload is only to keep binary compatibility with older os-lib versions. */
@@ -311,6 +319,7 @@ object copy {
311319
object remove extends Function1[Path, Boolean] {
312320
def apply(target: Path): Boolean = apply(target, false)
313321
def apply(target: Path, checkExists: Boolean = false): Boolean = {
322+
checker.value.onWrite(target)
314323
if (checkExists) {
315324
Files.delete(target.wrapped)
316325
true
@@ -322,6 +331,7 @@ object remove extends Function1[Path, Boolean] {
322331
object all extends Function1[Path, Unit] {
323332
def apply(target: Path) = {
324333
require(target.segmentCount != 0, s"Cannot remove a root directory: $target")
334+
checker.value.onWrite(target)
325335

326336
val nioTarget = target.wrapped
327337
if (Files.exists(nioTarget, LinkOption.NOFOLLOW_LINKS)) {
@@ -350,6 +360,8 @@ object exists extends Function1[Path, Boolean] {
350360
*/
351361
object hardlink {
352362
def apply(link: Path, dest: Path) = {
363+
checker.value.onWrite(link)
364+
checker.value.onWrite(dest)
353365
Files.createLink(link.wrapped, dest.wrapped)
354366
}
355367
}
@@ -359,6 +371,12 @@ object hardlink {
359371
*/
360372
object symlink {
361373
def apply(link: Path, dest: FilePath, perms: PermSet = null): Unit = {
374+
checker.value.onWrite(link)
375+
checker.value.onWrite(dest match {
376+
case p: RelPath => link / RelPath.up / p
377+
case p: SubPath => link / RelPath.up / p
378+
case p: Path => p
379+
})
362380
val permArray: Array[FileAttribute[_]] =
363381
if (perms == null) Array[FileAttribute[_]]()
364382
else Array(PosixFilePermissions.asFileAttribute(perms.toSet()))

os/src/Model.scala

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,30 @@ object PosixStatInfo {
283283
)
284284
}
285285
}
286+
287+
/**
288+
* Defines hooks for path based operations.
289+
*
290+
* This, in conjunction with [[checker]], can be used to implement custom checks like
291+
* - restricting an operation to some path(s)
292+
* - logging an operation
293+
*/
294+
@experimental
295+
trait Checker {
296+
297+
/** A hook for a read operation on `path`. */
298+
def onRead(path: ReadablePath): Unit
299+
300+
/** A hook for a write operation on `path`. */
301+
def onWrite(path: Path): Unit
302+
}
303+
304+
@experimental
305+
object Checker {
306+
307+
/** A no-op [[Checker]]. */
308+
object Nop extends Checker {
309+
def onRead(path: ReadablePath): Unit = ()
310+
def onWrite(path: Path): Unit = ()
311+
}
312+
}

os/src/PermsOps.scala

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ object perms extends Function1[Path, PermSet] {
2424
*/
2525
object set {
2626
def apply(p: Path, arg2: PermSet): Unit = {
27+
checker.value.onWrite(p)
2728
Files.setPosixFilePermissions(p.wrapped, arg2.toSet())
2829
}
2930
}
@@ -44,7 +45,10 @@ object owner extends Function1[Path, UserPrincipal] {
4445
* Set the owner of the file/folder at the given path
4546
*/
4647
object set {
47-
def apply(arg1: Path, arg2: UserPrincipal): Unit = Files.setOwner(arg1.wrapped, arg2)
48+
def apply(arg1: Path, arg2: UserPrincipal): Unit = {
49+
checker.value.onWrite(arg1)
50+
Files.setOwner(arg1.wrapped, arg2)
51+
}
4852
def apply(arg1: Path, arg2: String): Unit = {
4953
apply(
5054
arg1,
@@ -73,6 +77,7 @@ object group extends Function1[Path, GroupPrincipal] {
7377
*/
7478
object set {
7579
def apply(arg1: Path, arg2: GroupPrincipal): Unit = {
80+
checker.value.onWrite(arg1)
7681
Files.getFileAttributeView(
7782
arg1.wrapped,
7883
classOf[PosixFileAttributeView],

os/src/ReadWriteOps.scala

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ object write {
2727
createFolders: Boolean = false,
2828
openOptions: Seq[OpenOption] = Seq(CREATE, WRITE)
2929
) = {
30+
checker.value.onWrite(target)
3031
if (createFolders) makeDir.all(target / RelPath.up, perms)
3132
if (perms != null && !exists(target)) {
3233
val permArray =
@@ -53,6 +54,7 @@ object write {
5354
perms: PermSet,
5455
offset: Long
5556
) = {
57+
checker.value.onWrite(target)
5658

5759
import collection.JavaConverters._
5860
val permArray: Array[FileAttribute[_]] =
@@ -166,6 +168,7 @@ object write {
166168
*/
167169
object channel extends Function1[Path, SeekableByteChannel] {
168170
def write(p: Path, options: Seq[StandardOpenOption]) = {
171+
checker.value.onWrite(p)
169172
java.nio.file.Files.newByteChannel(p.toNIO, options.toArray: _*)
170173
}
171174
def apply(p: Path): SeekableByteChannel = {
@@ -212,6 +215,7 @@ object write {
212215
*/
213216
object truncate {
214217
def apply(p: Path, size: Long): Unit = {
218+
checker.value.onWrite(p)
215219
val channel = FileChannel.open(p.toNIO, StandardOpenOption.WRITE)
216220
try channel.truncate(size)
217221
finally channel.close()
@@ -242,16 +246,21 @@ object read extends Function1[ReadablePath, String] {
242246
* Opens a [[java.io.InputStream]] to read from the given file
243247
*/
244248
object inputStream extends Function1[ReadablePath, java.io.InputStream] {
245-
def apply(p: ReadablePath): java.io.InputStream = p.getInputStream
249+
def apply(p: ReadablePath): java.io.InputStream = {
250+
checker.value.onRead(p)
251+
p.getInputStream
252+
}
246253
}
247254

248255
object stream extends Function1[ReadablePath, geny.Readable] {
249-
def apply(p: ReadablePath): geny.Readable = new geny.Readable {
250-
override def contentLength: Option[Long] = p.toSource.contentLength
251-
def readBytesThrough[T](f: java.io.InputStream => T): T = {
252-
val is = p.getInputStream
253-
try f(is)
254-
finally is.close()
256+
def apply(p: ReadablePath): geny.Readable = {
257+
new geny.Readable {
258+
override def contentLength: Option[Long] = p.toSource.contentLength
259+
def readBytesThrough[T](f: java.io.InputStream => T): T = {
260+
val is = os.read.inputStream(p)
261+
try f(is)
262+
finally is.close()
263+
}
255264
}
256265
}
257266
}
@@ -260,7 +269,10 @@ object read extends Function1[ReadablePath, String] {
260269
* Opens a [[SeekableByteChannel]] to read from the given file.
261270
*/
262271
object channel extends Function1[Path, SeekableByteChannel] {
263-
def apply(p: Path): SeekableByteChannel = p.toSource.getChannel()
272+
def apply(p: Path): SeekableByteChannel = {
273+
checker.value.onRead(p)
274+
p.toSource.getChannel()
275+
}
264276
}
265277

266278
/**
@@ -271,15 +283,15 @@ object read extends Function1[ReadablePath, String] {
271283
object bytes extends Function1[ReadablePath, Array[Byte]] {
272284
def apply(arg: ReadablePath): Array[Byte] = {
273285
val out = new java.io.ByteArrayOutputStream()
274-
val stream = arg.getInputStream
286+
val stream = os.read.inputStream(arg)
275287
try Internals.transfer(stream, out)
276288
finally stream.close()
277289
out.toByteArray
278290
}
279291
def apply(arg: Path, offset: Long, count: Int): Array[Byte] = {
280292
val arr = new Array[Byte](count)
281293
val buf = ByteBuffer.wrap(arr)
282-
val channel = arg.toSource.getChannel()
294+
val channel = os.read.channel(arg)
283295
try {
284296
channel.position(offset)
285297
val finalCount = channel.read(buf)
@@ -360,7 +372,7 @@ object read extends Function1[ReadablePath, String] {
360372
def apply(arg: ReadablePath, charSet: Codec) = {
361373
new geny.Generator[String] {
362374
def generate(handleItem: String => Generator.Action) = {
363-
val is = arg.getInputStream
375+
val is = os.read.inputStream(arg)
364376
val isr = new InputStreamReader(is, charSet.decoder)
365377
val buf = new BufferedReader(isr)
366378
var currentAction: Generator.Action = Generator.Continue

os/src/StatOps.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ object mtime extends Function1[Path, Long] {
7474
*/
7575
object set {
7676
def apply(p: Path, millis: Long) = {
77+
checker.value.onWrite(p)
7778
Files.setLastModifiedTime(p.wrapped, FileTime.fromMillis(millis))
7879
}
7980
}

os/src/TempOps.scala

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,15 @@ object temp {
2828
deleteOnExit: Boolean = true,
2929
perms: PermSet = null
3030
): Path = {
31-
import collection.JavaConverters._
3231
val permArray: Array[FileAttribute[_]] =
3332
if (perms == null) Array.empty
3433
else Array(PosixFilePermissions.asFileAttribute(perms.toSet()))
3534

3635
val nioPath = dir match {
3736
case null => java.nio.file.Files.createTempFile(prefix, suffix, permArray: _*)
38-
case _ => java.nio.file.Files.createTempFile(dir.wrapped, prefix, suffix, permArray: _*)
37+
case _ =>
38+
checker.value.onWrite(dir)
39+
java.nio.file.Files.createTempFile(dir.wrapped, prefix, suffix, permArray: _*)
3940
}
4041

4142
if (contents != null) write.over(Path(nioPath), contents)
@@ -63,7 +64,9 @@ object temp {
6364

6465
val nioPath = dir match {
6566
case null => java.nio.file.Files.createTempDirectory(prefix, permArray: _*)
66-
case _ => java.nio.file.Files.createTempDirectory(dir.wrapped, prefix, permArray: _*)
67+
case _ =>
68+
checker.value.onWrite(dir)
69+
java.nio.file.Files.createTempDirectory(dir.wrapped, prefix, permArray: _*)
6770
}
6871

6972
if (deleteOnExit) nioPath.toFile.deleteOnExit()

os/src/ZipOps.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ object zip {
4545
deletePatterns: Seq[Regex] = List(),
4646
compressionLevel: Int = java.util.zip.Deflater.DEFAULT_COMPRESSION
4747
): os.Path = {
48+
checker.value.onWrite(dest)
49+
// check read preemptively in case "dest" is created
50+
for (source <- sources) checker.value.onRead(source.src)
4851

4952
if (os.exists(dest)) {
5053
val opened = open(dest)
@@ -268,6 +271,7 @@ object unzip {
268271
excludePatterns: Seq[Regex] = List(),
269272
includePatterns: Seq[Regex] = List()
270273
): Unit = {
274+
checker.value.onWrite(dest)
271275
for ((zipEntry, zipInputStream) <- streamRaw(source, excludePatterns, includePatterns)) {
272276
val newFile = dest / os.SubPath(zipEntry.getName)
273277
if (zipEntry.isDirectory) os.makeDir.all(newFile)

0 commit comments

Comments
 (0)