From e8519c0d4f09e36667d8e88f2532b33b834acb1e Mon Sep 17 00:00:00 2001 From: Ajay Chandran Date: Sun, 27 Oct 2024 10:57:13 +0530 Subject: [PATCH] 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](https://github.com/com-lihaoyi/mill/issues/3746). --- build.mill | 3 + os/src-jvm/package.scala | 3 + os/src-native/package.scala | 3 + os/src/FileOps.scala | 26 +- os/src/Model.scala | 27 + os/src/PermsOps.scala | 7 +- os/src/ReadWriteOps.scala | 34 +- os/src/StatOps.scala | 1 + os/src/TempOps.scala | 9 +- os/src/ZipOps.scala | 4 + os/src/experimental.scala | 8 + os/test/resources/restricted/File.txt | 1 + os/test/resources/restricted/Multi Line.txt | 4 + os/test/resources/restricted/folder1/one.txt | 1 + .../restricted/folder2/nestedA/a.txt | 1 + .../restricted/folder2/nestedB/b.txt | 1 + .../resources/restricted/misc/broken-symlink | 0 .../resources/restricted/misc/file-symlink | 1 + .../resources/restricted/misc/folder-symlink | 1 + os/test/src/CheckerTests.scala | 484 ++++++++++++++++++ os/test/src/FilesystemPermissionsTests.scala | 8 +- os/test/src/ReadingWritingTests.scala | 2 +- os/test/src/TestUtil.scala | 54 ++ 23 files changed, 659 insertions(+), 24 deletions(-) create mode 100644 os/src/experimental.scala create mode 100644 os/test/resources/restricted/File.txt create mode 100644 os/test/resources/restricted/Multi Line.txt create mode 100644 os/test/resources/restricted/folder1/one.txt create mode 100644 os/test/resources/restricted/folder2/nestedA/a.txt create mode 100644 os/test/resources/restricted/folder2/nestedB/b.txt create mode 100644 os/test/resources/restricted/misc/broken-symlink create mode 100644 os/test/resources/restricted/misc/file-symlink create mode 120000 os/test/resources/restricted/misc/folder-symlink create mode 100644 os/test/src/CheckerTests.scala diff --git a/build.mill b/build.mill index 4d3b9528..5e9f3e2a 100644 --- a/build.mill +++ b/build.mill @@ -73,6 +73,9 @@ trait MiMaChecks extends Mima { // this is fine, because ProcessLike is sealed (and its subclasses should be final) ProblemFilter.exclude[ReversedMissingMethodProblem]("os.ProcessLike.joinPumperThreadsHook") ) + override def mimaExcludeAnnotations: T[Seq[String]] = Seq( + "os.experimental" + ) } trait OsLibModule diff --git a/os/src-jvm/package.scala b/os/src-jvm/package.scala index 417aa673..caa08009 100644 --- a/os/src-jvm/package.scala +++ b/os/src-jvm/package.scala @@ -59,6 +59,9 @@ package object os { val sub: SubPath = SubPath.sub + @experimental + val checker: DynamicVariable[Checker] = new DynamicVariable[Checker](Checker.Nop) + /** * Extractor to let you easily pattern match on [[os.Path]]s. Lets you do * diff --git a/os/src-native/package.scala b/os/src-native/package.scala index 0dbef162..f0afa876 100644 --- a/os/src-native/package.scala +++ b/os/src-native/package.scala @@ -52,6 +52,9 @@ package object os { val sub: SubPath = SubPath.sub + @experimental + val checker: DynamicVariable[Checker] = new DynamicVariable[Checker](Checker.Nop) + /** * Extractor to let you easily pattern match on [[os.Path]]s. Lets you do * diff --git a/os/src/FileOps.scala b/os/src/FileOps.scala index d0276f0f..207aec12 100644 --- a/os/src/FileOps.scala +++ b/os/src/FileOps.scala @@ -22,8 +22,12 @@ import scala.util.Try * ignore the destination if it already exists, using [[os.makeDir.all]] */ object makeDir extends Function1[Path, Unit] { - def apply(path: Path): Unit = Files.createDirectory(path.wrapped) + def apply(path: Path): Unit = { + checker.value.onWrite(path) + Files.createDirectory(path.wrapped) + } def apply(path: Path, perms: PermSet): Unit = { + checker.value.onWrite(path) Files.createDirectory( path.wrapped, PosixFilePermissions.asFileAttribute(perms.toSet()) @@ -38,6 +42,7 @@ object makeDir extends Function1[Path, Unit] { object all extends Function1[Path, Unit] { def apply(path: Path): Unit = apply(path, null, true) def apply(path: Path, perms: PermSet = null, acceptLinkedDirectory: Boolean = true): Unit = { + checker.value.onWrite(path) // We special case calling makeDir.all on a symlink to a directory; // normally createDirectories blows up noisily, when really what most // people would want is for it to succeed since there is a (linked) @@ -84,6 +89,8 @@ object move { atomicMove: Boolean = false, createFolders: Boolean = false ): Unit = { + checker.value.onWrite(from) + checker.value.onWrite(to) if (createFolders && to.segmentCount != 0) makeDir.all(to / up) val opts1 = if (replaceExisting) Array[CopyOption](StandardCopyOption.REPLACE_EXISTING) @@ -176,6 +183,8 @@ object copy { createFolders: Boolean = false, mergeFolders: Boolean = false ): Unit = { + checker.value.onRead(from) + checker.value.onWrite(to) if (createFolders && to.segmentCount != 0) makeDir.all(to / up) val opts1 = if (followLinks) Array[CopyOption]() @@ -191,18 +200,17 @@ object copy { s"Can't copy a directory into itself: $to is inside $from" ) - def copyOne(p: Path): file.Path = { + def copyOne(p: Path): Unit = { val target = to / p.relativeTo(from) if (mergeFolders && isDir(p, followLinks) && isDir(target, followLinks)) { // nothing to do - target.wrapped } else { Files.copy(p.wrapped, target.wrapped, opts1 ++ opts2 ++ opts3: _*) } } copyOne(from) - if (stat(from, followLinks = followLinks).isDir) walk(from).map(copyOne) + if (stat(from, followLinks = followLinks).isDir) for (p <- walk(from)) copyOne(p) } /** This overload is only to keep binary compatibility with older os-lib versions. */ @@ -311,6 +319,7 @@ object copy { object remove extends Function1[Path, Boolean] { def apply(target: Path): Boolean = apply(target, false) def apply(target: Path, checkExists: Boolean = false): Boolean = { + checker.value.onWrite(target) if (checkExists) { Files.delete(target.wrapped) true @@ -322,6 +331,7 @@ object remove extends Function1[Path, Boolean] { object all extends Function1[Path, Unit] { def apply(target: Path) = { require(target.segmentCount != 0, s"Cannot remove a root directory: $target") + checker.value.onWrite(target) val nioTarget = target.wrapped if (Files.exists(nioTarget, LinkOption.NOFOLLOW_LINKS)) { @@ -350,6 +360,8 @@ object exists extends Function1[Path, Boolean] { */ object hardlink { def apply(link: Path, dest: Path) = { + checker.value.onWrite(link) + checker.value.onWrite(dest) Files.createLink(link.wrapped, dest.wrapped) } } @@ -359,6 +371,12 @@ object hardlink { */ object symlink { def apply(link: Path, dest: FilePath, perms: PermSet = null): Unit = { + checker.value.onWrite(link) + checker.value.onWrite(dest match { + case p: RelPath => link / RelPath.up / p + case p: SubPath => link / RelPath.up / p + case p: Path => p + }) val permArray: Array[FileAttribute[_]] = if (perms == null) Array[FileAttribute[_]]() else Array(PosixFilePermissions.asFileAttribute(perms.toSet())) diff --git a/os/src/Model.scala b/os/src/Model.scala index f2389407..f5095f5a 100644 --- a/os/src/Model.scala +++ b/os/src/Model.scala @@ -283,3 +283,30 @@ object PosixStatInfo { ) } } + +/** + * Defines hooks for path based operations. + * + * This, in conjunction with [[checker]], can be used to implement custom checks like + * - restricting an operation to some path(s) + * - logging an operation + */ +@experimental +trait Checker { + + /** A hook for a read operation on `path`. */ + def onRead(path: ReadablePath): Unit + + /** A hook for a write operation on `path`. */ + def onWrite(path: Path): Unit +} + +@experimental +object Checker { + + /** A no-op [[Checker]]. */ + object Nop extends Checker { + def onRead(path: ReadablePath): Unit = () + def onWrite(path: Path): Unit = () + } +} diff --git a/os/src/PermsOps.scala b/os/src/PermsOps.scala index 95f6bb32..73ef6d19 100644 --- a/os/src/PermsOps.scala +++ b/os/src/PermsOps.scala @@ -24,6 +24,7 @@ object perms extends Function1[Path, PermSet] { */ object set { def apply(p: Path, arg2: PermSet): Unit = { + checker.value.onWrite(p) Files.setPosixFilePermissions(p.wrapped, arg2.toSet()) } } @@ -44,7 +45,10 @@ object owner extends Function1[Path, UserPrincipal] { * Set the owner of the file/folder at the given path */ object set { - def apply(arg1: Path, arg2: UserPrincipal): Unit = Files.setOwner(arg1.wrapped, arg2) + def apply(arg1: Path, arg2: UserPrincipal): Unit = { + checker.value.onWrite(arg1) + Files.setOwner(arg1.wrapped, arg2) + } def apply(arg1: Path, arg2: String): Unit = { apply( arg1, @@ -73,6 +77,7 @@ object group extends Function1[Path, GroupPrincipal] { */ object set { def apply(arg1: Path, arg2: GroupPrincipal): Unit = { + checker.value.onWrite(arg1) Files.getFileAttributeView( arg1.wrapped, classOf[PosixFileAttributeView], diff --git a/os/src/ReadWriteOps.scala b/os/src/ReadWriteOps.scala index 3ea0d83d..62b8c0be 100644 --- a/os/src/ReadWriteOps.scala +++ b/os/src/ReadWriteOps.scala @@ -27,6 +27,7 @@ object write { createFolders: Boolean = false, openOptions: Seq[OpenOption] = Seq(CREATE, WRITE) ) = { + checker.value.onWrite(target) if (createFolders) makeDir.all(target / RelPath.up, perms) if (perms != null && !exists(target)) { val permArray = @@ -53,6 +54,7 @@ object write { perms: PermSet, offset: Long ) = { + checker.value.onWrite(target) import collection.JavaConverters._ val permArray: Array[FileAttribute[_]] = @@ -166,6 +168,7 @@ object write { */ object channel extends Function1[Path, SeekableByteChannel] { def write(p: Path, options: Seq[StandardOpenOption]) = { + checker.value.onWrite(p) java.nio.file.Files.newByteChannel(p.toNIO, options.toArray: _*) } def apply(p: Path): SeekableByteChannel = { @@ -212,6 +215,7 @@ object write { */ object truncate { def apply(p: Path, size: Long): Unit = { + checker.value.onWrite(p) val channel = FileChannel.open(p.toNIO, StandardOpenOption.WRITE) try channel.truncate(size) finally channel.close() @@ -242,16 +246,21 @@ object read extends Function1[ReadablePath, String] { * Opens a [[java.io.InputStream]] to read from the given file */ object inputStream extends Function1[ReadablePath, java.io.InputStream] { - def apply(p: ReadablePath): java.io.InputStream = p.getInputStream + def apply(p: ReadablePath): java.io.InputStream = { + checker.value.onRead(p) + p.getInputStream + } } object stream extends Function1[ReadablePath, geny.Readable] { - def apply(p: ReadablePath): geny.Readable = new geny.Readable { - override def contentLength: Option[Long] = p.toSource.contentLength - def readBytesThrough[T](f: java.io.InputStream => T): T = { - val is = p.getInputStream - try f(is) - finally is.close() + def apply(p: ReadablePath): geny.Readable = { + new geny.Readable { + override def contentLength: Option[Long] = p.toSource.contentLength + def readBytesThrough[T](f: java.io.InputStream => T): T = { + val is = os.read.inputStream(p) + try f(is) + finally is.close() + } } } } @@ -260,7 +269,10 @@ object read extends Function1[ReadablePath, String] { * Opens a [[SeekableByteChannel]] to read from the given file. */ object channel extends Function1[Path, SeekableByteChannel] { - def apply(p: Path): SeekableByteChannel = p.toSource.getChannel() + def apply(p: Path): SeekableByteChannel = { + checker.value.onRead(p) + p.toSource.getChannel() + } } /** @@ -271,7 +283,7 @@ object read extends Function1[ReadablePath, String] { object bytes extends Function1[ReadablePath, Array[Byte]] { def apply(arg: ReadablePath): Array[Byte] = { val out = new java.io.ByteArrayOutputStream() - val stream = arg.getInputStream + val stream = os.read.inputStream(arg) try Internals.transfer(stream, out) finally stream.close() out.toByteArray @@ -279,7 +291,7 @@ object read extends Function1[ReadablePath, String] { def apply(arg: Path, offset: Long, count: Int): Array[Byte] = { val arr = new Array[Byte](count) val buf = ByteBuffer.wrap(arr) - val channel = arg.toSource.getChannel() + val channel = os.read.channel(arg) try { channel.position(offset) val finalCount = channel.read(buf) @@ -360,7 +372,7 @@ object read extends Function1[ReadablePath, String] { def apply(arg: ReadablePath, charSet: Codec) = { new geny.Generator[String] { def generate(handleItem: String => Generator.Action) = { - val is = arg.getInputStream + val is = os.read.inputStream(arg) val isr = new InputStreamReader(is, charSet.decoder) val buf = new BufferedReader(isr) var currentAction: Generator.Action = Generator.Continue diff --git a/os/src/StatOps.scala b/os/src/StatOps.scala index 958138a3..cf8695ef 100644 --- a/os/src/StatOps.scala +++ b/os/src/StatOps.scala @@ -74,6 +74,7 @@ object mtime extends Function1[Path, Long] { */ object set { def apply(p: Path, millis: Long) = { + checker.value.onWrite(p) Files.setLastModifiedTime(p.wrapped, FileTime.fromMillis(millis)) } } diff --git a/os/src/TempOps.scala b/os/src/TempOps.scala index 900fe6b7..f31f16d7 100644 --- a/os/src/TempOps.scala +++ b/os/src/TempOps.scala @@ -28,14 +28,15 @@ object temp { deleteOnExit: Boolean = true, perms: PermSet = null ): Path = { - import collection.JavaConverters._ val permArray: Array[FileAttribute[_]] = if (perms == null) Array.empty else Array(PosixFilePermissions.asFileAttribute(perms.toSet())) val nioPath = dir match { case null => java.nio.file.Files.createTempFile(prefix, suffix, permArray: _*) - case _ => java.nio.file.Files.createTempFile(dir.wrapped, prefix, suffix, permArray: _*) + case _ => + checker.value.onWrite(dir) + java.nio.file.Files.createTempFile(dir.wrapped, prefix, suffix, permArray: _*) } if (contents != null) write.over(Path(nioPath), contents) @@ -63,7 +64,9 @@ object temp { val nioPath = dir match { case null => java.nio.file.Files.createTempDirectory(prefix, permArray: _*) - case _ => java.nio.file.Files.createTempDirectory(dir.wrapped, prefix, permArray: _*) + case _ => + checker.value.onWrite(dir) + java.nio.file.Files.createTempDirectory(dir.wrapped, prefix, permArray: _*) } if (deleteOnExit) nioPath.toFile.deleteOnExit() diff --git a/os/src/ZipOps.scala b/os/src/ZipOps.scala index b4721e76..a62d7d49 100644 --- a/os/src/ZipOps.scala +++ b/os/src/ZipOps.scala @@ -45,6 +45,9 @@ object zip { deletePatterns: Seq[Regex] = List(), compressionLevel: Int = java.util.zip.Deflater.DEFAULT_COMPRESSION ): os.Path = { + checker.value.onWrite(dest) + // check read preemptively in case "dest" is created + for (source <- sources) checker.value.onRead(source.src) if (os.exists(dest)) { val opened = open(dest) @@ -268,6 +271,7 @@ object unzip { excludePatterns: Seq[Regex] = List(), includePatterns: Seq[Regex] = List() ): Unit = { + checker.value.onWrite(dest) for ((zipEntry, zipInputStream) <- streamRaw(source, excludePatterns, includePatterns)) { val newFile = dest / os.SubPath(zipEntry.getName) if (zipEntry.isDirectory) os.makeDir.all(newFile) diff --git a/os/src/experimental.scala b/os/src/experimental.scala new file mode 100644 index 00000000..3294cfd2 --- /dev/null +++ b/os/src/experimental.scala @@ -0,0 +1,8 @@ +package os + +import scala.annotation.StaticAnnotation + +/** + * Annotation to mark experimental API, which is not guaranteed to stay. + */ +class experimental extends StaticAnnotation {} diff --git a/os/test/resources/restricted/File.txt b/os/test/resources/restricted/File.txt new file mode 100644 index 00000000..c295cb70 --- /dev/null +++ b/os/test/resources/restricted/File.txt @@ -0,0 +1 @@ +I am a restricted cow \ No newline at end of file diff --git a/os/test/resources/restricted/Multi Line.txt b/os/test/resources/restricted/Multi Line.txt new file mode 100644 index 00000000..03a7b2c7 --- /dev/null +++ b/os/test/resources/restricted/Multi Line.txt @@ -0,0 +1,4 @@ +I am restricted cow +Hear me moo +I weigh twice as much as you +And I look good on the barbecue \ No newline at end of file diff --git a/os/test/resources/restricted/folder1/one.txt b/os/test/resources/restricted/folder1/one.txt new file mode 100644 index 00000000..7959e0c6 --- /dev/null +++ b/os/test/resources/restricted/folder1/one.txt @@ -0,0 +1 @@ +Contents of restricted folder one \ No newline at end of file diff --git a/os/test/resources/restricted/folder2/nestedA/a.txt b/os/test/resources/restricted/folder2/nestedA/a.txt new file mode 100644 index 00000000..27ce3da0 --- /dev/null +++ b/os/test/resources/restricted/folder2/nestedA/a.txt @@ -0,0 +1 @@ +Contents of restricted nested A \ No newline at end of file diff --git a/os/test/resources/restricted/folder2/nestedB/b.txt b/os/test/resources/restricted/folder2/nestedB/b.txt new file mode 100644 index 00000000..f7539d86 --- /dev/null +++ b/os/test/resources/restricted/folder2/nestedB/b.txt @@ -0,0 +1 @@ +Contents of restricted nested B \ No newline at end of file diff --git a/os/test/resources/restricted/misc/broken-symlink b/os/test/resources/restricted/misc/broken-symlink new file mode 100644 index 00000000..e69de29b diff --git a/os/test/resources/restricted/misc/file-symlink b/os/test/resources/restricted/misc/file-symlink new file mode 100644 index 00000000..c295cb70 --- /dev/null +++ b/os/test/resources/restricted/misc/file-symlink @@ -0,0 +1 @@ +I am a restricted cow \ No newline at end of file diff --git a/os/test/resources/restricted/misc/folder-symlink b/os/test/resources/restricted/misc/folder-symlink new file mode 120000 index 00000000..6ff69ba0 --- /dev/null +++ b/os/test/resources/restricted/misc/folder-symlink @@ -0,0 +1 @@ +../folder1 \ No newline at end of file diff --git a/os/test/src/CheckerTests.scala b/os/test/src/CheckerTests.scala new file mode 100644 index 00000000..e992d3bf --- /dev/null +++ b/os/test/src/CheckerTests.scala @@ -0,0 +1,484 @@ +package test.os + +import test.os.TestUtil._ +import utest._ + +object CheckerTests extends TestSuite { + + def tests: Tests = Tests { + // restricted directory + val rd = os.Path(sys.env("OS_TEST_RESOURCE_FOLDER")) / "restricted" + + test("stat") { + test("mtime") - prepChecker { wd => + val before = os.mtime(rd / "File.txt") + intercept[WriteDenied] { + os.mtime.set(rd / "File.txt", 0) + } + os.mtime(rd / "File.txt") ==> before + + os.mtime.set(wd / "File.txt", 0) + os.mtime(wd / "File.txt") ==> 0 + + os.mtime.set(wd / "File.txt", 90000) + os.mtime(wd / "File.txt") ==> 90000 + os.mtime(wd / "misc/file-symlink") ==> 90000 + + os.mtime.set(wd / "misc/file-symlink", 70000) + os.mtime(wd / "File.txt") ==> 70000 + os.mtime(wd / "misc/file-symlink") ==> 70000 + assert(os.mtime(wd / "misc/file-symlink", followLinks = false) != 40000) + } + } + + test("perms") { + test - prepChecker { wd => + if (Unix()) { + val before = os.perms(rd / "File.txt") + intercept[WriteDenied] { + os.perms.set(rd / "File.txt", "rwxrwxrwx") + } + os.perms(rd / "File.txt") ==> before + + os.perms.set(wd / "File.txt", "rwxrwxrwx") + os.perms(wd / "File.txt").toString() ==> "rwxrwxrwx" + os.perms(wd / "File.txt").toInt() ==> Integer.parseInt("777", 8) + + os.perms.set(wd / "File.txt", Integer.parseInt("755", 8)) + os.perms(wd / "File.txt").toString() ==> "rwxr-xr-x" + + os.perms.set(wd / "File.txt", "r-xr-xr-x") + os.perms.set(wd / "File.txt", Integer.parseInt("555", 8)) + } + } + test("owner") - prepChecker { wd => + if (Unix()) { + // Only works as root :( + if (false) { + intercept[WriteDenied] { + os.owner.set(rd / "File.txt", "nobody") + } + + val originalOwner = os.owner(wd / "File.txt") + + os.owner.set(wd / "File.txt", "nobody") + os.owner(wd / "File.txt").getName ==> "nobody" + + os.owner.set(wd / "File.txt", originalOwner) + } + } + } + test("group") - prepChecker { wd => + if (Unix()) { + // Only works as root :( + if (false) { + intercept[WriteDenied] { + os.group.set(rd / "File.txt", "nobody") + } + + val originalGroup = os.group(wd / "File.txt") + + os.group.set(wd / "File.txt", "nobody") + os.group(wd / "File.txt").getName ==> "nobody" + + os.group.set(wd / "File.txt", originalGroup) + } + } + } + } + + test("move") - prepChecker { wd => + intercept[WriteDenied] { + os.move(rd / "folder1/one.txt", wd / "folder1/File.txt") + } + os.list(wd / "folder1") ==> Seq(wd / "folder1/one.txt") + + intercept[WriteDenied] { + os.move(wd / "folder1/one.txt", rd / "folder1/File.txt") + } + os.list(rd / "folder1") ==> Seq(rd / "folder1/one.txt") + + intercept[WriteDenied] { + os.move(wd / "folder2/nestedA", rd / "folder2/nestedC") + } + os.list(rd / "folder2") ==> Seq(rd / "folder2/nestedA", rd / "folder2/nestedB") + + os.list(wd / "folder1") ==> Seq(wd / "folder1/one.txt") + os.move(wd / "folder1/one.txt", wd / "folder1/first.txt") + os.list(wd / "folder1") ==> Seq(wd / "folder1/first.txt") + + os.list(wd / "folder2") ==> Seq(wd / "folder2/nestedA", wd / "folder2/nestedB") + os.move(wd / "folder2/nestedA", wd / "folder2/nestedC") + os.list(wd / "folder2") ==> Seq(wd / "folder2/nestedB", wd / "folder2/nestedC") + + os.read(wd / "File.txt") ==> "I am cow" + os.move(wd / "Multi Line.txt", wd / "File.txt", replaceExisting = true) + os.read(wd / "File.txt") ==> + """I am cow + |Hear me moo + |I weigh twice as much as you + |And I look good on the barbecue""".stripMargin + } + test("copy") - prepChecker { wd => + intercept[ReadDenied] { + os.copy(rd / "folder1/one.txt", wd / "folder1/File.txt") + } + os.list(wd / "folder1") ==> Seq(wd / "folder1/one.txt") + + intercept[WriteDenied] { + os.copy(wd / "folder1/one.txt", rd / "folder1/File.txt") + } + os.list(rd / "folder1") ==> Seq(rd / "folder1/one.txt") + + intercept[WriteDenied] { + os.copy(wd / "folder2/nestedA", rd / "folder2/nestedC") + } + os.list(rd / "folder2") ==> Seq(rd / "folder2/nestedA", rd / "folder2/nestedB") + + os.list(wd / "folder1") ==> Seq(wd / "folder1/one.txt") + os.copy(wd / "folder1/one.txt", wd / "folder1/first.txt") + os.list(wd / "folder1") ==> Seq(wd / "folder1/first.txt", wd / "folder1/one.txt") + + os.list(wd / "folder2") ==> Seq(wd / "folder2/nestedA", wd / "folder2/nestedB") + os.copy(wd / "folder2/nestedA", wd / "folder2/nestedC") + os.list(wd / "folder2") ==> Seq( + wd / "folder2/nestedA", + wd / "folder2/nestedB", + wd / "folder2/nestedC" + ) + + os.read(wd / "File.txt") ==> "I am cow" + os.copy(wd / "Multi Line.txt", wd / "File.txt", replaceExisting = true) + os.read(wd / "File.txt") ==> + """I am cow + |Hear me moo + |I weigh twice as much as you + |And I look good on the barbecue""".stripMargin + } + test("makeDir") { + test - prepChecker { wd => + intercept[WriteDenied] { + os.makeDir(rd / "new_folder") + } + os.exists(rd / "new_folder") ==> false + + os.exists(wd / "new_folder") ==> false + os.makeDir(wd / "new_folder") + os.exists(wd / "new_folder") ==> true + } + test("all") - prepChecker { wd => + intercept[WriteDenied] { + os.makeDir.all(rd / "new_folder/inner/deep") + } + os.exists(rd / "new_folder") ==> false + + os.exists(wd / "new_folder") ==> false + os.makeDir.all(wd / "new_folder/inner/deep") + os.exists(wd / "new_folder/inner/deep") ==> true + } + } + test("remove") { + test - prepChecker { wd => + intercept[WriteDenied] { + os.remove(rd / "File.txt") + } + os.exists(rd / "File.txt") ==> true + + intercept[WriteDenied] { + os.remove(rd / "folder1") + } + os.list(rd / "folder1") ==> Seq(rd / "folder1/one.txt") + + Unchecked.scope(os.makeDir(rd / "folder"), os.remove(rd / "folder")) { + intercept[WriteDenied] { + os.remove(rd / "folder") + } + os.exists(rd / "folder") ==> true + } + os.exists(rd / "folder") ==> false + + os.exists(wd / "File.txt") ==> true + os.remove(wd / "File.txt") + os.exists(wd / "File.txt") ==> false + + os.exists(wd / "folder1/one.txt") ==> true + os.remove(wd / "folder1/one.txt") + os.remove(wd / "folder1") + os.exists(wd / "folder1/one.txt") ==> false + os.exists(wd / "folder1") ==> false + } + test("link") - prepChecker { wd => + intercept[WriteDenied] { + os.remove(rd / "misc/file-symlink") + } + os.exists(rd / "misc/file-symlink", followLinks = false) ==> true + + intercept[WriteDenied] { + os.remove(rd / "misc/folder-symlink") + } + os.exists(rd / "misc/folder-symlink", followLinks = false) ==> true + + intercept[WriteDenied] { + os.remove(rd / "misc/broken-symlink") + } + os.exists(rd / "misc/broken-symlink", followLinks = false) ==> true + os.exists(rd / "misc/broken-symlink") ==> true + + os.remove(wd / "misc/file-symlink") + os.exists(wd / "misc/file-symlink", followLinks = false) ==> false + os.exists(wd / "File.txt", followLinks = false) ==> true + + os.remove(wd / "misc/folder-symlink") + os.exists(wd / "misc/folder-symlink", followLinks = false) ==> false + os.exists(wd / "folder1", followLinks = false) ==> true + os.exists(wd / "folder1/one.txt", followLinks = false) ==> true + + os.remove(wd / "misc/broken-symlink") + os.exists(wd / "misc/broken-symlink", followLinks = false) ==> false + } + test("all") { + test - prepChecker { wd => + intercept[WriteDenied] { + os.remove.all(rd / "folder1") + } + os.list(rd / "folder1") ==> Seq(rd / "folder1/one.txt") + + os.exists(wd / "folder1/one.txt") ==> true + os.remove.all(wd / "folder1") + os.exists(wd / "folder1/one.txt") ==> false + os.exists(wd / "folder1") ==> false + } + test("link") - prepChecker { wd => + intercept[WriteDenied] { + os.remove.all(rd / "misc/file-symlink") + } + os.exists(rd / "misc/file-symlink", followLinks = false) ==> true + + intercept[WriteDenied] { + os.remove.all(rd / "misc/folder-symlink") + } + os.exists(rd / "misc/folder-symlink", followLinks = false) ==> true + + intercept[WriteDenied] { + os.remove.all(rd / "misc/broken-symlink") + } + os.exists(rd / "misc/broken-symlink", followLinks = false) ==> true + + os.remove.all(wd / "misc/file-symlink") + os.exists(wd / "misc/file-symlink", followLinks = false) ==> false + + os.remove.all(wd / "misc/folder-symlink") + os.exists(wd / "misc/folder-symlink", followLinks = false) ==> false + os.exists(wd / "folder1", followLinks = false) ==> true + os.exists(wd / "folder1/one.txt", followLinks = false) ==> true + + os.remove.all(wd / "misc/broken-symlink") + os.exists(wd / "misc/broken-symlink", followLinks = false) ==> false + } + } + } + test("hardlink") - prepChecker { wd => + intercept[WriteDenied] { + os.hardlink(wd / "Linked.txt", rd / "File.txt") + } + os.exists(wd / "Linked.txt") ==> false + + intercept[WriteDenied] { + os.hardlink(rd / "Linked.txt", wd / "File.txt") + } + os.exists(rd / "Linked.txt") ==> false + + os.hardlink(wd / "Linked.txt", wd / "File.txt") + os.exists(wd / "Linked.txt") + os.read(wd / "Linked.txt") ==> "I am cow" + os.isLink(wd / "Linked.txt") ==> false + } + test("symlink") - prepChecker { wd => + intercept[WriteDenied] { + os.symlink(rd / "Linked.txt", wd / "File.txt") + } + os.exists(rd / "Linked.txt") ==> false + + intercept[WriteDenied] { + os.symlink(rd / "Linked.txt", os.rel / "File.txt") + } + os.exists(rd / "Linked.txt") ==> false + + intercept[WriteDenied] { + os.symlink(rd / "LinkedFolder1", wd / "folder1") + } + os.exists(rd / "LinkedFolder1") ==> false + + intercept[WriteDenied] { + os.symlink(rd / "LinkedFolder2", os.rel / "folder1") + } + os.exists(rd / "LinkedFolder2") ==> false + + os.symlink(wd / "Linked.txt", wd / "File.txt") + os.read(wd / "Linked.txt") ==> "I am cow" + os.isLink(wd / "Linked.txt") ==> true + + os.symlink(wd / "Linked2.txt", os.rel / "File.txt") + os.read(wd / "Linked2.txt") ==> "I am cow" + os.isLink(wd / "Linked2.txt") ==> true + + os.symlink(wd / "LinkedFolder1", wd / "folder1") + os.walk(wd / "LinkedFolder1", followLinks = true) ==> Seq(wd / "LinkedFolder1/one.txt") + os.isLink(wd / "LinkedFolder1") ==> true + + os.symlink(wd / "LinkedFolder2", os.rel / "folder1") + os.walk(wd / "LinkedFolder2", followLinks = true) ==> Seq(wd / "LinkedFolder2/one.txt") + os.isLink(wd / "LinkedFolder2") ==> true + } + test("temp") { + test - prepChecker { wd => + val before = os.walk(rd) + intercept[WriteDenied] { + os.temp("default content", dir = rd) + } + os.walk(rd) ==> before + + val tempOne = os.temp("default content", dir = wd) + os.read(tempOne) ==> "default content" + os.write.over(tempOne, "Hello") + os.read(tempOne) ==> "Hello" + } + test("dir") - prepChecker { wd => + val before = os.walk(rd) + intercept[WriteDenied] { + os.temp.dir(dir = rd) + } + os.walk(rd) ==> before + + val tempDir = os.temp.dir(dir = wd) + os.list(tempDir) ==> Nil + os.write(tempDir / "file", "Hello") + os.list(tempDir) ==> Seq(tempDir / "file") + } + } + + test("read") { + test("inputStream") - prepChecker { wd => + os.exists(rd / "File.txt") ==> true + intercept[ReadDenied] { + os.read.inputStream(rd / "File.txt") + } + + val is = os.read.inputStream(wd / "File.txt") // ==> "I am cow" + is.read() ==> 'I' + is.read() ==> ' ' + is.read() ==> 'a' + is.read() ==> 'm' + is.read() ==> ' ' + is.read() ==> 'c' + is.read() ==> 'o' + is.read() ==> 'w' + is.read() ==> -1 + is.close() + } + } + test("write") { + test - prepChecker { wd => + intercept[WriteDenied] { + os.write(rd / "New File.txt", "New File Contents") + } + os.exists(rd / "New File.txt") ==> false + + os.write(wd / "New File.txt", "New File Contents") + os.read(wd / "New File.txt") ==> "New File Contents" + + os.write(wd / "NewBinary.bin", Array[Byte](0, 1, 2, 3)) + os.read.bytes(wd / "NewBinary.bin") ==> Array[Byte](0, 1, 2, 3) + } + test("outputStream") - prepChecker { wd => + intercept[WriteDenied] { + os.write.outputStream(rd / "New File.txt") + } + os.exists(rd / "New File.txt") ==> false + + val out = os.write.outputStream(wd / "New File.txt") + out.write('H') + out.write('e') + out.write('l') + out.write('l') + out.write('o') + out.close() + + os.read(wd / "New File.txt") ==> "Hello" + } + } + test("truncate") - prepChecker { wd => + intercept[WriteDenied] { + os.truncate(rd / "File.txt", 4) + } + Unchecked(os.read(rd / "File.txt")) ==> "I am a restricted cow" + + os.read(wd / "File.txt") ==> "I am cow" + + os.truncate(wd / "File.txt", 4) + os.read(wd / "File.txt") ==> "I am" + } + + test("zip") - prepChecker { wd => + intercept[WriteDenied] { + os.zip( + dest = rd / "zipped.zip", + sources = Seq( + wd / "File.txt", + wd / "folder1" + ) + ) + } + os.exists(rd / "zipped.zip") ==> false + + intercept[ReadDenied] { + os.zip( + dest = wd / "zipped.zip", + sources = Seq( + wd / "File.txt", + rd / "folder1" + ) + ) + } + os.exists(wd / "zipped.zip") ==> false + + val zipFile = os.zip( + wd / "zipped.zip", + Seq( + wd / "File.txt", + wd / "folder1" + ) + ) + + val unzipDir = os.unzip(zipFile, wd / "unzipped") + os.walk(unzipDir).sorted ==> Seq( + unzipDir / "File.txt", + unzipDir / "one.txt" + ) + } + test("unzip") - prepChecker { wd => + val zipFileName = "zipped.zip" + val zipFile: os.Path = os.zip( + dest = wd / zipFileName, + sources = Seq( + wd / "File.txt", + wd / "folder1" + ) + ) + + intercept[WriteDenied] { + os.unzip( + source = zipFile, + dest = rd / "unzipped" + ) + } + os.exists(rd / "unzipped") ==> false + + val unzipDir = os.unzip( + source = zipFile, + dest = wd / "unzipped" + ) + os.walk(unzipDir).length ==> 2 + } + } +} diff --git a/os/test/src/FilesystemPermissionsTests.scala b/os/test/src/FilesystemPermissionsTests.scala index 3b6c8439..32580dc8 100644 --- a/os/test/src/FilesystemPermissionsTests.scala +++ b/os/test/src/FilesystemPermissionsTests.scala @@ -40,12 +40,12 @@ object FilesystemPermissionsTests extends TestSuite { if (Unix()) { // Only works as root :( if (false) { - val originalOwner = os.owner(wd / "File.txt") + val originalGroup = os.group(wd / "File.txt") - os.owner.set(wd / "File.txt", "nobody") - os.owner(wd / "File.txt").getName ==> "nobody" + os.group.set(wd / "File.txt", "nobody") + os.group(wd / "File.txt").getName ==> "nobody" - os.owner.set(wd / "File.txt", originalOwner) + os.group.set(wd / "File.txt", originalGroup) } } } diff --git a/os/test/src/ReadingWritingTests.scala b/os/test/src/ReadingWritingTests.scala index 69910b98..12d2b200 100644 --- a/os/test/src/ReadingWritingTests.scala +++ b/os/test/src/ReadingWritingTests.scala @@ -111,7 +111,7 @@ object ReadingWritingTests extends TestSuite { os.read(wd / "File.txt") ==> "We are sow" } } - test("inputStream") { + test("outputStream") { test - prep { wd => val out = os.write.outputStream(wd / "New File.txt") out.write('H') diff --git a/os/test/src/TestUtil.scala b/os/test/src/TestUtil.scala index e65a7a79..fc82a7c6 100644 --- a/os/test/src/TestUtil.scala +++ b/os/test/src/TestUtil.scala @@ -1,6 +1,7 @@ package test.os import utest.framework.TestPath + import java.io.IOException import java.nio.file._ import java.nio.file.attribute.BasicFileAttributes @@ -76,6 +77,59 @@ object TestUtil { f(os.Path(directory.toAbsolutePath)) } + def prepChecker[T](f: os.Path => T)(implicit tp: TestPath, fn: sourcecode.FullName): T = + prep(wd => os.checker.withValue(AccessChecker(wd))(f(wd))) + + object AccessChecker { + def apply(roots: os.Path*): AccessChecker = AccessChecker(roots, roots) + } + + case class AccessChecker(readRoots: Seq[os.Path], writeRoots: Seq[os.Path]) extends os.Checker { + + def onRead(path: os.ReadablePath): Unit = { + path match { + case path: os.Path => + if (!readRoots.exists(path.startsWith)) throw ReadDenied(path, readRoots) + case _ => + } + } + + def onWrite(path: os.Path): Unit = { + // skip check when not writing to filesystem (like when writing to a zip file) + if (path.wrapped.getFileSystem.provider().getScheme == "file") { + if (!writeRoots.exists(path.startsWith)) throw WriteDenied(path, writeRoots) + } + } + } + + object Unchecked { + + def apply[T](thunk: => T): T = + os.checker.withValue(os.Checker.Nop)(thunk) + + def scope[T](acquire: => Unit, release: => Unit)(thunk: => T): T = { + apply(acquire) + try thunk + finally apply(release) + } + } + + case class ReadDenied(requested: os.Path, allowed: Seq[os.Path]) + extends Exception( + s"Cannot read from $requested. Read is ${ + if (allowed.isEmpty) "not permitted" + else s"restricted to ${allowed.mkString(", ")}" + }." + ) + + case class WriteDenied(requested: os.Path, allowed: Seq[os.Path]) + extends Exception( + s"Cannot write to $requested. Write is ${ + if (allowed.isEmpty) "not permitted" + else s"restricted to ${allowed.mkString(", ")}" + }." + ) + lazy val isDotty = { val cl: ClassLoader = Thread.currentThread().getContextClassLoader try {