diff --git a/src/fwl_io/cli.py b/src/fwl_io/cli.py index de143c7..2ff78b5 100644 --- a/src/fwl_io/cli.py +++ b/src/fwl_io/cli.py @@ -36,10 +36,11 @@ def _cmd_list(args: argparse.Namespace) -> int: '' if ds.registry_path and ds.registry_path.is_file() else ' [NO REGISTRY]' ) print(f' {ds.key:50s} required_by: {consumers}{registry_note}') - if ds.name != ds.key: - # A name is a single-line label; drop control characters so a - # manifest cannot inject extra lines into the listing. - label = ''.join(c for c in ds.name if c.isprintable()) + # A name is a single-line label; drop control characters so a manifest + # cannot inject extra lines, then skip the line when there is nothing + # left to show or it collapses to the key already printed above. + label = ''.join(c for c in ds.name if c.isprintable()).strip() + if label and label != ds.key: print(f' {label}') for provider, message in sorted(errors.items()): print(f'[{provider}] FAILED TO LOAD: {message}', file=sys.stderr) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2e9fe49..4d6c134 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -116,12 +116,17 @@ def load(self): @pytest.mark.unit -def test_list_strips_control_characters_from_a_declared_name(tmp_path, capsys, monkeypatch): - """A name carrying a newline cannot inject an extra line into the listing.""" +@pytest.mark.parametrize( + 'escape', + ['\\n', '\\t', '\\r', '\\u2028'], + ids=['newline', 'tab', 'cr', 'line-separator'], +) +def test_list_strips_control_characters_from_a_declared_name(escape, tmp_path, capsys, monkeypatch): + """A name carrying a control character cannot inject an extra line into the listing.""" manifest = tmp_path / 'manifest.toml' manifest.write_text( '[evil]\n' - 'name = "harmless\\n forged.key required_by: victim [NO REGISTRY]"\n' + f'name = "harmless{escape} forged.key required_by: victim [NO REGISTRY]"\n' 'zenodo = "10.5281/zenodo.1"\n' ) @@ -151,6 +156,76 @@ def load(self): assert not any(line.startswith(' forged') for line in lines) +@pytest.mark.unit +@pytest.mark.parametrize( + 'toml_escaped_name', + ['\\u200b\\u200b', '\\u0001 \\u0001'], + ids=['all-non-printable', 'non-printable-padding-around-spaces'], +) +def test_list_omits_the_label_line_for_an_all_non_printable_name( + toml_escaped_name, tmp_path, capsys, monkeypatch +): + """A name that strips to nothing after filtering must not print a bare indented line.""" + manifest = tmp_path / 'manifest.toml' + manifest.write_text(f'[evil]\nname = "{toml_escaped_name}"\nzenodo = "10.5281/zenodo.1"\n') + + class _EP: + def __init__(self, name, target): + self.name = name + self._target = target + + def load(self): + return self._target + + monkeypatch.setattr( + 'fwl_io.manifest.entry_points', + lambda group: [_EP('demo', lambda: manifest)], + ) + + code = main(['list']) + out = capsys.readouterr().out + lines = out.splitlines() + assert code == 0 + assert len(lines) == 2 + assert lines[0] == '[demo]' + assert lines[1].startswith(' evil') + + +@pytest.mark.unit +@pytest.mark.parametrize( + 'toml_escaped_name', + ['evil ', 'evil\\u200b'], + ids=['trailing-space', 'trailing-invisible-char'], +) +def test_list_omits_the_label_line_when_it_collapses_to_the_key( + toml_escaped_name, tmp_path, capsys, monkeypatch +): + """A name that differs from the key raw but not after filtering must not repeat it.""" + manifest = tmp_path / 'manifest.toml' + manifest.write_text(f'[evil]\nname = "{toml_escaped_name}"\nzenodo = "10.5281/zenodo.1"\n') + + class _EP: + def __init__(self, name, target): + self.name = name + self._target = target + + def load(self): + return self._target + + monkeypatch.setattr( + 'fwl_io.manifest.entry_points', + lambda group: [_EP('demo', lambda: manifest)], + ) + + code = main(['list']) + out = capsys.readouterr().out + lines = out.splitlines() + assert code == 0 + assert len(lines) == 2 + assert lines[0] == '[demo]' + assert lines[1].startswith(' evil') + + @pytest.mark.unit def test_fetch_unknown_module_exits_nonzero(capsys, monkeypatch): """Asking for a model no manifest declares is an error, not an empty success."""