///|
let raw_cli_max_package_bytes : Int = 128 * 1024 * 1024
///|
let raw_cli_max_xml_bytes : Int = 1024 * 1024
///|
fn repeated_values(matches : @argparse.Matches, name : String) -> Array[String] {
match matches.values.get(name) {
Some(values) => values
None => []
}
}
///|
fn raw_failure(error : @raw.RawError) -> CliFailure {
CliFailure(error.to_protocol_error())
}
///|
fn raw_mutation_failure(
error : @raw.RawError,
) -> @transaction.TransactionError raise @transaction.TransactionError {
match error {
RawError(code~, message~, details~) =>
match details {
Some(value) =>
@transaction.transaction_failure(code, message, details=value)
None => @transaction.transaction_failure(code, message)
}
}
}
///|
fn raw_transaction_identifier() -> @transaction.TransactionIdentifier raise CliFailure {
@transaction.transaction_identifier("office-raw-bounded", (
path,
_bytes,
archive,
) => {
@raw.raw_identify_archive(path, archive) catch {
RawError(..) as error => raise raw_mutation_failure(error)
}
}) catch {
TransactionError(..) as error => raise transaction_failure(error)
}
}
///|
fn cli_file_error(code : String, message : String, path : String) -> CliFailure {
CliFailure(
@lib.protocol_error(
code,
message + human_text(path, 160),
details=Json::object({ "file": Json::string(bounded_text(path, 160)) }),
),
)
}
///|
async fn read_bounded_file(
path : String,
maximum : Int,
code : String,
description : String,
limit_code? : String = "office.raw.resource_limit",
) -> Bytes {
let kind = @afs.kind(path) catch {
error if @async.is_being_cancelled() => raise error
_ => raise cli_file_error(code, "could not inspect \{description}: ", path)
}
if !(kind is Regular) {
raise cli_file_error(code, "\{description} is not a regular file: ", path)
}
let file = @afs.open(
path,
mode=ReadOnly,
create_mode=OpenExisting,
sync=NoSync,
) catch {
error if @async.is_being_cancelled() => raise error
_ => raise cli_file_error(code, "could not open \{description}: ", path)
}
defer file.close()
if !(file.kind() is Regular) {
raise cli_file_error(code, "\{description} is not a regular file: ", path)
}
let size = file.size() catch {
error if @async.is_being_cancelled() => raise error
_ => raise cli_file_error(code, "could not size \{description}: ", path)
}
if size < 0L || size > maximum.to_int64() {
raise CliFailure(
@lib.protocol_error(
limit_code,
"\{description} exceeds the \{maximum}-byte limit",
details=Json::object({
"file": Json::string(bounded_text(path, 160)),
"limit": Json::number(maximum.to_double()),
"actual": Json::number(size.to_double()),
}),
),
)
}
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 cli_file_error(code, "could not read \{description}: ", path)
}
if count <= 0 || count > length - offset {
raise cli_file_error(code, "could not read \{description}: ", path)
}
offset = offset + count
}
let final_size = file.size() catch {
error if @async.is_being_cancelled() => raise error
_ =>
raise cli_file_error(
code,
"could not recheck \{description} size: ",
path,
)
}
if final_size != size {
if final_size < 0L || final_size > maximum.to_int64() {
raise CliFailure(
@lib.protocol_error(
limit_code,
"\{description} exceeds the \{maximum}-byte limit",
details=Json::object({
"file": Json::string(bounded_text(path, 160)),
"limit": Json::number(maximum.to_double()),
"actual": Json::number(final_size.to_double()),
}),
),
)
}
raise cli_file_error(
code,
"\{description} changed while being read: ",
path,
)
}
complete_bounded_file_read(buffer)
}
///|
/// Keep the final read boundary cancellable even when every filesystem call
/// completed synchronously from the host cache.
async fn complete_bounded_file_read(buffer : FixedArray[Byte]) -> Bytes {
@async.pause()
buffer.unsafe_reinterpret_as_bytes()
}
///|
async fn write_new_raw_file(path : String, data : BytesView) -> Unit {
@transaction.atomic_write_new(path, data) catch {
error if @async.is_being_cancelled() => raise error
_ =>
raise cli_file_error(
"office.raw.output_write_failed", "could not atomically create output file: ",
path,
)
}
}
///|
async fn xml_argument(matches : @argparse.Matches) -> String? {
match (optional_value(matches, "xml"), optional_value(matches, "xml-file")) {
(Some(value), None) => Some(value)
(None, Some(path)) => {
let bytes = read_bounded_file(
path, raw_cli_max_xml_bytes, "office.raw.xml_read_failed", "XML input file",
)
let text = @utf8.decode(bytes, ignore_bom=true) catch {
_ =>
raise CliFailure(
@lib.protocol_error(
"office.raw.invalid_xml_encoding",
"XML input file is not valid UTF-8",
details=Json::object({
"file": Json::string(bounded_text(path, 160)),
}),
),
)
}
Some(text)
}
(None, None) => None
(Some(_), Some(_)) =>
raise CliFailure(
@lib.protocol_error(
"office.invalid_arguments", "--xml and --xml-file are mutually exclusive",
),
)
}
}
///|
fn raw_inventory_human(inventory : @raw.RawInventory) -> String {
let parts = inventory.part_records()
let lines : Array[String] = [
"\{inventory.document_format().name()} package: \{parts.length()} parts",
]
for part in parts {
let kind = if part.is_xml() { "xml" } else { "binary" }
let aliases = part.semantic_aliases()
let alias_text = if aliases.is_empty() {
""
} else {
let values = []
for alternate in aliases {
values.push(human_text(alternate, 160))
}
" aliases=" + values.join(",")
}
lines.push(
"/\{human_text(part.part_name(), 160)} \{kind} \{part.byte_size()} bytes \{human_text(part.media_type(), 160)}" +
alias_text,
)
}
lines.join("\n")
}
///|
async fn run_raw_list(matches : @argparse.Matches) -> Unit {
let file = required_value(matches, "file")
let data = read_bounded_file(
file, raw_cli_max_package_bytes, "office.file_read_failed", "input package",
)
let inventory = @raw.raw_inventory(file, data) catch {
RawError(..) as error => raise raw_failure(error)
}
if matches.flags.get_or_default("json", false) {
println(@lib.output_success(inventory.to_json()).stringify(indent=2))
} else {
println(raw_inventory_human(inventory))
}
}
///|
fn raw_part_json(
part : @raw.RawPartData,
encoding : String,
content? : String,
output? : String,
) -> Json {
let info = part.part_info()
match (content, output) {
(Some(value), None) =>
Json::object({
"schema": Json::string(@lib.SCHEMA_RAW_PART),
"format": Json::string(part.document_format().name()),
"part": info.to_json(),
"encoding": Json::string(encoding),
"content": Json::string(value),
})
(None, Some(path)) =>
Json::object({
"schema": Json::string(@lib.SCHEMA_RAW_PART),
"format": Json::string(part.document_format().name()),
"part": info.to_json(),
"encoding": Json::string(encoding),
"output": Json::string(path),
})
_ =>
Json::object({
"schema": Json::string(@lib.SCHEMA_RAW_PART),
"format": Json::string(part.document_format().name()),
"part": info.to_json(),
"encoding": Json::string(encoding),
})
}
}
///|
async fn run_raw_read(matches : @argparse.Matches) -> Unit {
let file = required_value(matches, "file")
let selector = required_value(matches, "part")
let data = read_bounded_file(
file, raw_cli_max_package_bytes, "office.file_read_failed", "input package",
)
let part = @raw.raw_read(file, data, selector) catch {
RawError(..) as error => raise raw_failure(error)
}
let bytes = part.payload_bytes()
let json = matches.flags.get_or_default("json", false)
let base64 = matches.flags.get_or_default("base64", false)
match optional_value(matches, "output") {
Some(path) => {
write_new_raw_file(path, bytes)
if json {
println(
@lib.output_success(raw_part_json(part, "binary", output=path)).stringify(
indent=2,
),
)
} else {
println(
"wrote \{bytes.length()} bytes from /\{part.part_info().part_name()} to \{human_text(path, 160)}",
)
}
}
None if base64 => {
let encoded = @base64.encode(bytes)
if json {
println(
@lib.output_success(raw_part_json(part, "base64", content=encoded)).stringify(
indent=2,
),
)
} else {
println(encoded)
}
}
None => {
let text = part.xml_text() catch {
RawError(..) as error => raise raw_failure(error)
}
if json {
println(
@lib.output_success(raw_part_json(part, "xml", content=text)).stringify(
indent=2,
),
)
} else {
println(text)
}
}
}
}
///|
fn checked_transaction_options(
matches : @argparse.Matches,
positional? : String = "file",
) -> @transaction.TransactionOptions raise CliFailure {
let file = required_value(matches, positional)
let output = optional_value(matches, "out")
let dry_run = matches.flags.get_or_default("dry-run", false)
let overwrite = matches.flags.get_or_default("overwrite", false)
if overwrite && output is None {
raise CliFailure(
@lib.protocol_error(
"office.invalid_arguments", "--overwrite requires a separate --out destination",
),
)
}
@transaction.transaction_options(
file,
output_path?=output,
dry_run~,
overwrite~,
) catch {
TransactionError(..) as error => raise transaction_failure(error)
}
}
///|
fn raw_result_json(
mutation : @raw.RawMutation,
report : @transaction.TransactionReport,
) -> Json {
Json::object({
"schema": Json::string(@lib.SCHEMA_RAW_RESULT),
"change": mutation.to_json(),
"transaction": report.to_json(),
})
}
///|
fn raw_human_mutation_subject(mutation : @raw.RawMutation) -> String {
if mutation.action_name() == "replace-part" {
"whole-part replacement for /\{mutation.part_name()}"
} else {
"\{mutation.action_name()} matched \{mutation.selected_count()} element(s) in /\{mutation.part_name()}"
}
}
///|
fn print_raw_result(
matches : @argparse.Matches,
mutation : @raw.RawMutation,
report : @transaction.TransactionReport,
) -> Unit {
if matches.flags.get_or_default("json", false) {
let warnings = []
for warning in report.warning_records() {
warnings.push(warning)
}
println(
@lib.output_success(raw_result_json(mutation, report), warnings~).stringify(
indent=2,
),
)
} else {
let destination = optional_value(matches, "out").unwrap_or(
required_value(matches, "file") catch {
_ => ""
},
)
println(
"\{human_transaction_status(report)}: \{raw_human_mutation_subject(mutation)} -> \{human_text(destination, 160)}",
)
}
}
///|
fn raw_transaction_mutation(
mutation : @raw.RawMutation,
original : Bytes,
) -> @transaction.TransactionMutation raise @transaction.TransactionError {
let declared_parts = [mutation.part_name()]
if mutation.reuses_original() {
@transaction.transaction_reuse_original_with_manifest(declared_parts)
} else {
@transaction.transaction_mutation_with_manifest(
mutation.result_bytes(original),
declared_parts,
)
}
}
///|
async fn run_raw_replace(matches : @argparse.Matches) -> Unit {
let part = required_value(matches, "part")
let xml = match xml_argument(matches) {
Some(value) => value
None =>
raise CliFailure(
@lib.protocol_error(
"office.invalid_arguments", "raw replace requires --xml or --xml-file",
),
)
}
let options = checked_transaction_options(matches)
let completed : Ref[@raw.RawMutation?] = Ref(None)
let report = @transaction.transact(
options,
(format, original, archive, budget) => {
// A raw part replacement is still an edit of the document, so it
// meets the shared preflight. It waives the tracking condition
// alone: raw surgery makes no semantic claim about the document,
// and it is how a caller clears the flag in the first place.
if format is Docx {
@office_docx.docx_mutation_preflight(archive, tracking_waived=true)
}
let mutation = @raw.raw_replace_part_archive(
format,
archive,
part,
xml,
max_package_bytes=budget.max_candidate_package_bytes(),
) catch {
RawError(..) as error => raise raw_mutation_failure(error)
}
completed.val = Some(mutation)
raw_transaction_mutation(mutation, original)
},
identifier=raw_transaction_identifier(),
) catch {
@transaction.TransactionError(..) as error =>
raise transaction_failure(error)
error if @async.is_being_cancelled() => raise error
error =>
raise unexpected_cli_failure(
"office.transaction.failed", "raw replacement transaction", error,
)
}
guard completed.val is Some(mutation) else {
raise CliFailure(
@lib.protocol_error(
"office.transaction.invalid_contract", "raw mutation completed without metadata",
),
)
}
print_raw_result(matches, mutation, report)
}
///|
fn namespace_arguments(
matches : @argparse.Matches,
) -> Array[(String, String)] raise CliFailure {
let output = []
for value in repeated_values(matches, "namespace") {
let binding = @raw.raw_namespace_binding(value) catch {
RawError(..) as error => raise raw_failure(error)
}
output.push(binding)
}
output
}
///|
async fn run_raw_edit(matches : @argparse.Matches) -> Unit {
let part = required_value(matches, "part")
let path = required_value(matches, "path")
let action_name = required_value(matches, "action")
let action = match @raw.resolve_raw_action(action_name) {
Some(value) => value
None =>
raise CliFailure(
@lib.protocol_error(
"office.raw.invalid_action",
"unknown raw edit action '\{bounded_text(action_name, 80)}'",
details=Json::object({
"action": Json::string(bounded_text(action_name, 80)),
}),
),
)
}
let xml = xml_argument(matches)
let attribute = optional_value(matches, "attribute")
let value = optional_value(matches, "value")
let namespaces = namespace_arguments(matches)
let request = @raw.raw_edit_request(
part,
path,
action,
xml?,
attribute?,
value?,
namespaces~,
all=matches.flags.get_or_default("all", false),
) catch {
RawError(..) as error => raise raw_failure(error)
}
let options = checked_transaction_options(matches)
let completed : Ref[@raw.RawMutation?] = Ref(None)
let report = @transaction.transact(
options,
(format, original, archive, budget) => {
if format is Docx {
@office_docx.docx_mutation_preflight(archive, tracking_waived=true)
}
let mutation = @raw.raw_edit_archive(
format,
archive,
request,
max_package_bytes=budget.max_candidate_package_bytes(),
) catch {
RawError(..) as error => raise raw_mutation_failure(error)
}
completed.val = Some(mutation)
raw_transaction_mutation(mutation, original)
},
identifier=raw_transaction_identifier(),
) catch {
@transaction.TransactionError(..) as error =>
raise transaction_failure(error)
error if @async.is_being_cancelled() => raise error
error =>
raise unexpected_cli_failure(
"office.transaction.failed", "raw edit transaction", error,
)
}
guard completed.val is Some(mutation) else {
raise CliFailure(
@lib.protocol_error(
"office.transaction.invalid_contract", "raw mutation completed without metadata",
),
)
}
print_raw_result(matches, mutation, report)
}
///|
fn raw_mutation_flags() -> Array[@argparse.FlagArg] {
[
FlagArg("dry-run", long="dry-run", about="validate without publishing"),
FlagArg(
"overwrite",
long="overwrite",
about="allow replacement of an existing separate --out destination",
),
FlagArg("json", long="json", about="print office.output/1 JSON"),
]
}
///|
fn raw_mutation_output_options() -> Array[@argparse.OptionArg] {
[
OptionArg(
"out",
long="out",
about="publish to a separate path instead of replacing the input",
),
]
}
///|
fn raw_command() -> @argparse.Command {
let replace_options : Array[@argparse.OptionArg] = [
OptionArg("xml", long="xml", conflicts_with=["xml-file"]),
OptionArg("xml-file", long="xml-file", conflicts_with=["xml"]),
]
for option in raw_mutation_output_options() {
replace_options.push(option)
}
let edit_options : Array[@argparse.OptionArg] = [
OptionArg("path", long="path", required=true),
OptionArg("action", long="action", required=true),
OptionArg("xml", long="xml", conflicts_with=["xml-file"]),
OptionArg("xml-file", long="xml-file", conflicts_with=["xml"]),
OptionArg("attribute", long="attribute"),
OptionArg("value", long="value"),
OptionArg(
"namespace",
long="namespace",
action=Append,
about="selector namespace binding PREFIX=URI (repeatable)",
),
]
for option in raw_mutation_output_options() {
edit_options.push(option)
}
let edit_flags : Array[@argparse.FlagArg] = [FlagArg("all", long="all")]
for flag in raw_mutation_flags() {
edit_flags.push(flag)
}
Command(
"raw",
about="Inventory, read, and atomically edit validated OOXML parts",
subcommand_required=true,
subcommands=[
Command(
"list",
about="List canonical package parts and relationship-derived aliases",
positionals=[
PositionArg("file", num_args=@argparse.ValueRange::single()),
],
flags=[FlagArg("json", long="json")],
),
Command(
"read",
about="Read one existing package part",
positionals=[
PositionArg("file", num_args=@argparse.ValueRange::single()),
PositionArg("part", num_args=@argparse.ValueRange::single()),
],
flags=[
FlagArg("json", long="json"),
FlagArg(
"base64",
long="base64",
about="encode the exact payload as base64",
conflicts_with=["output"],
),
],
options=[
OptionArg(
"output",
long="output",
about="write the exact payload to a new file",
conflicts_with=["base64"],
),
],
),
Command(
"replace",
about="Replace one existing XML part through the Office transaction",
positionals=[
PositionArg("file", num_args=@argparse.ValueRange::single()),
PositionArg("part", num_args=@argparse.ValueRange::single()),
],
options=replace_options,
flags=raw_mutation_flags(),
),
Command(
"edit",
about="Apply one bounded namespace-aware XML element edit",
positionals=[
PositionArg("file", num_args=@argparse.ValueRange::single()),
PositionArg("part", num_args=@argparse.ValueRange::single()),
],
options=edit_options,
flags=edit_flags,
),
],
)
}
///|
async fn run_raw(matches : @argparse.Matches) -> Unit {
match matches.subcommand {
Some(("list", values)) => run_raw_list(values)
Some(("read", values)) => run_raw_read(values)
Some(("replace", values)) => run_raw_replace(values)
Some(("edit", values)) => run_raw_edit(values)
_ =>
raise CliFailure(
@lib.protocol_error(
"office.invalid_arguments", "raw requires list, read, replace, or edit",
),
)
}
}