// JSON output helpers for securitytxt-tool. A small local JSON escaper keeps the CLI self-contained:
// the library has no output-formatting concerns.
///|
/// Quote a string for JSON, escaping the required characters.
fn q(s : String) -> String {
"\"" + json_escape(s) + "\""
}
///|
fn json_escape(s : String) -> String {
let sb = StringBuilder::new(size_hint=s.length() + 8)
for c in s {
match c {
'"' => sb.write_string("\\\"")
'\\' => sb.write_string("\\\\")
'\n' => sb.write_string("\\n")
'\r' => sb.write_string("\\r")
'\t' => sb.write_string("\\t")
c =>
if c.to_int() < 0x20 {
sb.write_string("\\u")
sb.write_string(hex4(c.to_int()))
} else {
sb.write_char(c)
}
}
}
sb.to_string()
}
///|
fn hex4(n : Int) -> String {
let digits = [
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
]
let out = StringBuilder::new(size_hint=4)
out.write_char(digits[n / 4096 % 16])
out.write_char(digits[n / 256 % 16])
out.write_char(digits[n / 16 % 16])
out.write_char(digits[n % 16])
out.to_string()
}
///|
fn bool_json(v : Bool) -> String {
if v {
"true"
} else {
"false"
}
}
///|
/// Structured error as JSON (stage, kind, line, column, offset, message).
fn error_json(err : @lib.SecurityTxtError) -> String {
"{\"ok\":false,\"error\":{\"stage\":\{q(err.stage().to_string())},\"kind\":\{q(err.kind().to_string())}," +
"\"line\":\{err.line()},\"column\":\{err.column()}," +
"\"offset\":\{err.byte_offset()},\"message\":\{q(err.message())}}}"
}
///|
fn missing_option_json(message : String) -> String {
"{\"ok\":false,\"error\":{\"kind\":\"MissingOption\",\"message\":\{q(message)}}}"
}
///|
/// Parse a document, printing the error JSON on failure.
fn parse_or_print(o : CliOptions) -> @lib.SecurityTxt? {
match
@lib.parse_security_txt_with_limits(input_text(o), resolve_limits(o.limits)) {
Ok(document) => Some(document)
Err(err) => {
println(error_json(err))
None
}
}
}
///|
fn parse_json(document : @lib.SecurityTxt) -> String {
let preferred = match document.preferred_contact() {
Some(uri) => q(uri)
None => "null"
}
"{\"ok\":true,\"fields\":\{document.field_count()},\"standard_fields\":\{document.standard_field_count()}," +
"\"extension_fields\":\{document.extension_field_count()},\"contacts\":\{document.contacts().length()}," +
"\"comments\":\{document.comments().length()},\"signed\":\{bool_json(document.is_signed())}," +
"\"lines\":\{document.line_count()},\"bytes\":\{document.byte_count()},\"preferred_contact\":\{preferred}}"
}
///|
/// All validation errors as a JSON array fragment.
fn errors_json(errors : Array[@lib.SecurityTxtError]) -> String {
let sb = StringBuilder::new(size_hint=errors.length() * 80)
sb.write_char('[')
let mut i = 0
while i < errors.length() {
if i > 0 {
sb.write_char(',')
}
sb.write_string(
"{\"stage\":\{q(errors[i].stage().to_string())},\"kind\":\{q(errors[i].kind().to_string())}," +
"\"line\":\{errors[i].line()},\"column\":\{errors[i].column()},\"offset\":\{errors[i].byte_offset()}," +
"\"message\":\{q(errors[i].message())}}",
)
i += 1
}
sb.write_char(']')
sb.to_string()
}
///|
/// Validate document semantics and any retrieval context supplied on the CLI.
fn validate_with_context_json(
document : @lib.SecurityTxt,
o : CliOptions,
) -> String {
let errors = @lib.validate_all(document)
let context = resolve_context(o)
match @lib.validate_context(context) {
Err(err) => errors.push(err)
Ok(_) =>
if o.retrieval_uri_set {
match @lib.validate_retrieval_context(document, context) {
Err(err) => errors.push(err)
Ok(_) => ()
}
}
}
"{\"ok\":true,\"valid\":\{bool_json(errors.length() == 0)},\"errors\":\{errors_json(errors)}}"
}
///|
fn fresh_json(document : @lib.SecurityTxt, now : @lib.DateTime) -> String {
match document.expires() {
Ok(expires) => {
let remaining = @lib.time_until_expiry(expires, now)
let expires_soon = remaining >= 0L &&
remaining <= @lib.EXPIRES_SOON_SECONDS
"{\"ok\":true,\"now\":\{q(now.format_rfc3339())},\"expires\":\{q(expires.format_rfc3339())}," +
"\"expired\":\{bool_json(@lib.is_expired(expires, now))},\"seconds_until_expiry\":\{remaining}," +
"\"expires_within_30_days\":\{bool_json(expires_soon)}}"
}
Err(err) => error_json(err)
}
}
///|
fn audit_json(findings : Array[@lib.AuditFinding]) -> String {
let sb = StringBuilder::new(size_hint=findings.length() * 100)
sb.write_string("{\"ok\":true,\"findings\":[")
let mut i = 0
while i < findings.length() {
if i > 0 {
sb.write_char(',')
}
sb.write_string("{\"severity\":")
sb.write_string(q(findings[i].severity().label()))
sb.write_string(",\"kind\":")
sb.write_string(q(findings[i].kind().label()))
sb.write_string(",\"line\":")
sb.write_string("\{findings[i].line()}")
sb.write_string(",\"message\":")
sb.write_string(q(findings[i].message()))
sb.write_char('}')
i += 1
}
sb.write_string("]}")
sb.to_string()
}
///|
fn stats_json(document : @lib.SecurityTxt) -> String {
let present = fn(field_name : String) -> Bool {
let mut found = false
for field in document.fields() {
if field.name() == field_name {
found = true
}
}
found
}
"{\"ok\":true,\"fields\":\{document.field_count()},\"standard_fields\":\{document.standard_field_count()}," +
"\"extension_fields\":\{document.extension_field_count()},\"contacts\":\{document.contacts().length()}," +
"\"acknowledgments\":\{document.acknowledgments().length()},\"comments\":\{document.comments().length()}," +
"\"lines\":\{document.line_count()},\"bytes\":\{document.byte_count()}," +
"\"signed\":\{bool_json(document.is_signed())},\"has_expires\":\{bool_json(present("Expires"))}," +
"\"has_canonical\":\{bool_json(present("Canonical"))}," +
"\"has_encryption\":\{bool_json(present("Encryption"))},\"has_policy\":\{bool_json(present("Policy"))}," +
"\"has_hiring\":\{bool_json(present("Hiring"))}," +
"\"has_preferred_languages\":\{bool_json(present("Preferred-Languages"))}}"
}