Skip to content

Commit d9b4fd9

Browse files
authored
fix(cli): validate structure command inputs (#1329)
* fix(cli): validate structure command inputs Require the prepare-structure model to immediately follow the subcommand so family-option values cannot be mistaken for a model during bootstrap parsing. Reject equivalent structure and metadata destinations before the legacy runtime writes either output. Signed-off-by: Vivian Chen <140748220+xuanzic@users.noreply.github.com> * fix(cli): validate all sample output paths Precompute and normalize every structure and metadata destination before writing so cross-sample path collisions cannot overwrite earlier outputs. Cover a two-sample collision and verify that existing destination contents remain unchanged. Signed-off-by: Vivian Chen <140748220+xuanzic@users.noreply.github.com> --------- Signed-off-by: Vivian Chen <140748220+xuanzic@users.noreply.github.com>
1 parent 4b74779 commit d9b4fd9

4 files changed

Lines changed: 95 additions & 7 deletions

File tree

apps/cli/cli.cpp

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -937,12 +937,32 @@ int dispatch(const Command& command, ITask& task, std::ostream& output) {
937937
const fs::path metadata_path = has_option(command, "--output-json")
938938
? command.options.at("--output-json")
939939
: structure_path.string() + ".metadata.json";
940+
if (fs::absolute(structure_path).lexically_normal() ==
941+
fs::absolute(metadata_path).lexically_normal())
942+
throw std::invalid_argument("--output and --output-json must use different paths");
940943
auto indexed_path = [](const fs::path& path, std::size_t index) {
941944
if (index == 0)
942945
return path;
943946
return path.parent_path() / (path.stem().string() + "_sample_" + std::to_string(index) +
944947
path.extension().string());
945948
};
949+
const auto sample_count = result.samples.empty() ? 1U : result.samples.size();
950+
std::vector<std::pair<fs::path, fs::path>> output_paths;
951+
output_paths.reserve(sample_count);
952+
std::unordered_set<std::string> normalized_paths;
953+
for (std::size_t index = 0; index < sample_count; ++index) {
954+
auto current_structure = indexed_path(structure_path, index);
955+
auto current_metadata = has_option(command, "--output-json")
956+
? indexed_path(metadata_path, index)
957+
: fs::path(current_structure.string() + ".metadata.json");
958+
for (const auto& path : {current_structure, current_metadata}) {
959+
const auto normalized = fs::absolute(path).lexically_normal().string();
960+
if (!normalized_paths.insert(normalized).second)
961+
throw std::invalid_argument(
962+
"--output and --output-json must use different paths");
963+
}
964+
output_paths.emplace_back(std::move(current_structure), std::move(current_metadata));
965+
}
946966
auto write_output = [](const fs::path& path, const std::string& payload,
947967
const char* label) {
948968
if (!path.parent_path().empty())
@@ -957,11 +977,7 @@ int dispatch(const Command& command, ITask& task, std::ostream& output) {
957977
auto append_sample = [&](std::size_t index, const std::string& structure,
958978
const std::string& metadata,
959979
const StructureConfidence& confidence) {
960-
const auto current_structure = indexed_path(structure_path, index);
961-
const auto current_metadata =
962-
has_option(command, "--output-json")
963-
? indexed_path(metadata_path, index)
964-
: fs::path(current_structure.string() + ".metadata.json");
980+
const auto& [current_structure, current_metadata] = output_paths.at(index);
965981
write_output(current_structure, structure, "structure output");
966982
write_output(current_metadata, metadata, "structure metadata");
967983
sample_outputs.push_back({{"structure_path", current_structure.string()},

apps/cli/tests/test_sdk_detection_structure_cli.cpp

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,20 @@ void detection(const std::filesystem::path& root) {
119119
class ExistingStructure final : public trtmc::IStructurePrediction {
120120
public:
121121
trtmc::StructurePredictionRequest seen;
122+
bool multiple_samples{false};
122123
trtmc::StructurePredictionResult
123124
predict_structure(const trtmc::StructurePredictionRequest& request) override {
124125
seen = request;
125126
trtmc::StructurePredictionResult result;
126127
result.structure = "data_existing\n";
127128
result.metadata_json = "{\"existing\":true}";
129+
if (multiple_samples) {
130+
result.samples.resize(2);
131+
result.samples[0].structure = "data_sample_0\n";
132+
result.samples[0].metadata_json = "{\"sample\":0}";
133+
result.samples[1].structure = "data_sample_1\n";
134+
result.samples[1].metadata_json = "{\"sample\":1}";
135+
}
128136
return result;
129137
}
130138
};
@@ -249,6 +257,36 @@ void structure(const std::filesystem::path& root) {
249257
read_file("existing.cif") == "data_existing\n" &&
250258
read_file("existing.json") == "{\"existing\":true}",
251259
"unmigrated structure command preserves every existing explicit argument and binary input");
260+
write_file("existing-collision.cif", "keep legacy bytes");
261+
auto collision_command =
262+
parse({"trtmc", "predict-structure", "unused.bundle", "--input", prepared.string(),
263+
"--output", "existing-collision.cif", "--output-json", "./existing-collision.cif"});
264+
bool collision_rejected = false;
265+
try {
266+
(void)trtmc::cli::dispatch(collision_command, existing, existing_output);
267+
} catch (const std::invalid_argument& error) {
268+
collision_rejected =
269+
std::string_view(error.what()).find("different paths") != std::string_view::npos;
270+
}
271+
check(collision_rejected && read_file("existing-collision.cif") == "keep legacy bytes",
272+
"unmigrated structure command rejects equivalent output paths before writing");
273+
existing.multiple_samples = true;
274+
write_file("cross-sample.cif", "keep metadata bytes");
275+
write_file("cross-sample_sample_1.cif", "keep structure bytes");
276+
collision_command =
277+
parse({"trtmc", "predict-structure", "unused.bundle", "--input", prepared.string(),
278+
"--output", "cross-sample_sample_1.cif", "--output-json", "cross-sample.cif"});
279+
collision_rejected = false;
280+
try {
281+
(void)trtmc::cli::dispatch(collision_command, existing, existing_output);
282+
} catch (const std::invalid_argument& error) {
283+
collision_rejected =
284+
std::string_view(error.what()).find("different paths") != std::string_view::npos;
285+
}
286+
check(collision_rejected && read_file("cross-sample.cif") == "keep metadata bytes" &&
287+
read_file("cross-sample_sample_1.cif") == "keep structure bytes",
288+
"all multi-sample output paths are validated before writing");
289+
existing.multiple_samples = false;
252290
command.options["--input-encoding"] = "b2rq";
253291
bool rejected = false;
254292
try {

core/builder/tensorrt_model_connect/build_cli.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import argparse
99
import json
10+
import sys
1011
from pathlib import Path
1112
from typing import Sequence
1213

@@ -51,12 +52,20 @@ def _parser(prepare_family: object | None = None) -> argparse.ArgumentParser:
5152

5253

5354
def main(argv: Sequence[str] | None = None) -> int:
55+
arguments = list(sys.argv[1:] if argv is None else argv)
5456
base_parser = _parser()
55-
preliminary, _ = base_parser.parse_known_args(argv)
57+
if (
58+
len(arguments) > 1
59+
and arguments[0] == "prepare-structure"
60+
and arguments[1] not in {"-h", "--help"}
61+
and arguments[1].startswith("-")
62+
):
63+
base_parser.error("MODEL must immediately follow prepare-structure")
64+
preliminary, _ = base_parser.parse_known_args(arguments)
5665
model_dir = _resolve_model(preliminary.model, preliminary.revision)
5766
family, support = resolve_family(load_model_metadata(model_dir))
5867
family_module = _load_family(family) if preliminary.command == "prepare-structure" else None
59-
args = _parser(family_module).parse_args(argv)
68+
args = _parser(family_module).parse_args(arguments)
6069
if args.command == "prepare-structure":
6170
prepare = getattr(family_module, "prepare_structure_request", None)
6271
if not callable(prepare):

core/builder/tests/test_build_cli.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,31 @@ def cli_options(args):
366366
}
367367

368368

369+
def test_prepare_structure_requires_model_before_family_options(monkeypatch, capsys) -> None:
370+
monkeypatch.setattr(
371+
build_cli,
372+
"_resolve_model",
373+
lambda *_: pytest.fail("ambiguous family option value reached model resolution"),
374+
)
375+
376+
with pytest.raises(SystemExit) as error:
377+
build_cli.main(
378+
[
379+
"prepare-structure",
380+
"--num-steps",
381+
"300",
382+
"model",
383+
"--input",
384+
"request.yaml",
385+
"--output",
386+
"request.b2rq",
387+
]
388+
)
389+
390+
assert error.value.code == 2
391+
assert "MODEL must immediately follow prepare-structure" in capsys.readouterr().err
392+
393+
369394
def test_prepare_structure_uses_the_family_hook_after_task_migration(
370395
monkeypatch, tmp_path: Path, capsys
371396
) -> None:

0 commit comments

Comments
 (0)