// The `html` subcommand: render a workbook (or one sheet) to a
// self-contained HTML document — the "eyes" half of the agent loop. The
// document goes to stdout by default (wasm-safe) or to `--out`.
///|
async fn run_html(m : @argparse.Matches) -> Unit {
let file = arg(m, "file")
let workbook = @lib.open_file(file)
let max_rows = parse_positive_int(m, "max-rows", 1000)
let max_cols = parse_positive_int(m, "max-cols", 256)
let rendered = @xlsx2html.render_workbook(
workbook,
title=file,
sheet=arg(m, "sheet"),
max_rows~,
max_cols~,
include_images=!flag(m, "no-images"),
calc=flag(m, "calc"),
)
// stored strings in a hostile workbook can decode to isolated UTF-16
// surrogates; both the stdout and --out paths must stay total
let (document, _) = @xlsx2html.sanitize_utf16_lossy(rendered)
let out = arg(m, "out")
if out == "" {
println(document)
} else {
// `html` is a read-only command: writing the document over the input
// workbook — directly, via `./` spellings, or via a symlink — would
// destroy it. The write itself goes through the rename-based saver,
// so even an alias the checks cannot see (a hard link) replaces only
// the directory entry named by --out; the input's own entry keeps
// the original bytes.
if lexical_normalize(out) == lexical_normalize(file) ||
resolve_existing(out) == resolve_existing(file) {
raise CliError("error: --out must not be the input workbook")
}
// Write through the exclusive-temp + rename saver with the RAW --out
// path — never a re-resolved one. `rename` replaces the directory
// entry named by --out itself (it does not follow a symlink at that
// final component), and CreateNew never truncates, so whatever the
// --out leaf points to after the guard, the input's own entry keeps
// its bytes.
//
// Residual limitation: an adversary who swaps an INTERMEDIATE
// directory component of --out (e.g. retargets `slot/` in
// `slot/out.xlsx`) between the guard and the write could still
// redirect where the temp+rename lands. Fully closing that needs
// handle-relative openat/renameat on a pinned directory fd, which
// moonbitlang/async/fs does not expose (moonbitlang/async#493). It is
// out of scope here: the
// threat requires a concurrent process that already holds write
// access to the output's directory tree — which can clobber the
// input directly regardless — so it grants no capability a local
// spreadsheet tool must defend against.
write_file_replacing(out, @encoding/utf8.encode(document))
println("wrote \{out}")
}
}
///|
/// A path's resolved identity: native realpath, falling back to the
/// lexically-normalized spelling whenever realpath fails. That is not only
/// the does-not-exist case -- an existing path under a directory the process
/// cannot search fails with EACCES and takes the same fallback -- and the
/// fallback is what keeps the caller's alias check total.
#cfg(target="native")
async fn resolve_existing(path : String) -> String noraise {
resolve_save_target(path) catch {
_ => lexical_normalize(path)
}
}
///|
/// A path's resolved identity on wasm, where there is no realpath: the path
/// itself. `resolve_save_target` cannot fail here, so there is nothing to
/// fall back from. Callers that need the lexical form ask for it directly.
#cfg(target="wasm")
fn resolve_existing(path : String) -> String noraise {
resolve_save_target(path)
}
///|
/// Collapses `.` segments and resolves `..` against preceding segments
/// (purely textual — no filesystem access), so `./book.xlsx` and
/// `book.xlsx` compare equal even where realpath is unavailable.
fn lexical_normalize(path : String) -> String {
let absolute = path.has_prefix("/")
let out : Array[String] = []
for segment in path.split("/") {
let segment = segment.to_owned()
if segment == "" || segment == "." {
continue
}
if segment == ".." {
if out.length() > 0 && out[out.length() - 1] != ".." {
ignore(out.pop())
continue
}
if absolute {
continue
}
}
out.push(segment)
}
let joined = out.join("/")
if absolute {
"/" + joined
} else {
joined
}
}
///|
fn parse_positive_int(
m : @argparse.Matches,
name : String,
default : Int32,
) -> Int32 raise CliError {
let text = arg(m, name)
if text == "" {
return default
}
let value = @string.parse_int(text) catch {
_ =>
raise CliError("error: invalid --\{name} '\{text}' (expected a number)")
}
if value < 1 {
raise CliError("error: --\{name} must be at least 1")
}
value
}