///|
/// Validated immutable portable project contents. No filesystem operations.
/// Entries and bytes are copied at the public boundary.
pub struct Snapshot {
priv entries : Array[(String, Bytes)]
}
///|
fn copy_bytes(b : Bytes) -> Bytes {
Bytes::from_array(FixedArray::makei(b.length(), i => b[i]))
}
///|
/// Canonicalize and sort paths, reject case collisions and file/directory
/// conflicts. Limits: 1000 entries, 1 MiB/file, 64 KiB/mbt and 16 MiB total.
/// Source files must contain strict UTF8; support files may be binary.
pub fn Snapshot::new(
files : Array[(String, Bytes)],
) -> Result[Snapshot, String] {
if files.length() > 1000 {
return Err("MR_INPUT_LIMIT: too many project entries")
}
let entries : Array[(String, Bytes)] = []
let seen : Map[String, Bool] = Map([])
let mut total = 0
for entry in files {
let (path, bytes) = entry
let path = match @source.validate_path(path) {
Err(e) => return Err(e)
Ok(p) => p
}
let lower = path.to_lower()
if seen.contains(lower) {
return Err("MR_INPUT_PATH: duplicate or case-colliding path")
}
seen[lower] = true
if bytes.length() > 1048576 ||
(lower.has_suffix(".mbt") && bytes.length() > 65536) {
return Err("MR_INPUT_LIMIT: file too large")
}
total += bytes.length()
if total > 16777216 {
return Err("MR_INPUT_LIMIT: project exceeds 16 MiB")
}
if lower.has_suffix(".mbt") {
match @source.decode(bytes) {
Err(e) => return Err(e)
Ok(_) => ()
}
}
entries.push((path, bytes))
}
let directories : Map[String, String] = Map([])
for entry in entries {
let path = entry.0
let parts = path.split("/").map(x => x.to_owned()).to_array()
let mut parent = ""
for i in 0..<(parts.length() - 1) {
parent = if i == 0 { parts[i] } else { parent + "/" + parts[i] }
let lower = parent.to_lower()
match directories.get(lower) {
Some(spelling) if spelling != parent =>
return Err("MR_INPUT_PATH: directory case collision")
_ => directories[lower] = parent
}
if seen.contains(parent.to_lower()) {
return Err("MR_INPUT_PATH: file conflicts with directory")
}
}
}
entries.sort_by((a, b) => compare_path(a.0, b.0))
Ok({ entries: entries.map(x => (x.0, copy_bytes(x.1))) })
}
///|
/// Defensive copy in stable path order.
pub fn Snapshot::files(self : Snapshot) -> Array[(String, Bytes)] {
self.entries.map(x => (x.0, copy_bytes(x.1)))
}
///|
/// Replace an existing entry, returning a newly validated snapshot.
pub fn Snapshot::replace(
self : Snapshot,
path : String,
bytes : Bytes,
) -> Result[Snapshot, String] {
let path = match @source.validate_path(path) {
Ok(p) => p
Err(e) => return Err(e)
}
let mut found = false
let files = self.entries.map(x => {
if x.0 == path {
found = true
(path, bytes)
} else {
x
}
})
if !found {
return Err("MR_INPUT_PATH: replacement target missing")
}
Snapshot::new(files)
}
///|
/// Remove exactly one existing entry without mutating this snapshot.
pub fn Snapshot::remove(
self : Snapshot,
path : String,
) -> Result[Snapshot, String] {
let path = match @source.validate_path(path) {
Ok(p) => p
Err(e) => return Err(e)
}
let files = self.entries.filter(x => x.0 != path)
if files.length() == self.entries.length() {
return Err("MR_INPUT_PATH: removal target missing")
}
Snapshot::new(files)
}
///|
/// Exact versioned content key, not a digest. Includes every path and byte;
/// length framing prevents ambiguity. Evaluators must also key their config.
pub fn Snapshot::key(self : Snapshot) -> String {
let out = StringBuilder()
out.write_string("snapshot-v1;")
for entry in self.entries {
let (path, bytes) = entry
out.write_string(
path.length().to_string() +
":" +
path +
":" +
bytes.length().to_string() +
":",
)
for b in bytes {
let h = b.to_int().to_string(radix=16)
if h.length() == 1 {
out.write_string("0")
}
out.write_string(h)
}
out.write_string(";")
}
out.to_string()
}
///|
fn compare_path(a : String, b : String) -> Int {
for i in 0.. b[i] {
return 1
}
}
a.length() - b.length()
}