Fs / FsLayer.flix
FsLayer.flix
/*
* Copyright 2026 Flix Authors
*
* Use of this source code is governed by the Apache 2.0 license
* that can be found in the LICENSE.md file.
*/
/// Low-level file system operations shared by the `Fs` effect handlers.
mod Fs.FsLayer {
import java.io.File
import java.io.IOException
import java.io.UncheckedIOException
import java.lang.Class
import java.lang.IllegalArgumentException
import java.lang.{Object => JObject}
import java.lang.UnsupportedOperationException
import java.nio.charset.CharacterCodingException
import java.nio.charset.StandardCharsets
import java.nio.file.attribute.BasicFileAttributes
import java.nio.file.attribute.FileAttribute
import java.nio.file.FileAlreadyExistsException
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.LinkOption
import java.nio.file.NotDirectoryException
import java.nio.file.OpenOption
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.CopyOption
import java.nio.file.StandardCopyOption
import java.nio.file.FileSystems
import java.nio.file.FileVisitOption
import java.nio.file.NoSuchFileException
import java.nio.file.PathMatcher
import java.nio.file.StandardOpenOption
import java.util.stream.BaseStream
import java.util.UUID
import java.util.stream.Stream
use Fs.Size
use Fs.Size.bytes
use Fs.Size.toBytes
use IoError.IoError
use IoError.ErrorKind
use Logger.Severity
use RichString.{text, bold, cyan, green, red, gray}
// ─── Symlink safety ────────────────────────────────────────────────
///
/// Returns a singleton vector containing `NOFOLLOW_LINKS`.
///
/// Used by file-test and attribute operations so they inspect the
/// symbolic link itself rather than silently dereferencing to its target.
///
def noFollow(): Vector[LinkOption] =
Vector#{LinkOption.NOFOLLOW_LINKS}
// ─── Logging helpers ───────────────────────────────────────────────
///
/// Logs a pre-operation message at `Debug` level.
///
pub def logPre(op: String, path: String): Unit \ Logger =
Logger.log(Severity.Debug, gray("-->") + text(" ") + bold(op) + text(" ") + cyan(path))
///
/// Logs a pre-operation message for two-path operations (copy/move) at `Debug` level.
///
pub def logPre2(op: String, src: String, dst: String): Unit \ Logger =
Logger.log(Severity.Debug, gray("-->") + text(" ") + bold(op) + text(" ") + cyan(src) + gray(" -> ") + cyan(dst))
///
/// Logs a post-operation message: `Debug` on success, `Warn` on error.
///
pub def logPost(op: String, path: String, describe: a -> String, result: Result[IoError, a]): Unit \ Logger =
match result {
case Ok(v) =>
Logger.log(Severity.Debug, gray("<--") + text(" ") + green("OK") + text(" ") + bold(op) + text(" ") + cyan(path) + gray(" (${describe(v)})"))
case Err(err) =>
Logger.log(Severity.Warn, gray("<--") + text(" ") + red("ERR") + text(" ") + bold(op) + text(" ") + cyan(path) + text(" ") + red("${err}"))
}
///
/// Logs a post-operation message for two-path operations (copy/move).
///
pub def logPost2(op: String, src: String, dst: String, result: Result[IoError, Unit]): Unit \ Logger =
match result {
case Ok(_) =>
Logger.log(Severity.Debug, gray("<--") + text(" ") + green("OK") + text(" ") + bold(op) + text(" ") + cyan(src) + gray(" -> ") + cyan(dst))
case Err(err) =>
Logger.log(Severity.Warn, gray("<--") + text(" ") + red("ERR") + text(" ") + bold(op) + text(" ") + cyan(src) + gray(" -> ") + cyan(dst) + text(" ") + red("${err}"))
}
// ─── File-test operations ──────────────────────────────────────────
/// Returns `true` if the path `f` exists, without following symbolic links.
pub def exists(f: String): Result[IoError, Bool] \ IO =
try {
Ok(Files.exists(Paths.get(f), noFollow()))
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
}
/// Returns `true` if the path `f` is a directory, without following symbolic links.
pub def isDirectory(f: String): Result[IoError, Bool] \ IO =
try {
Ok(Files.isDirectory(Paths.get(f), noFollow()))
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
}
/// Returns `true` if the path `f` is a regular file, without following symbolic links.
pub def isRegularFile(f: String): Result[IoError, Bool] \ IO =
try {
Ok(Files.isRegularFile(Paths.get(f), noFollow()))
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
}
/// Returns `true` if the path `f` is a symbolic link.
pub def isSymbolicLink(f: String): Result[IoError, Bool] \ IO =
try {
Ok(Files.isSymbolicLink(Paths.get(f)))
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
}
/// Returns `true` if the file `f` is readable.
pub def isReadable(f: String): Result[IoError, Bool] \ IO =
try {
Ok(Files.isReadable(Paths.get(f)))
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
}
/// Returns `true` if the file `f` is writable.
pub def isWritable(f: String): Result[IoError, Bool] \ IO =
try {
Ok(Files.isWritable(Paths.get(f)))
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
}
/// Returns `true` if the file `f` is executable.
pub def isExecutable(f: String): Result[IoError, Bool] \ IO =
try {
Ok(Files.isExecutable(Paths.get(f)))
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
}
// ─── File-time operations ──────────────────────────────────────────
/// Returns the last access time of the file `f` in milliseconds since the epoch.
pub def accessTime(f: String): Result[IoError, Int64] \ IO =
try {
Ok(fileAttributes(Paths.get(f)).lastAccessTime().toMillis())
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: NoSuchFileException => Err(IoError(ErrorKind.NotFound, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
/// Returns the creation time of the file `f` in milliseconds since the epoch.
pub def creationTime(f: String): Result[IoError, Int64] \ IO =
try {
Ok(fileAttributes(Paths.get(f)).creationTime().toMillis())
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: NoSuchFileException => Err(IoError(ErrorKind.NotFound, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
/// Returns the last modification time of the file `f` in milliseconds since the epoch.
pub def modificationTime(f: String): Result[IoError, Int64] \ IO =
try {
Ok(fileAttributes(Paths.get(f)).lastModifiedTime().toMillis())
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: NoSuchFileException => Err(IoError(ErrorKind.NotFound, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
// ─── File-stat operations ──────────────────────────────────────────
/// Returns the size of the file `f`.
pub def size(f: String): Result[IoError, Size] \ IO =
try {
Ok(bytes(fileAttributes(Paths.get(f)).size()))
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: NoSuchFileException => Err(IoError(ErrorKind.NotFound, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
// ─── Read operations ───────────────────────────────────────────────
/// Returns the contents of the file `f` as a string.
pub def read(f: String): Result[IoError, String] \ IO =
try {
Ok(String.fromBytes(Array.toVector(Files.readAllBytes(Paths.get(f)))))
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: NoSuchFileException => Err(IoError(ErrorKind.NotFound, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
/// Returns the lines of the file `f`.
pub def readLines(f: String): Result[IoError, List[String]] \ IO =
try {
Ok(ToFlix.toFlix(Files.readAllLines(Paths.get(f))))
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: NoSuchFileException => Err(IoError(ErrorKind.NotFound, ex.getMessage()))
case ex: CharacterCodingException => Err(IoError(ErrorKind.InvalidData, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
/// Returns the contents of the file `f` as a vector of bytes.
pub def readBytes(f: String): Result[IoError, Vector[Int8]] \ IO =
try {
Ok(Array.toVector(Files.readAllBytes(Paths.get(f))))
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: NoSuchFileException => Err(IoError(ErrorKind.NotFound, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
// ─── Directory listing ─────────────────────────────────────────────
/// Returns the names of the entries in the directory `f`.
pub def list(f: String): Result[IoError, List[String]] \ IO =
let file = new File(f);
let arr = file.list();
if (Object.isNull(arr))
if (not file.exists())
Err(IoError(ErrorKind.NotFound, f))
else if (not file.isDirectory())
Err(IoError(ErrorKind.NotDirectory, f))
else
Err(IoError(ErrorKind.Other, "Unable to list: ${f}"))
else
Ok(Array.toList(arr))
// ─── Glob operation ─────────────────────────────────────────────────
/// Returns the paths under `base` that match the glob `pattern`.
pub def glob(base: String, pattern: String): Result[IoError, List[String]] \ IO =
try {
let basePath = Paths.get(base);
let matcher = FileSystems.getDefault().getPathMatcher("glob:${pattern}");
let stream = Files.walk(basePath);
let bs: BaseStream[Path, Stream[Path]] = checked_cast(stream);
let jiter = bs.iterator();
def loop(acc) = {
if (jiter.hasNext())
let path = jiter.next();
let rel = basePath.relativize(path);
if (matcher.matches(rel))
loop(path.toString() :: acc)
else
loop(acc)
else
acc
};
let result = List.reverse(loop(Nil));
bs.close();
Ok(result)
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: IllegalArgumentException => Err(IoError(ErrorKind.InvalidInput, ex.getMessage()))
case ex: UncheckedIOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
// ─── Write operations ──────────────────────────────────────────────
/// Writes the string `data` to the file `f`, replacing any existing contents.
pub def write(data: {str = String}, f: String): Result[IoError, Unit] \ IO =
try {
let bytes = data#str.getBytes(StandardCharsets.UTF_8);
Files.write(Paths.get(f), bytes);
Ok(())
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: UnsupportedOperationException => Err(IoError(ErrorKind.Unsupported, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
/// Writes the given lines to the file `f`, replacing any existing contents.
pub def writeLines(data: {lines = List[String]}, f: String): Result[IoError, Unit] \ IO =
try {
let bf = Files.newBufferedWriter(Paths.get(f));
List.forEach(
line -> {
bf.write(line, 0, String.length(line));
bf.newLine()
},
data#lines
);
bf.close();
Ok(())
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: UnsupportedOperationException => Err(IoError(ErrorKind.Unsupported, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
/// Writes the bytes `data` to the file `f`, replacing any existing contents.
pub def writeBytes(data: Vector[Int8], f: String): Result[IoError, Unit] \ IO =
try {
region rc {
let bytes = Vector.toArray(rc, data);
Files.write(Paths.get(f), bytes);
Ok(())
}
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: UnsupportedOperationException => Err(IoError(ErrorKind.Unsupported, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
// ─── Append operations ─────────────────────────────────────────────
/// Appends the string `data` to the file `f`, creating it if it does not exist.
pub def append(data: {str = String}, f: String): Result[IoError, Unit] \ IO =
try {
let bytes = data#str.getBytes(StandardCharsets.UTF_8);
let opts: Vector[OpenOption] = Vector#{
checked_cast(StandardOpenOption.APPEND),
checked_cast(StandardOpenOption.CREATE)
};
Files.write(Paths.get(f), bytes, opts);
Ok(())
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: UnsupportedOperationException => Err(IoError(ErrorKind.Unsupported, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
/// Appends the given lines to the file `f`, creating it if it does not exist.
pub def appendLines(data: {lines = List[String]}, f: String): Result[IoError, Unit] \ IO =
try {
let opts: Vector[OpenOption] = Vector#{
checked_cast(StandardOpenOption.APPEND),
checked_cast(StandardOpenOption.CREATE)
};
let bf = Files.newBufferedWriter(Paths.get(f), opts);
List.forEach(
line -> {
bf.write(line, 0, String.length(line));
bf.newLine()
},
data#lines
);
bf.close();
Ok(())
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: UnsupportedOperationException => Err(IoError(ErrorKind.Unsupported, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
/// Appends the bytes `data` to the file `f`, creating it if it does not exist.
pub def appendBytes(data: Vector[Int8], f: String): Result[IoError, Unit] \ IO =
try {
region rc {
let bytes = Vector.toArray(rc, data);
let opts: Vector[OpenOption] = Vector#{
checked_cast(StandardOpenOption.APPEND),
checked_cast(StandardOpenOption.CREATE)
};
Files.write(Paths.get(f), bytes, opts);
Ok(())
}
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: UnsupportedOperationException => Err(IoError(ErrorKind.Unsupported, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
// ─── Truncate ──────────────────────────────────────────────────────
/// Truncates the existing file `f` to zero length.
pub def truncate(f: String): Result[IoError, Unit] \ IO =
try {
region rc {
let opt: OpenOption = checked_cast(StandardOpenOption.TRUNCATE_EXISTING);
let arr: Array[Int8, rc] = Array#{} @ rc;
Files.write(Paths.get(f), arr, opt);
Ok(())
}
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: UnsupportedOperationException => Err(IoError(ErrorKind.Unsupported, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
// ─── Copy operation ────────────────────────────────────────────────
/// Copies the file `src` to `dst` using the given copy options `opts`.
pub def copyWith(src: {src = String}, dst: String, opts: Set[Fs.CopyOption]): Result[IoError, Unit] \ IO =
try {
let copyOpts: Vector[CopyOption] = Set.foldLeft(
(acc, opt) -> match opt {
case Fs.CopyOption.ReplaceExisting => Vector.append(acc, Vector#{checked_cast(StandardCopyOption.REPLACE_EXISTING)})
case Fs.CopyOption.CopyAttributes => Vector.append(acc, Vector#{checked_cast(StandardCopyOption.COPY_ATTRIBUTES)})
},
(Vector#{}: Vector[CopyOption]),
opts
);
discard Files.copy(Paths.get(src#src), Paths.get(dst), copyOpts);
Ok(())
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: FileAlreadyExistsException => Err(IoError(ErrorKind.AlreadyExists, ex.getMessage()))
case ex: NoSuchFileException => Err(IoError(ErrorKind.NotFound, ex.getMessage()))
case ex: UnsupportedOperationException => Err(IoError(ErrorKind.Unsupported, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
/// Copies the file `src` to `dst`, failing if `dst` already exists.
pub def copy(src: {src = String}, dst: String): Result[IoError, Unit] \ IO =
copyWith(src, dst, Set.empty())
// ─── Move operation ────────────────────────────────────────────────
/// Moves the file `src` to `dst` using the given move options `opts`.
pub def moveWith(src: {src = String}, dst: String, opts: Set[Fs.MoveOption]): Result[IoError, Unit] \ IO =
try {
let copyOpts: Vector[CopyOption] = Set.foldLeft(
(acc, opt) -> match opt {
case Fs.MoveOption.AtomicMove => Vector.append(acc, Vector#{checked_cast(StandardCopyOption.ATOMIC_MOVE)})
case Fs.MoveOption.ReplaceExisting => Vector.append(acc, Vector#{checked_cast(StandardCopyOption.REPLACE_EXISTING)})
},
(Vector#{}: Vector[CopyOption]),
opts
);
discard Files.move(Paths.get(src#src), Paths.get(dst), copyOpts);
Ok(())
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: FileAlreadyExistsException => Err(IoError(ErrorKind.AlreadyExists, ex.getMessage()))
case ex: NoSuchFileException => Err(IoError(ErrorKind.NotFound, ex.getMessage()))
case ex: UnsupportedOperationException => Err(IoError(ErrorKind.Unsupported, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
/// Moves the file `src` to `dst`, failing if `dst` already exists.
pub def move(src: {src = String}, dst: String): Result[IoError, Unit] \ IO =
moveWith(src, dst, Set.empty())
// ─── Delete operation ────────────────────────────────────────────────
/// Deletes the file or empty directory `f`.
pub def delete(f: String): Result[IoError, Unit] \ IO =
try {
Files.delete(Paths.get(f));
Ok(())
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: NoSuchFileException => Err(IoError(ErrorKind.NotFound, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
// ─── Directory operations ──────────────────────────────────────────
/// Creates the directory `d`, failing if it already exists.
pub def mkDir(d: String): Result[IoError, Unit] \ IO =
try {
Files.createDirectory(Paths.get(d));
Ok(())
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: UnsupportedOperationException => Err(IoError(ErrorKind.Unsupported, ex.getMessage()))
case ex: FileAlreadyExistsException => Err(IoError(ErrorKind.AlreadyExists, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
/// Creates the directory `d` and any missing parent directories.
pub def mkDirs(d: String): Result[IoError, Unit] \ IO =
try {
Files.createDirectories(Paths.get(d));
Ok(())
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: UnsupportedOperationException => Err(IoError(ErrorKind.Unsupported, ex.getMessage()))
case ex: FileAlreadyExistsException => Err(IoError(ErrorKind.AlreadyExists, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
/// Creates a new temporary directory whose name starts with `prefix` and returns its path.
pub def mkTempDir(prefix: String): Result[IoError, String] \ IO =
try {
let path = Files.createTempDirectory(prefix);
Ok(path.toString())
} catch {
case ex: IllegalArgumentException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: UnsupportedOperationException => Err(IoError(ErrorKind.Unsupported, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
// ─── Path helper ────────────────────────────────────────────────────
///
/// Resolves `path` against `baseDir` using Java's `Path.resolve` semantics:
///
/// - If `path` is absolute, it is returned as-is.
/// - If `path` is relative, it is joined to `baseDir`.
///
pub def resolve(baseDir: String, path: String): String =
unsafe IO { Paths.get(baseDir).resolve(path).toString() }
///
/// Resolves `path` to its real filesystem location by following all symbolic links.
///
/// Uses Java's `Path.toRealPath` to resolve every symlink component in the path.
/// If the file does not exist yet (e.g., a write target), the parent directory is
/// resolved instead and the filename is appended. This ensures that a symlink in
/// an ancestor directory is still caught, while allowing writes to new files.
///
/// Falls back to `normalize()` only when neither the path nor its parent can be
/// resolved (e.g., the entire directory tree is missing).
///
def resolveSymLink(path: String): Path =
unsafe IO {
let p = Paths.get(path).toAbsolutePath();
try {
p.toRealPath()
} catch {
case _: IOException =>
let parent = p.getParent();
if (Object.isNull(parent))
p.normalize()
else
try {
parent.toRealPath().resolve(p.getFileName())
} catch {
case _: IOException => p.normalize()
}
}
}
///
/// Resolves `path` to its real filesystem target by following all symlinks.
/// Returns `Ok(realPath)` if the path exists, or an appropriate `IoError` if the
/// path is invalid, does not exist, or cannot be resolved.
///
pub def resolveRealPath(path: String): Result[IoError, String] =
unsafe IO {
try {
Ok(Paths.get(path).toRealPath().toString())
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: NoSuchFileException => Err(IoError(ErrorKind.NotFound, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
}
///
/// Resolves `path` to its real filesystem target by following all symlinks.
/// Falls back to the original `path` if resolution fails (e.g., the file does not
/// exist yet). Useful for write/create operations where the target may not exist.
///
pub def resolveRealPathOrSelf(path: String): String =
match resolveRealPath(path) {
case Ok(p) => p
case _ => path
}
///
/// Resolves `path` against `chrootDir` and validates that the resolved result
/// stays within the chroot directory. Symlinks are resolved to their real
/// filesystem targets before the boundary check.
///
/// Returns `Ok(resolvedPath)` if the path is within bounds,
/// or `Err(PermissionDenied)` if the path escapes the chroot.
///
pub def chroot(chrootDir: String, path: String): Result[IoError, String] =
unsafe IO {
try {
let base = Paths.get(chrootDir).toRealPath();
let resolved = resolveSymLink(Fs.FsLayer.resolve(chrootDir, path));
if (resolved.startsWith(base))
Ok(resolved.toString())
else
Err(IoError(ErrorKind.PermissionDenied, "Path '${path}' escapes chroot '${chrootDir}'"))
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: NoSuchFileException => Err(IoError(ErrorKind.NotFound, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
}
///
/// Resolves `path` to an absolute, normalized form and checks that it falls within
/// at least one of the `allowedDirs`. Returns `Ok(resolvedPath)` if allowed,
/// or `Err(PermissionDenied)` if the path is outside every allowed directory.
///
pub def allowList(allowedDirs: Nel[String], path: String): Result[IoError, String] =
unsafe IO {
try {
let resolved = resolveSymLink(path);
let allowed = Nel.exists(
dir -> {
let base = Paths.get(dir).toRealPath();
resolved.startsWith(base)
},
allowedDirs
);
if (allowed)
Ok(resolved.toString())
else
Err(IoError(ErrorKind.PermissionDenied, "Path '${path}' is not within any allowed directory"))
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: NoSuchFileException => Err(IoError(ErrorKind.NotFound, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
}
///
/// Resolves `path` to an absolute, normalized form and checks that it does not fall
/// within any of the `deniedDirs`. Returns `Ok(resolvedPath)` if permitted,
/// or `Err(PermissionDenied)` if the path is within a denied directory.
///
pub def denyList(deniedDirs: Nel[String], path: String): Result[IoError, String] =
unsafe IO {
try {
let resolved = resolveSymLink(path);
let denied = Nel.exists(
dir -> {
let base = Paths.get(dir).toRealPath();
resolved.startsWith(base)
},
deniedDirs
);
if (denied)
Err(IoError(ErrorKind.PermissionDenied, "Path '${path}' is within a denied directory"))
else
Ok(resolved.toString())
} catch {
case ex: InvalidPathException => Err(IoError(ErrorKind.InvalidPath, ex.getMessage()))
case ex: NoSuchFileException => Err(IoError(ErrorKind.NotFound, ex.getMessage()))
case ex: IOException => Err(IoError(ErrorKind.Other, ex.getMessage()))
}
}
///
/// Resolves `path` to an absolute, normalized form and checks that it matches
/// at least one of the given glob `patterns`. Returns `Ok(resolvedPath)` if allowed,
/// or `Err(PermissionDenied)` if the path does not match any pattern.
///
pub def allowGlob(patterns: Nel[String], path: String): Result[IoError, String] =
unsafe IO {
try {
let resolved = resolveSymLink(path);
let allowed = Nel.exists(
pattern -> {
let matcher = FileSystems.getDefault().getPathMatcher("glob:${pattern}");
matcher.matches(resolved)
},
patterns
);
if (allowed)
Ok(resolved.toString())
else
Err(IoError(ErrorKind.PermissionDenied, "Path '${path}' does not match any allowed glob pattern"))
} catch {
case ex: IllegalArgumentException => Err(IoError(ErrorKind.InvalidInput, ex.getMessage()))
case ex: UnsupportedOperationException => Err(IoError(ErrorKind.Unsupported, ex.getMessage()))
}
}
///
/// Resolves `path` to an absolute, normalized form and checks that it does not match
/// any of the given glob `patterns`. Returns `Ok(resolvedPath)` if permitted,
/// or `Err(PermissionDenied)` if the path matches a denied pattern.
///
pub def denyGlob(patterns: Nel[String], path: String): Result[IoError, String] =
unsafe IO {
try {
let resolved = resolveSymLink(path);
let denied = Nel.exists(
pattern -> {
let matcher = FileSystems.getDefault().getPathMatcher("glob:${pattern}");
matcher.matches(resolved)
},
patterns
);
if (denied)
Err(IoError(ErrorKind.PermissionDenied, "Path '${path}' matches a denied glob pattern"))
else
Ok(resolved.toString())
} catch {
case ex: IllegalArgumentException => Err(IoError(ErrorKind.InvalidInput, ex.getMessage()))
case ex: UnsupportedOperationException => Err(IoError(ErrorKind.Unsupported, ex.getMessage()))
}
}
// ─── Atomic write helper ────────────────────────────────────────────
///
/// Performs an atomic write to `file` by writing to a temporary sibling path
/// first, then atomically moving it into place.
///
/// - `doWrite(tmp)` writes data to the temporary path.
/// - `doMoveAtomic(tmp, file)` atomically moves the temp file to the target.
/// - `doDelete(tmp)` cleans up the temp file on failure.
///
/// The caller supplies these as effect operations so that this helper works
/// with any effect (`FileWrite`, `FileSystem`, etc.).
///
pub def atomicWrite(
file: String,
doWrite: String -> Result[IoError, Unit] \ ef,
doMoveAtomic: String -> String -> Result[IoError, Unit] \ ef,
doDelete: String -> Result[IoError, Unit] \ ef
): Result[IoError, Unit] \ ef =
let tmp = tempPathFor(file);
match doWrite(tmp) {
case Ok(_) => match doMoveAtomic(tmp, file) {
case Ok(_) => Ok(())
case Err(e) => { discard doDelete(tmp); Err(e) }
}
case Err(e) => { discard doDelete(tmp); Err(e) }
}
///
/// Generates a unique temporary file path in the same directory as `target`.
///
/// Uses UUID for uniqueness. The result is a sibling of `target` with the
/// pattern `.~atomic~<name>.<uuid>.tmp`.
///
def tempPathFor(target: String): String =
unsafe IO {
let path = Paths.get(target);
let parent = path.getParent();
let parentDir = if (Object.isNull(parent)) Paths.get(".") else parent;
let name = path.getFileName().toString();
let uuid = UUID.randomUUID().toString();
parentDir.resolve(".~atomic~${name}.${uuid}.tmp").toString()
}
// ─── Parent directory helper ────────────────────────────────────────
///
/// Returns the parent directory of `path`, or `None` if the path has no parent
/// (e.g., a bare filename like `"test.txt"`).
///
pub def parentDir(path: String): Option[String] =
unsafe IO {
let parent = Paths.get(path).getParent();
if (Object.isNull(parent)) None else Some(parent.toString())
}
///
/// Ensures the parent directory of `file` exists before executing `doAction`.
///
/// - If `file` has no parent (bare filename), `doAction()` proceeds directly.
/// - If `doMkDirs(parent)` succeeds, `doAction()` proceeds.
/// - If `doMkDirs(parent)` fails, the error is returned and `doAction` is
/// not performed.
///
pub def ensureParentDirs(
file: String,
doMkDirs: String -> Result[IoError, Unit] \ ef,
doAction: Unit -> Result[IoError, a] \ ef
): Result[IoError, a] \ ef =
match parentDir(file) {
case None => doAction()
case Some(d) => match doMkDirs(d) {
case Ok(_) => doAction()
case Err(e) => Err(e)
}
}
// ─── Backup helper ───────────────────────────────────────────────
///
/// Performs a backup of `file` to `file + suffix` before executing `doAction`.
///
/// - `doCopy(file, bak)` copies the file to the backup path.
/// - If the copy succeeds, `doAction()` is executed.
/// - If the copy fails with `NotFound`, the file doesn't exist yet, so
/// `doAction()` proceeds without backup.
/// - If the copy fails with any other error, the operation is aborted.
///
pub def backup(
file: String,
suffix: String,
doCopy: String -> String -> Result[IoError, Unit] \ ef,
doAction: Unit -> Result[IoError, a] \ ef
): Result[IoError, a] \ ef =
let bak = file + suffix;
match doCopy(file, bak) {
case Ok(_) => doAction()
case Err(IoError(ErrorKind.NotFound, _)) => doAction()
case Err(e) => Err(e)
}
// ─── Transfer limit helper ──────────────────────────────────────────
///
/// Checks that `actualSize` does not exceed `maxSize`.
/// Returns `Ok(())` if within the limit, or `Err(TransferLimitExceeded)` otherwise.
///
pub def checkTransferLimit(maxSize: Size, actualSize: Size): Result[IoError, Unit] =
let maxBytes = toBytes(maxSize);
let actualBytes = toBytes(actualSize);
if (actualBytes <= maxBytes)
Ok(())
else
Err(IoError(ErrorKind.TransferLimitExceeded, "transfer limit exceeded: ${actualSize} > ${maxSize}"))
///
/// Estimates the UTF-8 byte size of a string by multiplying the character
/// count by 4 (the maximum bytes per character in UTF-8).
///
pub def estimateStringBytes(s: String): Size =
bytes(Int32.toInt64(String.length(s)) * 4i64)
///
/// Estimates the UTF-8 byte size of a list of strings by multiplying the
/// total character count by 4 (the maximum bytes per character in UTF-8).
///
pub def estimateStringListBytes(l: List[String]): Size =
bytes(Int32.toInt64(List.sumWith(String.length, l)) * 4i64)
// ─── Conflict check helpers ────────────────────────────────────────
///
/// Snapshots the modification time of `file` into `snapshots`.
///
/// If `getModTime` fails (file deleted, etc.), silently skips.
///
pub def snapshotModTime(
file: String,
snapshots: MutMap[String, Int64, r],
getModTime: String -> Result[IoError, Int64] \ ef
): Unit \ { ef, r } =
match getModTime(file) {
case Ok(t) => MutMap.put(file, t, snapshots)
case _ => ()
}
///
/// Checks whether `file` was modified externally since last snapshot.
///
/// - No snapshot → `Ok()` (file was never read, nothing to conflict with).
/// - Mod time matches snapshot → `Ok()`.
/// - Mod time differs → `Err(Conflict(...))`.
/// - File deleted (`NotFound`) → `Ok()` (file gone, proceed).
/// - Other `getModTime` error → propagated.
///
pub def conflictCheck(
file: String,
snapshots: MutMap[String, Int64, r],
getModTime: String -> Result[IoError, Int64] \ ef
): Result[IoError, Unit] \ { ef, r } =
match MutMap.get(file, snapshots) {
case None => Ok(())
case Some(oldTime) =>
match getModTime(file) {
case Ok(curTime) =>
if (curTime == oldTime)
Ok(())
else
Err(IoError(ErrorKind.Conflict, "Conflict on '${file}': expected mtime ${oldTime}, found ${curTime}"))
case Err(IoError(ErrorKind.NotFound, _)) => Ok(())
case Err(e) => Err(e)
}
}
// ─── Checksum helpers ──────────────────────────────────────────────
///
/// Verifies `actual` (a hex digest string) against the checksum stored in
/// the sidecar file `file + sidecar`.
///
/// - If the sidecar does not exist (`NotFound`), returns `Ok(())`.
/// - If the sidecar exists and matches, returns `Ok(())`.
/// - If the sidecar exists and does not match, returns
/// `Err(IoError(ChecksumMismatch, ...))`.
///
pub def verifyChecksum(
file: String,
sidecar: String,
actual: String,
doReadSidecar: String -> Result[IoError, String] \ ef
): Result[IoError, Unit] \ ef =
match doReadSidecar(file + sidecar) {
case Ok(expected) =>
if (String.trim(expected) == actual)
Ok(())
else
Err(IoError(ErrorKind.ChecksumMismatch, "Checksum mismatch for '${file}': expected ${String.trim(expected)}, got ${actual}"))
case Err(IoError(ErrorKind.NotFound, _)) => Ok(())
case Err(e) => Err(e)
}
///
/// Reads the bytes of `file`, hashes them with `computeHex`, and writes
/// the hex digest to the sidecar path `file + sidecar`.
///
pub def updateSidecar(
file: String,
sidecar: String,
computeHex: Vector[Int8] -> String,
doReadBytes: String -> Result[IoError, Vector[Int8]] \ ef1,
doWriteSidecar: (String, String) -> Result[IoError, Unit] \ ef2
): Result[IoError, Unit] \ ef1 + ef2 =
forM (
bytes <- doReadBytes(file);
_ <- doWriteSidecar(computeHex(bytes), file + sidecar)
) yield ()
///
/// Performs `doWrite`, then computes and writes a checksum sidecar for `file`.
///
/// `doUpdateSidecar(file)` should read the file bytes, hash them, and
/// write the hex digest to the sidecar path.
///
pub def checksumAfterWrite(
file: String,
doWrite: Unit -> Result[IoError, Unit] \ ef1,
doUpdateSidecar: String -> Result[IoError, Unit] \ ef2
): Result[IoError, Unit] \ ef1 + ef2 =
Result.flatMap(_ -> doUpdateSidecar(file), doWrite())
///
/// Verifies a `readBytes` result against a checksum sidecar.
///
/// The returned bytes are hashed via `computeHex` and compared against
/// the sidecar. If no sidecar exists, the result passes through.
///
pub def checksumOnReadBytes(
file: String,
sidecar: String,
computeHex: Vector[Int8] -> String,
readResult: Result[IoError, Vector[Int8]],
doReadSidecar: String -> Result[IoError, String] \ ef
): Result[IoError, Vector[Int8]] \ ef =
forM (
bytes <- readResult;
_ <- verifyChecksum(file, sidecar, computeHex(bytes), doReadSidecar)
) yield bytes
///
/// Verifies a `read` or `readLines` result against a checksum sidecar.
///
/// Since the result type (`String` or `List[String]`) does not preserve
/// raw bytes, `doReadBytes` is called to obtain the on-disk bytes for
/// hashing. If no sidecar exists, the result passes through.
///
pub def checksumOnRead(
file: String,
sidecar: String,
computeHex: Vector[Int8] -> String,
readResult: Result[IoError, a],
doReadBytes: String -> Result[IoError, Vector[Int8]] \ ef,
doReadSidecar: String -> Result[IoError, String] \ ef
): Result[IoError, a] \ ef =
forM (
v <- readResult;
bytes <- doReadBytes(file);
_ <- verifyChecksum(file, sidecar, computeHex(bytes), doReadSidecar)
) yield v
///
/// Moves the checksum sidecar after a successful `moveWith`.
///
/// If the source sidecar does not exist, the move result is returned
/// unchanged. Sidecar move errors are discarded.
///
pub def checksumAfterMove(
src: String,
dst: String,
sidecar: String,
moveResult: Result[IoError, Unit],
doMoveSidecar: String -> String -> Result[IoError, Unit] \ ef
): Result[IoError, Unit] \ ef =
match moveResult {
case Ok(_) => { discard doMoveSidecar(src + sidecar, dst + sidecar); Ok(()) }
case Err(e) => Err(e)
}
///
/// Deletes the checksum sidecar after a successful `delete`.
///
/// If the sidecar does not exist, the delete result is returned
/// unchanged. Sidecar delete errors are discarded.
///
pub def checksumAfterDelete(
file: String,
sidecar: String,
deleteResult: Result[IoError, Unit],
doDeleteSidecar: String -> Result[IoError, Unit] \ ef
): Result[IoError, Unit] \ ef =
match deleteResult {
case Ok(_) => { discard doDeleteSidecar(file + sidecar); Ok(()) }
case Err(e) => Err(e)
}
// ─── Size rotation helper ──────────────────────────────────────────
///
/// Rotates `file` if its size meets or exceeds `maxSize`, then executes `doAction`.
///
/// Rotation shifts existing numbered files (`file.1` … `file.{maxFiles}`)
/// upward by one, deletes the oldest (`file.{maxFiles}`), and moves `file`
/// to `file.1`. The `doAction` then writes to the now-absent original path.
///
/// If the file does not exist (`NotFound`), rotation is skipped and
/// `doAction` proceeds directly (append will create it).
///
pub def rotateIfNeeded(
file: String,
maxSize: Size,
maxFiles: Int32,
doSize: String -> Result[IoError, Size] \ ef1,
doMove: String -> String -> Result[IoError, Unit] \ ef2,
doDelete: String -> Result[IoError, Unit] \ ef2,
doAction: Unit -> Result[IoError, a] \ ef2
): Result[IoError, a] \ ef1 + ef2 =
match doSize(file) {
case Err(IoError(ErrorKind.NotFound, _)) => doAction()
case Err(e) => Err(e)
case Ok(sz) =>
if (toBytes(sz) < toBytes(maxSize))
doAction()
else {
// Delete the oldest rotated file (ignore NotFound)
discard match doDelete("${file}.${maxFiles}") {
case Err(IoError(ErrorKind.NotFound, _)) => Ok(())
case other => other
};
// Shift file.{i} → file.{i+1} for i from maxFiles-1 down to 1
def shift(i) = {
if (i < 1)
Ok(())
else
match doMove("${file}.${i}", "${file}.${i + 1}") {
case Ok(_) => shift(i - 1)
case Err(IoError(ErrorKind.NotFound, _)) => shift(i - 1)
case Err(e) => Err(e)
}
};
match shift(maxFiles - 1) {
case Err(e) => Err(e)
case Ok(_) =>
// Move the current file to file.1
match doMove(file, "${file}.1") {
case Err(e) => Err(e)
case Ok(_) => doAction()
}
}
}
}
// ─── Private helper ────────────────────────────────────────────────
///
/// Returns the attributes of the given file `f`.
/// May throw `IOException` and `SecurityException`.
///
def fileAttributes(path: Path): BasicFileAttributes \ IO =
Files.readAttributes(
path,
Class.forName("java.nio.file.attribute.BasicFileAttributes"),
noFollow()
)
}