///|
/// Reject ambiguous paths rather than silently normalizing a requested action.
///
/// ```mbt check
/// test {
/// assert_true(@moonguard.valid_path("notes/设计.md"))
/// assert_false(@moonguard.valid_path("../private.txt"))
/// }
/// ```
pub fn valid_path(path : String) -> Bool {
if path.is_empty() || path.length() > 240 || !valid_text(path) {
return false
}
for c in path {
if c < ' ' ||
c == '\u{007f}' ||
c == '\\' ||
c == ':' ||
c == '<' ||
c == '>' ||
c == '"' ||
c == '|' ||
c == '?' ||
c == '*' {
return false
}
}
for part in path.split("/") {
let part = part.to_owned()
if part.is_empty() ||
part.has_prefix(".") ||
part.has_suffix(".") ||
part.has_suffix(" ") {
return false
}
let base = part.split(".").to_array()[0].to_owned().to_lower()
if base == "con" ||
base == "prn" ||
base == "aux" ||
base == "nul" ||
base == "conin$" ||
base == "conout$" {
return false
}
if base.length() == 4 && (base.has_prefix("com") || base.has_prefix("lpt")) {
if base[3] >= '1' && base[3] <= '9' {
return false
}
}
}
true
}
///|
fn text_path(path : String) -> Bool {
let lower = path.to_lower()
lower.has_suffix(".md") ||
lower.has_suffix(".txt") ||
lower.has_suffix(".json") ||
lower.has_suffix(".csv")
}
///|
// JSON may carry lone UTF-16 surrogates which cannot round-trip through UTF-8.
fn valid_text(content : String) -> Bool {
let mut i = 0
while i < content.length() {
let unit = content[i].to_int()
if unit == 0 || (unit >= 0xDC00 && unit <= 0xDFFF) {
return false
}
if unit >= 0xD800 && unit <= 0xDBFF {
if i + 1 >= content.length() {
return false
}
let next = content[i + 1].to_int()
if next < 0xDC00 || next > 0xDFFF {
return false
}
i += 2
} else {
i += 1
}
}
true
}
///|
fn parent_paths(path : String) -> Array[String] {
let parts = path.split("/").map(v => v.to_owned()).to_array()
let paths = []
for i in 1.. Bool {
let lower = path.to_lower()
for other, _ in files {
let other = other.to_lower()
if lower == other ||
lower.has_prefix(other + "/") ||
other.has_prefix(lower + "/") {
return true
}
}
for other in snapshot.blocked {
let other = other.to_lower()
if lower == other ||
lower.has_prefix(other + "/") ||
other.has_prefix(lower + "/") {
return true
}
}
for dir in snapshot.folders {
if lower == dir.to_lower() || dir.to_lower().has_prefix(lower + "/") {
return true
}
}
false
}
///|
/// Full snapshot validation is shared by all frontends.
pub fn validate_snapshot(snapshot : Snapshot) -> Array[Issue] {
let issues = []
let seen : Map[String, String] = Map([])
if snapshot.files.length() > 200 ||
snapshot.folders.length() > 500 ||
snapshot.blocked.length() > 500 {
issues.push(issue("limit", "", "工作区超过首版容量限制"))
}
let mut total = 0
for file in snapshot.files {
total += file.content.length()
if !valid_text(file.content) {
issues.push(
issue(
"invalid_text",
file.path,
"内容包含 NUL 或不能编码为 UTF-8 的字符",
),
)
}
if !valid_path(file.path) || !text_path(file.path) {
issues.push(
issue(
"invalid_path",
file.path,
"仅支持工作区内的非隐藏文本文件",
),
)
}
if file.content.length() > 262144 {
issues.push(
issue("limit", file.path, "单文件内容超过 256K 字符"),
)
}
if seen.contains(file.path.to_lower()) {
issues.push(
issue("collision", file.path, "存在重复或大小写冲突的路径"),
)
}
seen[file.path.to_lower()] = file.path
}
if total > 2097152 {
issues.push(issue("limit", "", "文本总量超过 2M 字符"))
}
for file in snapshot.files {
for parent in parent_paths(file.path) {
if seen.contains(parent.to_lower()) {
issues.push(
issue("collision", file.path, "父级路径已被文件占用"),
)
}
}
for other in snapshot.blocked {
if file.path.to_lower() == other.to_lower() ||
file.path.to_lower().has_prefix(other.to_lower() + "/") {
issues.push(
issue(
"blocked",
file.path,
"路径与不可操作的文件或链接冲突",
),
)
}
}
for dir in snapshot.folders {
if file.path.to_lower() == dir.to_lower() ||
dir.to_lower().has_prefix(file.path.to_lower() + "/") {
issues.push(
issue("collision", file.path, "文件路径与目录冲突"),
)
}
}
}
issues
}