├── .gitignore ├── LICENSE-GPLv2.txt ├── README.md ├── alias_create.py ├── alias_file.py ├── alias_record.py ├── app_archive_install.py ├── box_bootstrap.py ├── box_create.py ├── box_up.py ├── catalog_create.py ├── catalog_diff.py ├── classicbox ├── __init__.py ├── alias │ ├── __init__.py │ ├── file.py │ └── record.py ├── archive.py ├── box.py ├── disk │ ├── __init__.py │ └── hfs.py ├── io.py ├── macbinary.py ├── resource_fork.py ├── time.py └── util.py ├── macbinary_file.py ├── resource_fork.py ├── test.py └── test_data ├── AppAlias.bin ├── AppAlias.rsrc.dat ├── AppAlias.rsrcfork.dat ├── MultipleResource.rsrcfile.bin ├── MultipleResource.rsrcfork.dat └── Unicode(tm).bin /.gitignore: -------------------------------------------------------------------------------- 1 | # OS X 2 | .DS_Store 3 | 4 | # Python 5 | *.pyc 6 | 7 | # Command-line tools (hfsutils, unar) 8 | tools 9 | 10 | # Link to the package cache for the box_bootstrap command 11 | api.classicbox.io 12 | 13 | # Code coverage output (by coverage.py) 14 | .coverage 15 | htmlcov 16 | -------------------------------------------------------------------------------- /LICENSE-GPLv2.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Classic Box (CPM Project) 2 | 3 | The CPM project contains a collection of tools and prototypes for manipulating virtual machines that run early Macintosh operating systems (System 1 - Mac OS 9). 4 | 5 | Much of this software is incomplete. I am publishing it now because I am no longer actively working on it. 6 | 7 | ## Vision 8 | 9 | Allow one-click installation of arbitrary Mac OS 0.x - 9.x 10 | games and apps from the 1980s and 1990s. 11 | 12 | ## Tools 13 | 14 | * **app_archive_install** 15 | - Attempts to automatically install an application from an archive 16 | file downloaded from Macintosh Garden or a similar site into a box. 17 | - This tool is incomplete. 18 | * **box_create, box_up** 19 | - Low-level tools for manipulating boxes. 20 | - A **box** is a self-contained classic Mac OS virtual machine. 21 | It is a directory in a special layout containing emulation 22 | software, a machine ROM, mounted disk images, and preferences. 23 | * **box_bootstrap** 24 | - Similar to box_create but automatically installs the minimum 25 | set of components for the box to function, namely an emulator, 26 | a machine ROM, and a boot disk image. 27 | - Requires an emulator package, a ROM package, and an OS boot 28 | disk package as inputs. 29 | - I am not currently distributing any of these packages myself 30 | for copyright reasons. 31 | * **catalog_create, catalog_diff** 32 | - Utilities that manipulate *catalog* structures, which describe the 33 | name and last modified date of files on an HFS disk image. 34 | 35 | ## Libraries 36 | 37 | Much functionality here is likely to be useful in other tools. 38 | 39 | * **classicbox.alias.file** 40 | - Read and write alias files. 41 | * **classicbox.alias.record** 42 | - Read and write alias records, typically found in alias files. 43 | * **classicbox.archive** 44 | - Extracts compressed archives in arbitrary formats. 45 | - Depends on [unar] to do the heavy lifting. 46 | * **classicbox.disk.hfs** 47 | - Manipulate and inspect HFS disk images and contained files. 48 | - Depends on [hfsutils] to do the heavy lifting. 49 | * **classicbox.io** 50 | - Read and write complex binary structures. 51 | - Shims for performing I/O in Python 2 and 3 with the same interface. 52 | * **classicbox.macbinary** 53 | - Read MacBinary I, II, or III files. 54 | - Write MacBinary III files. 55 | * **classicbox.resource_fork** 56 | - Read and write Mac resource forks. 57 | 58 | ## Requirements 59 | 60 | * Mac OS X 10.7 (Lion) 61 | * Support for Windows and other versions of Mac OS X will be added over time. 62 | * Python 2.7 63 | * Experimental support for Python 3 exists after conversion by the `2to3` tool. 64 | Particularly for code exercised by the `test` tool. 65 | * The following tools must be installed and in your system path: 66 | * [hfsutils] 3.2.6 – hdel, hdir, hmkdir, hmount, hpwd 67 | * [unar] 1.3 – unar 68 | 69 | [hfsutils]: http://www.mars.org/home/rob/proj/hfs/ 70 | [unar]: http://unarchiver.c3.cx/commandline 71 | 72 | ## License 73 | 74 | Copyright (c) 2013 David Foster. 75 | 76 | This software is licensed under the GPLv2. See the [LICENSE](LICENSE-GPLv2.txt) file for more information. 77 | 78 | ## Historical Notes 79 | 80 | ### Names 81 | 82 | The acronym "CPM" originally signified "Classic Package Manager", when I 83 | conceived of this project as a kind of package manager that could install 84 | prepared packages into a classic Mac OS virtual machine. 85 | CPM is now an umbrella term for all tools related to the effort of 86 | making it *easy* to run classic Mac software on modern hardware. 87 | 88 | "Classic Box" refers to the idea of a classic Mac OS virtual machine 89 | bundled as a single executable program. Similar to VMware or Parallels, 90 | but without support for managing multiple machines. Classic Box is part 91 | of the larger CPM project. 92 | 93 | "classicbox" is the root package name for shared and reusable components 94 | of the CPM project. 95 | -------------------------------------------------------------------------------- /alias_create.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Creates an alias file that points to another file. 5 | """ 6 | 7 | from classicbox.alias.file import create_alias_file 8 | import sys 9 | 10 | def main(args): 11 | # Parse arguments 12 | if len(args) != 4: 13 | sys.exit('syntax: alias_create.py output_disk_image_filepath, output_macfilepath, target_disk_image_filepath, target_macitempath') 14 | return 15 | output_disk_image_filepath = args[0] 16 | output_macfilepath = args[1].decode('macroman') 17 | target_disk_image_filepath = args[2] 18 | target_macitempath = args[3].decode('macroman') 19 | 20 | create_alias_file( 21 | output_disk_image_filepath, output_macfilepath, 22 | target_disk_image_filepath, target_macitempath) 23 | 24 | 25 | if __name__ == '__main__': 26 | main(sys.argv[1:]) 27 | -------------------------------------------------------------------------------- /alias_file.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Writes MacOS alias files. 5 | """ 6 | 7 | from classicbox.alias.file import create_alias_file 8 | import sys 9 | 10 | # ------------------------------------------------------------------------------ 11 | 12 | VERBOSE_ALIAS_OUTPUT = False 13 | 14 | def main(args): 15 | command = args.pop(0) 16 | 17 | if command == 'create': 18 | create_alias_file(*args) 19 | else: 20 | sys.exit('Unknown command: %s' % command) 21 | return 22 | 23 | # ------------------------------------------------------------------------------ 24 | 25 | if __name__ == '__main__': 26 | main(sys.argv[1:]) 27 | -------------------------------------------------------------------------------- /alias_record.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Manipulates MacOS alias records. 5 | """ 6 | 7 | from classicbox.alias.record import Extra 8 | from classicbox.alias.record import print_alias_record 9 | from classicbox.alias.record import read_alias_record 10 | from classicbox.alias.record import write_alias_record 11 | from classicbox.io import BytesIO 12 | import sys 13 | 14 | 15 | def main(args): 16 | # Path to a file that contains an alias record. 17 | # 18 | # This is equivalent to the contents of an 'alis' resource, 19 | # which is the primary resource contained in an alias file 20 | (command, alias_record_file_filepath) = args 21 | 22 | if alias_record_file_filepath == '-': 23 | alias_record = None 24 | else: 25 | with open(alias_record_file_filepath, 'rb') as input: 26 | alias_record = read_alias_record(input) 27 | 28 | if command == 'info': 29 | print_alias_record(alias_record) 30 | 31 | elif command == 'test_read_write': 32 | output = BytesIO() 33 | write_alias_record(output, alias_record) 34 | 35 | verify_matches(output, alias_record_file_filepath, alias_record) 36 | 37 | elif command == 'test_read_write_no_extras': 38 | alias_record['extras'] = [] 39 | 40 | output = BytesIO() 41 | write_alias_record(output, alias_record) 42 | 43 | output.seek(0) 44 | alias_record_no_extras = read_alias_record(output) 45 | 46 | if alias_record_no_extras['extras'] == []: 47 | print 'OK' 48 | else: 49 | print 'Expected empty extras.' 50 | 51 | elif command == 'test_write_custom_matching': 52 | test_write_custom_matching(alias_record_file_filepath, alias_record) 53 | 54 | else: 55 | sys.exit('Unrecognized command: %s' % command) 56 | return 57 | 58 | 59 | def test_write_custom_matching(alias_record_file_filepath, alias_record): 60 | # "AppAlias.rsrc.dat" 61 | output = BytesIO() 62 | write_alias_record(output, { 63 | 'alias_kind': 0, 64 | 'volume_name': 'Boot', 65 | 'volume_created': 3431272487, 66 | 'parent_directory_id': 542, 67 | 'file_name': 'app', 68 | 'file_number': 543, 69 | # NOTE: Can't get file_created reliably from hfsutil CLI 70 | 'file_created': 3265652246, 71 | 'file_type': 'APPL', 72 | 'file_creator': 'AQt7', 73 | 'nlvl_from': 1, 74 | 'nlvl_to': 1, 75 | 'extras': [ 76 | Extra(0, 'parent_directory_name', 'B'), 77 | Extra(1, 'directory_ids', [542, 541, 484]), 78 | Extra(2, 'absolute_path', 'Boot:AutQuit7:A:B:app'), 79 | Extra(0xFFFF, 'end', None) 80 | ] 81 | }) 82 | 83 | if alias_record_file_filepath != '-': 84 | verify_matches(output, alias_record_file_filepath, alias_record) 85 | 86 | 87 | def verify_matches(output, alias_record_file_filepath, alias_record): 88 | actual_output = output.getvalue() 89 | with open(alias_record_file_filepath, 'rb') as file: 90 | expected_output = file.read() 91 | 92 | matches = (actual_output == expected_output) 93 | if matches: 94 | print 'OK' 95 | else: 96 | print ' Expected: ' + repr(expected_output) 97 | print ' Actual: ' + repr(actual_output) 98 | print 99 | print_alias_record(alias_record) 100 | 101 | # ------------------------------------------------------------------------------ 102 | 103 | if __name__ == '__main__': 104 | main(sys.argv[1:]) 105 | -------------------------------------------------------------------------------- /app_archive_install.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Automatically installs an app from an archive file. 5 | 6 | Syntax: 7 | app_archive_install.py 8 | """ 9 | 10 | # TODO: Extract common functionality to classicbox.box 11 | import box_up 12 | 13 | # TODO: Extract common functionality to classicbox.catalog 14 | from catalog_create import create_catalog 15 | from catalog_diff import create_catalog_diff 16 | from catalog_diff import DirectoryDiff 17 | from catalog_diff import EMPTY_DIFF 18 | from catalog_diff import FileDiff 19 | from catalog_diff import print_catalog_diff 20 | from catalog_diff import remove_ignored_parts_from_catalog_diff 21 | 22 | from classicbox.alias.file import create_alias_file 23 | from classicbox.archive import archive_extract 24 | from classicbox.disk import is_basilisk_supported_disk_image 25 | from classicbox.disk import is_disk_image 26 | from classicbox.disk.hfs import hfs_delete 27 | from classicbox.disk.hfs import hfs_exists 28 | from classicbox.disk.hfs import hfs_ls 29 | from classicbox.disk.hfs import hfs_mount 30 | from classicbox.disk.hfs import hfs_stat 31 | from contextlib import contextmanager 32 | import os 33 | import os.path 34 | import sys 35 | from tempfile import NamedTemporaryFile 36 | 37 | 38 | RECOGNIZED_INSTALLER_APP_CREATORS = [ 39 | 'STi0', # Stuffit InstallerMaker 40 | 'bbkr', # Apple Installer 41 | 'VIS3', # InstallerVISE 42 | 'EXTR', # CompactPro AutoExtractor Self-Extracting Archive 43 | ] 44 | 45 | OS_7_5_3_IGNORE_TREE = [ 46 | ["System Folder", [ 47 | ["Apple Menu Items", [ 48 | "Recent Applications" 49 | ]], 50 | ["Control Panels", [ 51 | "Apple Menu Options", 52 | "MacTCP" 53 | ]], 54 | ["Extensions", [ 55 | "Printer Share" 56 | ]], 57 | "MacTCP DNR", 58 | ["Preferences", [ 59 | "ASLM Preferences", 60 | "Apple Menu Options Prefs", 61 | "Finder Preferences", 62 | "Macintosh Easy Open Preferences", 63 | "Users & Groups Data File", 64 | "WindowShade Preferences" 65 | ]] 66 | ]] 67 | ] 68 | 69 | 70 | VERBOSE_CATALOG_DIFF = True 71 | 72 | def main(args): 73 | # Parse arguments 74 | (box_dirpath, archive_filepath) = args 75 | 76 | # Extract the archive 77 | with archive_extract(archive_filepath) as archive: 78 | contents_dirpath = archive.extraction_dirpath 79 | 80 | # TODO: Probably will need to be smarter here for archives 81 | # that expand with a single directory at the root 82 | 83 | # Locate disk images in archive, if any 84 | disk_image_filepaths = [] 85 | for filename in os.listdir(contents_dirpath): 86 | if is_disk_image(filename): 87 | disk_image_filepaths.append(os.path.join(contents_dirpath, filename)) 88 | 89 | # Identify the primary disk image 90 | if len(disk_image_filepaths) == 0: 91 | # TODO: ... 92 | raise NotImplementedError('Did not find any disk images in archive. Not sure what to do.') 93 | 94 | elif len(disk_image_filepaths) == 1: 95 | primary_disk_image_filepath = disk_image_filepaths[0] 96 | 97 | elif len(disk_image_filepaths) >= 2: 98 | choice = choose_from_menu( 99 | 'Found multiple disk images in archive.', 100 | 'Please choose the primary disk image containing the program or installer:', 101 | [os.path.basename(path) for path in disk_image_filepaths] + [ 102 | '']) 103 | 104 | if choice == len(disk_image_filepaths): 105 | # Cancel 106 | return 107 | else: 108 | primary_disk_image_filepath = disk_image_filepaths[choice] 109 | 110 | # Open the primary disk image 111 | hfs_mount(primary_disk_image_filepath) 112 | 113 | # List the root items 114 | root_items = hfs_ls() 115 | 116 | # Look for installer apps 117 | installer_app_items = [] 118 | app_creators = [] 119 | for item in root_items: 120 | if item.type == 'APPL': 121 | app_creators.append(item.creator) 122 | 123 | if item.creator in RECOGNIZED_INSTALLER_APP_CREATORS: 124 | installer_app_items.append(item) 125 | 126 | # Identify the primary installer app 127 | if len(installer_app_items) == 0: 128 | if len(app_creators) == 0: 129 | details = 'Did not find any applications.' 130 | else: 131 | details = 'However applications of type %s were found.' % \ 132 | repr(app_creators) 133 | 134 | # TODO: Continue looking for the designated app... 135 | raise NotImplementedError( 136 | ('Did not find any installer applications. %s ' + 137 | 'Not sure what to do.') % details) 138 | 139 | elif len(installer_app_items) == 1: 140 | primary_installer_app_item = installer_app_items[0] 141 | 142 | elif len(installer_app_items) >= 2: 143 | choice = choose_from_menu( 144 | 'Found multiple installer applications.', 145 | 'Please choose the primary installer for this program:', 146 | [item.name for item in installer_app_items] + ['']) 147 | 148 | if choice == len(installer_app_items): 149 | # Cancel 150 | return 151 | else: 152 | primary_installer_app_item = installer_app_items[choice] 153 | 154 | # Temporarily mount the disk images inside the VM 155 | with mount_disk_images_temporarily(box_dirpath, disk_image_filepaths): 156 | 157 | # Set the installer app as the boot app 158 | set_boot_app_of_box( 159 | box_dirpath, 160 | primary_disk_image_filepath, 161 | [primary_installer_app_item.name]) 162 | 163 | while True: 164 | # Remember state of boot volume prior to installation 165 | boot_disk_image_filepath = locate_boot_volume_of_box(box_dirpath) 166 | preinstall_catalog = create_catalog(boot_disk_image_filepath) 167 | 168 | # Boot the box and wait for the user to install the app 169 | run_box(box_dirpath) 170 | 171 | # Detect changes on the boot volume since installation 172 | postinstall_catalog = create_catalog(boot_disk_image_filepath) 173 | install_diff = create_catalog_diff(preinstall_catalog, postinstall_catalog) 174 | remove_ignored_parts_from_catalog_diff( 175 | install_diff, 176 | OS_7_5_3_IGNORE_TREE) 177 | 178 | # If the user didn't appear to install anything, 179 | # provide the option to try again 180 | if install_diff == EMPTY_DIFF: 181 | choice = choose_from_menu( 182 | None, 183 | "It appears you didn't install anything.", 184 | ['Try Again', 'Cancel']) 185 | 186 | if choice == 0: # Try Again 187 | continue 188 | else: # Cancel 189 | return 190 | 191 | if VERBOSE_CATALOG_DIFF: 192 | print_catalog_diff(install_diff) 193 | print 194 | 195 | # Look for the installed app 196 | installed_apps = [] 197 | for (item, itempath_components) in \ 198 | walk_added_files_in_catalog_diff( 199 | install_diff, boot_disk_image_filepath): 200 | if item.type == 'APPL': 201 | installed_apps.append(itempath_components) 202 | 203 | if len(installed_apps) == 0: 204 | # TODO: Offer to run the installer again or cancel 205 | raise NotImplementedError( 206 | 'No applications found in the installed items. ' + 207 | 'Not sure what to do.') 208 | elif len(installed_apps) == 1: 209 | installed_app_filepath_components = installed_apps[0] 210 | elif len(installed_apps) >= 2: 211 | choice = choose_from_menu( 212 | 'Multiple applications were installed.', 213 | 'Please choose the primary application:', 214 | [components[-1] for components in installed_apps] + \ 215 | ['']) 216 | 217 | if choice == len(installed_apps): 218 | # Cancel 219 | return 220 | else: 221 | installed_app_filepath_components = \ 222 | installed_apps[choice] 223 | 224 | # (Found the installed app) 225 | break 226 | 227 | # (Unmount the archive's disk images since installation is done) 228 | 229 | # (Close the archive since installation is done) 230 | 231 | # Set the installed app as the boot app 232 | set_boot_app_of_box( 233 | box_dirpath, 234 | boot_disk_image_filepath, 235 | installed_app_filepath_components) 236 | 237 | 238 | def choose_from_menu(prompt, subprompt, menuitems): 239 | if prompt is not None: 240 | print prompt 241 | print 242 | 243 | while True: 244 | print subprompt 245 | i = 1 246 | for item in menuitems: 247 | print ' %d: %s' % (i, item); i += 1 248 | try: 249 | choice = int(raw_input('Choice? ')) 250 | if 1 <= choice < i: 251 | break 252 | else: 253 | raise ValueError 254 | except ValueError: 255 | print 'Not a valid choice.' 256 | continue 257 | finally: 258 | print 259 | 260 | return choice - 1 261 | 262 | 263 | @contextmanager 264 | def mount_disk_images_temporarily(box_dirpath, disk_image_filepaths): 265 | for x in _mdit_helper(box_dirpath, 0, disk_image_filepaths): 266 | yield 267 | 268 | 269 | def _mdit_helper(box_dirpath, i, disk_image_filepaths): 270 | if i == len(disk_image_filepaths): 271 | yield 272 | return 273 | disk_image_filepath = disk_image_filepaths[i] 274 | 275 | mount_dirpath = os.path.join(box_dirpath, 'mount') 276 | disk_image_ext = os.path.splitext(disk_image_filepath)[1] 277 | link_filepath = _mktemp_dammit(dir=mount_dirpath, suffix=disk_image_ext) 278 | 279 | # NOTE: os.symlink() is not available on Windows. 280 | # The underlying emulators support Windows .lnk files, so those should 281 | # be created on Windows systems. Be sure to test box_up() when such 282 | # links are present. 283 | os.symlink(disk_image_filepath, link_filepath) 284 | try: 285 | for x in _mdit_helper(box_dirpath, i+1, disk_image_filepaths): 286 | yield 287 | finally: 288 | os.remove(link_filepath) 289 | 290 | 291 | def _mktemp_dammit(*args, **kwargs): 292 | """ 293 | Same as tempfile.mktemp(), but isn't a deprecated function. 294 | 295 | Note that this function is vulnerable to symlink attacks. 296 | Recommended only when `dir` is specified explicitly. 297 | """ 298 | with NamedTemporaryFile(*args, delete=False, **kwargs) as file: 299 | pass 300 | 301 | temp_filepath = file.name 302 | os.remove(temp_filepath) 303 | return temp_filepath 304 | 305 | 306 | def walk_added_files_in_catalog_diff(diff, disk_image_filepath): 307 | for x in _walk_added_files_in_diff(diff, disk_image_filepath, ()): 308 | yield x 309 | 310 | def _walk_added_files_in_diff( 311 | diff, disk_image_filepath, parent_dirpath_components): 312 | 313 | for add in diff.adds: 314 | for x in walk_files( 315 | disk_image_filepath, 316 | parent_dirpath_components + (add,)): 317 | yield x 318 | 319 | for edit in diff.edits: 320 | if isinstance(edit, FileDiff): 321 | edit_itempath_components = (parent_dirpath_components + (edit.name,)) 322 | edit_item = hfs2_stat(disk_image_filepath, edit_itempath_components) 323 | yield (edit_item, edit_itempath_components) 324 | elif isinstance(edit, DirectoryDiff): 325 | for x in _walk_added_files_in_diff( 326 | edit.listing_diff, 327 | disk_image_filepath, 328 | parent_dirpath_components + (edit.name,)): 329 | yield x 330 | else: 331 | raise ValueError 332 | 333 | 334 | def walk_files(disk_image_filepath, top_itempath_components): 335 | top_item = hfs2_stat(disk_image_filepath, top_itempath_components) 336 | if top_item.is_file: 337 | yield (top_item, top_itempath_components) 338 | else: 339 | for x in _walk_files_in_directory( 340 | disk_image_filepath, top_itempath_components): 341 | yield x 342 | 343 | 344 | def _walk_files_in_directory(disk_image_filepath, parent_dirpath_components): 345 | for item in hfs2_ls(disk_image_filepath, parent_dirpath_components): 346 | if item.is_file: 347 | yield (item, parent_dirpath_components + (item.name,)) 348 | else: 349 | for x in _walk_files_in_directory( 350 | disk_image_filepath, 351 | parent_dirpath_components + (item.name,)): 352 | yield x 353 | 354 | # ------------------------------------------------------------------------------ 355 | 356 | def set_boot_app_of_box(box_dirpath, disk_image_filepath, app_filepath_components): 357 | install_autoquit_in_box(box_dirpath) 358 | set_autoquit_app(box_dirpath, disk_image_filepath, app_filepath_components) 359 | 360 | # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 361 | # install_autoquit_in_box 362 | 363 | def install_autoquit_in_box(box_dirpath): 364 | # Check whether already installed 365 | if is_autoquit_in_box(box_dirpath): 366 | return 367 | 368 | # NOTE: I can delay implementing this by cheating and installing AutQuit7 369 | # manually for the time being. 370 | raise NotImplementedError 371 | 372 | 373 | def is_autoquit_in_box(box_dirpath): 374 | try: 375 | locate_autoquit_in_box(box_dirpath) 376 | return True 377 | except AutoQuitNotFoundError: 378 | return False 379 | 380 | 381 | class AutoQuitNotFoundError(Exception): 382 | pass 383 | 384 | def locate_autoquit_in_box(box_dirpath): 385 | boot_disk_image_filepath = locate_boot_volume_of_box(box_dirpath) 386 | 387 | # TODO: Eventually need to also check for the AutoQuit variant of this 388 | # program for System 6 and below. 389 | autoquit_dirpath_components = ['System Folder', 'AutQuit7'] 390 | 391 | if hfs2_exists(boot_disk_image_filepath, autoquit_dirpath_components): 392 | return (boot_disk_image_filepath, autoquit_dirpath_components) 393 | else: 394 | raise AutoQuitNotFoundError 395 | 396 | 397 | class BootVolumeNotFoundError(Exception): 398 | pass 399 | 400 | def locate_boot_volume_of_box(box_dirpath): 401 | for disk_image_filepath in volumes_of_box(box_dirpath): 402 | if hfs2_exists(disk_image_filepath, ['System Folder']): 403 | return disk_image_filepath 404 | raise BootVolumeNotFoundError 405 | 406 | 407 | def volumes_of_box(box_dirpath): 408 | mount_dirpath = os.path.join(box_dirpath, 'mount') 409 | 410 | for root, dirs, files in os.walk(mount_dirpath): 411 | for file in files: 412 | # FIXME: Determine emulator type of box first to determine what 413 | # types of disk images are supported. Here we assume that 414 | # the box is a Basilisk box. 415 | if is_basilisk_supported_disk_image(file): 416 | yield os.path.join(root, file) 417 | 418 | # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 419 | # set_autoquit_app 420 | 421 | def set_autoquit_app(box_dirpath, disk_image_filepath, app_filepath_components): 422 | (boot_disk_image_filepath, autoquit_dirpath_components) = locate_autoquit_in_box(box_dirpath) 423 | 424 | autoquit_app_alias_filepath_components = autoquit_dirpath_components + ['app'] 425 | if hfs2_exists(boot_disk_image_filepath, autoquit_app_alias_filepath_components): 426 | hfs2_delete(boot_disk_image_filepath, autoquit_app_alias_filepath_components) 427 | 428 | create_alias_file_2( 429 | boot_disk_image_filepath, autoquit_app_alias_filepath_components, 430 | disk_image_filepath, app_filepath_components) 431 | 432 | 433 | def run_box(box_dirpath): 434 | try: 435 | box_up.main([box_dirpath]) 436 | except SystemExit as e: 437 | if e.code == 0: 438 | pass 439 | else: 440 | raise 441 | print 442 | 443 | # ------------------------------------------------------------------------------ 444 | # TODO: Make consistent and extract to classicbox.alias.file 445 | 446 | def create_alias_file_2( 447 | output_disk_image_filepath, output_filepath_components, 448 | target_disk_image_filepath, target_filepath_components): 449 | 450 | output_macitempath = _mount_disk_image_and_resolve_path( 451 | output_disk_image_filepath, output_filepath_components) 452 | target_macitempath = _mount_disk_image_and_resolve_path( 453 | target_disk_image_filepath, target_filepath_components) 454 | create_alias_file( 455 | output_disk_image_filepath, output_macitempath, 456 | target_disk_image_filepath, target_macitempath) 457 | 458 | # ------------------------------------------------------------------------------ 459 | # TODO: Make consistent and extract to classicbox.disk.hfs 460 | 461 | def hfs2_exists(disk_image_filepath, itempath_components): 462 | macitempath = _mount_disk_image_and_resolve_path(disk_image_filepath, itempath_components) 463 | return hfs_exists(macitempath) 464 | 465 | 466 | def hfs2_delete(disk_image_filepath, itempath_components): 467 | macitempath = _mount_disk_image_and_resolve_path(disk_image_filepath, itempath_components) 468 | hfs_delete(macitempath) 469 | 470 | 471 | def hfs2_delete_if_exists(disk_image_filepath, itempath_components): 472 | if hfs2_exists(disk_image_filepath, itempath_components): 473 | hfs2_delete(disk_image_filepath, itempath_components) 474 | 475 | 476 | def hfs2_ls(disk_image_filepath, itempath_components): 477 | macitempath = _mount_disk_image_and_resolve_path(disk_image_filepath, itempath_components) 478 | return hfs_ls(macitempath) 479 | 480 | 481 | def hfs2_stat(disk_image_filepath, itempath_components): 482 | macitempath = _mount_disk_image_and_resolve_path(disk_image_filepath, itempath_components) 483 | return hfs_stat(macitempath) 484 | 485 | 486 | def _mount_disk_image_and_resolve_path(disk_image_filepath, itempath_components): 487 | volume_info = hfs_mount(disk_image_filepath) 488 | volume_name = volume_info['name'] 489 | 490 | macitempath = '%s:%s' % (volume_name, ':'.join(itempath_components)) 491 | return macitempath 492 | 493 | # ------------------------------------------------------------------------------ 494 | 495 | if __name__ == '__main__': 496 | main(sys.argv[1:]) -------------------------------------------------------------------------------- /box_bootstrap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Creates a box directory for use with the box_up command. 5 | 6 | The box is automatically bootstrapped with: 7 | (1) An emulator such as Basilisk. 8 | (2) A ROM package. 9 | (3) A boot disk. 10 | 11 | These components are automatically installed from the packages specified by the user. 12 | 13 | Syntax: 14 | box_bootstrap.py 15 | """ 16 | 17 | from classicbox.archive import archive_extract 18 | from classicbox.box import box_create 19 | import json 20 | import os 21 | import os.path 22 | import shutil 23 | import sys 24 | 25 | 26 | SCRIPT_DIRPATH = os.path.dirname(__file__) 27 | 28 | # NOTE: We are temporarily cheating by mapping the package cache directory 29 | # to the website contents, which would not normally be possible on 30 | # an end user's machine. This also means that no existant package needs 31 | # to be downloaded to the cache (because it will already be there). 32 | PACKAGE_CACHE_DIRPATH = os.path.join( 33 | SCRIPT_DIRPATH, 'api.classicbox.io', 'packages') 34 | #SCRIPT_DIRPATH, 'package_cache') 35 | 36 | DEVNULL = open(os.devnull, 'wb') 37 | 38 | 39 | def main(args): 40 | # Parse arguments 41 | if len(args) != 4: 42 | # TODO: Support installing from an emulator group (i.e. "Basilisk_II") 43 | # rather than an exact package name. 44 | # TODO: Support installing using a "box package" that 45 | # automatically specifies what emulator, ROM, and OS to use. 46 | sys.exit('syntax: box_bootstrap.py ') 47 | return 48 | (box_dirpath, emulator_package_name, rom_package_name, os_boot_disk_package_name) = args 49 | 50 | if os.path.exists(box_dirpath): 51 | sys.exit('Directory already exists: %s' % box_dirpath) 52 | return 53 | 54 | # TODO: Automatically download the specified packages if necessary. 55 | # (Currently we just assume that they're already available.) 56 | 57 | box_create(box_dirpath) 58 | try: 59 | install_binary_from_emulator_package( 60 | emulator_package_name, 61 | os.path.join(box_dirpath, 'bin')) 62 | 63 | install_rom_from_rom_package( 64 | rom_package_name, 65 | os.path.join(box_dirpath, 'rom')) 66 | 67 | install_disk_from_disk_package( 68 | os_boot_disk_package_name, 69 | os.path.join(box_dirpath, 'mount')) 70 | 71 | install_recommended_preferences( 72 | os.path.join(box_dirpath, 'etc')) 73 | except: 74 | # Cleanup incomplete box if there was an error 75 | shutil.rmtree(box_dirpath) 76 | 77 | raise 78 | 79 | 80 | def install_binary_from_emulator_package(package_name, output_dirpath): 81 | install_package_contents_to_directory(package_name, output_dirpath, 'emulator') 82 | 83 | 84 | def install_rom_from_rom_package(package_name, output_dirpath): 85 | install_package_contents_to_directory(package_name, output_dirpath, 'rom') 86 | 87 | 88 | def install_disk_from_disk_package(package_name, output_dirpath): 89 | install_package_contents_to_directory(package_name, output_dirpath, 'boot_disk') 90 | 91 | 92 | def install_package_contents_to_directory(package_name, output_dirpath, expected_package_type): 93 | # Ensure the specified package exists 94 | package_dirpath = os.path.join(PACKAGE_CACHE_DIRPATH, package_name) 95 | if not os.path.exists(package_dirpath): 96 | raise Exception('No such package "%s".' % package_name) 97 | 98 | # Locate the archive file in the package 99 | with open(os.path.join(package_dirpath, 'metadata.json'), 'rb') as metadata_file: 100 | metadata = json.load(metadata_file) 101 | actual_package_type = metadata['type'] 102 | if actual_package_type != expected_package_type: 103 | raise Exception('Expected package "%s" to be an "%s" package. It is a "%s" package.' % ( 104 | package_name, expected_package_type, actual_package_type)) 105 | files = metadata['files'] 106 | # TODO: This limitation is not great for forward compatibility. 107 | if len(files) != 1: 108 | raise Exception('Expected emulator package "%s" to have exactly one file. It has %s.' % ( 109 | package_name, len(files))) 110 | archive_filename = files.keys()[0] 111 | archive_metadata = files.values()[0] 112 | archive_filepath = os.path.join(package_dirpath, 'files', archive_filename) 113 | 114 | # Extract the archive file to a temporary extraction directory 115 | with archive_extract(archive_filepath) as archive: 116 | extraction_dirpath = archive.extraction_dirpath 117 | 118 | # Locate the target item in the extraction directory 119 | target_itempath_components = archive_metadata['contents'] 120 | target_itempath = os.path.join(extraction_dirpath, *target_itempath_components) 121 | 122 | # Move the emulator binary to the output directory 123 | shutil.move(target_itempath, output_dirpath) 124 | 125 | 126 | def install_recommended_preferences(prefs_dirpath): 127 | with open(os.path.join(prefs_dirpath, 'nojit.prefs'), 'wb') as nojit_file: 128 | nojit_file.write('# Disable JIT, as it causes compatibility problems with many programs\n') 129 | nojit_file.write('jit false\n') 130 | nojit_file.write('jitfpu false\n') 131 | 132 | 133 | if __name__ == '__main__': 134 | main(sys.argv[1:]) 135 | -------------------------------------------------------------------------------- /box_create.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Creates a blank box directory, for use with the box_up command. 5 | 6 | The user must manually populate the box with: 7 | (1) [bin] An emulator such as Basilisk. 8 | (2) [rom] A ROM package. 9 | (3) [mount] A boot disk. 10 | 11 | Syntax: 12 | box_create.py 13 | """ 14 | 15 | from classicbox.box import box_create 16 | import sys 17 | 18 | 19 | def main(args): 20 | # Parse arguments 21 | if len(args) != 1: 22 | sys.exit('syntax: box_create.py ') 23 | return 24 | dirpath = args[0] 25 | 26 | box_create(dirpath) 27 | 28 | 29 | if __name__ == '__main__': 30 | main(sys.argv[1:]) 31 | -------------------------------------------------------------------------------- /box_up.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Launches Basilisk, SheepShaver, or Mini vMac from a "box" directory that 5 | contains all the necessary components of the virtual machine. 6 | 7 | This script is responsible for generating an emulator preferences file 8 | that points to the items in the box (if applicable) and launching 9 | the emulator with that preferences file. 10 | 11 | This is a tracer. 12 | """ 13 | 14 | from classicbox.disk import is_basilisk_supported_disk_image 15 | from classicbox.disk import is_mini_vmac_supported_disk_image 16 | import md5 17 | import os 18 | import os.path 19 | import subprocess 20 | import sys 21 | 22 | 23 | def main(args): 24 | # Parse flags 25 | verify_rom = True 26 | use_exec = False 27 | while len(args) >= 1: 28 | if args[0] == '-f': 29 | verify_rom = False 30 | elif args[0] == '--exec': 31 | use_exec = True 32 | else: 33 | break 34 | args = args[1:] 35 | 36 | # Parse arguments 37 | if len(args) != 1: 38 | sys.exit('syntax: box_up [-f] [--exec] ') 39 | return 40 | box_dirpath = os.path.abspath(args[0]) 41 | if not os.path.exists(box_dirpath): 42 | sys.exit('file not found: ' + box_dirpath) 43 | return 44 | 45 | # Compute paths to important directories and files 46 | bin_dirpath = os.path.join(box_dirpath, 'bin') 47 | etc_dirpath = os.path.join(box_dirpath, 'etc') 48 | rom_dirpath = os.path.join(box_dirpath, 'rom') 49 | share_dirpath = os.path.join(box_dirpath, 'share') 50 | mount_dirpath = os.path.join(box_dirpath, 'mount') 51 | 52 | minivmac_filepath = os.path.join(bin_dirpath, 53 | 'Mini vMac.app', 'Contents', 'MacOS', 'minivmac') 54 | basilisk_filepath = os.path.join(bin_dirpath, 55 | 'BasiliskII.app', 'Contents', 'MacOS', 'BasiliskII') 56 | sheepshaver_filepath = os.path.join(bin_dirpath, 57 | 'SheepShaver.app', 'Contents', 'MacOS', 'SheepShaver') 58 | 59 | if os.path.exists(minivmac_filepath): 60 | emulator_filepath = minivmac_filepath 61 | prefs_filepath = None 62 | elif os.path.exists(basilisk_filepath): 63 | emulator_filepath = basilisk_filepath 64 | prefs_filepath = os.path.join(etc_dirpath, '.basilisk_ii_prefs') 65 | elif os.path.exists(sheepshaver_filepath): 66 | emulator_filepath = sheepshaver_filepath 67 | prefs_filepath = os.path.join(etc_dirpath, '.sheepshaver_prefs') 68 | else: 69 | sys.exit('Cannot locate a supported emulator in the bin directory.') 70 | return 71 | 72 | # Locate ROM file 73 | rom_filepath = None 74 | for root, dirs, files in os.walk(rom_dirpath): 75 | for file in files: 76 | # Find the first .rom file and use it 77 | if file.lower().endswith('.rom'): 78 | if rom_filepath is not None: 79 | sys.exit('Multiple ROM files found.') 80 | return 81 | rom_filepath = os.path.join(root, file) 82 | if rom_filepath is None: 83 | sys.exit('Cannot locate ROM file.') 84 | return 85 | 86 | # Load ROM file for further checks 87 | with open(rom_filepath, 'rb') as rom_file: 88 | rom = rom_file.read() 89 | rom_size = len(rom) 90 | 91 | # A user may want to override the following ROM checks, since 92 | # an invalid ROM often works inside emulators. Furthermore some 93 | # commonly distributed copies of ROMs are bogus (especially vMac.ROM). 94 | if verify_rom: 95 | # Verify ROM checksum (if OldWorld ROM) 96 | is_newworld_rom = rom.startswith('') 97 | is_oldworld_rom = not is_newworld_rom 98 | if is_oldworld_rom: 99 | expected_checksum = get_oldworld_rom_embedded_checksum(rom) 100 | actual_checksum = compute_oldworld_rom_actual_checksum(rom) 101 | if actual_checksum != expected_checksum: 102 | sys.exit('Invalid ROM checksum. Expected %08x but found %08x.' % ( 103 | expected_checksum, 104 | actual_checksum 105 | )) 106 | return 107 | 108 | # Verify ROM size 109 | if is_oldworld_rom: 110 | if rom_size not in [64*1024, 128*1024, 256*1024, 512*1024, 1*1024*1024, 2*1024*1024, 4*1024*1024]: 111 | sys.exit('Invalid ROM file size. Expected 64k, 128k, 256k, 512k, 1m, 2m, or 4m.') 112 | return 113 | 114 | # There are chopped versions of the Mac Classic ROM on the internet 115 | # that only have the first 256k. Detect this. 116 | if get_oldworld_rom_embedded_checksum(rom) == 0xA49F9914 and rom_size != 512 * 1024: 117 | if rom_size == 256 * 1024: 118 | sys.exit('This Mac Classic ROM is missing its last 256k. Booting it with Command-Option-X-O will not work.') 119 | return 120 | else: 121 | sys.exit('Invalid ROM size. Expected %d bytes but found %d bytes.' % ( 122 | 512 * 1024, 123 | rom_size 124 | )) 125 | return 126 | 127 | using_minivmac = (emulator_filepath == minivmac_filepath) 128 | using_basilisk = (emulator_filepath == basilisk_filepath) 129 | if using_minivmac: 130 | rom = None # permit early garbage collection 131 | 132 | # Locate disk images 133 | disk_filepaths = [] 134 | for root, dirs, files in os.walk(mount_dirpath): 135 | for file in files: 136 | if is_mini_vmac_supported_disk_image(file): 137 | disk_filepaths.append(os.path.join(root, file)) 138 | 139 | # Create a symlink to the ROM file with the name required by Mini vMac, 140 | # and locate it in the same directory as the Mini vMac binary. 141 | # NOTE: When porting to Windows, Mini vMac explicitly supports a 142 | # Windows-style .lnk file. See the docs for details. 143 | rom_link_filepath = os.path.join(bin_dirpath, 'vMac.ROM') 144 | if os.path.exists(rom_link_filepath): 145 | # Cleanup old link 146 | # TODO: Fail if isn't a link (i.e. if there is already 147 | # a full ROM file here). 148 | os.remove(rom_link_filepath) 149 | os.symlink(rom_filepath, rom_link_filepath) 150 | 151 | returncode = 1 152 | try: 153 | # Start Mini vMac 154 | # NOTE: Unlike for Basilisk or SheepShaver, it is not necessary 155 | # to fake the home directory, since Mini vMac does not 156 | # have any configuration files at all. 157 | minivmac_process = subprocess.Popen([emulator_filepath]) 158 | 159 | # Mount all of the disks (by simulating a drag of each disk to the 160 | # Mini vMac application icon). 161 | # 162 | # Do this twice because the system will eject disks until a valid 163 | # boot disk is inserted. 164 | # Mini vMac complains about multiple mounts & file in use. 165 | # TODO: Will need a more creative solution, perhaps checking whether 166 | # a file is "in use" before dragging to Mini vMac. 167 | for i in xrange(1): #xrange(2): 168 | for disk_filepath in disk_filepaths: 169 | subprocess.call(['open', '-a', emulator_filepath, disk_filepath]) 170 | 171 | # Wait for Mini vMac to terminate 172 | minivmac_process.wait() 173 | returncode = minivmac_process.returncode 174 | finally: 175 | os.remove(rom_link_filepath) 176 | 177 | # Exit with emulator's return code 178 | sys.exit(returncode) 179 | return 180 | 181 | else: 182 | # Create preferences file 183 | with open(prefs_filepath, 'wb') as prefs: 184 | # Write ROM section 185 | prefs.write('# ROM\n') 186 | prefs.write('rom ' + rom_filepath + '\n') 187 | rom_prefs_filepath = rom_filepath + '.prefs' 188 | if os.path.exists(rom_prefs_filepath): 189 | prefs.write(open(rom_prefs_filepath, 'rb').read()) 190 | 191 | # Write disks section 192 | prefs.write('# Disks\n') 193 | for root, dirs, files in os.walk(mount_dirpath): 194 | for file in files: 195 | if is_basilisk_supported_disk_image(file): 196 | prefs.write('disk ' + os.path.join(root, file) + '\n') 197 | 198 | # Write shared folder section 199 | if os.path.exists(share_dirpath): 200 | prefs.write('# Shared folder\n') 201 | prefs.write('extfs ' + share_dirpath + '\n') 202 | 203 | # Write networking section 204 | prefs.write('# Networking\n') 205 | if using_basilisk: 206 | prefs.write('udptunnel true\n') 207 | prefs.write('udpport 6066\n') # default, but it's nice to be explicit 208 | 209 | # Write extra preferences 210 | for root, dirs, files in os.walk(etc_dirpath): 211 | for file in files: 212 | if file.lower().endswith('.prefs'): 213 | prefs.write('# Extra: ' + file + '\n') 214 | prefs.write(open(os.path.join(root, file), 'rb').read()) 215 | 216 | zap_pram_if_rom_changed(rom, etc_dirpath) 217 | rom = None # permit early garbage collection 218 | 219 | # Launch Basilisk/SheepShaver, relocating its preferences file to the 'etc' directory 220 | box_env = { 221 | 'HOME': etc_dirpath, 222 | } 223 | if use_exec: 224 | os.chdir(box_dirpath) 225 | arg0 = os.path.basename(emulator_filepath) 226 | os.execle(emulator_filepath, arg0, box_env) 227 | return 228 | else: 229 | returncode = subprocess.call( 230 | [emulator_filepath], 231 | cwd=box_dirpath, 232 | env=box_env) 233 | 234 | # Exit with emulator's return code 235 | sys.exit(returncode) 236 | return 237 | 238 | 239 | def get_oldworld_rom_embedded_checksum(rom): 240 | if len(rom) < 4: 241 | return 0 242 | 243 | # read_uint32(0) 244 | return ( 245 | (ord(rom[0]) << 24) | 246 | (ord(rom[1]) << 16) | 247 | (ord(rom[2]) << 8) | 248 | (ord(rom[3]) << 0) 249 | ) 250 | 251 | 252 | def compute_oldworld_rom_actual_checksum(rom): 253 | """ 254 | Computes the checksum of the ROM which can be verified 255 | against the checksum stored in the first 4 bytes of the ROM. 256 | 257 | Kudos for Dennis Nedry for discovering the algorithm by 258 | reverse engineering the checksum verification code in the 259 | Mac SE ROM. 260 | """ 261 | rom_size = len(rom) 262 | 263 | start = 4 264 | # Only sum the first 3 MB for 4 MB ROMs 265 | end = min(rom_size & ~1, 3 * 1024 * 1024) 266 | 267 | # The checksum for the Mac Classic ROM covers only the 268 | # first 256k out of the full 512k. 269 | if get_oldworld_rom_embedded_checksum(rom) == 0xA49F9914: # Mac Classic 270 | end = 256 * 1024 271 | 272 | sum = 0 273 | i = start 274 | while i < end: 275 | sum += (ord(rom[i]) << 8) | ord(rom[i + 1]) 276 | sum &= 0xFFFFFFFF 277 | i += 2 278 | 279 | return sum 280 | 281 | 282 | def zap_pram_if_rom_changed(rom, etc_dirpath): 283 | last_rom_md5_filepath = os.path.join(etc_dirpath, '.last_rom_md5') 284 | pram_filepath = os.path.join(etc_dirpath, '.basilisk_ii_xpram') 285 | 286 | # Compute MD5 of current ROM 287 | rom_md5 = md5.new(rom).hexdigest() 288 | 289 | # Lookup MD5 of last ROM 290 | if os.path.exists(last_rom_md5_filepath): 291 | with open(last_rom_md5_filepath, 'rb') as last_rom_md5_file: 292 | last_rom_md5 = last_rom_md5_file.read() 293 | else: 294 | last_rom_md5 = None 295 | 296 | # Zap PRAM if current ROM is different than last ROM 297 | if rom_md5 != last_rom_md5: 298 | if os.path.exists(pram_filepath): 299 | print 'Zapping PRAM because ROM changed.' 300 | os.remove(pram_filepath) 301 | 302 | # Save MD5 of current ROM 303 | with open(last_rom_md5_filepath, 'wb') as last_rom_md5_file: 304 | last_rom_md5_file.write(rom_md5) 305 | 306 | 307 | if __name__ == '__main__': 308 | main(sys.argv[1:]) -------------------------------------------------------------------------------- /catalog_create.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Scans the contents of an HFS-Standard volume and outputs a catalog document 4 | that describes the names and last modified timestamps of all files on the volume. 5 | 6 | This tool handles non-ASCII filenames correctly. 7 | 8 | Requirements: 9 | * hfsutils >= 3.2.6 10 | * Binaries must be in your shell's PATH. 11 | 12 | Catalog Format: 13 | * It's JSON. Strings are UTF-8 encoded. 14 | * Grammar: 15 | * ROOT: DirectoryListing 16 | * DirectoryListing: [Item, ...] 17 | * Item: File | Directory 18 | * File: (name : unicode, date_modified : unicode) 19 | * Directory: (name : unicode, date_modified : unicode, DirectoryListing) 20 | """ 21 | 22 | from classicbox.disk.hfs import hfs_ls 23 | from classicbox.disk.hfs import hfs_mount 24 | import json 25 | import os.path 26 | import pprint 27 | import sys 28 | 29 | 30 | def main(args): 31 | if len(args) > 0 and args[0] == '--pretty': 32 | pretty = True 33 | args = args[1:] 34 | else: 35 | pretty = False 36 | 37 | if len(args) != 1: 38 | sys.exit('syntax: catalog_create [--pretty] ') 39 | return 40 | 41 | dsk_filepath = args[0] 42 | if not os.path.exists(dsk_filepath): 43 | sys.exit('file not found: %s' % dsk_filepath) 44 | return 45 | 46 | catalog = create_catalog(dsk_filepath) 47 | 48 | if pretty: 49 | pprint.pprint(catalog) 50 | else: 51 | print json.dumps(catalog, ensure_ascii=True) 52 | 53 | 54 | def create_catalog(dsk_filepath): 55 | # NOTE: Will fail if the specified file is not an HFS Standard disk image 56 | volume_info = hfs_mount(dsk_filepath) 57 | volume_name = volume_info['name'] 58 | volume_dirpath = volume_name + ':' 59 | 60 | # NOTE: Constructs entire disk catalog in memory, which could be large. 61 | return _list_descendants(volume_dirpath) 62 | 63 | 64 | def _list_descendants(parent_dirpath): 65 | tree = [] 66 | for item in hfs_ls(parent_dirpath): 67 | if item.is_file: 68 | tree.append((item.name, item.date_modified)) 69 | else: 70 | descendants = _list_descendants(parent_dirpath + item.name + ':') 71 | tree.append((item.name, item.date_modified, descendants)) 72 | 73 | return tree 74 | 75 | 76 | if __name__ == '__main__': 77 | main(sys.argv[1:]) -------------------------------------------------------------------------------- /catalog_diff.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Given two catalog files, computes a diff of what changed. 4 | 5 | Optionally, remove parts of the resultant diff that match an "ignoretree" file. 6 | 7 | Catalog Format: 8 | * See the documentation for the `catalog_create` program. 9 | 10 | Catalog Diff Format: 11 | * It's JSON. Strings are UTF-8 encoded. 12 | * Grammar: 13 | * ROOT: DirectoryListingDiff 14 | * DirectoryListingDiff: (Deletes, Adds, Edits) 15 | * Deletes: : unicode[] 16 | * Adds: : unicode[] 17 | * Edits: : ItemDiff[] 18 | * ItemDiff: FileDiff | DirectoryDiff 19 | * FileDiff: (name : unicode, (date1 : unicode, date2 : unicode)) 20 | * DirectoryDiff: (name : unicode, (date1 : unicode, date2 : unicode), DirectoryListingDiff) 21 | 22 | Ignore Tree Format: 23 | * It's JSON. Strings are UTF-8 encoded. 24 | * Grammar: 25 | * ROOT: IgnoredDirectoryListing 26 | * IgnoredDirectoryListing: [IgnoredItem, ...] 27 | * IgnoredItem: IgnoredFileOrDirectory | IgnoredDirectory 28 | * IgnoredFileOrDirectory: filename : unicode 29 | * IgnoredDirectory: [filename : unicode, IgnoredDirectoryListing] 30 | """ 31 | 32 | from collections import namedtuple 33 | import json 34 | import os.path 35 | from pprint import pprint 36 | import sys 37 | 38 | 39 | DirectoryListingDiff = namedtuple( 40 | 'DirectoryListingDiff', 41 | ('deletes', 'adds', 'edits')) 42 | 43 | FileDiff = namedtuple( 44 | 'FileDiff', 45 | ('name', 'dates')) 46 | 47 | DirectoryDiff = namedtuple( 48 | 'DirectoryDiff', 49 | ('name', 'dates', 'listing_diff')) 50 | 51 | EMPTY_DIFF = DirectoryListingDiff([], [], []) 52 | 53 | # ------------------------------------------------------------------------------ 54 | 55 | def main(args): 56 | if len(args) > 0 and args[0] == '--pretty': 57 | pretty = True 58 | args = args[1:] 59 | else: 60 | pretty = False 61 | 62 | if len(args) not in [2, 3]: 63 | sys.exit('syntax: catalog_diff [--pretty] []') 64 | return 65 | 66 | catalog1_filepath = args[0] 67 | catalog2_filepath = args[1] 68 | if not os.path.exists(catalog1_filepath): 69 | sys.exit('file not found: %s' % catalog1_filepath) 70 | return 71 | if not os.path.exists(catalog2_filepath): 72 | sys.exit('file not found: %s' % catalog2_filepath) 73 | return 74 | 75 | with open(catalog1_filepath, 'rt') as catalog1_file: 76 | catalog1 = json.loads(catalog1_file.read()) 77 | with open(catalog2_filepath, 'rt') as catalog2_file: 78 | catalog2 = json.loads(catalog2_file.read()) 79 | 80 | catalog_diff = create_catalog_diff(catalog1, catalog2) 81 | 82 | # If an ignore tree is specified, remove elements from the diff 83 | # that the tree matches. 84 | if len(args) == 3: 85 | ignore_tree_filepath = args[2] 86 | with open(ignore_tree_filepath, 'rt') as ignore_tree_file: 87 | ignore_tree = json.loads(ignore_tree_file.read()) 88 | 89 | remove_ignored_parts_from_catalog_diff(catalog_diff, ignore_tree) 90 | 91 | if pretty: 92 | print_catalog_diff(catalog_diff) 93 | else: 94 | print json.dumps(catalog_diff, ensure_ascii=True) 95 | 96 | # ------------------------------------------------------------------------------ 97 | 98 | def create_catalog_diff(catalog1, catalog2): 99 | return _diff_tree(catalog1, catalog2) 100 | 101 | 102 | def _diff_tree(tree1, tree2): 103 | # For files, determine (+) add, (-) delete, (%) edit 104 | # For directories, determine (+) add, (-) delete, (%) edit 105 | 106 | files1 = set() 107 | dirs1 = set() 108 | name_to_date1 = dict() 109 | name_to_descendants1 = dict() 110 | for item in tree1: 111 | if len(item) == 2: # file 112 | (name, date_modified) = item 113 | 114 | files1.add(name) 115 | name_to_date1[name] = date_modified 116 | elif len(item) == 3: # dir 117 | (name, date_modified, descendants) = item 118 | is_file = False 119 | 120 | dirs1.add(name) 121 | name_to_date1[name] = date_modified 122 | name_to_descendants1[name] = descendants 123 | else: 124 | raise ValueError 125 | 126 | files2 = set() 127 | dirs2 = set() 128 | name_to_date2 = dict() 129 | name_to_descendants2 = dict() 130 | for item in tree2: 131 | if len(item) == 2: # file 132 | (name, date_modified) = item 133 | 134 | files2.add(name) 135 | name_to_date2[name] = date_modified 136 | elif len(item) == 3: # dir 137 | (name, date_modified, descendants) = item 138 | is_file = False 139 | 140 | dirs2.add(name) 141 | name_to_date2[name] = date_modified 142 | name_to_descendants2[name] = descendants 143 | else: 144 | raise ValueError 145 | 146 | deletes = sorted(list(files1 - files2) + list(dirs1 - dirs2)) 147 | adds = sorted(list(files2 - files1) + list(dirs2 - dirs1)) 148 | 149 | edits = [] 150 | 151 | shared_files = files1 & files2 152 | for name in shared_files: 153 | date1 = name_to_date1[name] 154 | date2 = name_to_date2[name] 155 | 156 | if date1 != date2: 157 | edits.append(FileDiff(name, (date1, date2))) 158 | 159 | shared_dirs = dirs1 & dirs2 160 | for name in shared_dirs: 161 | date1 = name_to_date1[name] 162 | date2 = name_to_date2[name] 163 | descendants1 = name_to_descendants1[name] 164 | descendants2 = name_to_descendants2[name] 165 | 166 | descendants_diff = _diff_tree(descendants1, descendants2) 167 | if date1 != date2 or descendants_diff != EMPTY_DIFF: 168 | edits.append(DirectoryDiff(name, (date1, date2), descendants_diff)) 169 | 170 | edits = sorted(edits) 171 | 172 | return DirectoryListingDiff(deletes, adds, edits) 173 | 174 | 175 | def remove_ignored_parts_from_catalog_diff(catalog_diff, ignored_tree): 176 | _remove_ignored_diff_parts(catalog_diff, ignored_tree) 177 | 178 | 179 | def _remove_ignored_diff_parts(diff, ignored_tree): 180 | """ 181 | Removes the parts of `diff` that occur in `ignored_tree`, 182 | modifying it in place. 183 | """ 184 | (deletes, adds, edits) = diff 185 | 186 | for i in xrange(len(deletes)-1, -1, -1): 187 | # Check whether this file/directory is marked to be ignored completely 188 | if deletes[i] in ignored_tree: 189 | del deletes[i] 190 | 191 | for i in xrange(len(adds)-1, -1, -1): 192 | # Check whether this file/directory is marked to be ignored completely 193 | if adds[i] in ignored_tree: 194 | del adds[i] 195 | 196 | i = 0 197 | while i < len(edits): 198 | cur_edit = edits[i] # (name, (date1, date2), descendants_diff?) 199 | cur_edit_name = cur_edit[0] 200 | 201 | # Check whether this file/directory is marked to be ignored completely 202 | if cur_edit_name in ignored_tree: 203 | del edits[i] 204 | continue 205 | 206 | cur_edit_is_dir = len(cur_edit) == 3 207 | if cur_edit_is_dir: 208 | cur_edit_descendants = cur_edit[2] 209 | 210 | for ignored_dir in ignored_tree: 211 | if type(ignored_dir) == list: # is directory? 212 | (ignored_name, ignored_descendants) = ignored_dir 213 | if cur_edit_name == ignored_name: 214 | _remove_ignored_diff_parts(cur_edit_descendants, ignored_descendants) 215 | 216 | # Everything inside this directory was ignored, so mark the 217 | # directory itself as ignored 218 | if cur_edit_descendants == EMPTY_DIFF: 219 | del edits[i] 220 | continue 221 | 222 | i += 1 223 | 224 | 225 | def print_catalog_diff(catalog_diff): 226 | pprint(_strip_to_json(catalog_diff)) 227 | 228 | 229 | def _strip_to_json(obj): 230 | return json.loads(json.dumps(obj)) 231 | 232 | # ------------------------------------------------------------------------------ 233 | 234 | if __name__ == '__main__': 235 | main(sys.argv[1:]) -------------------------------------------------------------------------------- /classicbox/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidfstr/ClassicBox/adf44436cef41849c376b7f772f71df1d5ff269e/classicbox/__init__.py -------------------------------------------------------------------------------- /classicbox/alias/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidfstr/ClassicBox/adf44436cef41849c376b7f772f71df1d5ff269e/classicbox/alias/__init__.py -------------------------------------------------------------------------------- /classicbox/alias/file.py: -------------------------------------------------------------------------------- 1 | """ 2 | Writes MacOS alias files. 3 | """ 4 | 5 | from classicbox.alias.record import Extra 6 | from classicbox.alias.record import write_alias_record 7 | from classicbox.disk.hfs import hfs_copy_in_from_stream 8 | from classicbox.disk.hfs import hfs_mount 9 | from classicbox.disk.hfs import hfs_stat 10 | from classicbox.disk.hfs import hfspath_dirpath 11 | from classicbox.disk.hfs import hfspath_itemname 12 | from classicbox.disk.hfs import hfspath_normpath 13 | from classicbox.io import BytesIO 14 | from classicbox.macbinary import FF_HAS_BEEN_INITED 15 | from classicbox.macbinary import FF_IS_ALIAS 16 | from classicbox.macbinary import write_macbinary_to_buffer 17 | from classicbox.resource_fork import write_resource_fork 18 | 19 | 20 | def create_alias_file( 21 | output_disk_image_filepath, output_macfilepath, 22 | target_disk_image_filepath, target_macitempath): 23 | """ 24 | Creates an alias at the specified output path that references the specified 25 | target item. 26 | 27 | Both paths reside within disk images. The target must already exist. 28 | """ 29 | 30 | alias_file_filename = hfspath_itemname(output_macfilepath) 31 | 32 | # Create alias info for the target item 33 | alias_info = create_alias_info_for_item_on_disk_image( 34 | target_disk_image_filepath, target_macitempath) 35 | alias_record = alias_info['alias_record'] 36 | alias_resource_info = alias_info['alias_resource_info'] 37 | alias_file_info = alias_info['alias_file_info'] 38 | 39 | # Serialize alias record 40 | alis_resource_contents_output = BytesIO() 41 | write_alias_record(alis_resource_contents_output, alias_record) 42 | alis_resource_contents = alis_resource_contents_output.getvalue() 43 | 44 | # Serialize alias file resource fork 45 | resource_fork = BytesIO() 46 | write_resource_fork(resource_fork, { 47 | 'resource_types': [ 48 | { 49 | 'code': alias_resource_info['type'], 50 | 'resources': [ 51 | { 52 | 'id': alias_resource_info['id'], 53 | 'name': alias_resource_info['name'], 54 | 'attributes': alias_resource_info['attributes'], 55 | 'data': alis_resource_contents 56 | } 57 | ] 58 | } 59 | ] 60 | }) 61 | resource_fork_contents = resource_fork.getvalue() 62 | 63 | # Serialize MacBinary-encoded alias file 64 | macbinary_buffer = write_macbinary_to_buffer({ 65 | 'filename': alias_file_filename, 66 | 'file_type': alias_file_info['alias_file_type'], 67 | 'file_creator': alias_file_info['alias_file_creator'], 68 | 'finder_flags': alias_file_info['alias_file_finder_flags'], 69 | 'resource_fork': resource_fork_contents, 70 | }) 71 | 72 | # Write the alias file to the source path 73 | hfs_mount(output_disk_image_filepath) 74 | hfs_copy_in_from_stream(macbinary_buffer, output_macfilepath) 75 | 76 | 77 | def create_alias_info_for_item_on_disk_image(disk_image_filepath, target_macitempath): 78 | """ 79 | Creates an alias that targets the specified item on the specified disk image. 80 | 81 | Arguments: 82 | * disk_image_filepath -- Path to a disk image. 83 | * target_macitempath -- The absolute MacOS path to the desired target of 84 | the alias, which resides on the disk image. 85 | """ 86 | # Normalize target path 87 | target_macitempath = hfspath_normpath(target_macitempath) 88 | 89 | volume_info = hfs_mount(disk_image_filepath) 90 | target_item_info = hfs_stat(target_macitempath) 91 | 92 | # Compute alias resource info 93 | alias_resource_info = { 94 | 'type': 'alis', 95 | 'id': 0, 96 | 'name': target_item_info.name + ' alias', 97 | 'attributes': 0 98 | } 99 | 100 | # Fill in common fields of the alias record. 101 | # Additional fields will be filled in later depending on the alias type. 102 | alias_record = { 103 | 'volume_name': volume_info['name'], 104 | 'volume_created': volume_info['created'], 105 | 'file_name': target_item_info.name, 106 | 'file_number': target_item_info.id, 107 | # NOTE: Can't get file_created from hfsutil CLI. It outputs a date, 108 | # but not with sufficient precision to compute the Mac timestamp. 109 | 'file_created': 0, 110 | 'nlvl_from': 1, # assume alias file on same volume as target 111 | 'nlvl_to': 1, # assume alias file on same volume as target 112 | } 113 | 114 | # Fill in common fields of the alias file info. 115 | # Additional fields will be filled in later depending on the alias type. 116 | alias_file_info = { 117 | # NOTE: Alias files will additionally have the FF_HAS_BEEN_INITED bit 118 | # set when the Finder sees it for the first time and adds its 119 | # icon to the desktop database (if applicable). 120 | 'alias_file_finder_flags': FF_IS_ALIAS 121 | } 122 | 123 | target_is_volume = target_macitempath.endswith(':') 124 | if target_is_volume: 125 | # Target is volume 126 | 127 | # A Finder-created alias file to a volume additionally contains a custom icon 128 | # {'ICN#', 'ics#', 'SICN'} that matches the volume that it refers to. 129 | # 130 | # Here, we are creating an alias file without a custom icon, that will 131 | # appear visually as a regular document alias (ick!) but will still work. 132 | # Finder will actually add the custom icon to the existing alias file 133 | # when the user attempts to open the file. 134 | # 135 | # If it is desired to add the custom icon in the future, it is important 136 | # to include the FF_HAS_CUSTOM_ICON flag for the alias file (in addition 137 | # to FF_IS_ALIAS). 138 | 139 | alias_record.update({ 140 | 'alias_kind': 1, # 1 = directory 141 | 'parent_directory_id': 1, # special ID for parent of all volumes 142 | 'file_created': volume_info['created'], # special case 143 | 'file_type': 0, # random junk in native MacOS implementation 144 | 'file_creator': 0, # random junk in native MacOS implementation 145 | 'nlvl_from': 0xFFFF, # assume alias file on different volume from target 146 | 'nlvl_to': 0xFFFF, # assume alias file on different volume from target 147 | 'extras': [ 148 | Extra(0xFFFF, 'end', None) 149 | ] 150 | }) 151 | 152 | alias_file_info.update({ 153 | 'alias_file_type': 'hdsk', 154 | 'alias_file_creator': 'MACS', 155 | }) 156 | 157 | else: 158 | # Target is file or folder 159 | 160 | # Lookup ancestors 161 | ancestor_infos = [] 162 | cur_ancestor_dirpath = hfspath_dirpath(target_macitempath) 163 | while cur_ancestor_dirpath is not None: 164 | ancestor_infos.append(hfs_stat(cur_ancestor_dirpath)) 165 | cur_ancestor_dirpath = hfspath_dirpath(cur_ancestor_dirpath) 166 | 167 | parent_dir_info = ancestor_infos[0] # possibly a volume 168 | ancestor_dir_infos = ancestor_infos[:-1] # exclude volume 169 | 170 | alias_record.update({ 171 | 'parent_directory_id': parent_dir_info.id, 172 | 'extras': _create_standard_extras_list( 173 | parent_dir_info, ancestor_dir_infos, target_macitempath) 174 | }) 175 | 176 | if target_item_info.is_file: 177 | # Target is file 178 | alias_record.update({ 179 | 'alias_kind': 0, # 0 = file 180 | 'file_type': target_item_info.type, 181 | 'file_creator': target_item_info.creator, 182 | }) 183 | 184 | if target_item_info.type == 'APPL': 185 | # Target is application file 186 | alias_file_info.update({ 187 | 'alias_file_type': 'adrp', 188 | 'alias_file_creator': target_item_info.creator, 189 | }) 190 | 191 | else: 192 | # Target is document file 193 | alias_file_info.update({ 194 | 'alias_file_type': target_item_info.type, 195 | 'alias_file_creator': target_item_info.creator, 196 | }) 197 | 198 | else: 199 | # Target is folder 200 | alias_record.update({ 201 | 'alias_kind': 1, # 1 = directory 202 | 'file_type': 0, # random junk in native MacOS implementation 203 | 'file_creator': 0, # random junk in native MacOS implementation 204 | }) 205 | 206 | alias_file_info.update({ 207 | # NOTE: Various special folders have their own special type code. 208 | # For example 'Boot:System Folder:' is 'fasy', 209 | # and 'Disk:Trash:' is 'trsh'. 210 | # 211 | # For a complete list, see Finder.h in the Carbon headers. 212 | # 213 | # Using the generic folder type code (as this 214 | # implementation does) results in an alias that works, 215 | # but that will initially have a generic folder icon 216 | # until the alias is opened. 217 | 'alias_file_type': 'fdrp', 218 | 'alias_file_creator': 'MACS', 219 | }) 220 | 221 | return { 222 | 'alias_record': alias_record, 223 | 'alias_resource_info': alias_resource_info, 224 | 'alias_file_info': alias_file_info, 225 | } 226 | 227 | 228 | def _create_standard_extras_list(parent_dir_info, ancestor_dir_infos, target_macitempath): 229 | extras = [] 230 | extras.append(Extra(0, 'parent_directory_name', parent_dir_info.name)) 231 | if len(ancestor_dir_infos) > 0: 232 | extras.append(Extra(1, 'directory_ids', [d.id for d in ancestor_dir_infos])) 233 | extras.append(Extra(2, 'absolute_path', target_macitempath)) 234 | extras.append(Extra(0xFFFF, 'end', None)) 235 | return extras 236 | -------------------------------------------------------------------------------- /classicbox/alias/record.py: -------------------------------------------------------------------------------- 1 | """ 2 | Manipulates MacOS alias records. 3 | """ 4 | 5 | from classicbox.io import at_eof 6 | from classicbox.io import BytesIO 7 | from classicbox.io import NULL_BYTE 8 | from classicbox.io import read_fixed_bytes 9 | from classicbox.io import read_structure 10 | from classicbox.io import read_unsigned 11 | from classicbox.io import StructMember 12 | from classicbox.io import write_structure 13 | from classicbox.io import write_unsigned 14 | 15 | from collections import namedtuple 16 | 17 | 18 | # Alias file format reference: http://xhelmboyx.tripod.com/formats/alias-layout.txt 19 | _ALIAS_RECORD_MEMBERS = [ 20 | StructMember('user_type_name', 'fixed_string', 4, 0), # 0 = none 21 | StructMember('record_size', 'unsigned', 2, None), 22 | StructMember('record_version', 'unsigned', 2, 2), # 2 = current version 23 | StructMember('alias_kind', 'unsigned', 2, None), # 0 = file, 1 = directory 24 | StructMember('volume_name', 'pascal_string', 27, None), 25 | StructMember('volume_created', 'unsigned', 4, 0), # may be 0; seconds since 1904 26 | StructMember('volume_signature', 'fixed_string', 2, 'BD'), # 'RW' = MFS, 'BD' = HFS (or foreign), 'H+' = HFS+ 27 | StructMember('drive_type', 'unsigned', 2, 0), 28 | # 0 = Fixed HD, 1 = Network Disk, 29 | # 2 = 400kB FD, 3 = 800kB FD, 30 | # 4 = 1.4MB FD, 5 = Other Ejectable Media 31 | StructMember('parent_directory_id', 'unsigned', 4, 0), # may be 0 32 | StructMember('file_name', 'pascal_string', 63, None), 33 | StructMember('file_number', 'unsigned', 4, 0), # may be 0 34 | StructMember('file_created', 'unsigned', 4, 0), # may be 0; seconds since 1904 35 | StructMember('file_type', 'fixed_string', 4, 0), # may be 0 36 | StructMember('file_creator', 'fixed_string', 4, 0), # may be 0 37 | StructMember('nlvl_from', 'unsigned', 2, None), 38 | StructMember('nlvl_to', 'unsigned', 2, None), 39 | # -1 = alias on different volume, 40 | # 1 = alias and target in same directory 41 | StructMember('volume_attributes', 'unsigned', 4, 0), # may be 0 42 | StructMember('volume_filesystem_id', 'fixed_string', 2, 0),# 0 for MFS or HFS 43 | StructMember('reserved', 'fixed_bytes', 10, 0), 44 | StructMember('extras', 'extras', None, []), 45 | StructMember('trailing', 'until_eof', None, b''), 46 | ] 47 | 48 | _ExtraType = namedtuple( 49 | '_ExtraType', 50 | ('code', 'name')) 51 | 52 | _EXTRA_TYPES = [ 53 | _ExtraType(0, 'parent_directory_name'), 54 | _ExtraType(1, 'directory_ids'), 55 | _ExtraType(2, 'absolute_path'), 56 | # 3 = AppleShare Zone Name 57 | # 4 = AppleShare Server Name 58 | # 5 = AppleShare User Name 59 | # 6 = Driver Name 60 | # 9 = Revised AppleShare info 61 | # 10 = AppleRemoteAccess dialup info 62 | _ExtraType(0xFFFF, 'end'), 63 | ] 64 | 65 | Extra = namedtuple( 66 | 'Extra', 67 | ('type', 'name', 'value')) 68 | 69 | # ------------------------------------------------------------------------------ 70 | 71 | def read_alias_record(input): 72 | return read_structure(input, _ALIAS_RECORD_MEMBERS, external_readers={ 73 | 'read_extras': _read_extras 74 | }) 75 | 76 | 77 | def _read_extras(input, ignored): 78 | if at_eof(input): 79 | # EOF'ed immediately. No extras. 80 | return [] 81 | 82 | extras = [] 83 | this_module = globals() 84 | while True: 85 | extra_type = read_unsigned(input, 2) 86 | extra_length = read_unsigned(input, 2) 87 | extra_content = read_fixed_bytes(input, extra_length) 88 | if extra_length & 0x1 == 1: 89 | input.read(1) # padding byte 90 | 91 | for type in _EXTRA_TYPES: 92 | if extra_type == type.code: 93 | extra_name = type.name 94 | extra_value = this_module['_read_' + type.name + '_extra_content'](extra_content) 95 | break 96 | else: 97 | extra_name = 'unknown' 98 | extra_value = read_unknown_extra_content(extra_content) 99 | 100 | extras.append(Extra(extra_type, extra_name, extra_value)) 101 | if extra_name == 'end': 102 | break 103 | 104 | return extras 105 | 106 | 107 | def _read_parent_directory_name_extra_content(extra_content): 108 | return extra_content.decode('macroman') 109 | 110 | 111 | def _read_directory_ids_extra_content(extra_content): 112 | extra_value = [] 113 | extra_content_input = BytesIO(extra_content) 114 | for i in xrange(len(extra_content) // 4): 115 | extra_value.append(read_unsigned(extra_content_input, 4)) 116 | return extra_value 117 | 118 | 119 | def _read_absolute_path_extra_content(extra_content): 120 | return extra_content.decode('macroman') 121 | 122 | 123 | def _read_end_extra_content(extra_content): 124 | return None 125 | 126 | 127 | def _read_unknown_extra_content(extra_content): 128 | return extra_content 129 | 130 | # ------------------------------------------------------------------------------ 131 | 132 | def write_alias_record(output, alias_record): 133 | if 'record_size' in alias_record: 134 | _write_alias_record_structure(output, alias_record) 135 | else: 136 | alias_record['record_size'] = 0 137 | 138 | # Write record, except for the 'record_size' field 139 | start_offset = output.tell() 140 | _write_alias_record_structure(output, alias_record) 141 | end_offset = output.tell() 142 | 143 | # Write the 'record_size' field 144 | output.seek(start_offset + 4) 145 | record_size = end_offset - start_offset 146 | write_unsigned(output, 2, record_size) 147 | output.seek(end_offset) 148 | 149 | 150 | def _write_alias_record_structure(output, alias_record): 151 | write_structure(output, _ALIAS_RECORD_MEMBERS, alias_record, external_writers={ 152 | 'write_extras': _write_extras 153 | }) 154 | 155 | 156 | def _write_extras(output, ignored, value): 157 | extras = value 158 | 159 | this_module = globals() 160 | for extra in extras: 161 | extra_content_output = BytesIO() 162 | this_module['_write_' + extra.name + '_extra_content'](extra_content_output, extra.value) 163 | extra_content = extra_content_output.getvalue() 164 | extra_length = len(extra_content) 165 | 166 | write_unsigned(output, 2, extra.type) 167 | write_unsigned(output, 2, extra_length) 168 | output.write(extra_content) 169 | if extra_length & 0x1 == 1: 170 | output.write(NULL_BYTE) # padding byte 171 | 172 | 173 | def _write_parent_directory_name_extra_content(output, extra_value): 174 | output.write(extra_value.encode('macroman')) 175 | 176 | 177 | def _write_directory_ids_extra_content(output, extra_value): 178 | for path_id in extra_value: 179 | write_unsigned(output, 4, path_id) 180 | 181 | 182 | def _write_absolute_path_extra_content(output, extra_value): 183 | output.write(extra_value.encode('macroman')) 184 | 185 | 186 | def _write_end_extra_content(output, extra_value): 187 | pass 188 | 189 | 190 | def _write_unknown_extra_content(output, extra_value): 191 | output.write(extra_value) 192 | 193 | # ------------------------------------------------------------------------------ 194 | 195 | def print_alias_record(alias_record): 196 | print 'Alias Information' 197 | print '=================' 198 | for member in _ALIAS_RECORD_MEMBERS: 199 | value = alias_record[member.name] 200 | 201 | if member.name == 'extras': 202 | print 'extras:' 203 | for extra in value: 204 | print ' ' + repr(extra) 205 | else: 206 | print '%s: %s' % (member.name, repr(value)) 207 | -------------------------------------------------------------------------------- /classicbox/archive.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions to examine and extract compressed archives. 3 | """ 4 | 5 | from classicbox.util import DEVNULL 6 | import shutil 7 | import subprocess 8 | import tempfile 9 | 10 | 11 | class ExtractedArchive(object): 12 | """ 13 | Represents a compressed archive that has been extracted to a temporary directory. 14 | """ 15 | 16 | def __init__(self, archive_filepath, extraction_dirpath): 17 | self.archive_filepath = archive_filepath 18 | self.extraction_dirpath = extraction_dirpath 19 | 20 | def __enter__(self): 21 | return self 22 | 23 | def __exit__(self, type, value, traceback): 24 | self.close() 25 | 26 | def close(self): 27 | shutil.rmtree(self.extraction_dirpath) 28 | 29 | 30 | # TODO: Alter default behavior to NOT extract resource forks natively, 31 | # as this behavior is not compatible with Windows. 32 | def archive_extract(archive_filepath): 33 | """ 34 | Extracts the specified archive to a temporary directory. 35 | 36 | Returns an ExtractedArchive object. 37 | 38 | This function is intended to be used in a `with` statement, 39 | so that the temporary extraction directory is eventually deleted. 40 | """ 41 | 42 | extraction_dirpath = tempfile.mkdtemp() 43 | try: 44 | subprocess.check_call([ 45 | 'unar', 46 | # recursively extract inner archives by default 47 | '-forks', 'fork', # save resource forks natively (OS X only) 48 | '-no-quarantine', # don't display warnings upon launch of extracted apps 49 | '-no-directory', # don't create an extra enclosing directory 50 | '-output-directory', extraction_dirpath, 51 | archive_filepath 52 | ], stdout=DEVNULL, stderr=DEVNULL) 53 | 54 | return ExtractedArchive(archive_filepath, extraction_dirpath) 55 | except: 56 | shutil.rmtree(extraction_dirpath) 57 | raise -------------------------------------------------------------------------------- /classicbox/box.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions for manipulating "box" directories, which are self-contained 3 | virtual machines. 4 | """ 5 | 6 | import os 7 | 8 | 9 | def box_create(box_dirpath): 10 | """ 11 | Creates an empty box at the specified path. 12 | """ 13 | os.mkdir(box_dirpath) 14 | os.mkdir(os.path.join(box_dirpath, 'bin')) 15 | os.mkdir(os.path.join(box_dirpath, 'etc')) 16 | os.mkdir(os.path.join(box_dirpath, 'mount')) 17 | os.mkdir(os.path.join(box_dirpath, 'mount-disabled')) 18 | os.mkdir(os.path.join(box_dirpath, 'rom')) 19 | os.mkdir(os.path.join(box_dirpath, 'share')) -------------------------------------------------------------------------------- /classicbox/disk/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions to manipulate and examine disk images. 3 | """ 4 | 5 | 6 | _DISK_IMAGE_EXTENSIONS = [ 7 | '.dsk', '.hfv', # Raw disk image 8 | '.toast', # Toast disk image 9 | '.iso', '.cdr', # ISO disk image 10 | '.dmg', # UDIF disk image 11 | '.img', # NDIF disk image (Disk Copy 6.3) 12 | ] 13 | 14 | _MVM_DISK_IMAGE_EXTENSIONS = [ 15 | '.dsk', '.hfv', # Raw disk image 16 | # TODO: Check whether other disk image formats are 17 | # supported by Mini vMac. 18 | # (Basilisk and SheepShaver support a TON of formats.) 19 | ] 20 | 21 | _BASILISK_DISK_IMAGE_EXTENSIONS = [ 22 | '.dsk', '.hfv', # Raw disk image 23 | '.toast', # Toast disk image 24 | '.iso', '.cdr', # ISO disk image 25 | 26 | # NOTE: Basilisk doesn't seem to consistently be able mount .img and .dmg correctly. 27 | # Workaround on OS X by converting it to a .dsk using the `dd` tool. 28 | # [TODO: Inspect the Basilisk source to see if it attempts to support UDIF/NDIF images.] 29 | #'.dmg', # UDIF disk image 30 | #'.img', # NDIF disk image (Disk Copy 6.3) 31 | ] 32 | 33 | 34 | def is_disk_image(filename): 35 | """ 36 | Returns whether the specified file is a disk image. 37 | """ 38 | return _filename_has_extension_in_list(filename, _DISK_IMAGE_EXTENSIONS) 39 | 40 | 41 | def is_mini_vmac_supported_disk_image(filename): 42 | """ 43 | Returns whether the specified file is a disk image that can be mounted by 44 | Mini vMac. 45 | """ 46 | return _filename_has_extension_in_list(filename, _MVM_DISK_IMAGE_EXTENSIONS) 47 | 48 | 49 | def is_basilisk_supported_disk_image(filename): 50 | """ 51 | Returns whether the specified file is a disk image that can be mounted by 52 | Basilisk II. In general, any such image can also be mounted by SheepShaver. 53 | """ 54 | return _filename_has_extension_in_list(filename, _BASILISK_DISK_IMAGE_EXTENSIONS) 55 | 56 | 57 | def _filename_has_extension_in_list(filename, extensions): 58 | filename = filename.lower() 59 | for ext in extensions: 60 | if filename.endswith(ext): 61 | return True 62 | return False 63 | -------------------------------------------------------------------------------- /classicbox/disk/hfs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions to manipulate and examine the contents of HFS Standard disk images. 3 | 4 | Support for HFS Extended (HFS+) disk images may be added in the future. 5 | Support for MFS disk images may be added in the future. 6 | """ 7 | 8 | from __future__ import absolute_import 9 | 10 | from classicbox.io import write_nulls 11 | from classicbox.time import convert_ctime_string_to_mac_timestamp 12 | from classicbox.util import DEVNULL 13 | from collections import namedtuple 14 | import os 15 | import re 16 | import shutil 17 | import subprocess 18 | from tempfile import NamedTemporaryFile 19 | import time 20 | 21 | 22 | """ 23 | Represents a single item (i.e. a file or directory) from an HFS directory listing. 24 | 25 | Fields: 26 | * id : int -- The file number or directory ID that uniquely identifies the file 27 | on the disk. 28 | * name : unicode 29 | * is_file : bool 30 | * type : unicode(4) 31 | * creator : unicode(4) 32 | * data_size : int 33 | * rsrc_size : int 34 | * date_modified : unicode -- Human-readable modification date of the file. 35 | Unfortunately hfsutils does not provide a 36 | machine-readable version. 37 | """ 38 | HFSItem = namedtuple( 39 | 'HFSItem', 40 | ('id', 'name', 'is_file', 'type', 'creator', 'data_size', 'rsrc_size', 'date_modified')) 41 | 42 | 43 | _HMOUNT_VOLUME_NAME_RE = re.compile(br'^Volume name is "(.*)"$') 44 | _HMOUNT_CREATED_RE = re.compile(br'^Volume was created on (.*)$') 45 | _HMOUNT_MODIFIED_RE = re.compile(br'^Volume was last modified on (.*)$') 46 | _HMOUNT_BYTES_FREE_RE = re.compile(br'^Volume has ([0-9]+) bytes free$') 47 | 48 | def hfs_mount(disk_image_filepath): 49 | """ 50 | Opens the specified disk image. 51 | All subsequent hfs_* functions will operate on this disk image. 52 | 53 | Raises an exception if: 54 | * the disk image format is not recognized, 55 | * an HFS Standard partition cannot be found, or 56 | * any other error occurs. 57 | 58 | Arguments: 59 | * disk_image_filepath : unicode|str-native 60 | """ 61 | hmount_lines = subprocess.check_output( 62 | ['hmount', disk_image_filepath], 63 | stderr=DEVNULL).split(b'\n')[:-1] 64 | 65 | volume_info = {} 66 | for line in hmount_lines: 67 | line = line.strip(b'\r\n') 68 | 69 | matcher = _HMOUNT_VOLUME_NAME_RE.search(line) 70 | if matcher is not None: 71 | name = matcher.group(1).decode('macroman') 72 | volume_info['name'] = name 73 | 74 | matcher = _HMOUNT_CREATED_RE.search(line) 75 | if matcher is not None: 76 | ctime_string = matcher.group(1).decode('ascii') 77 | volume_info['created_ctime'] = ctime_string 78 | volume_info['created'] = convert_ctime_string_to_mac_timestamp(ctime_string) 79 | 80 | matcher = _HMOUNT_MODIFIED_RE.search(line) 81 | if matcher is not None: 82 | ctime_string = matcher.group(1).decode('ascii') 83 | volume_info['modified_ctime'] = ctime_string 84 | volume_info['modified'] = convert_ctime_string_to_mac_timestamp(ctime_string) 85 | 86 | matcher = _HMOUNT_BYTES_FREE_RE.search(line) 87 | if matcher is not None: 88 | bytes_free = int(matcher.group(1)) 89 | volume_info['bytes_free'] = bytes_free 90 | 91 | return volume_info 92 | 93 | 94 | def hfs_ls(macdirpath=None, _stat_path=False): 95 | """ 96 | Lists the specified directory on the mounted HFS volume, 97 | or the current working directory if no directory is specified. 98 | 99 | Behavior is undefined if there is no such directory. 100 | 101 | Arguments: 102 | * macdirpath : unicode -- An absolute MacOS path. 103 | For example 'Boot:' or 'Boot:System Folder'. 104 | 105 | Returns a list of HFSItems. 106 | """ 107 | hdir_command = ['hdir', '-i'] 108 | if _stat_path: 109 | hdir_command += ['-d'] 110 | if macdirpath is not None: 111 | hdir_command += [macdirpath.encode('macroman')] 112 | 113 | hdir_lines = subprocess.check_output(hdir_command, stderr=DEVNULL).split(b'\n')[:-1] 114 | items = [_parse_hdir_line(line) for line in hdir_lines] 115 | return items 116 | 117 | 118 | def hfs_stat(macitempath): 119 | """ 120 | Gets information about the specified item on the mounted HFS volume. 121 | 122 | Behavior is undefined if there is no such item. 123 | 124 | Arguments: 125 | * macitempath : unicode -- An absolute MacOS path. 126 | For example 'Boot:' or 'Boot:System Folder'. 127 | 128 | Returns an HFSItem. 129 | """ 130 | 131 | item_with_path_as_name = hfs_ls(macitempath, _stat_path=True)[0] 132 | itemname = hfspath_itemname(macitempath) 133 | return item_with_path_as_name._replace(name=itemname) 134 | 135 | 136 | _FILE_LINE_RE = re.compile(br'^ *([0-9]+) [fF][ i] (....)/(....) +([0-9]+) +([0-9]+) ([^ ]...........) (.+)$') 137 | _DIR_LINE_RE = re.compile(br'^ *([0-9]+) [dd][ i] +([0-9]+) items? +([^ ]...........) (.+)$') 138 | 139 | def _parse_hdir_line(line): 140 | """ 141 | Arguments: 142 | * line : str-macroman -- A line from the `hdir -i` command. 143 | 144 | Returns an HFSItem. 145 | """ 146 | file_matcher = _FILE_LINE_RE.search(line) 147 | if file_matcher is not None: 148 | (id, type, creator, data_size, rsrc_size, date_modified, name) = file_matcher.groups() 149 | return HFSItem( 150 | int(id), name.decode('macroman'), True, 151 | type.decode('macroman'), creator.decode('macroman'), 152 | int(data_size), int(rsrc_size), date_modified.decode('ascii')) 153 | 154 | dir_matcher = _DIR_LINE_RE.search(line) 155 | if dir_matcher is not None: 156 | (id, num_children, date_modified, name) = dir_matcher.groups() 157 | return HFSItem( 158 | int(id), name.decode('macroman'), False, 159 | 4*' ', 4*' ', 160 | 0, 0, date_modified.decode('ascii')) 161 | 162 | raise ValueError('Unable to parse hdir output line: %s' % line) 163 | 164 | 165 | def hfs_copy_in(source_filepath, target_macfilepath): 166 | """ 167 | Copies the specified MacBinary-encoded file from the local filesystem to the 168 | mounted HFS volume. 169 | 170 | Any file already at the target path will be overridden. 171 | 172 | Arguments: 173 | * source_filepath : unicode|str-native -- Path to a MacBinary-encoded file 174 | in the local filesystem. 175 | * target_macfilepath : unicode -- An absolute MacOS path. 176 | Location on the HFS volume where the file 177 | will be copied to. 178 | """ 179 | subprocess.check_call( 180 | ['hcopy', '-m', source_filepath, target_macfilepath.encode('macroman')], 181 | stdout=DEVNULL, stderr=DEVNULL) 182 | 183 | 184 | def hfs_copy_in_from_stream(source_stream, target_macfilepath): 185 | """ 186 | Same as `hfs_copy_in()` but copies from a source stream 187 | (i.e. a file-like object) instead of from a source file. 188 | 189 | Arguments: 190 | * source_stream : stream 191 | * target_macfilepath : unicode -- An absolute MacOS path. 192 | """ 193 | temp_file = NamedTemporaryFile(suffix='.bin', mode='wb', delete=False) 194 | temp_filepath = temp_file.name 195 | try: 196 | # Copy stream to local filesystem, since we need an actual file 197 | # as the source of the copy 198 | try: 199 | shutil.copyfileobj(source_stream, temp_file) 200 | finally: 201 | temp_file.close() 202 | 203 | # Copy alias file from local filesystem to disk image 204 | hfs_copy_in(temp_filepath, target_macfilepath) 205 | finally: 206 | os.remove(temp_filepath) 207 | 208 | 209 | def hfs_exists(macitempath): 210 | """ 211 | Returns whether the specified item exists on the mounted HFS volume. 212 | 213 | Arguments: 214 | * macdirpath : unicode -- An absolute MacOS path. 215 | """ 216 | process = subprocess.Popen( 217 | ['hdir', '-i', '-d', macitempath.encode('macroman')], 218 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 219 | (out, err) = process.communicate() 220 | if err.endswith(b'no such file or directory\n'): 221 | return False 222 | if process.returncode != 0 or err != b'': 223 | raise IOError('Called process returned error. Code %s: %s' % ( 224 | process.returncode, err)) 225 | return True 226 | 227 | 228 | def hfs_delete(macitempath): 229 | """ 230 | Deletes the specified item on the mounted HFS volume. 231 | 232 | Arguments: 233 | * macdirpath : unicode -- An absolute MacOS path. 234 | """ 235 | subprocess.check_call( 236 | ['hdel', macitempath.encode('macroman')], 237 | stdout=DEVNULL, stderr=DEVNULL) 238 | 239 | 240 | def hfs_format(disk_image_filepath, name): 241 | """ 242 | Formats an existing disk image file and mounts it. 243 | 244 | Arguments: 245 | * disk_image_filepath : unicode|str-native -- Path to the disk image file. 246 | * name : unicode -- Name of the new volume. 247 | """ 248 | subprocess.check_call( 249 | ['hformat', '-l', name.encode('macroman'), disk_image_filepath], 250 | stdout=DEVNULL, stderr=DEVNULL) 251 | 252 | 253 | def hfs_format_new(disk_image_filepath, name, size): 254 | """ 255 | Creates a new disk image file, formats it, and mounts it. 256 | 257 | Arguments: 258 | * disk_image_filepath : unicode|str-native -- Path to the disk image file. 259 | * name : unicode -- Name of the new volume. 260 | * size : int -- Size of the volume to create, in bytes. 261 | """ 262 | with open(disk_image_filepath, 'wb') as output: 263 | write_nulls(output, size) 264 | 265 | hfs_format(disk_image_filepath, name) 266 | 267 | 268 | def hfs_mkdir(macdirpath): 269 | """ 270 | Creates a directory at the specified path on the mounted HFS volume. 271 | 272 | Arguments: 273 | * macdirpath : unicode -- An absolute MacOS path. 274 | """ 275 | subprocess.check_call( 276 | ['hmkdir', macdirpath.encode('macroman')], 277 | stdout=DEVNULL, stderr=DEVNULL) 278 | 279 | # ------------------------------------------------------------------------------ 280 | # HFS Path Manipulation 281 | 282 | def hfspath_dirpath(itempath): 283 | """ 284 | Returns the absolute MacOS path to the volume or directory containing the 285 | specified item. If the item refers to a volume, None is returned. 286 | 287 | Note that the semantics are similar to but not the same as `os.path.dirname`. 288 | 289 | Examples: 290 | * hfspath_dirpath('Boot:') -> None 291 | * hfspath_dirpath('Boot:System Folder') -> 'Boot:' 292 | * hfspath_dirpath('Boot:System Folder:Preferences') -> 'Boot:System Folder' 293 | 294 | Arguments: 295 | * itempath -- An absolute MacOS path. 296 | For example 'Boot:' or 'Boot:System Folder'. 297 | """ 298 | 299 | itempath = hfspath_normpath(itempath) 300 | if itempath.endswith(':'): 301 | # Item is a volume and has no parent directory 302 | return None 303 | 304 | parent_path = itempath.rsplit(':', 1)[0] 305 | if ':' not in parent_path: 306 | # Parent is a volume 307 | return parent_path + ':' 308 | else: 309 | # Parent is a directory 310 | return parent_path 311 | 312 | 313 | def hfspath_itemname(itempath): 314 | """ 315 | Returns the name of the specified item. 316 | 317 | Note that the semantics are similar to but not the same as `os.path.basename`. 318 | 319 | Examples: 320 | * hfspath_itemname('Boot:') -> 'Boot' 321 | * hfspath_itemname('Boot:System Folder') -> 'System Folder' 322 | * hfspath_itemname('Boot:System Folder:') -> 'System Folder' 323 | * hfspath_itemname('Boot:SimpleText') -> 'SimpleText' 324 | 325 | Arguments: 326 | * itempath -- An absolute MacOS path. 327 | For example 'Boot:' or 'Boot:System Folder'. 328 | """ 329 | 330 | if itempath.endswith(':'): 331 | itempath = itempath[:-1] 332 | return itempath.rsplit(':', 1)[-1] 333 | 334 | 335 | def hfspath_normpath(itempath): 336 | """ 337 | Returns the normalized form of the specified absolute MacOS path. 338 | 339 | This is the path without any trailing colon (:), unless the path refers to 340 | a volume. 341 | 342 | Examples: 343 | * hfspath_normpath('Boot:') -> 'Boot:' 344 | * hfspath_normpath('Boot:System Folder') -> 'Boot:System Folder' 345 | * hfspath_normpath('Boot:System Folder:') -> 'Boot:System Folder' 346 | * hfspath_normpath('Boot:SimpleText') -> 'Boot:SimpleText' 347 | 348 | Arguments: 349 | * itempath -- An absolute MacOS path. 350 | * For example 'Boot:' or 'Boot:System Folder'. 351 | """ 352 | 353 | if itempath.endswith(':'): 354 | if itempath.index(':') == len(itempath) - 1: 355 | # Refers to a volume 356 | return itempath 357 | else: 358 | # Refers to a directory 359 | return itempath[:-1] 360 | else: 361 | # Refers to a file or directory 362 | return itempath 363 | -------------------------------------------------------------------------------- /classicbox/io.py: -------------------------------------------------------------------------------- 1 | """ 2 | Reads and writes binary data from structures and streams. 3 | """ 4 | 5 | from __future__ import absolute_import 6 | 7 | from collections import namedtuple 8 | from contextlib import contextmanager 9 | import os 10 | import tempfile 11 | 12 | 13 | StructMember = namedtuple( 14 | 'StructMember', 15 | ('name', 'type', 'subtype', 'default_value')) 16 | 17 | # ------------------------------------------------------------------------------ 18 | # Read 19 | 20 | def read_structure(input, structure_members, external_readers=None): 21 | v = {} 22 | this_module = globals() 23 | for member in structure_members: 24 | member_reader_name = 'read_' + member.type 25 | member_reader = ( 26 | this_module.get(member_reader_name, None) or 27 | external_readers[member_reader_name]) 28 | 29 | v[member.name] = member_reader(input, member.subtype) 30 | 31 | return v 32 | 33 | 34 | def read_fixed_string(input, num_bytes): 35 | return read_fixed_bytes(input, num_bytes).decode('macroman') 36 | 37 | 38 | def read_fixed_bytes(input, num_bytes): 39 | return input.read(num_bytes) 40 | 41 | 42 | def read_unsigned(input, num_bytes): 43 | value = 0 44 | for i in xrange(num_bytes): 45 | value = value << 8 46 | value = value | ord(input.read(1)) 47 | return value 48 | 49 | 50 | def read_signed(input, num_bytes): 51 | overflow_value = (1 << (8*num_bytes - 1)) 52 | 53 | value = read_unsigned(input, num_bytes) 54 | signed_value = value if value < overflow_value else value - overflow_value*2 55 | return signed_value 56 | 57 | 58 | def read_pascal_string(input, max_string_length): 59 | return read_pascal_bytes(input, max_string_length).decode('macroman') 60 | 61 | 62 | def read_pascal_bytes(input, max_string_length): 63 | str_length = ord(input.read(1)) 64 | str = input.read(str_length) 65 | if max_string_length is not None: 66 | zero = input.read(max_string_length - str_length) 67 | return str 68 | 69 | 70 | def read_until_eof(input, ignored): 71 | return input.read() 72 | 73 | # ------------------------------------------------------------------------------ 74 | # Write 75 | 76 | def write_structure(output, structure_members, structure, external_writers=None): 77 | this_module = globals() 78 | for member in structure_members: 79 | member_writer_name = 'write_' + member.type 80 | member_writer = ( 81 | this_module.get(member_writer_name, None) or 82 | external_writers[member_writer_name]) 83 | 84 | value = structure.get(member.name, member.default_value) 85 | if value is None: 86 | raise ValueError('No value specified for member "%s", which lacks a default value.' % member.name) 87 | 88 | member_writer(output, member.subtype, value) 89 | 90 | 91 | def write_fixed_string(output, num_bytes, value): 92 | write_fixed_bytes(output, num_bytes, 93 | 0 if value == 0 else value.encode('macroman')) 94 | 95 | 96 | def write_fixed_bytes(output, num_bytes, value): 97 | if value == 0: 98 | value = b'\x00' * num_bytes 99 | if len(value) != num_bytes: 100 | raise ValueError('Value does not have the expected byte count.') 101 | output.write(value) 102 | 103 | 104 | def write_unsigned(output, num_bytes, value): 105 | shift = (num_bytes - 1) * 8 106 | mask = 0xFF << shift 107 | 108 | for i in xrange(num_bytes): 109 | output.write(bchr((value & mask) >> shift)) 110 | shift -= 8 111 | mask = mask >> 8 112 | 113 | 114 | def write_signed(output, num_bytes, value): 115 | overflow_value = (1 << (8*num_bytes - 1)) 116 | 117 | unsigned_value = value if value >= 0 else value + overflow_value*2 118 | write_unsigned(output, num_bytes, unsigned_value) 119 | 120 | 121 | def write_pascal_string(output, max_string_length, value): 122 | write_pascal_bytes(output, max_string_length, value.encode('macroman')) 123 | 124 | 125 | def write_pascal_bytes(output, max_string_length, value): 126 | if max_string_length is not None: 127 | if len(value) > max_string_length: 128 | raise ValueError('Value exceeds the maximum byte count.') 129 | str_length = len(value) 130 | output.write(bchr(str_length)) 131 | output.write(value) 132 | if max_string_length is not None: 133 | write_nulls(output, max_string_length - str_length) 134 | 135 | 136 | def write_until_eof(output, ignored, value): 137 | output.write(value) 138 | 139 | # ------------------------------------------------------------------------------ 140 | # Misc 141 | 142 | def print_structure(structure, members, name): 143 | print name 144 | print '=' * len(name) 145 | for member in members: 146 | value = structure[member.name] 147 | print '%s: %s' % (member.name, repr(value)) 148 | print 149 | 150 | 151 | def print_structure_format(members, name): 152 | print name 153 | print '=' * len(name) 154 | offset = 0 155 | for member in members: 156 | print '%s: %s' % (offset, member.name) 157 | offset += sizeof_structure_member(member) 158 | print 159 | 160 | 161 | def sizeof_structure(members): 162 | total_size = 0 163 | for member in members: 164 | total_size += sizeof_structure_member(member) 165 | return total_size 166 | 167 | 168 | def sizeof_structure_member(member): 169 | if member.type in ('unsigned', 'signed', 'fixed_string', 'fixed_bytes'): 170 | return member.subtype 171 | elif member.type in ('pascal_string', 'pascal_bytes'): 172 | max_string_length = member.subtype 173 | if max_string_length is None: 174 | raise ValueError("Can't determine size of a dynamic pascal string.") 175 | return max_string_length + 1 176 | else: 177 | raise ValueError("Don't know how to find the size of member with type: %s" % member.type) 178 | 179 | 180 | def offset_to_structure_member(members, member_name): 181 | offset = 0 182 | for member in members: 183 | if member.name == member_name: 184 | return offset 185 | offset += sizeof_structure_member(member) 186 | raise ValueError('No such member in structure.') 187 | 188 | 189 | def fill_missing_structure_members_with_defaults(structure_members, structure): 190 | for member in structure_members: 191 | if member.name not in structure: 192 | structure[member.name] = member.default_value 193 | 194 | 195 | def at_eof(input): 196 | """ 197 | Returns whether the specified input stream is at EOF. 198 | """ 199 | with save_stream_position(input): 200 | at_eof = input.read(1) == b'' 201 | return at_eof 202 | 203 | 204 | @contextmanager 205 | def save_stream_position(stream): 206 | original_position = stream.tell() 207 | yield 208 | stream.seek(original_position) 209 | 210 | 211 | def write_nulls(output, num_bytes): 212 | """ 213 | Writes the specified number of NULL bytes to the specified output stream. 214 | 215 | This implementation is optimized to write a large number of bytes quickly. 216 | """ 217 | zero_byte = NULL_BYTE # save to local to improve performance 218 | 219 | # Write blocks of 1024 bytes first 220 | zero_kilobyte = zero_byte * 1024 221 | while num_bytes >= 1024: 222 | output.write(zero_kilobyte) 223 | num_bytes -= 1024 224 | 225 | # Write remaining bytes 226 | for i in xrange(num_bytes): 227 | output.write(zero_byte) 228 | 229 | def touch_temp(*args, **kwargs): 230 | """ 231 | Return an absolute pathname of a file that did not exist at the time the 232 | call is made. The arguments are the same as for tempfile.mkstemp(). 233 | 234 | After the call is made, a new file will exist at the returned filepath. 235 | The caller is responsible for deleting this file. 236 | 237 | This is intended to be a secure alternative to tempfile.mktemp(), 238 | where the caller really needs a temporary *filepath* as opposed 239 | to a file-object. 240 | """ 241 | (fd, filepath) = tempfile.mkstemp(*args, **kwargs) 242 | os.close(fd) 243 | return filepath 244 | 245 | # ------------------------------------------------------------------------------ 246 | # Unicode & Python 3 Shims 247 | 248 | # BytesIO presents a stream interface to an in-memory bytestring. 249 | # 250 | # This is equivalent to StringIO in Python 2 and to BytesIO in Python 3. 251 | try: 252 | from io import BytesIO # Python 3 253 | except ImportError: 254 | from StringIO import StringIO as BytesIO 255 | 256 | # StringIO presents a stream interface to an in-memory string 257 | # (which is a bytestring in Python 2 and a unicode string in Python 3). 258 | # 259 | # This is equivalent to StringIO in both Python 2 and 3. 260 | try: 261 | from StringIO import StringIO 262 | except ImportError: 263 | from io import StringIO # Python 3 264 | 265 | # bchr() converts the specified byte integer value to a single character 266 | # bytestring. 267 | # 268 | # This is equivalent to chr() in Python 2 but requires special handling in 269 | # Python 3. 270 | if bytes == str: 271 | def bchr(byte_ordinal): 272 | return chr(byte_ordinal) 273 | else: 274 | def bchr(byte_ordinal): 275 | return bytes([byte_ordinal]) # Python 3 276 | 277 | NULL_BYTE = bchr(0) 278 | 279 | # iterord() iterates over the integer values of the bytes in the specified 280 | # bytestring. 281 | if bytes == str: 282 | def iterord(bytes_value): # Python 2 283 | for b in bytes_value: 284 | yield ord(b) 285 | else: 286 | def iterord(bytes_value): # Python 3 287 | return bytes_value 288 | -------------------------------------------------------------------------------- /classicbox/macbinary.py: -------------------------------------------------------------------------------- 1 | """ 2 | Manipulates MacBinary files. 3 | """ 4 | 5 | from __future__ import absolute_import 6 | 7 | from classicbox.io import BytesIO 8 | from classicbox.io import iterord 9 | from classicbox.io import NULL_BYTE 10 | from classicbox.io import offset_to_structure_member 11 | from classicbox.io import print_structure 12 | from classicbox.io import read_structure 13 | from classicbox.io import save_stream_position 14 | from classicbox.io import sizeof_structure_member 15 | from classicbox.io import StructMember 16 | from classicbox.io import write_structure 17 | from classicbox.io import write_unsigned 18 | from classicbox.time import convert_local_to_mac_timestamp 19 | import time 20 | 21 | 22 | # 23 | # Script Constants -- for the 'filename_script' field 24 | # 25 | # Taken from the Script Manager Reference 26 | # http://developer.apple.com/legacy/mac/library/documentation/Carbon/reference/Script_Manager/Script_Manager.pdf 27 | # 28 | SM_ROMAN = 0 29 | # Specifies the Roman script system. 30 | SM_JAPANESE = 1 31 | SM_TRAD_CHINESE = 2 32 | # Specifies the traditional Chinese script system. 33 | SM_KOREAN = 3 34 | SM_ARABIC = 4 35 | SM_HEBREW = 5 36 | SM_GREEK = 6 37 | SM_CYRILLIC = 7 38 | SM_R_SYMBOL = 8 39 | # Specifies right-to-left symbols. The script code represented by the 40 | # constant smRSymbol is available as an alternative to smUninterp, for 41 | # representation of special symbols that have a right-to-left line 42 | # direction. Note, however, that the script management system provides no 43 | # direct support for representation of text with this script code. 44 | SM_DEVANAGARI = 9 45 | SM_GURMUKHI = 10 46 | SM_GUJARATI = 11 47 | SM_ORIYA = 12 48 | SM_BENGALI = 13 49 | SM_TAMIL = 14 50 | SM_TELUGU = 15 51 | SM_KANNADA = 16 52 | # Specifies the Kannada/Kanarese script system. 53 | SM_MALAYALAM = 17 54 | SM_SINHALESE = 18 55 | SM_BURMESE = 19 56 | SM_KHMER = 20 57 | SM_THAI = 21 58 | SM_LAO = 22 59 | # Specifies the Laotian script system. 60 | SM_GEORGIAN = 23 61 | SM_ARMENIAN = 24 62 | SM_SIMP_CHINESE = 25 63 | # Specifies the simplified Chinese script system. 64 | SM_TIBETAN = 26 65 | SM_MONGOLIAN = 27 66 | SM_ETHIOPIC = 28 67 | # Specifies the Geez/Ethiopic script system. 68 | # This constant is the same as smGeez. 69 | SM_GEEZ = 28 70 | # Specifies the Geez/Ethiopic script system. 71 | SM_CENTRAL_EURO_ROMAN = 29 72 | # Used for Czech, Slovak, Polish, Hungarian, Baltic languages. 73 | SM_VIETNAMESE = 30 74 | # Specifies the Extended Roman script system for Vietnamese. 75 | SM_EXT_ARABIC = 31 76 | # Specifies the extended Arabic for Sindhi script system. 77 | SM_UNINTERP = 32 78 | # Uninterpreted symbols. The script code represented by the constant 79 | # smUninterp is available for representation of special symbols, such as 80 | # items in a tool palette, that must not be considered as part of any actual 81 | # script system. For manipulating and drawing such symbols, the smUninterp 82 | # constant should be treated as if it indicated the Roman script system 83 | # rather than the system script; that is, the default behavior of 84 | # uninterpreted symbols should be Roman. 85 | SM_UNICODE_SCRIPT = 0x7E 86 | # The extended script code for full Unicode input. 87 | 88 | # 89 | # Finder Flags -- for the 'finder_flags' field 90 | # 91 | # Taken from MacBinary III documentation. 92 | # Descriptions taken from Finder.h in the Carbon headers. 93 | # 94 | FF_IS_ALIAS = 1 << 7 95 | # (Files only) 96 | FF_IS_INVISIBLE = 1 << 6 97 | # (Files and folders) 98 | FF_HAS_BUNDLE = 1 << 5 99 | # (Files and folders) 100 | # Indicates that a file has a BNDL resource. 101 | # Indicates that a folder is displayed as a package. 102 | FF_NAME_LOCKED = 1 << 4 103 | # (Files and folders) 104 | FF_IS_STATIONARY = 1 << 3 105 | # (Files only) 106 | FF_HAS_CUSTOM_ICON = 1 << 2 107 | # (Files and folders) 108 | FF_RESERVED = 1 << 1 109 | FF_HAS_BEEN_INITED = 1 << 0 110 | # (Files only) 111 | # Clear if the file contains desktop database resources ('BNDL', 'FREF', 112 | # 'open', 'kind'...) that have not been added yet. Set only by the Finder. 113 | 114 | # 115 | # Finder Extra Flags -- for the `extra_finder_flags` field 116 | # 117 | # Taken from MacBinary III documentation. 118 | # Descriptions taken from Finder.h in the Carbon headers. 119 | # 120 | FFE_HAS_NO_INITS = 1 << 7 121 | # (Extensions/Control Panels only) 122 | # This file contains no INIT resource. 123 | FFE_IS_SHARED = 1 << 6 124 | # (Applications only) 125 | # If clear, the application needs to write to its resource fork, and 126 | # therefore cannot be shared on a server 127 | FFE_REQUIRES_SWITCH_LAUNCH = 1 << 5 128 | # (Reserved) 129 | FFE_COLOR_RESERVED = 1 << 4 130 | FFE_COLOR = (1 << 3) | (1 << 2) | (1 << 1) 131 | # (Files and folders) 132 | FFE_IS_ON_DESK = 1 << 0 133 | # (Files and folders, System 6) 134 | 135 | # 136 | # MacBinary format reference: 137 | # http://code.google.com/p/theunarchiver/wiki/MacBinarySpecs 138 | # 139 | _MACBINARY_HEADER_MEMBERS = [ 140 | StructMember('old_version', 'unsigned', 1, 0), 141 | StructMember('filename', 'pascal_bytes', 63, None), 142 | StructMember('file_type', 'fixed_string', 4, None), 143 | StructMember('file_creator', 'fixed_string', 4, None), 144 | StructMember('finder_flags', 'unsigned', 1, 0), 145 | # Bit 7 - isAlias 146 | # Bit 6 - isInvisible 147 | # Bit 5 - hasBundle 148 | # Bit 4 - nameLocked 149 | # Bit 3 - isStationery 150 | # Bit 2 - hasCustomIcon 151 | # Bit 1 - reserved 152 | # Bit 0 - hasBeenInited 153 | StructMember('zero_1', 'unsigned', 1, 0), 154 | StructMember('y_position', 'unsigned', 2, 0), 155 | StructMember('x_position', 'unsigned', 2, 0), 156 | StructMember('parent_directory_id', 'unsigned', 2, 0), 157 | StructMember('protected', 'unsigned', 1, 0), 158 | StructMember('zero_2', 'unsigned', 1, 0), 159 | StructMember('data_fork_length', 'unsigned', 4, None), 160 | StructMember('resource_fork_length', 'unsigned', 4, None), 161 | StructMember('created', 'unsigned', 4, None), 162 | StructMember('modified', 'unsigned', 4, None), 163 | StructMember('comment_length', 'unsigned', 2, None), 164 | StructMember('extra_finder_flags', 'unsigned', 1, 0), 165 | # Bit 7 - hasNoInits 166 | # Bit 6 - isShared 167 | # Bit 5 - requiresSwitchLaunch 168 | # Bit 4 - ColorReserved 169 | # Bits 1-3 - color 170 | # Bit 0 - isOnDesk 171 | StructMember('signature', 'fixed_bytes', 4, b'mBIN'), 172 | # See SM_* constants for valid values. 173 | StructMember('filename_script', 'unsigned', 1, SM_ROMAN), 174 | StructMember('extended_finder_flags', 'unsigned', 1, 0), 175 | # fdXFlags field of an fxInfo record 176 | StructMember('reserved', 'fixed_bytes', 8, 0), 177 | StructMember('reserved_for_unpacked_size', 'unsigned', 4, 0), 178 | StructMember('reserved_for_second_header_length', 'unsigned', 2, 0), 179 | StructMember('version', 'unsigned', 1, 130), 180 | StructMember('min_version_to_read', 'unsigned', 1, 129), 181 | StructMember('header_crc', 'unsigned', 2, None), 182 | # NOTE: Somebody forgot to include this field in the MacBinary II and III 183 | # documentation, although it is in the MacBinary I docs. Grr. 184 | StructMember('reserved_for_computer_type_and_os_id', 'unsigned', 2, 0), 185 | ] 186 | 187 | # ------------------------------------------------------------------------------ 188 | 189 | def read_macbinary(input): 190 | """ 191 | Reads a MacBinary I, II, or III file from the specified input stream. 192 | 193 | Returns a MacBinary object. This object is in the format described by 194 | `write_macbinary()` and has all its optional fields filled out. 195 | """ 196 | macbinary_header = _read_macbinary_header(input) 197 | data_fork = _read_macbinary_section(input, 'data_fork', macbinary_header) 198 | resource_fork = _read_macbinary_section(input, 'resource_fork', macbinary_header) 199 | comment = _read_macbinary_section(input, 'comment', macbinary_header) 200 | 201 | # Reclassify MacBinary header as MacBinary object 202 | macbinary = macbinary_header 203 | 204 | # Record remaining components in the MacBinary object 205 | macbinary.update({ 206 | 'data_fork': data_fork, 207 | 'resource_fork': resource_fork, 208 | 'comment': comment, 209 | }) 210 | 211 | return macbinary 212 | 213 | 214 | def _read_macbinary_header(input): 215 | macbinary_header = read_structure(input, _MACBINARY_HEADER_MEMBERS) 216 | 217 | # Decode the filename to unicode, which might not be MacRoman encoded 218 | if macbinary_header['filename_script'] == SM_ROMAN: 219 | macbinary_header['filename'] = macbinary_header['filename'].decode('macroman') 220 | else: 221 | raise NotImplementedError( 222 | "Filename is encoded in a script other than MacRoman. " + 223 | "Don't know how to decode non-MacRoman scripts.") 224 | 225 | return macbinary_header 226 | 227 | 228 | def _read_macbinary_section(input, section_type, macbinary_header): 229 | section_length = macbinary_header[section_type + '_length'] 230 | if section_length == 0: 231 | section = b'' 232 | else: 233 | section = input.read(section_length) 234 | _seek_to_next_128_byte_boundary(input) 235 | return section 236 | 237 | 238 | def _seek_to_next_128_byte_boundary(input): 239 | current_offset = input.tell() 240 | offset_to_next_boundary = 128 - (current_offset % 128) 241 | if offset_to_next_boundary < 128: 242 | input.seek(current_offset + offset_to_next_boundary) 243 | 244 | # ------------------------------------------------------------------------------ 245 | 246 | def write_macbinary_to_buffer(macbinary): 247 | """ 248 | Convenience method that writes a MacBinary file to an in-memory BytesIO 249 | buffer and then returns that (rewound) buffer. 250 | 251 | Do not use this method for potentially large files, since the entire 252 | file is buffered in memory. 253 | """ 254 | buffer = BytesIO() 255 | write_macbinary(buffer, macbinary) 256 | buffer.seek(0) 257 | return buffer 258 | 259 | 260 | def write_macbinary(output, macbinary): 261 | """ 262 | Writes a MacBinary III file to the specified output stream, with the 263 | specified contents. 264 | 265 | A MacBinary object is a dictionary of the format: 266 | * filename : unicode -- Name of the encoded file. 267 | * filename_script : unsigned(1) (optional) -- Text encoding of the filename. 268 | * Defaults to MacRoman (SM_ROMAN). 269 | * See SM_* constants for other options. 270 | * file_type : unicode(4) -- Code for the file type. 271 | * file_creator : unicode(4) -- Code for the file creator. 272 | * data_fork : str-binary (optional) -- The contents of the data fork. 273 | * resource_fork : str-binary (optional) -- The contents of the resource fork. 274 | 275 | * created : mac_timestamp (optional) -- Creation date of the encoded file. 276 | Defaults to the current datetime. 277 | * modified : mac_timestamp (optional) -- Modification date of the encoded file. 278 | Defaults to the current datetime. 279 | * protected : unsigned(1) (optional) -- 0 if the file is unlocked (the default). 280 | 1 if the file is locked. 281 | * finder_flags : unsigned(1) (optional) -- See FF_* constants. 282 | * extra_finder_flags : unsigned(1) (optional) -- See FFE_* constants. 283 | * extended_finder_flags : unsigned(1) (optional) -- fdXFlags field of an fxInfo record. 284 | * comment : unicode (optional) -- The Finder comment of the file. 285 | * parent_directory_id : unsigned(2) (optional) -- 286 | ID of the directory that originally contained the encoded file. 287 | * x_position : unsigned(2) (optional) - X position of the encoded file within its parent directory. 288 | * y_position : unsigned(2) (optional) - Y position of the encoded file within its parent directory. 289 | 290 | Arguments: 291 | * output : stream -- An output stream. 292 | * macbinary -- A MacBinary object. See documentation above. 293 | """ 294 | if 'data_fork' not in macbinary and 'resource_fork' not in macbinary: 295 | raise ValueError( 296 | 'Must explicitly specify a data fork, a resource fork, or both.') 297 | 298 | macbinary_header = macbinary 299 | data_fork = macbinary.get('data_fork', b'') 300 | resource_fork = macbinary.get('resource_fork', b'') 301 | comment = macbinary.get('comment', '') 302 | 303 | # Fill in header 304 | macbinary_header.update({ 305 | 'data_fork_length': len(data_fork), 306 | 'resource_fork_length': len(resource_fork), 307 | 'comment_length': len(comment), 308 | }) 309 | 310 | # Write everything 311 | _write_macbinary_header(output, macbinary) 312 | _write_macbinary_section(output, data_fork) 313 | _write_macbinary_section(output, resource_fork) 314 | _write_macbinary_section(output, comment) 315 | 316 | 317 | def _write_macbinary_header(output, macbinary_header): 318 | # Try to convert unicode filenames to MacRoman automatically 319 | if macbinary_header.get('filename_script', SM_ROMAN) != SM_ROMAN: 320 | raise NotImplementedError( 321 | "Filename script other than MacRoman specified. " + 322 | "Don't know how to encode scripts other than MacRoman.") 323 | macbinary_header['filename'] = macbinary_header['filename'].encode('macroman') 324 | 325 | # If datetime fields not specified, use the current datetime 326 | if 'created' not in macbinary_header or 'modified' not in macbinary_header: 327 | now_mac_timestamp = convert_local_to_mac_timestamp(time.time()) 328 | if 'created' not in macbinary_header: 329 | macbinary_header['created'] = now_mac_timestamp 330 | if 'modified' not in macbinary_header: 331 | macbinary_header['modified'] = now_mac_timestamp 332 | 333 | # Write the header 334 | macbinary_header['header_crc'] = 0 335 | write_structure(output, _MACBINARY_HEADER_MEMBERS, macbinary_header) 336 | 337 | # Amend the header with the actual CRC 338 | with save_stream_position(output): 339 | offset_to_crc_member = offset_to_structure_member( 340 | _MACBINARY_HEADER_MEMBERS, 'header_crc') 341 | 342 | # Compute CRC of header 343 | output.seek(0) 344 | header_section_to_crc = output.read(offset_to_crc_member) 345 | header_crc = _compute_macbinary_crc(header_section_to_crc) 346 | 347 | # Write CRC to header 348 | write_unsigned(output, 2, header_crc) 349 | 350 | # Save CRC to MacBinary object in case the caller is interested 351 | macbinary_header['header_crc'] = header_crc 352 | 353 | 354 | def _write_macbinary_section(output, section_content): 355 | if len(section_content) > 0: 356 | output.write(section_content) 357 | _pad_until_next_128_byte_boundary(output) 358 | 359 | 360 | def _pad_until_next_128_byte_boundary(output): 361 | current_offset = output.tell() 362 | offset_to_next_boundary = 128 - (current_offset % 128) 363 | if offset_to_next_boundary < 128: 364 | for i in xrange(offset_to_next_boundary): 365 | output.write(NULL_BYTE) 366 | 367 | # ------------------------------------------------------------------------------ 368 | 369 | _CRC_MAGIC = ( 370 | 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7, 371 | 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef, 372 | 0x1231, 0x0210, 0x3273, 0x2252, 0x52b5, 0x4294, 0x72f7, 0x62d6, 373 | 0x9339, 0x8318, 0xb37b, 0xa35a, 0xd3bd, 0xc39c, 0xf3ff, 0xe3de, 374 | 375 | 0x2462, 0x3443, 0x0420, 0x1401, 0x64e6, 0x74c7, 0x44a4, 0x5485, 376 | 0xa56a, 0xb54b, 0x8528, 0x9509, 0xe5ee, 0xf5cf, 0xc5ac, 0xd58d, 377 | 0x3653, 0x2672, 0x1611, 0x0630, 0x76d7, 0x66f6, 0x5695, 0x46b4, 378 | 0xb75b, 0xa77a, 0x9719, 0x8738, 0xf7df, 0xe7fe, 0xd79d, 0xc7bc, 379 | 380 | 0x48c4, 0x58e5, 0x6886, 0x78a7, 0x0840, 0x1861, 0x2802, 0x3823, 381 | 0xc9cc, 0xd9ed, 0xe98e, 0xf9af, 0x8948, 0x9969, 0xa90a, 0xb92b, 382 | 0x5af5, 0x4ad4, 0x7ab7, 0x6a96, 0x1a71, 0x0a50, 0x3a33, 0x2a12, 383 | 0xdbfd, 0xcbdc, 0xfbbf, 0xeb9e, 0x9b79, 0x8b58, 0xbb3b, 0xab1a, 384 | 385 | 0x6ca6, 0x7c87, 0x4ce4, 0x5cc5, 0x2c22, 0x3c03, 0x0c60, 0x1c41, 386 | 0xedae, 0xfd8f, 0xcdec, 0xddcd, 0xad2a, 0xbd0b, 0x8d68, 0x9d49, 387 | 0x7e97, 0x6eb6, 0x5ed5, 0x4ef4, 0x3e13, 0x2e32, 0x1e51, 0x0e70, 388 | 0xff9f, 0xefbe, 0xdfdd, 0xcffc, 0xbf1b, 0xaf3a, 0x9f59, 0x8f78, 389 | 390 | 0x9188, 0x81a9, 0xb1ca, 0xa1eb, 0xd10c, 0xc12d, 0xf14e, 0xe16f, 391 | 0x1080, 0x00a1, 0x30c2, 0x20e3, 0x5004, 0x4025, 0x7046, 0x6067, 392 | 0x83b9, 0x9398, 0xa3fb, 0xb3da, 0xc33d, 0xd31c, 0xe37f, 0xf35e, 393 | 0x02b1, 0x1290, 0x22f3, 0x32d2, 0x4235, 0x5214, 0x6277, 0x7256, 394 | 395 | 0xb5ea, 0xa5cb, 0x95a8, 0x8589, 0xf56e, 0xe54f, 0xd52c, 0xc50d, 396 | 0x34e2, 0x24c3, 0x14a0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405, 397 | 0xa7db, 0xb7fa, 0x8799, 0x97b8, 0xe75f, 0xf77e, 0xc71d, 0xd73c, 398 | 0x26d3, 0x36f2, 0x0691, 0x16b0, 0x6657, 0x7676, 0x4615, 0x5634, 399 | 400 | 0xd94c, 0xc96d, 0xf90e, 0xe92f, 0x99c8, 0x89e9, 0xb98a, 0xa9ab, 401 | 0x5844, 0x4865, 0x7806, 0x6827, 0x18c0, 0x08e1, 0x3882, 0x28a3, 402 | 0xcb7d, 0xdb5c, 0xeb3f, 0xfb1e, 0x8bf9, 0x9bd8, 0xabbb, 0xbb9a, 403 | 0x4a75, 0x5a54, 0x6a37, 0x7a16, 0x0af1, 0x1ad0, 0x2ab3, 0x3a92, 404 | 405 | 0xfd2e, 0xed0f, 0xdd6c, 0xcd4d, 0xbdaa, 0xad8b, 0x9de8, 0x8dc9, 406 | 0x7c26, 0x6c07, 0x5c64, 0x4c45, 0x3ca2, 0x2c83, 0x1ce0, 0x0cc1, 407 | 0xef1f, 0xff3e, 0xcf5d, 0xdf7c, 0xaf9b, 0xbfba, 0x8fd9, 0x9ff8, 408 | 0x6e17, 0x7e36, 0x4e55, 0x5e74, 0x2e93, 0x3eb2, 0x0ed1, 0x1ef0 409 | ) 410 | 411 | def _compute_macbinary_crc(data, crc=0): 412 | """ 413 | Computes a MacBinary II style CRC checksum of the specified data. 414 | """ 415 | for b in iterord(data): 416 | crc ^= b << 8 417 | crc = ((crc << 8) ^ _CRC_MAGIC[crc >> 8]) & 0xFFFF 418 | return crc 419 | 420 | # ------------------------------------------------------------------------------ 421 | 422 | def print_macbinary(macbinary): 423 | _print_macbinary_header(macbinary) 424 | 425 | print 'Data Fork' 426 | print '=========' 427 | print repr(macbinary['data_fork']) 428 | print 429 | print 'Resource Fork' 430 | print '=============' 431 | print repr(macbinary['resource_fork']) 432 | print 433 | print 'Comment' 434 | print '=======' 435 | print repr(macbinary['comment']) 436 | print 437 | 438 | 439 | def _print_macbinary_header(macbinary_header): 440 | print_structure(macbinary_header, _MACBINARY_HEADER_MEMBERS, 'MacBinary Header') 441 | -------------------------------------------------------------------------------- /classicbox/resource_fork.py: -------------------------------------------------------------------------------- 1 | """ 2 | Manipulates MacOS resource forks. 3 | """ 4 | 5 | from classicbox.io import print_structure 6 | from classicbox.io import read_pascal_string 7 | from classicbox.io import read_structure 8 | from classicbox.io import read_unsigned 9 | from classicbox.io import sizeof_structure 10 | from classicbox.io import StructMember 11 | from classicbox.io import write_pascal_string 12 | from classicbox.io import write_structure 13 | from classicbox.io import write_unsigned 14 | 15 | import sys 16 | 17 | 18 | _RESOURCE_FORK_HEADER_MEMBERS = [ 19 | StructMember('offset_to_resource_data_area', 'unsigned', 4, None), 20 | StructMember('offset_to_resource_map', 'unsigned', 4, None), 21 | StructMember('resource_data_area_length', 'unsigned', 4, None), 22 | StructMember('resource_map_length', 'unsigned', 4, None), 23 | 24 | # The format of this member is undocumented. If omitted, ResEdit will 25 | # complain that the resulting resource fork is damaged. Reserving the 26 | # appropriate amount of space and filling it with zeros seems to make 27 | # ResEdit happy. 28 | StructMember('reserved_for_system_use', 'fixed_bytes', 256 - 16, 0), 29 | ] 30 | 31 | _RESOURCE_MAP_HEADER_MEMBERS = [ 32 | StructMember('reserved_for_resource_fork_header_copy', 'fixed_bytes', 16, 0), 33 | StructMember('reserved_for_next_resource_map_handle', 'unsigned', 4, 0), 34 | StructMember('reserved_for_file_reference_number', 'unsigned', 2, 0), 35 | StructMember('attributes', 'unsigned', 2, 0), 36 | StructMember('offset_to_resource_type_list', 'unsigned', 2, None), 37 | StructMember('offset_to_resource_name_list', 'unsigned', 2, None), 38 | StructMember('resource_type_count_minus_one', 'unsigned', 2, None), 39 | ] 40 | 41 | _RESOURCE_TYPE_MEMBERS = [ 42 | StructMember('code', 'fixed_string', 4, None), 43 | StructMember('resource_count_minus_one', 'unsigned', 2, None), 44 | StructMember('offset_from_resource_type_list_to_reference_list', 'unsigned', 2, None), 45 | ] 46 | 47 | _RESOURCE_REFERENCE_MEMBERS = [ 48 | StructMember('id', 'signed', 2, None), 49 | StructMember('offset_from_resource_name_list_to_name', 'unsigned', 2, None), 50 | # -1 if the resource has no name 51 | StructMember('attributes', 'unsigned', 1, None), 52 | StructMember('offset_from_resource_data_area_to_data', 'unsigned', 3, None), 53 | StructMember('reserved_for_handle', 'unsigned', 4, 0), 54 | ] 55 | 56 | # Resource Attributes 57 | RES_SYS_HEAP = 64 # set if read into system heap 58 | RES_PURGEABLE = 32 # set if purgeable 59 | RES_LOCKED = 16 # set if locked 60 | RES_PROTECTED = 8 # set if protected 61 | RES_PRELOAD = 4 # set if to be preloaded 62 | RES_CHANGED = 2 # set if to be written to resource fork 63 | 64 | # Resource Map Attributes 65 | MAP_READ_ONLY = 128 # set to make file read-only 66 | MAP_COMPACT = 64 # set to compact file on update 67 | MAP_CHANGED = 32 # set to write map on update 68 | 69 | # ------------------------------------------------------------------------------ 70 | 71 | def read_resource_fork( 72 | input, 73 | read_all_resource_names=True, 74 | read_all_resource_data=False, 75 | read_everything=False, 76 | _verbose=False): 77 | """ 78 | Reads a resource fork from the specified input stream, returning a 79 | resource map object. Resource data is not read into memory by default. 80 | 81 | All resource names can be skipped by passing `False` for the 82 | `read_all_resource_names` parameter. Skipping the names uses less memory. 83 | 84 | All resource data can be read by passing `True` for the `read_all_resource_data` 85 | parameter. This is not recommended unless the resource fork is known 86 | to fit into memory completely. Instead, most callers should use 87 | read_resource_data() to read individual resources of interest. 88 | 89 | A resource map object is a dictionary of the format: 90 | * resource_types : list 91 | * attributes : unsigned(2) -- Resource map attributes. See MAP_* constants. 92 | * absolute_offset : int -- Offset to the beginning of the resource map. 93 | 94 | A ResourceType object is a dictionary of the format: 95 | * code : unicode(4) -- Code for the resource type. 96 | * resources : list 97 | 98 | A Resource object is a dictionary of the format: 99 | * id : signed(2) -- ID of this resource. 100 | * name : unicode -- Name of this resource. 101 | Only available if `read_all_resource_names` is 102 | True (the default). 103 | * attributes : unsigned(1) (optional) -- Attributes of this resource. 104 | See RES_* constants. 105 | * data : str-binary -- Data of this resource. 106 | Only available if `read_all_resource_data` is True. 107 | 108 | Other undocumented keys may be present in the above dictionary types. 109 | Callers should not rely upon such keys. 110 | 111 | Arguments: 112 | * input -- Input stream to read the resource fork from. 113 | * read_all_resource_names : bool -- Whether to read all resource names. 114 | Defaults to True. 115 | * read_all_resource_data : bool - Whether to read all resource data. 116 | Defaults to False. 117 | * read_everything : bool -- Convenience argument that implies 118 | `read_all_resource_names` and 119 | `read_all_resource_data` if True. 120 | Defaults to False. 121 | 122 | Returns a resource map object. 123 | """ 124 | 125 | if read_everything: 126 | read_all_resource_names = True 127 | read_all_resource_data = True 128 | 129 | # Read resource fork header 130 | resource_fork_header = read_structure(input, _RESOURCE_FORK_HEADER_MEMBERS) 131 | 132 | if _verbose: 133 | print_structure( 134 | resource_fork_header, 135 | _RESOURCE_FORK_HEADER_MEMBERS, 'Resource Fork Header') 136 | 137 | # Read resource map header 138 | resource_map_absolute_offset = resource_fork_header['offset_to_resource_map'] 139 | input.seek(resource_map_absolute_offset) 140 | resource_map_header = read_structure(input, _RESOURCE_MAP_HEADER_MEMBERS) 141 | 142 | if _verbose: 143 | print_structure( 144 | resource_map_header, 145 | _RESOURCE_MAP_HEADER_MEMBERS, 'Resource Map') 146 | 147 | # Read all resource types 148 | resource_type_count = resource_map_header['resource_type_count_minus_one'] + 1 149 | resource_types = [_read_resource_type(input) for i in xrange(resource_type_count)] 150 | 151 | if _verbose: 152 | print '######################' 153 | print '### Resource Types ###' 154 | print '######################' 155 | print 156 | for type in resource_types: 157 | print_structure( 158 | type, 159 | _RESOURCE_TYPE_MEMBERS, 'Resource Type') 160 | 161 | # Read all resource references 162 | for type in resource_types: 163 | # Read resource reference list for this resource type 164 | resource_reference_count = type['resource_count_minus_one'] + 1 165 | resource_references = [_read_resource_reference(input) for i in 166 | xrange(resource_reference_count)] 167 | 168 | if _verbose: 169 | print '########################' 170 | print '### "%s" Resources ###' % type['code'] 171 | print '########################' 172 | print 173 | for resource in resource_references: 174 | print_structure( 175 | resource, 176 | _RESOURCE_REFERENCE_MEMBERS, 'Resource') 177 | 178 | # Save the resource references in the associated resource type structure 179 | type['resources'] = resource_references 180 | 181 | # Reclassify resource map header as resource map object 182 | resource_map = resource_map_header 183 | 184 | # Record useful information in the resource map structure 185 | resource_map['resource_fork_header'] = resource_fork_header 186 | resource_map['resource_types'] = resource_types 187 | 188 | # Read resource names if requested 189 | if read_all_resource_names: 190 | if _verbose: 191 | print '######################' 192 | print '### Resource Names ###' 193 | print '######################' 194 | print 195 | 196 | for type in resource_map['resource_types']: 197 | for resource in type['resources']: 198 | resource_name = read_resource_name(input, resource_map, resource) 199 | 200 | # Save the resource name in the resource reference structure 201 | resource['name'] = resource_name 202 | 203 | if _verbose: 204 | print '\'%s\' %s: "%s"' % (type['code'], resource['id'], resource_name) 205 | 206 | if _verbose: 207 | print 208 | 209 | # Read resource data if requested 210 | if read_all_resource_data: 211 | for type in resource_map['resource_types']: 212 | for resource in type['resources']: 213 | resource_data = read_resource_data(input, resource_map, resource) 214 | 215 | # Save the resource name in the resource reference structure 216 | resource['data'] = resource_data 217 | 218 | return resource_map 219 | 220 | 221 | def _read_resource_type(input): 222 | return read_structure(input, _RESOURCE_TYPE_MEMBERS) 223 | 224 | 225 | def _read_resource_reference(input): 226 | return read_structure(input, _RESOURCE_REFERENCE_MEMBERS) 227 | 228 | 229 | def read_resource_name(input, resource_map, resource): 230 | """ 231 | Reads the name of the specified resource. 232 | """ 233 | absolute_offset_to_resource_name = ( 234 | resource_map['resource_fork_header']['offset_to_resource_map'] + 235 | resource_map['offset_to_resource_name_list'] + 236 | resource['offset_from_resource_name_list_to_name']) 237 | 238 | input.seek(absolute_offset_to_resource_name) 239 | resource_name = read_pascal_string(input, None) 240 | return resource_name 241 | 242 | 243 | def read_resource_data(input, resource_map, resource): 244 | """ 245 | Reads the data of the specified resource. 246 | """ 247 | absolute_offset_to_resource_data = ( 248 | resource_map['resource_fork_header']['offset_to_resource_data_area'] + 249 | resource['offset_from_resource_data_area_to_data']) 250 | 251 | input.seek(absolute_offset_to_resource_data) 252 | resource_data_length = read_unsigned(input, 4) 253 | resource_data = input.read(resource_data_length) 254 | return resource_data 255 | 256 | # ------------------------------------------------------------------------------ 257 | 258 | def write_resource_fork(output, resource_map, _preserve_order=True): 259 | """ 260 | Writes a resource fork to the specified output stream using the specified 261 | resource map. All resource names and data must be read into memory. 262 | 263 | The specified resource map must be in the format documented by 264 | `read_resource_fork()`. (It is not necessary for undocumented keys to be 265 | present.) 266 | """ 267 | resource_types = resource_map['resource_types'] 268 | 269 | # Verify that resource names and data are present 270 | for type in resource_types: 271 | for resource in type['resources']: 272 | if 'name' not in resource: 273 | raise ValueError('Missing name for "%s" resource %d.' % ( 274 | type.code, resource['id'])) 275 | if 'data' not in resource: 276 | raise ValueError('Missing data for "%s" resource %d.' % ( 277 | type.code, resource['id'])) 278 | 279 | # Compute ordering of resources in resource data area and name list 280 | if not _preserve_order: 281 | resources_in_resource_data_area = [] 282 | resources_in_resource_name_list = [] 283 | for type in resource_types: 284 | for resource in type['resources']: 285 | resources_in_resource_data_area.append(resource) 286 | resources_in_resource_name_list.append(resource) 287 | else: 288 | # Compute ordering of resources in resource data area 289 | resources_in_resource_data_area = [] 290 | for type in resource_types: 291 | for resource in type['resources']: 292 | resources_in_resource_data_area.append(( 293 | # (Allow undocumented key to be missing. New resources sort last.) 294 | resource.get('offset_from_resource_data_area_to_data', sys.maxint), 295 | resource 296 | )) 297 | resources_in_resource_data_area.sort() # NOTE: depends on stable sort 298 | resources_in_resource_data_area = ( 299 | [resource for (_, resource) in resources_in_resource_data_area] 300 | ) 301 | 302 | # Compute ordering of resources in resource name list 303 | resources_in_resource_name_list = [] 304 | for type in resource_types: 305 | for resource in type['resources']: 306 | resources_in_resource_name_list.append(( 307 | # (Allow undocumented key to be missing. New resources sort last.) 308 | resource.get('offset_from_resource_name_list_to_name', sys.maxint), 309 | resource 310 | )) 311 | resources_in_resource_name_list.sort() # NOTE: depends on stable sort 312 | resources_in_resource_name_list = ( 313 | [resource for (_, resource) in resources_in_resource_name_list] 314 | ) 315 | 316 | # Compute offsets within the resource data area 317 | next_data_offset = 0 318 | for resource in resources_in_resource_data_area: 319 | data_size = 4 + len(resource['data']) 320 | 321 | resource['offset_from_resource_data_area_to_data'] = next_data_offset 322 | next_data_offset += data_size 323 | resource_data_area_length = next_data_offset 324 | 325 | # Compute offsets within the resource name list 326 | next_name_offset = 0 327 | for resource in resources_in_resource_name_list: 328 | if len(resource['name']) == 0: 329 | resource['offset_from_resource_name_list_to_name'] = 0xFFFF 330 | else: 331 | name_size = 1 + len(resource['name']) 332 | 333 | resource['offset_from_resource_name_list_to_name'] = next_name_offset 334 | next_name_offset += name_size 335 | resource_name_list_length = next_name_offset 336 | 337 | resource_map_header_length = ( 338 | sizeof_structure(_RESOURCE_MAP_HEADER_MEMBERS) + 339 | # (Apparently the 'resource_type_count_minus_one' field at the end of 340 | # the resource map header is considered part of the resource type list) 341 | -2 342 | ) 343 | resource_type_list_length = ( 344 | # (Apparently the 'resource_type_count_minus_one' field at the end of 345 | # the resource map header is considered part of the resource type list) 346 | 2 + 347 | len(resource_types) * sizeof_structure(_RESOURCE_TYPE_MEMBERS)) 348 | 349 | # Compute offsets within the reference list area, 350 | # that resource types refer to 351 | next_reference_list_area_offset = 0 352 | for type in resource_types: 353 | resource_count = len(type['resources']) 354 | reference_list_length = resource_count * sizeof_structure(_RESOURCE_REFERENCE_MEMBERS) 355 | 356 | if resource_count == 0: 357 | raise ValueError( 358 | ('Resource type "%s" has no resources and should be removed ' + 359 | 'from the resource map before serialization') % type['code']) 360 | type['resource_count_minus_one'] = resource_count - 1 361 | 362 | type['offset_from_resource_type_list_to_reference_list'] = ( 363 | resource_type_list_length + 364 | next_reference_list_area_offset 365 | ) 366 | next_reference_list_area_offset += reference_list_length 367 | 368 | reference_list_area_length = next_reference_list_area_offset 369 | 370 | # Compute offsets within the resource map, 371 | # that the resource map header refers to 372 | resource_map_length = ( 373 | resource_map_header_length + 374 | resource_type_list_length + 375 | reference_list_area_length + 376 | resource_name_list_length 377 | ) 378 | 379 | resource_map['offset_to_resource_type_list'] = resource_map_header_length 380 | resource_map['offset_to_resource_name_list'] = ( 381 | resource_map_header_length + 382 | resource_type_list_length + 383 | reference_list_area_length 384 | ) 385 | 386 | if len(resource_types) == 0: 387 | raise ValueError( 388 | 'No resource types. ' + 389 | 'Cannot serialize resource fork without at least one type.') 390 | resource_map['resource_type_count_minus_one'] = len(resource_types) - 1 391 | 392 | # Fill in resource fork header 393 | # (Allow undocumented key to be missing) 394 | resource_fork_header = resource_map.get('resource_fork_header', {}) 395 | resource_fork_header_length = sizeof_structure(_RESOURCE_FORK_HEADER_MEMBERS) 396 | resource_fork_header.update({ 397 | 'offset_to_resource_data_area': resource_fork_header_length, 398 | 'offset_to_resource_map': resource_fork_header_length + resource_data_area_length, 399 | 'resource_data_area_length': resource_data_area_length, 400 | 'resource_map_length': resource_map_length, 401 | }) 402 | 403 | # Write everything 404 | _write_resource_fork_header(output, resource_fork_header) 405 | _write_resource_data_area_using_map(output, resource_map, resources_in_resource_data_area) 406 | _write_resource_map(output, resource_map, resources_in_resource_name_list) 407 | 408 | 409 | def _write_resource_fork_header(output, resource_fork_header): 410 | write_structure(output, _RESOURCE_FORK_HEADER_MEMBERS, resource_fork_header) 411 | 412 | 413 | def _write_resource_data_area_using_map(output, resource_map, resources_in_resource_data_area): 414 | for resource in resources_in_resource_data_area: 415 | resource_data = resource['data'] 416 | resource_data_length = len(resource_data) 417 | 418 | write_unsigned(output, 4, resource_data_length) 419 | output.write(resource_data) 420 | 421 | 422 | def _write_resource_map(output, resource_map, resources_in_resource_name_list): 423 | _write_resource_map_header(output, resource_map) 424 | 425 | # Write resource type list 426 | for type in resource_map['resource_types']: 427 | _write_resource_type(output, type) 428 | 429 | # Write reference list area 430 | for type in resource_map['resource_types']: 431 | for resource in type['resources']: 432 | _write_resource_reference(output, resource) 433 | 434 | # Write resource name list 435 | for resource in resources_in_resource_name_list: 436 | write_pascal_string(output, None, resource['name']) 437 | # (Consider writing a padding byte if not word-aligned.) 438 | 439 | 440 | def _write_resource_map_header(output, resource_map_header): 441 | write_structure(output, _RESOURCE_MAP_HEADER_MEMBERS, resource_map_header) 442 | 443 | 444 | def _write_resource_type(output, resource_type): 445 | write_structure(output, _RESOURCE_TYPE_MEMBERS, resource_type) 446 | 447 | 448 | def _write_resource_reference(output, resource_reference): 449 | write_structure(output, _RESOURCE_REFERENCE_MEMBERS, resource_reference) 450 | -------------------------------------------------------------------------------- /classicbox/time.py: -------------------------------------------------------------------------------- 1 | """ 2 | Converts between MacOS time values and native time values. 3 | 4 | A Mac timestamp is the number of seconds since Jan 1, 1904. 5 | """ 6 | 7 | from __future__ import absolute_import 8 | 9 | import time 10 | 11 | 12 | _UNIX_EPOCH_TIMESTAMP = int(time.mktime(time.strptime('1970', '%Y'))) 13 | _MAC_EPOCH_TIMESTAMP = int(time.mktime(time.strptime('1904', '%Y'))) 14 | _TIMEDIFF = _UNIX_EPOCH_TIMESTAMP - _MAC_EPOCH_TIMESTAMP # 2082844800 15 | 16 | 17 | def _calctzdiff(): 18 | """ 19 | Calculate the timezone difference between local time and UTC. 20 | """ 21 | now = int(time.time()) 22 | isdst = time.localtime(now).tm_isdst 23 | 24 | now_utc = list(time.gmtime(now)) 25 | now_utc[8] = isdst # fill in tm_isdst field 26 | now_utc_with_dst = time.struct_time(now_utc) 27 | 28 | tzdiff = now - int(time.mktime(now_utc_with_dst)) 29 | return tzdiff 30 | 31 | _TZDIFF = _calctzdiff() 32 | 33 | 34 | def convert_mac_to_local_timestamp(mtime): 35 | """ 36 | Converts a Mac timestamp to a native timestamp. 37 | 38 | This function is compatible with d_ltime() from hfsutil 3.2.6. 39 | """ 40 | return mtime - _TIMEDIFF - _TZDIFF; 41 | 42 | 43 | def convert_local_to_mac_timestamp(ltime): 44 | """ 45 | Converts a local timestamp to a Mac timestamp. 46 | 47 | This function is compatible with d_mtime() from hfsutil 3.2.6. 48 | """ 49 | return int(ltime) + _TZDIFF + _TIMEDIFF 50 | 51 | 52 | def convert_ctime_string_to_mac_timestamp(ctime_string): 53 | """ 54 | Converts a string output by ctime() (such as 'Sun Sep 23 19:14:47 2012') 55 | to a Mac timestamp. 56 | 57 | This function is compatible with ctime output from hfsutil 3.2.6. 58 | """ 59 | local_timestamp = int(time.mktime(time.strptime(ctime_string))) 60 | return convert_local_to_mac_timestamp(local_timestamp) -------------------------------------------------------------------------------- /classicbox/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | Miscellaneous internal utilities. 3 | """ 4 | 5 | import os 6 | 7 | 8 | """ 9 | Constant that can be passed as the `stdout` or `stderr` arguments of 10 | `subprocess.Popen` and similar functions. 11 | 12 | Is not necessarily a file object. 13 | """ 14 | try: 15 | from subprocess import DEVNULL # Python 3.3+ 16 | except: 17 | DEVNULL = open(os.devnull, 'wb') 18 | -------------------------------------------------------------------------------- /macbinary_file.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Manipulates MacBinary files. 5 | """ 6 | 7 | from __future__ import absolute_import 8 | 9 | from classicbox.io import BytesIO 10 | from classicbox.io import print_structure_format 11 | from classicbox.macbinary import _MACBINARY_HEADER_MEMBERS 12 | from classicbox.macbinary import print_macbinary 13 | from classicbox.macbinary import read_macbinary 14 | from classicbox.macbinary import write_macbinary 15 | import sys 16 | 17 | # ------------------------------------------------------------------------------ 18 | 19 | _VERBOSE_HEADER_FORMAT = False 20 | 21 | def main(args): 22 | (command, macbinary_filepath, ) = args 23 | 24 | if _VERBOSE_HEADER_FORMAT: 25 | print_structure_format(_MACBINARY_HEADER_MEMBERS, 'MacBinary Header Format') 26 | 27 | if macbinary_filepath == '-': 28 | macbinary = None 29 | else: 30 | with open(macbinary_filepath, 'rb') as input: 31 | macbinary = read_macbinary(input) 32 | 33 | if command == 'info': 34 | print_macbinary(macbinary) 35 | 36 | elif command == 'test_read_write': 37 | output_macbinary = BytesIO() 38 | write_macbinary(output_macbinary, macbinary) 39 | 40 | with open(macbinary_filepath, 'rb') as file: 41 | expected_output = file.read() 42 | actual_output = output_macbinary.getvalue() 43 | 44 | matches = (actual_output == expected_output) 45 | if matches: 46 | print 'OK' 47 | else: 48 | print ' Expected: ' + repr(expected_output) 49 | print ' Actual: ' + repr(actual_output) 50 | print 51 | 52 | elif command == 'test_write_custom': 53 | output_macbinary = BytesIO() 54 | write_macbinary(output_macbinary, { 55 | 'filename': 'Greetings.txt', 56 | 'file_type': 'TEXT', 57 | 'file_creator': 'ttxt', 58 | 'data_fork': b'Hello World!', 59 | }) 60 | 61 | else: 62 | sys.exit('Unrecognized command: %s' % command) 63 | return 64 | 65 | # ------------------------------------------------------------------------------ 66 | 67 | if __name__ == '__main__': 68 | main(sys.argv[1:]) 69 | -------------------------------------------------------------------------------- /resource_fork.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Reads resource forks. 5 | """ 6 | 7 | from classicbox.io import BytesIO 8 | from classicbox.resource_fork import read_resource_fork 9 | from classicbox.resource_fork import write_resource_fork 10 | import sys 11 | 12 | # ------------------------------------------------------------------------------ 13 | 14 | def main(args): 15 | (command, resource_file_filepath, ) = args 16 | 17 | if command == 'info': 18 | with open(resource_file_filepath, 'rb') as input: 19 | # Read and print the contents of the resource map 20 | print_resource_fork(input) 21 | 22 | elif command == 'test_read_write_approx': 23 | test_read_write_approx(resource_file_filepath) 24 | 25 | elif command == 'test_read_write_exact': 26 | test_read_write_exact(resource_file_filepath) 27 | 28 | elif command == 'test_write_custom': 29 | # NOTE: Doesn't require an input file 30 | test_write_custom() 31 | 32 | else: 33 | sys.exit('Unrecognized command: %s' % command) 34 | return 35 | 36 | 37 | def test_read_write_approx(resource_file_filepath): 38 | """ 39 | Tests that the specified fork written by write_resource_fork() is read 40 | by read_resource_fork() as exactly the same data structure. 41 | 42 | Does NOT test that an arbitrary fork read by read_resource_fork() will 43 | be output by write_resource_fork() as exactly the same byte stream. 44 | See test_read_write_exact() for that test. 45 | """ 46 | 47 | with open(resource_file_filepath, 'rb') as input: 48 | original_resource_map = read_resource_fork( 49 | input, 50 | read_everything=True) 51 | 52 | # Must write to an intermediate "normalized" fork 53 | normalized_fork = BytesIO() 54 | write_resource_fork(normalized_fork, original_resource_map) 55 | 56 | normalized_fork.seek(0) 57 | normalized_resource_map = read_resource_fork( 58 | normalized_fork, 59 | read_everything=True) 60 | 61 | output_fork = BytesIO() 62 | write_resource_fork(output_fork, normalized_resource_map) 63 | 64 | expected_output = normalized_fork.getvalue() 65 | actual_output = output_fork.getvalue() 66 | 67 | matches = (actual_output == expected_output) 68 | if matches: 69 | print 'OK' 70 | else: 71 | print ' Expected: ' + repr(expected_output) 72 | print ' Actual: ' + repr(actual_output) 73 | print 74 | 75 | 76 | def test_read_write_exact(resource_file_filepath): 77 | """ 78 | Tests that the specified fork read by read_resource_fork() is 79 | output by write_resource_fork() as exactly the same byte stream. 80 | 81 | NOTE: There exist valid resource fork inputs for which this test fails. 82 | This is because the resource fork format does not strictly 83 | define the precise ordering, arrangement, and spacing of 84 | several elements in the resource fork. 85 | 86 | However, this implementation SHOULD correctly reconstruct any 87 | resource fork generated by ResEdit 2.1.3. 88 | """ 89 | 90 | with open(resource_file_filepath, 'rb') as input: 91 | original_resource_map = read_resource_fork( 92 | input, 93 | read_everything=True) 94 | 95 | output_fork = BytesIO() 96 | write_resource_fork(output_fork, original_resource_map) 97 | 98 | with open(resource_file_filepath, 'rb') as file: 99 | expected_output = file.read() 100 | actual_output = output_fork.getvalue() 101 | 102 | matches = (actual_output == expected_output) 103 | if matches: 104 | print 'OK' 105 | else: 106 | print ' Expected: ' + repr(expected_output) 107 | print ' Actual: ' + repr(actual_output) 108 | print 109 | print ('#' * 32) + ' EXPECTED ' + ('#' * 32) 110 | print_resource_fork(BytesIO(expected_output)) 111 | 112 | print ('#' * 32) + ' ACTUAL ' + ('#' * 32) 113 | print_resource_fork(BytesIO(actual_output)) 114 | 115 | 116 | def test_write_custom(): 117 | """ 118 | Ensure write_resource_fork() does not crash when passed a minimal resource 119 | map structure in the documented format. 120 | """ 121 | output_fork = BytesIO() 122 | write_resource_fork(output_fork, { 123 | 'resource_types': [ 124 | { 125 | 'code': 'alis', 126 | 'resources': [ 127 | { 128 | 'id': 0, 129 | 'name': 'app alias', 130 | 'attributes': 0, 131 | 'data': b'meow' 132 | } 133 | ] 134 | } 135 | ] 136 | }) 137 | 138 | # ------------------------------------------------------------------------------ 139 | 140 | def print_resource_fork(input_resource_fork_stream): 141 | # NOTE: Depends on an undocumented argument 142 | resource_map = read_resource_fork( 143 | input_resource_fork_stream, 144 | read_all_resource_names=True, 145 | _verbose=True) 146 | 147 | # ------------------------------------------------------------------------------ 148 | 149 | if __name__ == '__main__': 150 | main(sys.argv[1:]) 151 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Run automated unit tests. 5 | """ 6 | 7 | # Commands through which tests are run 8 | import alias_file 9 | import alias_record 10 | import catalog_create 11 | import catalog_diff 12 | import macbinary_file 13 | import resource_fork 14 | 15 | # For _test_create_alias_file() 16 | from classicbox.disk.hfs import hfs_copy_in_from_stream 17 | from classicbox.disk.hfs import hfs_exists 18 | from classicbox.disk.hfs import hfs_format_new 19 | from classicbox.disk.hfs import hfs_mkdir 20 | from classicbox.disk.hfs import hfs_mount 21 | from classicbox.macbinary import write_macbinary_to_buffer 22 | import os 23 | import os.path 24 | 25 | # For _test_catalog_create_output() 26 | from classicbox.time import convert_local_to_mac_timestamp 27 | import json 28 | from pprint import pprint 29 | from tempfile import NamedTemporaryFile 30 | import time 31 | 32 | from classicbox.io import StringIO 33 | from classicbox.io import touch_temp 34 | from contextlib import contextmanager 35 | import sys 36 | import traceback 37 | 38 | 39 | def main(args): 40 | # Test each module 41 | # 42 | # Modules should be tested in order of dependencies first. 43 | # So if module A depends on B, B should be tested before A. 44 | # This ensures that the first test failure corresponds most 45 | # closely with what actually needs to be fixed. 46 | 47 | # classicbox.alias.file (and dependencies) 48 | test_classicbox_io() 49 | test_classicbox_alias_record() 50 | test_classicbox_resource_fork() 51 | test_classicbox_macbinary() 52 | test_classicbox_alias_file() 53 | 54 | # catalog_create, catalog_diff 55 | test_catalog_create() 56 | test_catalog_diff() 57 | 58 | # TODO: box_create 59 | # TODO: box_bootstrap 60 | # TODO: box_up 61 | 62 | 63 | def test_classicbox_io(): 64 | # No specific tests for this module. 65 | # Currently io is tested indirectly by all of its (numerous) dependants. 66 | pass 67 | 68 | 69 | def test_classicbox_alias_record(): 70 | # 'AppAlias.rsrc.dat' is an alias record with the properties: 71 | # * The alias's target is not at the root level of the volume. 72 | # Therefore it has a parent directory. 73 | # * The alias matches the output expected by the 74 | # 'test_write_custom_matching' test. 75 | alias_record_filepath = 'test_data/AppAlias.rsrc.dat' 76 | 77 | test_throws_no_exceptions( 78 | 'test_alias_record_read_print', lambda: \ 79 | alias_record.main( 80 | ['info', alias_record_filepath])) 81 | test_ends_output_with_ok( 82 | 'test_alias_record_read_write', lambda: \ 83 | alias_record.main( 84 | ['test_read_write', alias_record_filepath])) 85 | test_ends_output_with_ok( 86 | 'test_alias_record_read_write_no_extras', lambda: \ 87 | alias_record.main( 88 | ['test_read_write_no_extras', alias_record_filepath])) 89 | test_throws_no_exceptions( 90 | 'test_alias_record_write_custom', lambda: \ 91 | alias_record.main( 92 | ['test_write_custom_matching', '-'])) 93 | test_throws_no_exceptions( 94 | 'test_alias_record_write_custom_matching', lambda: \ 95 | alias_record.main( 96 | ['test_write_custom_matching', alias_record_filepath])) 97 | 98 | 99 | def test_classicbox_resource_fork(): 100 | # (1) 'AppAlias.rsrcfork.dat' is a simple resource fork with: 101 | # * A single resource of type 'alis'. 102 | # 103 | # (2) 'MultipleResource.rsrcfork.dat' is a more complex resource fork with: 104 | # * Multiple resources, all of type 'alis' 105 | # * Resources with names that vary in even and odd length. 106 | # (This matters when tested whether padding bytes are inserted.) 107 | # * Resource are arranged in the resource data area in a different 108 | # order than their IDs. 109 | SAMPLES = [ 110 | ('simple', 'test_data/AppAlias.rsrcfork.dat'), 111 | ('complex', 'test_data/MultipleResource.rsrcfork.dat'), 112 | ] 113 | 114 | for (sample_name, resource_fork_filepath) in SAMPLES: 115 | test_throws_no_exceptions( 116 | 'test_resource_fork_read_print_' + sample_name, lambda: \ 117 | resource_fork.main( 118 | ['info', resource_fork_filepath])) 119 | test_ends_output_with_ok( 120 | 'test_resource_fork_read_write_approx_' + sample_name, lambda: \ 121 | resource_fork.main( 122 | ['test_read_write_approx', resource_fork_filepath])) 123 | test_ends_output_with_ok( 124 | 'test_resource_fork_read_write_exact_' + sample_name, lambda: \ 125 | resource_fork.main( 126 | ['test_read_write_exact', resource_fork_filepath])) 127 | 128 | test_throws_no_exceptions( 129 | 'test_resource_fork_write_custom', lambda: \ 130 | resource_fork.main( 131 | ['test_write_custom', '-'])) 132 | 133 | 134 | def test_classicbox_macbinary(): 135 | macbinary_filepath = 'test_data/AppAlias.bin' 136 | 137 | test_throws_no_exceptions( 138 | 'test_macbinary_read_print', lambda: \ 139 | macbinary_file.main( 140 | ['info', macbinary_filepath])) 141 | test_ends_output_with_ok( 142 | 'test_macbinary_read_write', lambda: \ 143 | macbinary_file.main( 144 | ['test_read_write', macbinary_filepath])) 145 | test_throws_no_exceptions( 146 | 'test_macbinary_write_custom', lambda: \ 147 | macbinary_file.main( 148 | ['test_write_custom', '-'])) 149 | 150 | #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 151 | 152 | def test_classicbox_alias_file(): 153 | test_throws_no_exceptions( 154 | 'test_alias_file_create_on_disk_image', lambda: \ 155 | _test_create_alias_file()) 156 | 157 | 158 | def _test_create_alias_file(): 159 | source_disk_image_filepath = touch_temp( 160 | prefix='SourceDisk', suffix='.dsk') 161 | target_disk_image_filepath = touch_temp( 162 | prefix='TargetDisk', suffix='.dsk') 163 | 164 | try: 165 | # Create empty source disk image 166 | hfs_format_new(source_disk_image_filepath, 'Source', 800 * 1024) 167 | 168 | # Create target disk image containing fake app at 'Target:App:app' 169 | hfs_format_new(target_disk_image_filepath, 'Target', 800 * 1024) 170 | hfs_mkdir('Target:App') 171 | hfs_copy_in_from_stream(write_macbinary_to_buffer({ 172 | 'filename': 'app', 173 | 'file_type': 'APPL', 174 | 'file_creator': 'TEST', 175 | 'data_fork': b'' 176 | }), 'Target:App:app') 177 | 178 | # Ensure an alias can be created without any exceptions 179 | alias_file.main(['create', 180 | source_disk_image_filepath, 'Source:app alias', 181 | target_disk_image_filepath, 'Target:App:app']) 182 | 183 | # Ensure the target alias actually exists 184 | hfs_mount(source_disk_image_filepath) 185 | if not hfs_exists('Source:app alias'): 186 | raise AssertionError('Alias not created in the expected location.') 187 | finally: 188 | if os.path.exists(target_disk_image_filepath): 189 | os.remove(target_disk_image_filepath) 190 | if os.path.exists(source_disk_image_filepath): 191 | os.remove(source_disk_image_filepath) 192 | 193 | #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 194 | 195 | def test_catalog_create(): 196 | test_throws_no_exceptions( 197 | 'test_catalog_create_output', lambda: \ 198 | _test_catalog_create_output()) 199 | 200 | 201 | def _test_catalog_create_output(): 202 | disk_image_filepath = touch_temp( 203 | prefix='Catalog', suffix='.dsk') 204 | 205 | try: 206 | now = time.time() 207 | 208 | day = int(time.strftime('%d', time.localtime(now))) 209 | day = str(day) if day >= 10 else (' ' + str(day)) 210 | now_string = time.strftime('%b '+day+' %H:%M', time.localtime(now)) 211 | if not isinstance(now_string, unicode): 212 | now_string = now_string.decode('ascii') 213 | 214 | now_mactimestamp = convert_local_to_mac_timestamp(now) 215 | 216 | created = 1 217 | modified = now_mactimestamp 218 | 219 | # Create disk image with some files on it 220 | hfs_format_new(disk_image_filepath, u'MyDisk', 800 * 1024) 221 | hfs_mkdir(u'MyDisk:CoolApp\u2122') 222 | hfs_copy_in_from_stream(write_macbinary_to_buffer({ 223 | 'filename': u'CoolApp\u2122', 224 | 'file_type': u'APPL', 225 | 'file_creator': u'TEST', 226 | 'data_fork': b'', 227 | 'created': created, 228 | 'modified': modified, 229 | }), u'MyDisk:CoolApp\u2122:CoolApp\u2122') 230 | hfs_copy_in_from_stream(write_macbinary_to_buffer({ 231 | 'filename': u'Readme', 232 | 'file_type': u'ttro', 233 | 'file_creator': u'ttxt', 234 | 'data_fork': b'RTFM!', 235 | 'created': created, 236 | 'modified': modified, 237 | }), u'MyDisk:CoolApp\u2122:Readme') 238 | hfs_copy_in_from_stream(write_macbinary_to_buffer({ 239 | 'filename': u'CoolApp Install Log', 240 | 'file_type': u'TEXT', 241 | 'file_creator': u'ttxt', 242 | 'data_fork': b'I installed CoolApp!', 243 | 'created': created, 244 | 'modified': modified, 245 | }), u'MyDisk:CoolApp\u2122 Install Log') 246 | 247 | catalog_json = capture_stdout(lambda: \ 248 | catalog_create.main([disk_image_filepath])) 249 | catalog = json.loads(catalog_json) 250 | 251 | expected_output = [ 252 | [u'CoolApp\u2122', now_string, [ 253 | [u'CoolApp\u2122', now_string], 254 | [u'Readme', now_string], 255 | ]], 256 | [u'CoolApp\u2122 Install Log', now_string], 257 | ] 258 | actual_output = catalog 259 | assert_equal(expected_output, actual_output, 260 | 'Catalog output did not match expected output.') 261 | finally: 262 | if os.path.exists(disk_image_filepath): 263 | os.remove(disk_image_filepath) 264 | 265 | 266 | def test_catalog_diff(): 267 | test_names = [ 268 | 'test_catalog_diff_add_file', 269 | 'test_catalog_diff_edit_file', 270 | 'test_catalog_diff_delete_file', 271 | 'test_catalog_diff_file_becomes_directory', 272 | 'test_catalog_diff_directory_becomes_file', 273 | # TODO: Make tests that exercise the "ignore tree" functionality as well 274 | ] 275 | 276 | this_module = globals() 277 | for test_name in test_names: 278 | test_throws_no_exceptions( 279 | test_name, lambda: \ 280 | this_module['_' + test_name]()) 281 | 282 | 283 | def _test_catalog_diff_add_file(): 284 | catalog1 = [ 285 | [u'File\u2122', u'Jan 10 10:00'], 286 | ] 287 | catalog2 = [ 288 | [u'File\u2122', u'Jan 10 10:00'], 289 | [u'NewFile\u2122', u'Jan 10 10:00'], 290 | ] 291 | expected_output = [[], [ 292 | u'NewFile\u2122' 293 | ], []] 294 | _ensure_catalog_diff_matches(catalog1, catalog2, expected_output) 295 | 296 | 297 | def _test_catalog_diff_edit_file(): 298 | catalog1 = [ 299 | [u'File\u2122', u'Jan 10 10:00'], 300 | ] 301 | catalog2 = [ 302 | [u'File\u2122', u'Jan 22 22:22'], 303 | ] 304 | expected_output = [[], [], [ 305 | [u'File\u2122', [u'Jan 10 10:00', u'Jan 22 22:22']] 306 | ]] 307 | _ensure_catalog_diff_matches(catalog1, catalog2, expected_output) 308 | 309 | 310 | def _test_catalog_diff_delete_file(): 311 | catalog1 = [ 312 | [u'File\u2122', u'Jan 10 10:00'], 313 | ] 314 | catalog2 = [ 315 | ] 316 | expected_output = [[ 317 | u'File\u2122' 318 | ], [], []] 319 | _ensure_catalog_diff_matches(catalog1, catalog2, expected_output) 320 | 321 | 322 | def _test_catalog_diff_file_becomes_directory(): 323 | catalog1 = [ 324 | [u'Hybrid\u2122', u'Jan 10 10:00'], 325 | ] 326 | catalog2 = [ 327 | [u'Hybrid\u2122', u'Jan 10 10:10', [ 328 | [u'File', u'Jan 22 22:22'], 329 | ]], 330 | ] 331 | expected_output = [[u'Hybrid\u2122'], [u'Hybrid\u2122'], []] 332 | _ensure_catalog_diff_matches(catalog1, catalog2, expected_output) 333 | 334 | 335 | def _test_catalog_diff_directory_becomes_file(): 336 | catalog1 = [ 337 | [u'Hybrid\u2122', u'Jan 10 10:10', [ 338 | [u'File', u'Jan 22 22:22'], 339 | ]], 340 | ] 341 | catalog2 = [ 342 | [u'Hybrid\u2122', u'Jan 10 10:00'], 343 | ] 344 | expected_output = [[u'Hybrid\u2122'], [u'Hybrid\u2122'], []] 345 | _ensure_catalog_diff_matches(catalog1, catalog2, expected_output) 346 | 347 | 348 | def _ensure_catalog_diff_matches(catalog1, catalog2, expected_output): 349 | with NamedTemporaryFile(mode='wt', delete=True) as catalog1_file: 350 | json.dump(catalog1, catalog1_file) 351 | catalog1_file.flush() 352 | 353 | with NamedTemporaryFile(mode='wt', delete=True) as catalog2_file: 354 | json.dump(catalog2, catalog2_file) 355 | catalog2_file.flush() 356 | 357 | the_catalog_diff_json = capture_stdout(lambda: \ 358 | catalog_diff.main([catalog1_file.name, catalog2_file.name]))[:-1] 359 | the_catalog_diff = json.loads(the_catalog_diff_json) 360 | 361 | actual_output = the_catalog_diff 362 | assert_equal(expected_output, actual_output, 363 | 'Catalog diff output did not match expected output.') 364 | 365 | # ------------------------------------------------------------------------------ 366 | # Test Infrastructure 367 | 368 | def test_throws_no_exceptions(test_name, block): 369 | if _try_run_test_block(test_name, block) is not None: 370 | _print_test_success(test_name) 371 | 372 | 373 | def test_ends_output_with_ok(test_name, block): 374 | test_stdout = _try_run_test_block(test_name, block) 375 | if test_stdout is not None: 376 | if test_stdout.endswith('OK\n'): 377 | _print_test_success(test_name) 378 | else: 379 | _print_test_error(test_name, test_stdout) 380 | 381 | 382 | def _try_run_test_block(test_name, block): 383 | """ 384 | Tries to run the specified block. 385 | 386 | If successful, returns the stdout from running the block. 387 | If failure, prints the failure and returns None. 388 | """ 389 | test_stdout_buffer = StringIO() 390 | try: 391 | with _replace_stdout(test_stdout_buffer): 392 | block() 393 | return test_stdout_buffer.getvalue() 394 | except: 395 | (type, value, tb) = sys.exc_info() 396 | _print_test_error(test_name, test_stdout_buffer.getvalue(), tb) 397 | return None 398 | 399 | 400 | @contextmanager 401 | def _replace_stdout(new_stdout): 402 | original_stdout = sys.stdout 403 | sys.stdout = new_stdout 404 | try: 405 | yield 406 | finally: 407 | sys.stdout = original_stdout 408 | 409 | 410 | def _print_test_error(test_name, test_stdout, tb=None): 411 | print bold_red('ERR %s') % test_name 412 | print 413 | if tb is not None: 414 | traceback.print_exc() 415 | print 416 | if test_stdout != '': 417 | print test_stdout, 418 | print 419 | 420 | 421 | def _print_test_success(test_name): 422 | print bold_green('OK %s') % test_name 423 | 424 | 425 | def capture_stdout(block): 426 | test_stdout_buffer = StringIO() 427 | with _replace_stdout(test_stdout_buffer): 428 | block() 429 | return test_stdout_buffer.getvalue() 430 | 431 | 432 | def assert_equal(expected_output, actual_output, message='Assertion failed.'): 433 | # (Use repr() to ensure that unicodeness of strings is preserved.) 434 | if repr(expected_output) != repr(actual_output): 435 | print 'Expected:' 436 | pprint(expected_output) 437 | print 438 | print 'Actual:' 439 | pprint(actual_output) 440 | 441 | raise AssertionError(message) 442 | 443 | # ------------------------------------------------------------------------------ 444 | # Terminal Colors 445 | 446 | # ANSI color codes 447 | # Obtained from: http://www.bri1.com/files/06-2008/pretty.py 448 | TERM_FG_BLUE = '\033[0;34m' 449 | TERM_FG_BOLD_BLUE = '\033[1;34m' 450 | TERM_FG_RED = '\033[0;31m' 451 | TERM_FG_BOLD_RED = '\033[1;31m' 452 | TERM_FG_GREEN = '\033[0;32m' 453 | TERM_FG_BOLD_GREEN = '\033[1;32m' 454 | TERM_FG_CYAN = '\033[0;36m' 455 | TERM_FG_BOLD_CYAN = '\033[1;36m' 456 | TERM_FG_YELLOW = '\033[0;33m' 457 | TERM_FG_BOLD_YELLOW = '\033[1;33m' 458 | TERM_RESET = '\033[0m' 459 | 460 | 461 | def bold_red(str_value): 462 | return TERM_FG_BOLD_RED + str_value + TERM_RESET 463 | 464 | 465 | def bold_green(str_value): 466 | return TERM_FG_BOLD_GREEN + str_value + TERM_RESET 467 | 468 | # ------------------------------------------------------------------------------ 469 | 470 | if __name__ == '__main__': 471 | main(sys.argv[1:]) 472 | -------------------------------------------------------------------------------- /test_data/AppAlias.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidfstr/ClassicBox/adf44436cef41849c376b7f772f71df1d5ff269e/test_data/AppAlias.bin -------------------------------------------------------------------------------- /test_data/AppAlias.rsrc.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidfstr/ClassicBox/adf44436cef41849c376b7f772f71df1d5ff269e/test_data/AppAlias.rsrc.dat -------------------------------------------------------------------------------- /test_data/AppAlias.rsrcfork.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidfstr/ClassicBox/adf44436cef41849c376b7f772f71df1d5ff269e/test_data/AppAlias.rsrcfork.dat -------------------------------------------------------------------------------- /test_data/MultipleResource.rsrcfile.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidfstr/ClassicBox/adf44436cef41849c376b7f772f71df1d5ff269e/test_data/MultipleResource.rsrcfile.bin -------------------------------------------------------------------------------- /test_data/MultipleResource.rsrcfork.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidfstr/ClassicBox/adf44436cef41849c376b7f772f71df1d5ff269e/test_data/MultipleResource.rsrcfork.dat -------------------------------------------------------------------------------- /test_data/Unicode(tm).bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidfstr/ClassicBox/adf44436cef41849c376b7f772f71df1d5ff269e/test_data/Unicode(tm).bin --------------------------------------------------------------------------------