diff --git a/README.md b/README.md index 8ab233e..f6eeb6d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,15 @@ +# Installation + +`wozardry` is written in [Python 3](https://www.python.org). + +It requires [bitarray](https://pypi.org/project/bitarray/), which can be installed thusly: + +``` +$ pip3 install -U bitarray +``` + +(Developers who wish to run the test suite should also install the `pytest` module with `pip3 install -U pytest`) + # Command line usage wozardry is primarily designed to be used on the command line to directly manipulate `.woz` disk images. It supports multiple commands, which are listed in the `wozardry -h` output. diff --git a/test_wozardry.py b/test_wozardry.py new file mode 100755 index 0000000..69a4ddd --- /dev/null +++ b/test_wozardry.py @@ -0,0 +1,437 @@ +#!/usr/bin/env python3 + +# Sources of all truth: +# +# - WOZ1 specification +# - WOZ2 specification +# +# There is no spec but the spec itself. + +import wozardry +import pytest # https://pypi.org/project/pytest/ +import argparse +import tempfile +import shutil +import io + +# two valid .woz files in the repository +kValid1 = "test/valid1.woz" +kValid2 = "test/valid2.woz" + +# valid WOZ1 header as string of hex +kHeader1 = "57 4F 5A 31 FF 0A 0D 0A 00 00 00 00 " + +# valid WOZ2 header as string of hex +kHeader2 = "57 4F 5A 32 FF 0A 0D 0A 00 00 00 00 " + +def bfh(s): + """utility function to convert string of hex into a BytesIO stream""" + return io.BytesIO(bytes.fromhex(s)) + +#----- test file parser ----- + +def test_parse_header(): + # incomplete header + with pytest.raises(wozardry.WozEOFError): + wozardry.WozReader(stream=bfh("57 4F 5A 32")) + + with pytest.raises(wozardry.WozEOFError): + wozardry.WozReader(stream=bfh("57 4F 5A 32 FF 0A 0D")) + + with pytest.raises(wozardry.WozEOFError): + wozardry.WozReader(stream=bfh("57 4F 5A 32 FF 0A 0D 0A 00 00 00")) + + # invalid signature at offset 0 + with pytest.raises(wozardry.WozHeaderError_NoWOZMarker): + wozardry.WozReader(stream=bfh("57 4F 5A 30 00 00 00 00")) + + # invalid signature at offset 0 + with pytest.raises(wozardry.WozHeaderError_NoWOZMarker): + wozardry.WozReader(stream=bfh("57 4F 5A 33 00 00 00 00")) + + # missing FF byte at offset 4 + with pytest.raises(wozardry.WozHeaderError_NoFF): + wozardry.WozReader(stream=bfh("57 4F 5A 32 00 0A 0D 0A")) + + # missing 0A byte at offset 5 + with pytest.raises(wozardry.WozHeaderError_NoLF): + wozardry.WozReader(stream=bfh("57 4F 5A 32 FF 0D 0D 0D")) + + # missing 0D byte at offset 6 + with pytest.raises(wozardry.WozHeaderError_NoLF): + wozardry.WozReader(stream=bfh("57 4F 5A 32 FF 0A 0A 0A")) + + # missing 0A byte at offset 7 + with pytest.raises(wozardry.WozHeaderError_NoLF): + wozardry.WozReader(stream=bfh("57 4F 5A 32 FF 0A 0D 0D")) + +def test_parse_info(): + # TMAP chunk before INFO chunk + with pytest.raises(wozardry.WozINFOFormatError_MissingINFOChunk): + wozardry.WozReader(stream=bfh(kHeader2 + "54 4D 41 50 A0 00 00 00 " + "FF "*160)) + + # wrong INFO chunk size (too small) + with pytest.raises(wozardry.WozINFOFormatError): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3B 00 00 00 " + "00 "*59)) + + # wrong INFO chunk size (too big) + with pytest.raises(wozardry.WozINFOFormatError): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3D 00 00 00 " + "00 "*61)) + + # invalid version (0) in a WOZ1 file + with pytest.raises(wozardry.WozINFOFormatError_BadVersion): + wozardry.WozReader(stream=bfh(kHeader1 + "49 4E 46 4F 3C 00 00 00 00" + "00 "*59)) + + # invalid version (0) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadVersion): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 00" + "00 "*59)) + + # invalid version (1) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadVersion): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 01" + "00 "*59)) + + # invalid version (2) in a WOZ1 file + with pytest.raises(wozardry.WozINFOFormatError_BadVersion): + wozardry.WozReader(stream=bfh(kHeader1 + "49 4E 46 4F 3C 00 00 00 02" + "00 "*59)) + + # invalid disk type (0) in a WOZ1 file + with pytest.raises(wozardry.WozINFOFormatError_BadDiskType): + wozardry.WozReader(stream=bfh(kHeader1 + "49 4E 46 4F 3C 00 00 00 01 00 " + "00 "*58)) + + # invalid disk type (0) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadDiskType): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 00 " + "00 "*58)) + + # invalid disk type (3) in a WOZ1 file + with pytest.raises(wozardry.WozINFOFormatError_BadDiskType): + wozardry.WozReader(stream=bfh(kHeader1 + "49 4E 46 4F 3C 00 00 00 01 03 " + "00 "*58)) + + # invalid disk type (3) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadDiskType): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 03 " + "00 "*58)) + + # invalid write protected flag (2) in a WOZ1 file + with pytest.raises(wozardry.WozINFOFormatError_BadWriteProtected): + wozardry.WozReader(stream=bfh(kHeader1 + "49 4E 46 4F 3C 00 00 00 01 01 02 " + "00 "*57)) + + # invalid write protected flag (2) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadWriteProtected): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 02 " + "00 "*57)) + + # invalid synchronized flag (2) in a WOZ1 file + with pytest.raises(wozardry.WozINFOFormatError_BadSynchronized): + wozardry.WozReader(stream=bfh(kHeader1 + "49 4E 46 4F 3C 00 00 00 01 01 00 02 " + "00 "*56)) + + # invalid synchronized flag (2) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadSynchronized): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 02 " + "00 "*56)) + + # invalid cleaned flag (2) in a WOZ1 file + with pytest.raises(wozardry.WozINFOFormatError_BadCleaned): + wozardry.WozReader(stream=bfh(kHeader1 + "49 4E 46 4F 3C 00 00 00 01 01 00 00 02 " + "00 "*55)) + + # invalid cleaned flag (2) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadCleaned): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 00 02 " + "00 "*55)) + + # invalid creator (bad UTF-8 bytes) in a WOZ1 file + with pytest.raises(wozardry.WozINFOFormatError_BadCreator): + wozardry.WozReader(stream=bfh(kHeader1 + "49 4E 46 4F 3C 00 00 00 01 01 00 00 00 E0 80 80 " + "00 "*52)) + + # invalid creator (bad UTF-8 bytes) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadCreator): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 E0 80 80 " + "00 "*52)) + + # invalid disk sides (0) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadDiskSides): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 " + "20 "*32 + "00 " + "00 "*22)) + + # invalid disk sides (3) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadDiskSides): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 " + "20 "*32 + "03 " + "00 "*22)) + + # invalid disk sides (2, when disk type = 1) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadDiskSides): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 " + "20 "*32 + "02 " + "00 "*22)) + + # invalid boot sector format (4) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadBootSectorFormat): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 " + "20 "*32 + "01 04 " + "00 "*21)) + + # invalid boot sector format (1, when disk type = 2) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadBootSectorFormat): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 02 00 00 00 " + "20 "*32 + "01 01 " + "00 "*21)) + + # invalid boot sector format (2, when disk type = 2) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadBootSectorFormat): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 02 00 00 00 " + "20 "*32 + "01 02 " + "00 "*21)) + + # invalid boot sector format (3, when disk type = 2) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadBootSectorFormat): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 02 00 00 00 " + "20 "*32 + "01 02 " + "00 "*21)) + + # invalid optimal bit timing (23, when disk type = 1) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadOptimalBitTiming): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 " + "20 "*32 + "01 00 17 " + "00 "*20)) + + # invalid optimal bit timing (41, when disk type = 1) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadOptimalBitTiming): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 " + "20 "*32 + "01 00 29 " + "00 "*20)) + + # invalid optimal bit timing (7, when disk type = 2) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadOptimalBitTiming): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 02 00 00 00 " + "20 "*32 + "01 00 07 " + "00 "*20)) + + # invalid optimal bit timing (25, when disk type = 2) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadOptimalBitTiming): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 02 00 00 00 " + "20 "*32 + "01 00 19 " + "00 "*20)) + + # invalid optimal bit timing (0, when disk type = 1) in a WOZ2 file + # unlike other fields, this does not allow a 0 value to mean "unknown" + with pytest.raises(wozardry.WozINFOFormatError_BadOptimalBitTiming): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 " + "20 "*32 + "01 00 00 " + "00 "*20)) + + # invalid optimal bit timing (0, when disk type = 2) in a WOZ2 file + # unlike other fields, this does not allow a 0 value to mean "unknown" + with pytest.raises(wozardry.WozINFOFormatError_BadOptimalBitTiming): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 02 00 00 00 " + "20 "*32 + "01 00 00 " + "00 "*20)) + + # invalid compatible hardware (00000010 00000000) in a WOZ2 file + # this field only uses the lower 9 bits (for 9 hardware models), so the 7 high bits must all be 0 + with pytest.raises(wozardry.WozINFOFormatError_BadCompatibleHardware): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 " + "20 "*32 + "01 00 20 00 02 " + "00 "*18)) + + # invalid compatible hardware (00000100 00000000) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadCompatibleHardware): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 " + "20 "*32 + "01 00 20 00 04 " + "00 "*18)) + + # invalid compatible hardware (00001000 00000000) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadCompatibleHardware): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 " + "20 "*32 + "01 00 20 00 08 " + "00 "*18)) + + # invalid compatible hardware (00010000 00000000) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadCompatibleHardware): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 " + "20 "*32 + "01 00 20 00 10 " + "00 "*18)) + + # invalid compatible hardware (00100000 00000000) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadCompatibleHardware): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 " + "20 "*32 + "01 00 20 00 20 " + "00 "*18)) + + # invalid compatible hardware (01000000 00000000) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadCompatibleHardware): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 " + "20 "*32 + "01 00 20 00 40 " + "00 "*18)) + + # invalid compatible hardware (10000000 00000000) in a WOZ2 file + with pytest.raises(wozardry.WozINFOFormatError_BadCompatibleHardware): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 " + "20 "*32 + "01 00 20 00 80 " + "00 "*18)) + +def test_parse_tmap(): + # missing TMAP chunk + with pytest.raises(wozardry.WozTMAPFormatError_MissingTMAPChunk): + wozardry.WozReader(stream=bfh(kHeader2 + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 " + "20 "*32 + "01 00 20 00 00 " + "00 "*18)) + + # TRKS chunk before TMAP chunk + with pytest.raises(wozardry.WozTMAPFormatError_MissingTMAPChunk): + wozardry.WozReader( + stream=bfh( + kHeader2 + \ + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 " + "20 "*32 + "01 00 20 00 00 " + "00 "*18 + \ + "54 52 4B 53 00 00 00 00 ")) + + # TMAP points to non-existent TRK in TRKS chunk + with pytest.raises(wozardry.WozTMAPFormatError_BadTRKS): + wozardry.WozReader( + stream=bfh( + kHeader2 + \ + "49 4E 46 4F 3C 00 00 00 02 01 00 00 00 " + "20 "*32 + "01 00 20 00 00 " + "00 "*18 + \ + "54 4D 41 50 A0 00 00 00 00 " + "FF "*159 + \ + "54 52 4B 53 00 00 00 00 ")) + +def test_parse_meta(): + + + +#----- test command-line interface ----- + +def test_command_verify(): + """verify a valid WOZ1/WOZ2 file and exit cleanly""" + wozardry.parse_args(["verify", kValid1]) + wozardry.parse_args(["verify", kValid2]) + +def test_command_dump_woz1(capsys): + """dump a WOZ1 file and ensure it prints expected output""" + wozardry.parse_args(["dump", kValid1]) + captured = capsys.readouterr() + assert "INFO: File format version: 1" in captured.out + assert "INFO: Disk type: 5.25-inch (140K)" in captured.out + assert "INFO: Write protected: no" in captured.out + assert "INFO: Tracks synchronized: no" in captured.out + assert "INFO: Weakbits cleaned: no" in captured.out + assert "INFO: Creator: wozardry" in captured.out + +def test_command_dump_woz2(capsys): + """dump a WOZ2 file and ensure it prints expected output""" + wozardry.parse_args(["dump", kValid2]) + captured = capsys.readouterr() + assert "INFO: File format version: 2" in captured.out + assert "INFO: Disk type: 5.25-inch (140K)" in captured.out + assert "INFO: Write protected: no" in captured.out + assert "INFO: Tracks synchronized: no" in captured.out + assert "INFO: Weakbits cleaned: no" in captured.out + assert "INFO: Creator: wozardry" in captured.out + assert "INFO: Boot sector format: 0 (unknown)" in captured.out + assert "INFO: Optimal bit timing: 32 (standard)" in captured.out + assert "INFO: Compatible hardware: unknown" in captured.out + assert "INFO: Required RAM: unknown" in captured.out + assert "INFO: Largest track: 0 blocks" in captured.out + +def test_command_edit_info_version_1_to_2(): + """convert a WOZ1 file to WOZ2 and ensure new info fields are set to default values""" + with tempfile.NamedTemporaryFile() as tmp: + shutil.copy(kValid1, tmp.name) + + wozardry.parse_args(["edit", "-i", "version:2", tmp.name]) + woz = wozardry.WozReader(tmp.name) + assert woz.woz_version == 2 + assert woz.info["version"] == 2 + assert woz.info["boot_sector_format"] == 0 + assert woz.info["optimal_bit_timing"] == 32 + assert woz.info["compatible_hardware"] == [] + assert woz.info["required_ram"] == 0 + +def test_command_edit_info_disk_type(): + """edit a WOZ1/WOZ2 file to change the disk type""" + # this is pathological, don't do this in real life + def f(inputfile): + with tempfile.NamedTemporaryFile() as tmp: + shutil.copy(inputfile, tmp.name) + + # disk_type = 1 is ok + wozardry.parse_args(["edit", "-i", "disk_type:1", tmp.name]) + woz = wozardry.WozReader(tmp.name) + assert woz.info["disk_type"] == 1 + + # disk_type = 2 is ok + wozardry.parse_args(["edit", "-i", "disk_type:2", tmp.name]) + woz = wozardry.WozReader(tmp.name) + assert woz.info["disk_type"] == 2 + + # disk_type = 0 is not ok + with pytest.raises(wozardry.WozINFOFormatError_BadDiskType): + wozardry.parse_args(["edit", "-i", "disk_type:0", tmp.name]) + + # disk_type = 3 is not ok + with pytest.raises(wozardry.WozINFOFormatError_BadDiskType): + wozardry.parse_args(["edit", "-i", "disk_type:3", tmp.name]) + f(kValid1) + f(kValid2) + +def test_command_edit_info_changing_disk_type_resets_optimal_bit_timing(): + """edit a WOZ2 file to change the disk type and ensure optimal bit timing is reset to default value""" + # this is pathological, don't do this in real life + with tempfile.NamedTemporaryFile() as tmp: + shutil.copy(kValid2, tmp.name) + wozardry.parse_args(["edit", "-i", "disk_type:2", tmp.name]) + woz = wozardry.WozReader(tmp.name) + assert woz.info["optimal_bit_timing"] == wozardry.kDefaultBitTiming[2] + +def test_command_edit_info_boolean_flags(): + """edit a WOZ1/WOZ2 file to change Boolean flags in a variety of ways and ensure they change""" + def f(inputfile): + with tempfile.NamedTemporaryFile() as tmp: + shutil.copy(inputfile, tmp.name) + + for flag in ("write_protected", "synchronized", "cleaned"): + for true_value, false_value in (("1", "0"), + ("yes", "no"), + ("YES", "No"), + ("true", "false"), + ("tRuE", "FaLsE")): + wozardry.parse_args(["edit", "-i", "%s:%s" % (flag, true_value), tmp.name]) + woz = wozardry.WozReader(tmp.name) + assert woz.info[flag] == True + wozardry.parse_args(["edit", "-i", "%s:%s" % (flag, false_value), tmp.name]) + woz = wozardry.WozReader(tmp.name) + assert woz.info[flag] == False + f(kValid1) + f(kValid2) + +def test_command_edit_disk_sides(): + """edit a WOZ2 file to change disk sides""" + with tempfile.NamedTemporaryFile() as tmp: + shutil.copy(kValid2, tmp.name) + + # this file is a 5.25-inch disk image + woz = wozardry.WozReader(tmp.name) + assert woz.info["disk_type"] == 1 + assert woz.info["disk_sides"] == 1 + + # 5.25-inch disk images can only be "1-sided" + with pytest.raises(wozardry.WozINFOFormatError_BadDiskSides): + wozardry.parse_args(["edit", "-i", "disk_sides:2", tmp.name]) + + # now change it to a 3.5-inch disk image + wozardry.parse_args(["edit", "-i", "disk_type:2", tmp.name]) + woz = wozardry.WozReader(tmp.name) + assert woz.info["disk_type"] == 2 + + # 3.5-inch disk images can be 1- or 2-sided + wozardry.parse_args(["edit", "-i", "disk_sides:2", tmp.name]) + woz = wozardry.WozReader(tmp.name) + assert woz.info["disk_sides"] == 2 + wozardry.parse_args(["edit", "-i", "disk_sides:1", tmp.name]) + woz = wozardry.WozReader(tmp.name) + assert woz.info["disk_sides"] == 1 + + # ...but not 3-sided, that's silly + with pytest.raises(wozardry.WozINFOFormatError_BadDiskSides): + wozardry.parse_args(["edit", "-i", "disk_sides:3", tmp.name]) + +def test_command_edit_language(): + """edit a WOZ1/WOZ2 file to change the metadata language field""" + def f(inputfile): + with tempfile.NamedTemporaryFile() as tmp: + shutil.copy(inputfile, tmp.name) + + for lang in wozardry.kLanguages: + wozardry.parse_args(["edit", "-m", "language:%s" % lang, tmp.name]) + woz = wozardry.WozReader(tmp.name) + assert woz.meta["language"] == lang + f(kValid1) + f(kValid2) + +def test_command_edit_requires_ram(): + """edit a WOZ1/WOZ2 file to change the metadata requires_ram field""" + def f(inputfile): + with tempfile.NamedTemporaryFile() as tmp: + shutil.copy(inputfile, tmp.name) + + for ram in wozardry.kRequiresRAM: + wozardry.parse_args(["edit", "-m", "requires_ram:%s" % ram, tmp.name]) + woz = wozardry.WozReader(tmp.name) + assert woz.meta["requires_ram"] == ram + + # invalid required RAM (must be one of enumerated values) + with pytest.raises(wozardry.WozMETAFormatError_BadRAM): + wozardry.parse_args(["edit", "-m", "requires_ram:65K", tmp.name]) + + f(kValid1) + f(kValid2) + +def test_command_edit_requires_machine(): + """edit a WOZ1/WOZ2 file to change the metadata requires_machine field""" + def f(inputfile): + with tempfile.NamedTemporaryFile() as tmp: + shutil.copy(inputfile, tmp.name) + + for model in wozardry.kRequiresMachine: + wozardry.parse_args(["edit", "-m", "requires_machine:%s" % model, tmp.name]) + woz = wozardry.WozReader(tmp.name) + assert woz.meta["requires_machine"] == model + + # invalid machine (Apple IV) + with pytest.raises(wozardry.WozMETAFormatError_BadMachine): + wozardry.parse_args(["edit", "-m", "requires_machine:4", tmp.name]) + + f(kValid1) + f(kValid2) diff --git a/wozardry.py b/wozardry.py index 488032b..bb6fea1 100755 --- a/wozardry.py +++ b/wozardry.py @@ -10,9 +10,10 @@ import collections import json import itertools import os +import sys -__version__ = "2.0-alpha" # https://semver.org -__date__ = "2019-02-17" +__version__ = "2.0-beta" # https://semver.org +__date__ = "2019-02-23" __progname__ = "wozardry" __displayname__ = __progname__ + " " + __version__ + " by 4am (" + __date__ + ")" @@ -28,6 +29,7 @@ kBitstreamLengthInBytes = 6646 kLanguages = ("English","Spanish","French","German","Chinese","Japanese","Italian","Dutch","Portuguese","Danish","Finnish","Norwegian","Swedish","Russian","Polish","Turkish","Arabic","Thai","Czech","Hungarian","Catalan","Croatian","Greek","Hebrew","Romanian","Slovak","Ukrainian","Indonesian","Malay","Vietnamese","Other") kRequiresRAM = ("16K","24K","32K","48K","64K","128K","256K","512K","768K","1M","1.25M","1.5M+","Unknown") kRequiresMachine = ("2","2+","2e","2c","2e+","2gs","2c+","3","3+") +kDefaultBitTiming = (0, 32, 16) # strings and things, for print routines and error messages sEOF = "Unexpected EOF" @@ -50,6 +52,7 @@ class WozHeaderError_NoWOZMarker(WozHeaderError): pass class WozHeaderError_NoFF(WozHeaderError): pass class WozHeaderError_NoLF(WozHeaderError): pass class WozINFOFormatError(WozFormatError): pass +class WozINFOFormatError_MissingINFOChunk(WozINFOFormatError): pass class WozINFOFormatError_BadVersion(WozINFOFormatError): pass class WozINFOFormatError_BadDiskType(WozINFOFormatError): pass class WozINFOFormatError_BadWriteProtected(WozINFOFormatError): pass @@ -59,7 +62,9 @@ class WozINFOFormatError_BadCreator(WozINFOFormatError): pass class WozINFOFormatError_BadDiskSides(WozINFOFormatError): pass class WozINFOFormatError_BadBootSectorFormat(WozINFOFormatError): pass class WozINFOFormatError_BadOptimalBitTiming(WozINFOFormatError): pass +class WozINFOFormatError_BadCompatibleHardware(WozINFOFormatError): pass class WozTMAPFormatError(WozFormatError): pass +class WozTMAPFormatError_MissingTMAPChunk(WozTMAPFormatError): pass class WozTMAPFormatError_BadTRKS(WozTMAPFormatError): pass class WozTRKSFormatError(WozFormatError): pass class WozMETAFormatError(WozFormatError): pass @@ -317,6 +322,13 @@ class WozValidator: raise_if(optimal_bit_timing not in range(8, 25), WozINFOFormatError_BadOptimalBitTiming, "Bad optimal bit timing (expected 8-24 for a 3.5-inch disk, found %s)" % optimal_bit_timing) return optimal_bit_timing + def validate_info_compatible_hardware(self, compatible_hardware): + """|compatible_hardware| is bytes, returns same value as int""" + # assumes WOZ version 2 or later + compatible_hardware = from_uint16(compatible_hardware) + raise_if(compatible_hardware >= 0x01FF, WozINFOFormatError_BadCompatibleHardware, "Bad compatible hardware (7 high bits must be 0 but some were 1)") + return compatible_hardware + def validate_info_required_ram(self, required_ram): """|required_ram| can be str, bytes, or int. returns same value as int""" # assumes WOZ version 2 or later @@ -351,6 +363,8 @@ class WozReader(WozDiskImage, WozValidator): def __init__(self, filename=None, stream=None): WozDiskImage.__init__(self) self.filename = filename + seen_info = False + seen_tmap = False with stream or open(filename, "rb") as f: header_raw = f.read(8) raise_if(len(header_raw) != 8, WozEOFError, sEOF) @@ -374,15 +388,23 @@ class WozReader(WozDiskImage, WozValidator): if chunk_id == kINFO: raise_if(chunk_size != 60, WozINFOFormatError, sBadChunkSize) self.__process_info(data) - elif chunk_id == kTMAP: + seen_info = True + continue + raise_if(not seen_info, WozINFOFormatError_MissingINFOChunk, "Expected INFO chunk at offset 20") + if chunk_id == kTMAP: raise_if(chunk_size != 160, WozTMAPFormatError, sBadChunkSize) self.__process_tmap(data) - elif chunk_id == kTRKS: + seen_tmap = True + continue + raise_if(not seen_tmap, WozTMAPFormatError_MissingTMAPChunk, "Expected TMAP chunk at offset 88") + if chunk_id == kTRKS: self.__process_trks(data) elif chunk_id == kWRIT: self.__process_writ(data) elif chunk_id == kMETA: self.__process_meta(data) + raise_if(not seen_info, WozINFOFormatError_MissingINFOChunk, "Expected INFO chunk at offset 20") + raise_if(not seen_tmap, WozTMAPFormatError_MissingTMAPChunk, "Expected TMAP chunk at offset 88") if crc: raise_if(crc != binascii.crc32(b"".join(all_data)) & 0xffffffff, WozCRCError, "Bad CRC") @@ -403,7 +425,7 @@ class WozReader(WozDiskImage, WozValidator): self.info["disk_sides"] = self.validate_info_disk_sides(data[37]) # int self.info["boot_sector_format"] = self.validate_info_boot_sector_format(data[38]) # int self.info["optimal_bit_timing"] = self.validate_info_optimal_bit_timing(data[39]) # int - compatible_hardware_bitfield = from_uint16(data[40:42]) + compatible_hardware_bitfield = self.validate_info_compatible_hardware(data[40:42]) # int compatible_hardware_list = [] for offset in range(9): if compatible_hardware_bitfield & (1 << offset): @@ -729,10 +751,9 @@ class CommandDump(BaseCommand): if disk_type == 1: # 5.25-inch disk boot_sector_format = info["boot_sector_format"] print("INFO: Boot sector format:".ljust(self.kWidth), "%s (%s)" % (boot_sector_format, tBootSectorFormat[boot_sector_format])) - default_bit_timing = 32 else: # 3.5-inch disk print("INFO: Disk sides:".ljust(self.kWidth), disk_sides) - default_bit_timing = 16 + default_bit_timing = kDefaultBitTiming[disk_type] optimal_bit_timing = info["optimal_bit_timing"] print("INFO: Optimal bit timing:".ljust(self.kWidth), optimal_bit_timing, optimal_bit_timing == default_bit_timing and "(standard)" or @@ -811,6 +832,9 @@ class WriterBaseCommand(BaseCommand): try: global raise_if raise_if = old_raise_if + except: + pass + try: WozReader(tmpfile) except Exception as e: sys.stderr.write("WozInternalError: refusing to write an invalid .woz file (this is the developer's fault)\n") @@ -861,7 +885,11 @@ requires_machine, notes, side, side_name, contributor, image_date. Other keys ar for i in self.args.info or (): k, v = i.split(":", 1) if k == "disk_type": - self.output.info["disk_type"] = self.output.validate_info_disk_type(v) + old_disk_type = self.output.info["disk_type"] + new_disk_type = self.output.validate_info_disk_type(v) + if old_disk_type != new_disk_type: + self.output.info["disk_type"] = new_disk_type + self.output.info["optimal_bit_timing"] = kDefaultBitTiming[new_disk_type] # then update all other info fields for i in self.args.info or (): @@ -941,10 +969,7 @@ class CommandImport(WriterBaseCommand): def update(self): self.output.from_json(sys.stdin.read()) -if __name__ == "__main__": - import sys - old_raise_if = raise_if -# raise_if = lambda cond, e, s="": cond and sys.exit("%s: %s" % (e.__name__, s)) +def parse_args(args): cmds = [CommandDump(), CommandVerify(), CommandEdit(), CommandRemove(), CommandExport(), CommandImport()] parser = argparse.ArgumentParser(prog=__progname__, description="""A multi-purpose tool for manipulating .woz disk images. @@ -955,5 +980,10 @@ See '""" + __progname__ + """ -h' for help on individual commands.""", sp = parser.add_subparsers(dest="command", help="command") for command in cmds: command.setup(sp) - args = parser.parse_args() + args = parser.parse_args(args) args.action(args) + +if __name__ == "__main__": + old_raise_if = raise_if + raise_if = lambda cond, e, s="": cond and sys.exit("%s: %s" % (e.__name__, s)) + parse_args(sys.argv[1:])