// Store paths, and the rules that make them safe to hand a backend.
//
// A store path is not a filesystem path. It is relative to the store root,
// always uses `/`, and says whether it means a directory by ending in one. The
// root is `""`, never `"/"`.
//
// Every path a caller supplies goes through `validate` in `Operator` before any
// backend sees it. That is deliberate and it is load-bearing: because a
// validated path has no `..`, no leading `/` and no `\`, `FsStore` may
// concatenate it onto its root and cannot escape. Validation is here rather than
// in each backend so that there is one place to be right.
///|
/// Reduce `path` to canonical form. Total; never fails.
///
/// - runs of `/` collapse to one
/// - `.` segments drop
/// - a leading `/` drops
/// - a trailing `/` survives, because that is what says "directory" -- except
/// that the root is `""`, so `"/"`, `"."`, `"./"` and `"//"` all become `""`
///
/// `..` is NOT resolved. `validate` rejects it instead, because resolving it
/// would make `a/../../b` mean something and there is nothing above the root of
/// a store for it to mean.
pub fn normalize(path : String) -> String {
let wants_dir = path.has_suffix("/")
let segments = []
for seg in path.split("/") {
if seg != "" && seg != "." {
segments.push(seg)
}
}
if segments.is_empty() {
return ""
}
let out = StringBuilder()
for i, seg in segments {
if i > 0 {
out.write_string("/")
}
out.write_string(seg.to_owned())
}
if wants_dir {
out.write_string("/")
}
out.to_string()
}
///|
/// True for the root (`""`) and for anything ending in `/`.
pub fn is_dir_path(path : String) -> Bool {
path == "" || path.has_suffix("/")
}
///|
/// Append a `/` unless the path is the root or already has one.
pub fn ensure_dir_path(path : String) -> String {
if is_dir_path(path) {
path
} else {
path + "/"
}
}
///|
/// Drop a trailing `/`. The root stays the root.
pub fn strip_dir_path(path : String) -> String {
match path.strip_suffix("/") {
Some(v) => v.to_owned()
None => path
}
}
///|
/// The containing directory, always in directory form.
///
/// `parent("a/b/c") == "a/b/"`, `parent("a/b/") == "a/"`, `parent("a") == ""`,
/// `parent("") == ""`.
pub fn parent(path : String) -> String {
let stripped = strip_dir_path(path)
if stripped == "" {
return ""
}
match stripped.rev_find("/") {
Some(i) => stripped[:i + 1].to_owned()
None => ""
}
}
///|
/// The last segment, keeping the `/` for a directory.
///
/// `basename("a/b/c") == "c"`, `basename("a/b/") == "b/"`, `basename("") == ""`.
pub fn basename(path : String) -> String {
if path == "" {
return ""
}
let dir = is_dir_path(path)
let stripped = strip_dir_path(path)
let base = match stripped.rev_find("/") {
Some(i) => stripped[i + 1:].to_owned()
None => stripped
}
if dir {
base + "/"
} else {
base
}
}
///|
/// Join, treating `base` as a directory whether or not it says so.
///
/// `join("a/b", "c") == "a/b/c"`, `join("", "c") == "c"`, `join("a/", "") == "a/"`.
pub fn join(base : String, child : String) -> String {
if child == "" {
base
} else if base == "" {
child
} else {
ensure_dir_path(base) + child
}
}
///|
/// Number of `/`-separated segments. `depth("") == 0`, `depth("a/b/") == 2`.
pub fn depth(path : String) -> Int {
let mut n = 0
for seg in path.split("/") {
if seg != "" {
n = n + 1
}
}
n
}
///|
/// Every strict ancestor directory, shallowest first, in directory form.
///
/// `ancestors("a/b/c") == ["a/", "a/b/"]`, `ancestors("a") == []`.
///
/// This is what the object-store backends walk to materialise the directory
/// records a filesystem gets for free from `mkdir -p`.
pub fn ancestors(path : String) -> Array[String] {
let out = []
let stripped = strip_dir_path(path)
let mut acc = ""
for seg in stripped.split("/") {
if seg == "" {
continue
}
if acc != "" {
out.push(acc)
}
acc = acc + seg.to_owned() + "/"
}
out
}
///|
/// `normalize` plus the rules a store path must obey, or `InvalidPath`.
///
/// Rejects any `..` segment, a NUL, a `\`, a Windows drive prefix (`C:`), and
/// `U+FFFF`.
///
/// That last one looks arbitrary and is not: the IndexedDB backend bounds its
/// prefix cursor with `prefix + "\u{FFFF}"`, and the bound is only sound if no
/// stored key can contain the character. One rule in the core buys a correct
/// range scan in a backend.
pub fn validate(path : String, operation~ : String) -> String raise SosError {
fn reject(why : String) -> SosError {
SosError::new(InvalidPath, operation~, path~, message=why)
}
if path.contains_char('\u{0}') {
raise reject("path contains a NUL")
}
if path.contains_char('\\') {
raise reject("path contains a backslash; store paths always use '/'")
}
if path.contains_char('\u{FFFF}') {
raise reject(
"path contains U+FFFF, which is reserved as a range terminator",
)
}
if path.get_char(1) is Some(':') {
raise reject("path looks like a Windows drive; store paths are relative")
}
let normalized = normalize(path)
for seg in normalized.split("/") {
if seg == ".." {
raise reject("path contains '..'; there is nothing above a store root")
}
}
normalized
}
///|
/// `validate`, and reject a directory path.
///
/// For the operations that need bytes: `read`, `write`, `copy`, `rename`.
pub fn validate_file(
path : String,
operation~ : String,
) -> String raise SosError {
let normalized = validate(path, operation~)
guard !is_dir_path(normalized) else {
raise SosError::new(
IsADirectory,
operation~,
path=normalized,
message="\{operation} needs a file path, not a directory",
)
}
normalized
}
///|
/// `validate`, then `ensure_dir_path`, so a caller may write `list("logs")` and
/// mean the directory.
///
/// For `list` and `create_dir`.
pub fn validate_dir(
path : String,
operation~ : String,
) -> String raise SosError {
ensure_dir_path(validate(path, operation~))
}
///|
/// Order two store paths, ascending, by UTF-16 code unit.
///
/// MoonBit's `Compare for String` is **length-first**: it compares lengths and
/// only then contents, so `"b/" < "a.txt"`. That is a fine total order and it is
/// not the one a listing promises -- pagination with `start_after` is only
/// resumable if "ascending" means what every other store means by it. So every
/// ordering decision in this library goes through here, and none through `<`.
///
/// Code units rather than code points, deliberately, because that is what
/// IndexedDB compares by. The `IdbStore` cursor is bounded by
/// `prefix + "\u{FFFF}"`, and in code-unit order every astral character starts
/// with a surrogate below `0xFFFF` and so stays inside the bound. In code-point
/// order it would not, and a key with an emoji in it would silently vanish from
/// a listing.
pub fn path_compare(a : String, b : String) -> Int {
let shared = @cmp.minimum(a.length(), b.length())
for i in 0.. Bool {
path_compare(a, b) < 0
}