///|
priv struct BatchItem {
label : String
code : Int
report : Json
text : String
markdown : String
}
///|
/// Bounded, ordered offline review. No cross-file execution or LDAP state is inferred.
/// Only compact reports are retained, not input bytes or parsed documents.
pub struct BatchReview {
priv items : Array[BatchItem]
priv options : Options
priv risk_policy : RiskPolicy
priv legacy_dn_spaces : Bool
priv mut requested : Int
priv mut input_bytes : Int
}
///|
pub fn BatchReview::new(
options? : Options = Options::default(),
risk_policy? : RiskPolicy = RiskPolicy::default(),
legacy_dn_spaces? : Bool = false,
) -> BatchReview {
{
items: [],
options,
risk_policy,
legacy_dn_spaces,
requested: 0,
input_bytes: 0,
}
}
///|
fn batch_label(label : String) -> String {
let out = StringBuilder()
let mut count = 0
for c in label.iter() {
if count >= 200 {
break
}
count += 1
let n = c.to_int()
if n < 32 ||
n == 127 ||
(n >= 0x202A && n <= 0x202E) ||
(n >= 0x2066 && n <= 0x2069) ||
c == '/' ||
c == '\\' ||
c == ':' {
out.write_char('_')
} else {
out.write_char(c)
}
}
if count == 0 {
"unnamed"
} else {
out.to_string()
}
}
///|
fn BatchReview::failure(
self : BatchReview,
label : String,
reason : String,
) -> Unit {
let safe_label = batch_label(label)
self.items.push({
label: safe_label,
code: 2,
report: {
"status": "unavailable",
"exit_code": 2,
"source": Json::null(),
"diagnostics": [
{
"code": "batch-input-error",
"severity": "error",
"reason": reason.to_json(),
},
],
},
text: "Not analysed: " + reason + "\n",
markdown: "**Not analysed:** " + markdown_escape(reason) + "\n",
})
}
///|
/// Record a host I/O failure without leaking an OS error or local path.
pub fn BatchReview::add_unavailable(self : BatchReview, label : String) -> Unit {
self.requested += 1
if self.requested <= 50 {
self.failure(
label, "Input could not be read within the batch/file limits; no content was analysed.",
)
}
}
///|
/// Remaining cumulative accepted-input-byte budget, for bounded host I/O.
pub fn BatchReview::remaining_bytes(self : BatchReview) -> Int {
32 * 1024 * 1024 - self.input_bytes
}
///|
/// The host must compute input_sha256 from data. Syntax is checked here;
/// this identity is not an authenticity proof. Labels are display names, not paths.
pub fn BatchReview::add_bytes(
self : BatchReview,
label : String,
data : Bytes,
input_sha256 : String,
) -> Unit {
self.requested += 1
if self.requested > 50 {
return
}
if data.length() > 8 * 1024 * 1024 || data.length() > self.remaining_bytes() {
self.failure(label, "Input exceeds the 8 MiB file or 32 MiB batch limit.")
return
}
self.input_bytes += data.length()
if input_sha256.length() != 64 ||
!input_sha256
.iter()
.all(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
self.failure(
label, "Source SHA-256 must be 64 lowercase hexadecimal characters.",
)
return
}
let report = check(
data,
options=self.options,
risk_policy=self.risk_policy,
legacy_dn_spaces=self.legacy_dn_spaces,
)
let metadata : ReviewMetadata = {
input_sha256,
input_byte_length: data.length(),
options: self.options,
risk_policy: self.risk_policy,
legacy_dn_spaces: self.legacy_dn_spaces,
}
self.items.push({
label: batch_label(label),
code: report.exit_code(),
report: report.review_json(metadata),
text: "Source SHA-256: " +
input_sha256 +
"; bytes: " +
data.length().to_string() +
"\n" +
report.to_text() +
report.review().to_text(),
markdown: report.review_markdown(metadata),
})
}
///|
pub fn BatchReview::exit_code(self : BatchReview) -> Int {
if self.requested == 0 ||
self.requested > 50 ||
self.items.any(item => item.code == 2) {
2
} else if self.items.any(item => item.code == 1) {
1
} else {
0
}
}
///|
pub fn BatchReview::to_json(self : BatchReview) -> Json {
let diagnostics : Array[Json] = []
if self.requested == 0 || self.requested > 50 {
diagnostics.push({
"code": "batch-file-count",
"severity": "error",
"reason": "A batch requires 1 to 50 explicit inputs. Unreported inputs were not analysed.",
})
}
let files : Array[Json] = []
for i, item in self.items {
files.push({
"index": i.to_json(),
"label": item.label.to_json(),
"report": item.report,
})
}
{
"tool": "MoonLDIF",
"version": version().to_json(),
"kind": "batch-review",
"report_schema_version": 1,
"exit_code": self.exit_code().to_json(),
"status": (if self.exit_code() == 2 { "incomplete" } else { "complete" }).to_json(),
"analysis_complete": (self.exit_code() != 2).to_json(),
"requested_files": self.requested.to_json(),
"reported_files": self.items.length().to_json(),
"unreported_files": (self.requested - self.items.length()).to_json(),
"input_byte_budget_used": self.input_bytes.to_json(),
"passed_files": self.items.filter(i => i.code == 0).length().to_json(),
"blocked_files": self.items.filter(i => i.code == 1).length().to_json(),
"incomplete_or_error_files": self.items
.filter(i => i.code == 2)
.length()
.to_json(),
"options": self.options_json(),
"diagnostics": diagnostics.to_json(),
"files": files.to_json(),
}
}
///|
fn BatchReview::options_json(self : BatchReview) -> Json {
{
"allow_missing_version": self.options.allow_missing_version.to_json(),
"deny_delete": self.options.deny_delete.to_json(),
"deny_clear": self.risk_policy.deny_clear.to_json(),
"deny_rename": self.risk_policy.deny_rename.to_json(),
"legacy_dn_spaces": self.legacy_dn_spaces.to_json(),
}
}
///|
pub fn BatchReview::to_text(self : BatchReview) -> String {
let out = StringBuilder()
out.write_string(
"MoonLDIF " +
version() +
" batch review; exit " +
self.exit_code().to_string() +
"; files " +
self.items.length().to_string() +
"/" +
self.requested.to_string() +
"\n",
)
out.write_string(
"Reports contain filenames and DNs, not attribute values. Files are checked independently, not as a combined transaction.\n",
)
out.write_string("Options: " + self.options_json().stringify() + "\n")
if self.requested == 0 || self.requested > 50 {
out.write_string(
"batch-file-count: expected 1 to 50 inputs; unreported inputs were not analysed.\n",
)
}
for i, item in self.items {
out.write_string(
"\n[" +
(i + 1).to_string() +
"] " +
item.label +
"; exit " +
item.code.to_string() +
"\n" +
item.text,
)
}
out.to_string()
}
///|
pub fn BatchReview::to_markdown(self : BatchReview) -> String {
let out = StringBuilder()
out.write_string(
"# MoonLDIF batch review\n\nVersion: " +
markdown_escape(version()) +
"; exit: " +
self.exit_code().to_string() +
"; reported files: " +
self.items.length().to_string() +
"/" +
self.requested.to_string() +
"\n\nFiles are checked independently; this is not a transaction or server import approval. Reports contain filenames and target DNs, not attribute values.\n\n",
)
out.write_string(
"Options: " + markdown_escape(self.options_json().stringify()) + "\n\n",
)
if self.exit_code() == 2 {
out.write_string(
"**Incomplete batch: do not treat missing findings as approval.**\n\n",
)
}
if self.requested == 0 || self.requested > 50 {
out.write_string(
"batch-file-count: expected 1 to 50 inputs; unreported inputs were not analysed.\n\n",
)
}
for i, item in self.items {
out.write_string(
"## File " +
(i + 1).to_string() +
": " +
markdown_escape(item.label) +
"\n\n",
)
for line in item.markdown.split("\n") {
if line.has_prefix("#") {
out.write_string("##")
}
out.write_string(line.to_owned() + "\n")
}
out.write_string("\n")
}
out.to_string()
}