// The `batch` subcommand: apply a versioned XLSX batch op script in one
// open → mutate → write cycle. All-or-nothing: parse and op failures leave
// the file untouched, zero ops writes nothing, and the final save goes
// through an exclusively-created unique temp file — written through the
// exclusive handle itself — then renamed over the resolved target, so a
// failed write cannot truncate the original and no other process can
// interpose on the temp path. (Rename replaces the inode: on POSIX the
// saved file is created owner-only, 0600 — never wider than the
// original; Windows ignores the mode and follows directory ACLs. ACLs
// and xattrs are not carried over. chmod afterwards for wider access.)
///|
async fn run_batch(m : @argparse.Matches) -> Unit {
let file = arg(m, "file")
let script_path = arg(m, "script")
let script = read_batch_script(script_path)
let plan = @batch.parse_script_bytes(script) catch {
BatchError(message) => raise CliError("error: " + message)
}
// Resolve a symlinked workbook to its target up front: the same resolved
// path is opened and saved, so a link retargeted mid-run cannot make the
// save clobber a different file than the one that was read.
let target = resolve_save_target(file)
let workbook = @lib.open_file(target)
plan.apply(workbook) catch {
BatchError(message) =>
raise CliError("error: \{message}; \{file} not modified")
}
if flag(m, "dry-run") {
println("dry-run ok: \{plan.operation_count()} op(s); \{file} not modified")
} else if plan.operation_count() == 0 {
// A zero-op script is a valid no-op: nothing to save, file untouched.
println("applied 0 op(s) to \{file}")
} else {
write_file_replacing(target, @lib.write(workbook))
println("applied \{plan.operation_count()} op(s) to \{file}")
}
}
///|
/// Reads a regular script file through one pinned handle and rejects its size
/// before allocation. A size change during the read fails instead of silently
/// applying a stale prefix.
async fn read_batch_script(path : String) -> Bytes {
let kind = @async/fs.kind(path) catch {
error if @async.is_being_cancelled() => raise error
_ => raise CliError("error: could not inspect batch script \{path}")
}
if !(kind is Regular) {
raise CliError("error: batch script is not a regular file: \{path}")
}
let file = @async/fs.open(
path,
mode=ReadOnly,
create_mode=OpenExisting,
sync=NoSync,
) catch {
error if @async.is_being_cancelled() => raise error
_ => raise CliError("error: could not open batch script \{path}")
}
defer file.close()
if !(file.kind() is Regular) {
raise CliError("error: batch script is not a regular file: \{path}")
}
let size = file.size() catch {
error if @async.is_being_cancelled() => raise error
_ => raise CliError("error: could not size batch script \{path}")
}
if size < 0L || size > @batch.MAX_BATCH_SCRIPT_BYTES.to_int64() {
raise CliError(
"error: batch script exceeds the \{@batch.MAX_BATCH_SCRIPT_BYTES}-byte limit",
)
}
let length = size.to_int()
let buffer = FixedArray::make(length, b'\x00')
let mut offset = 0
while offset < length {
let count = file.read_at(
buffer,
position=offset.to_int64(),
offset~,
len=length - offset,
) catch {
error if @async.is_being_cancelled() => raise error
_ => raise CliError("error: could not read batch script \{path}")
}
if count <= 0 || count > length - offset {
raise CliError("error: could not read batch script \{path}")
}
offset = offset + count
}
let final_size = file.size() catch {
error if @async.is_being_cancelled() => raise error
_ => raise CliError("error: could not recheck batch script \{path}")
}
if final_size != size {
raise CliError("error: batch script changed while it was read: \{path}")
}
@async.pause()
buffer.unsafe_reinterpret_as_bytes()
}
///|
/// Saves `bytes` over the (already-resolved) `target` atomically: the
/// bytes are written through the exclusively-created temp file's own
/// handle — never by reopening the path, which another process could
/// swap for a symlink — and the temp is renamed over the target. Only
/// true EEXIST collisions are retried; once the temp exists, every
/// subsequent failure removes it.
async fn write_file_replacing(target : String, bytes : Bytes) -> Unit {
let stamp = @env.now()
let mut temp = ""
let mut handle : @async/fs.File? = None
for attempt in 0..<5 {
let candidate = "\{target}.tmp-\{stamp}-\{attempt}"
let file = @async/fs.create(
candidate,
allow_existing=false,
permission=0o600,
) catch {
@os_error.OSError(_) as ose if ose.is_EEXIST() && attempt < 4 => continue
err => raise err
}
temp = candidate
handle = Some(file)
break
}
let file = match handle {
Some(file) => file
None =>
raise CliError("error: could not create a temp file next to \{target}")
}
// The exclusive handle is ours; from here on, any failure must close
// the handle and remove the temp so no partial workbook bytes leak. The
// block bounds the `errdefer` to the region that still owns the handle:
// past `file.close()` the cleanup must no longer close it again.
{
errdefer {
file.close()
@async/fs.remove(temp) catch {
_ => ()
}
}
file.write(bytes)
// Flush to stable storage before the rename: with delayed writeback, an
// ENOSPC/EIO surfacing after a NoSync close could replace the last good
// copy with a corrupt one.
file.sync()
}
file.close()
{
errdefer (@async/fs.remove(temp) catch { _ => () })
@async/fs.rename(temp, target, replace=true)
}
// Best-effort parent-directory sync so the rename itself is durable;
// some platforms cannot fsync a directory, which is not worth failing
// a successful save over.
sync_parent_dir(target)
}
///|
async fn sync_parent_dir(target : String) -> Unit noraise {
let dir = match target.rev_find("/") {
Some(0) => "/"
Some(index) => target[:index].to_owned()
None => "."
}
let handle = @async/fs.open(dir, mode=ReadOnly) catch { _ => return }
handle.sync() catch {
_ => ()
}
handle.close()
}
///|
/// The real file the save should replace: symlinks resolve to their
/// target on native (resolution failures propagate — silently falling
/// back to the link path could redirect the save); the wasm sandbox has
/// no realpath (and practically no symlinks), so the path is used as-is.
#cfg(target="native")
async fn resolve_save_target(path : String) -> String {
@async/fs.realpath(path)
}
///|
#cfg(target="wasm")
fn resolve_save_target(path : String) -> String noraise {
path
}