pax_global_header00006660000000000000000000000064145721655330014525gustar00rootroot0000000000000052 comment=74efb8831a6c484584a0e760abc4d566baf1104f nobodd-0.4/000077500000000000000000000000001457216553300126355ustar00rootroot00000000000000nobodd-0.4/.github/000077500000000000000000000000001457216553300141755ustar00rootroot00000000000000nobodd-0.4/.github/workflows/000077500000000000000000000000001457216553300162325ustar00rootroot00000000000000nobodd-0.4/.github/workflows/test.yml000066400000000000000000000022031457216553300177310ustar00rootroot00000000000000name: nobodd-test-suite on: push: branches: - main pull_request: branches: - main jobs: test: strategy: fail-fast: false matrix: include: - os: ubuntu-20.04 python: "3.7" experimental: false - os: ubuntu-20.04 python: "3.8" experimental: false - os: ubuntu-20.04 python: "3.9" experimental: false - os: ubuntu-22.04 python: "3.10" experimental: false - os: ubuntu-22.04 python: "3.11" experimental: false - os: ubuntu-22.04 python: "3.12" experimental: false runs-on: ${{ matrix.os }} continue-on-error: ${{ matrix.experimental }} steps: - name: Install Python ${{ matrix.python }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - name: Checkout nobodd uses: actions/checkout@v4 - name: Install dependencies run: | make develop - name: Run tests run: | make test nobodd-0.4/.gitignore000066400000000000000000000003711457216553300146260ustar00rootroot00000000000000# Python stuff *.py[cdo] # Vim stuff *.vim *.swp # Translations *.mo # Miscellaneous tags # Packages *.egg *.egg-info *.pyc *.whl dist build man # Unit test / coverage reports coverage .cache .coverage .coverage.* .tox .pytest_cache .env .eggs nobodd-0.4/.readthedocs.yaml000066400000000000000000000002541457216553300160650ustar00rootroot00000000000000version: 2 formats: all python: install: - method: pip path: . extra_requirements: - doc build: os: ubuntu-22.04 tools: python: "3.10" nobodd-0.4/LICENSE.txt000066400000000000000000001045571457216553300144740ustar00rootroot00000000000000SPDX-License-Identifier: GPL-3.0 GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . nobodd-0.4/Makefile000066400000000000000000000075111457216553300143010ustar00rootroot00000000000000# vim: set noet sw=4 ts=4 fileencoding=utf-8: # External utilities PYTHON=python3 PIP=pip PYTEST=pytest TWINE=twine PYFLAGS= MSGINIT=msginit MSGMERGE=msgmerge MSGFMT=msgfmt XGETTEXT=xgettext DEST_DIR=/ # Calculate the base names of the distribution, the location of all source, # documentation, packaging, icon, and executable script files NAME:=$(shell $(PYTHON) $(PYFLAGS) setup.py --name) WHEEL_NAME:=$(subst -,_,$(NAME)) VER:=$(shell $(PYTHON) $(PYFLAGS) setup.py --version) PY_SOURCES:=$(shell \ $(PYTHON) $(PYFLAGS) setup.py egg_info >/dev/null 2>&1 && \ cat $(WHEEL_NAME).egg-info/SOURCES.txt | grep -v "\.egg-info" | grep -v "\.mo$$") DOC_SOURCES:=docs/conf.py \ $(wildcard docs/*.png) \ $(wildcard docs/*.svg) \ $(wildcard docs/*.dot) \ $(wildcard docs/*.mscgen) \ $(wildcard docs/*.gpi) \ $(wildcard docs/*.rst) \ $(wildcard docs/*.pdf) SUBDIRS:= # Calculate the name of all outputs DIST_WHEEL=dist/$(WHEEL_NAME)-$(VER)-py3-none-any.whl DIST_TAR=dist/$(NAME)-$(VER).tar.gz DIST_ZIP=dist/$(NAME)-$(VER).zip POT_FILE=po/$(NAME).pot PO_FILES:=$(wildcard po/*.po) MO_FILES:=$(patsubst po/%.po,po/mo/%/LC_MESSAGES/$(NAME).mo,$(PO_FILES)) MAN_PAGES=man/nobodd-tftpd.1 man/nobodd-prep.1 # Default target all: @echo "make install - Install on local system" @echo "make develop - Install symlinks for development" @echo "make pot - Update translation template and sources" @echo "make mo - Generate translation files" @echo "make test - Run tests" @echo "make doc - Generate HTML and PDF documentation" @echo "make source - Create source package" @echo "make wheel - Generate a PyPI wheel package" @echo "make zip - Generate a source zip package" @echo "make tar - Generate a source tar package" @echo "make dist - Generate all packages" @echo "make clean - Get rid of all generated files" @echo "make release - Create and tag a new release" @echo "make upload - Upload the new release to repositories" install: $(SUBDIRS) $(PYTHON) $(PYFLAGS) setup.py install --root $(DEST_DIR) doc: $(DOC_SOURCES) $(MAKE) -C docs clean $(MAKE) -C docs html $(MAKE) -C docs epub $(MAKE) -C docs latexpdf $(MAKE) $(MAN_PAGES) preview: $(MAKE) -C docs preview source: $(DIST_TAR) $(DIST_ZIP) wheel: $(DIST_WHEEL) zip: $(DIST_ZIP) tar: $(DIST_TAR) dist: $(DIST_WHEEL) $(DIST_TAR) $(DIST_ZIP) pot: $(POT_FILE) $(PO_FILES) mo: $(MO_FILES) develop: @# These have to be done separately to avoid a cockup... $(PIP) install -U setuptools $(PIP) install -U pip $(PIP) install -U twine $(PIP) install -U tox $(PIP) install -e .[doc,test] test: $(PYTEST) clean: rm -fr dist/ build/ man/ .pytest_cache/ .mypy_cache/ $(WHEEL_NAME).egg-info/ tags .coverage for dir in $(SUBDIRS); do \ $(MAKE) -C $$dir clean; \ done find $(CURDIR) -name "*.pyc" -delete find $(CURDIR) -name "__pycache__" -delete tags: $(PY_SOURCES) ctags -R --exclude="build/*" --exclude="docs/*" --languages="Python" lint: $(PY_SOURCES) pylint $(WHEEL_NAME) $(SUBDIRS): $(MAKE) -C $@ $(MAN_PAGES): $(DOC_SOURCES) $(MAKE) -C docs man mkdir -p man/ cp build/man/*.[0-9] man/ $(POT_FILE): $(PY_SOURCES) $(XGETTEXT) -o $@ $(filter %.py,$^) $(filter %.ui,$^) po/%.po: $(POT_FILE) $(MSGMERGE) -U $@ $< po/mo/%/LC_MESSAGES/$(NAME).mo: po/%.po mkdir -p $(dir $@) $(MSGFMT) $< -o $@ $(DIST_TAR): $(PY_SOURCES) $(SUBDIRS) $(PYTHON) $(PYFLAGS) setup.py sdist --formats gztar $(DIST_ZIP): $(PY_SOURCES) $(SUBDIRS) $(PYTHON) $(PYFLAGS) setup.py sdist --formats zip $(DIST_WHEEL): $(PY_SOURCES) $(SUBDIRS) $(PYTHON) $(PYFLAGS) setup.py bdist_wheel release: $(MAKE) clean test -z "$(shell git status --porcelain)" git tag -s v$(VER) -m "Release $(VER)" git push origin v$(VER) upload: $(DIST_TAR) $(DIST_WHEEL) $(TWINE) check $(DIST_TAR) $(DIST_WHEEL) $(TWINE) upload $(DIST_TAR) $(DIST_WHEEL) .PHONY: all install develop test doc source wheel zip tar dist clean tags release upload $(SUBDIRS) nobodd-0.4/README.rst000066400000000000000000000034751457216553300143350ustar00rootroot00000000000000====== nobodd ====== nobodd is a confusingly named, but simple TFTP server intended for net-booting Raspberry Pis directly from OS images without having to loop-back mount those images. Even customization of an image for booting on a particular board is handled without loop devices or mounts (making it possible to operate completely unprivileged), via a read/write FAT implementation within the ``nobodd-prep`` tool. Usage ===== If you have an appropriately customized OS image already placed in a file (``ubuntu.img``), and the serial number of the Pi in question (``1234ABCD``) then serving it as simple as: .. code-block:: console $ sudo nobodd-tftpd --board 1234ABCD,ubuntu.img This defaults to reading the first partition from the file, and pretends (to TFTP clients) that the contents of the first partition appears under the ``1234ABCD/`` directory. Hence a TFTP request for ``1234ABCD/cmdline.txt`` will serve the ``cmdline.txt`` file from the first partition contained in ``ubuntu.img``. The service either needs to run from root (because the default TFTP port is the privileged port 69), or can be run as a **systemd** or **inetd** socket-activated service, in which case the service manager will provide the initial socket and the service can run without any special privileges. The mapping of Pi serial numbers to OS image files can also be placed in a configuration file under ``/etc/nobodd/conf.d``. A tool, ``nobodd-prep``, is provided to both customize images for boot and generate basic configuration files for ``nobodd-tftpd`` and ``nbd-server``. Useful Links ============ * `Source code`_ on GitHub * `Issues`_ on GitHub * `Documentation`_ on ReadTheDocs .. _Source code: https://github.com/waveform80/nobodd .. _Issues: https://github.com/waveform80/nobodd/issues .. _Documentation: https://nobodd.readthedocs.io/ nobodd-0.4/docs/000077500000000000000000000000001457216553300135655ustar00rootroot00000000000000nobodd-0.4/docs/Makefile000066400000000000000000000126431457216553300152330ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = ../build PY_SOURCES := $(wildcard ../nobodd/*.py) DOT_DIAGRAMS := $(wildcard images/*.dot images/*/*.dot) MSC_DIAGRAMS := $(wildcard images/*.mscgen images/*/*.mscgen) GPI_DIAGRAMS := $(wildcard images/*.gpi images/*/*.gpi) SVG_IMAGES := $(wildcard images/*.svg images/*/*.svg) $(DOT_DIAGRAMS:%.dot=%.svg) $(MSC_DIAGRAMS:%.mscgen=%.svg) PNG_IMAGES := $(wildcard images/*.png images/*/*.png) $(GPI_DIAGRAMS:%.gpi=%.png) $(SVG_IMAGES:%.svg=%.png) PDF_IMAGES := $(SVG_IMAGES:%.svg=%.pdf) $(GPI_DIAGRAMS:%.gpi=%.pdf) $(DOT_DIAGRAMS:%.dot=%.pdf) $(MSC_DIAGRAMS:%.mscgen=%.pdf) INKSCAPE_VER := $(shell inkscape --version | sed -ne '/^Inkscape/ s/^Inkscape \([0-9]\+\)\..*$$/\1/p') # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " preview to start a web-server that watches for file changes" @echo " and re-builds the docs when required" @echo " json to make JSON files" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SVG_IMAGES) $(PNG_IMAGES) $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(PDF_IMAGES) $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(PDF_IMAGES) $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." preview: ../scripts/previewer $(BUILDDIR)/html %.svg: %.mscgen mscgen -T svg -o $@ $< %.svg: %.dot dot -T svg -o $@ $< %.png: %.gpi gnuplot -e "set term pngcairo transparent size 400,400" $< > $@ ifeq ($(INKSCAPE_VER),0) %.png: %.svg inkscape --export-dpi 150 -e $@ $< %.pdf: %.svg inkscape -A $@ $< else %.png: %.svg inkscape --export-dpi 150 --export-type png -o $@ $< %.pdf: %.svg inkscape --export-type pdf -o $@ $< endif %.pdf: %.gpi gnuplot -e "set term pdfcairo size 5cm,5cm" $< > $@ %.pdf: %.mscgen mscgen -T eps -o - $< | ps2pdf -dEPSCrop - $@ .PHONY: help clean html preview json epub latex latexpdf text man changes linkcheck doctest gettext nobodd-0.4/docs/api.rst000066400000000000000000000044071457216553300150750ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2023-2024 Dave Jones .. Copyright (c) 2023-2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ============= API Reference ============= In additional to being a service, nobodd can also be used as an API from Python to access disk images, determining their partitioning style, enumerating the available partitions, and manipulating FAT file-systems (either from within a disk image, or just standalone). It can also be used as the basis of a generic TFTP service. The following sections list the modules by their topic. Disk Images =========== The :class:`nobodd.disk.DiskImage` class is the primary entry-point for dealing with disk images. .. toctree:: :maxdepth: 1 api_disk api_gpt api_mbr FAT Filesystem ============== The :class:`nobodd.fs.FatFileSystem` class is the primary entry-point for handling FAT file-systems. .. toctree:: :maxdepth: 1 api_fs api_fat api_path TFTP Service ============ The :class:`nobodd.tftpd.TFTPBaseServer` and :class:`nobodd.tftpd.TFTPBaseHandler` are two classes which may be customized to produce a TFTP server. Two example classes are included, :class:`nobodd.tftpd.SimpleTFTPServer` and :class:`nobodd.tftpd.SimpleTFTPHandler` which serve files directly from a specified path. .. toctree:: :maxdepth: 1 api_tftpd api_tftp api_netascii Command line applications ========================= The :mod:`nobodd.server` module contains the primary classes, :class:`~nobodd.server.BootServer` and :class:`~nobodd.server.BootHandler` which define a TFTP server (:program:`nobodd-tftpd`) that reads files from FAT file-systems contained in OS images. The :mod:`nobodd.prep` module contains the implementation of the :program:`nobodd-prep` command, which customizes images prior to first net boot. The :mod:`nobodd.config` module provides configuration parsing facilities to these applications. .. toctree:: :maxdepth: 1 api_server api_prep api_config api_systemd Miscellaneous ============= The :mod:`nobodd.tools` module contains a variety of utility functions that either cross boundaries in the system or are entirely generic. .. toctree:: :maxdepth: 1 api_tools nobodd-0.4/docs/api_config.rst000066400000000000000000000013461457216553300164210ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2023-2024 Dave Jones .. Copyright (c) 2023-2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ================ nobodd.config ================ .. module:: nobodd.config This module contains the classes and functions used to configure the main nobodd application. These are not likely to be of much use to other applications, but are documented here just in case. ConfigArgumentParser ==================== .. autoclass:: ConfigArgumentParser Board ===== .. autoclass:: Board Conversion Functions ==================== .. autofunction:: port .. autofunction:: boolean .. autofunction:: size .. autofunction:: duration nobodd-0.4/docs/api_disk.rst000066400000000000000000000060051457216553300161030ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2023-2024 Dave Jones .. Copyright (c) 2023-2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ============== nobodd.disk ============== .. module:: nobodd.disk The :mod:`nobodd.disk` module contains the :class:`DiskImage` class which is the primary entry point for handling disk images. Constructed with a filename (or file-like object which provides a valid :meth:`~io.IOBase.fileno` method), the class will attempt to determine if `MBR`_ or `GPT`_ style partitioning is in use. The :attr:`DiskImage.partitions` attribute can then be queried to enumerate, or access the data of, individual partitions: .. code-block:: pycon >>> from nobodd.disk import DiskImage >>> img = DiskImage('gpt_disk.img') >>> img style='gpt' signature=UUID('733b49a8-6918-4e44-8d3d-47ed9b481335')> >>> img.style 'gpt' >>> len(img.partitions) 4 >>> img.partitions DiskPartitionsGPT({ 1: , 2: , 5: , 6: , }) Note that partitions are numbered from 1 and that, especially in the case of `MBR`_, partition numbers may not be contiguous: primary partitions are numbered 1 through 4, but logical partitions may only exist in one primary partition, and are numbered from 5. Hence it is entirely valid to have partitions 1, 5, and 6: .. code-block:: pycon >>> from nobodd.disk import DiskImage >>> img = DiskImage('test-ebr.img') >>> img.style 'mbr' >>> len(img.partitions) 3 >>> list(img.partitions.keys()) [1, 5, 6] >>> img.partitions[1] >>> img.partitions[5] >>> img.partitions[6] `GPT`_ partition tables may also have non-contiguous numbering, although this is less common in practice. The :attr:`DiskPartition.data` attribute can be used to access the content of the partition as a buffer object (see :class:`memoryview`). DiskImage ========= .. autoclass:: DiskImage DiskPartition ============= .. autoclass:: DiskPartition Internal Classes ================ You should not need to use these classes directly; they will be instantiated automatically when querying the :attr:`DiskImage.partitions` attribute according to the detected table format. .. autoclass:: DiskPartitionsGPT .. autoclass:: DiskPartitionsMBR .. _MBR: https://en.wikipedia.org/wiki/Master_boot_record .. _GPT: https://en.wikipedia.org/wiki/GUID_Partition_Table nobodd-0.4/docs/api_fat.rst000066400000000000000000000017011457216553300157210ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2023-2024 Dave Jones .. Copyright (c) 2023-2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ============= nobodd.fat ============= .. module:: nobodd.fat Defines the data structures used by the `FAT`_ file system. You should never need these directly; use the :class:`nobodd.fs.FatFileSystem` class instead. .. _FAT: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system Data Structures =============== .. autoclass:: BIOSParameterBlock .. autoclass:: ExtendedBIOSParameterBlock .. autoclass:: FAT32BIOSParameterBlock .. autoclass:: FAT32InfoSector .. autoclass:: DirectoryEntry .. autoclass:: LongFilenameEntry Functions ========= These utility functions help decode certain fields within the aforementioned structure, or check that tentative contents are valid. .. autofunction:: lfn_checksum .. autofunction:: lfn_valid nobodd-0.4/docs/api_fs.rst000066400000000000000000000051711457216553300155640ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2023-2024 Dave Jones .. Copyright (c) 2023-2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 =============== nobodd.fs =============== .. module:: nobodd.fs The :mod:`nobodd.fs` module contains the :class:`FatFileSystem` class which is the primary entry point for reading FAT file-systems. Constructed with a buffer object representing a memory mapping of the file-system, the class will determine whether the format is FAT12, FAT16, or FAT32. The :attr:`~FatFileSystem.root` attribute provides a Path-like object representing the root directory of the file-system. .. code-block:: pycon >>> from nobodd.disk import DiskImage >>> from nobodd.fs import FatFileSystem >>> img = DiskImage('test-gpt.img') >>> fs = FatFileSystem(img.partitions[1].data) >>> fs.fat_type 'fat16' >>> fs.root FatPath(, '/') .. warning:: At the time of writing, the implementation is strictly *not thread-safe*. Attempting to write to the file-system from multiple threads (whether in separate instances or not) is likely to result in corruption. Attempting to write to the file-system from one thread, while reading from another will result in undefined behaviour including incorrect reads. .. warning:: The implementation will *not* handle certain "obscure" extensions to FAT, such as sub-directory style roots on FAT-12/16. It will attempt to warn about these and abort if they are found. FatFileSystem ============= .. autoclass:: FatFileSystem FatFile ======= .. autoclass:: FatFile Exceptions and Warnings ======================= .. autoexception:: FatWarning .. autoexception:: DirtyFileSystem .. autoexception:: DamagedFileSystem .. autoexception:: OrphanedLongFilename .. autoexception:: BadLongFilename Internal Classes and Functions ============================== You should never need to interact with these classes directly; use :class:`FatFileSystem` instead. These classes exist to enumerate and manipulate the FAT, and different types of root directory under FAT-12, FAT-16, and FAT-32, and sub-directories (which are common across FAT types). .. autoclass:: FatTable .. autoclass:: Fat12Table .. autoclass:: Fat16Table .. autoclass:: Fat32Table .. autoclass:: FatClusters .. autoclass:: FatDirectory :members: :private-members: .. autoclass:: FatRoot .. autoclass:: FatSubDirectory .. autoclass:: Fat12Root .. autoclass:: Fat16Root .. autoclass:: Fat32Root .. autofunction:: fat_type .. autofunction:: fat_type_from_count nobodd-0.4/docs/api_gpt.rst000066400000000000000000000011221457216553300157360ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2023-2024 Dave Jones .. Copyright (c) 2023-2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ============= nobodd.gpt ============= .. module:: nobodd.gpt Defines the data structures used by `GUID Partition Tables`_. You should never need these directly; use the :class:`nobodd.disk.DiskImage` class instead. Data Structures =============== .. autoclass:: GPTHeader .. autoclass:: GPTPartition .. _GUID Partition Tables: https://en.wikipedia.org/wiki/GUID_Partition_Table nobodd-0.4/docs/api_mbr.rst000066400000000000000000000011451457216553300157310ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2023-2024 Dave Jones .. Copyright (c) 2023-2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ============ nobodd.mbr ============ .. module:: nobodd.mbr Defines the data structures used by the `Master Boot Record`_ (MBR) partitioning style. You should never need these directly; use the :class:`nobodd.disk.DiskImage` class instead. Data Structures =============== .. autoclass:: MBRHeader .. autoclass:: MBRPartition .. _Master Boot Record: https://en.wikipedia.org/wiki/Master_boot_record nobodd-0.4/docs/api_netascii.rst000066400000000000000000000030301457216553300167430ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2024 Dave Jones .. Copyright (c) 2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ================= nobodd.netascii ================= .. module:: nobodd.netascii Registers a Python codec to translate strings to the TFTP netascii encoding (defined in the TELNET `RFC 764`_, under the printer and keyboard section). This is intended to translate line-endings of text files transparently between platforms, but only handles ASCII characters. .. note:: TFTPd implementations could *probably* ignore this as a historical artefact at this point and assume all transfers will be done with "octet" (straight byte for byte) encoding, as seems to be common practice. However, netascii isn't terribly hard to support, hence the inclusion of this module. The functions in this module should never need to be accessed directly. Simply use the 'netascii' encoding as you would any other Python byte-encoding: .. code-block:: pycon >>> import os >>> os.linesep '\n' >>> import nobodd.netascii >>> 'foo\nbar\r'.encode('netascii') b'foo\r\nbar\r\0' >>> b'foo\r\nbar\r\0\r\r'.decode('netascii', errors='replace') 'foo\nbar\r??' .. _RFC 764: https://datatracker.ietf.org/doc/html/rfc764 Internal Functions ================== .. autofunction:: encode .. autofunction:: decode .. autoclass:: IncrementalEncoder .. autoclass:: IncrementalDecoder .. autoclass:: StreamWriter .. autoclass:: StreamReader nobodd-0.4/docs/api_path.rst000066400000000000000000000026021457216553300161040ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2023-2024 Dave Jones .. Copyright (c) 2023-2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ============ nobodd.path ============ .. module:: nobodd.path Defines the :class:`FatPath` class, a Path-like class for interacting with directories and sub-directories in a :class:`~nobodd.fs.FatFileSystem` instance. You should never need to construct this class directly; instead it should be derived from the :attr:`~nobodd.fs.FatFileSystem.root` attribute which is itself a :class:`FatPath` instance. .. code-block:: pycon >>> from nobodd.disk import DiskImage >>> from nobodd.fs import FatFileSystem >>> img = DiskImage('test.img') >>> fs = FatFileSystem(img.partitions[1].data) >>> for p in fs.root.iterdir(): ... print(repr(p)) ... FatPath(, '/foo') FatPath(, '/bar.txt') FatPath(, '/setup.cfg') FatPath(, '/baz') FatPath(, '/adir') FatPath(, '/BDIR') FatPath ======= .. autoclass:: FatPath Internal Functions ================== .. autofunction:: get_cluster nobodd-0.4/docs/api_prep.rst000066400000000000000000000011701457216553300161150ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2024 Dave Jones .. Copyright (c) 2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ============= nobodd.prep ============= .. module:: nobodd.prep This module contains the implementation (and entry point) of the :program:`nobodd-prep` application. Application Functions ===================== .. autofunction:: main .. autofunction:: get_parser .. autofunction:: prepare_image .. autofunction:: remove_items .. autofunction:: copy_items .. autofunction:: rewrite_cmdline .. autofunction:: detect_partitions nobodd-0.4/docs/api_server.rst000066400000000000000000000014331457216553300164570ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2023-2024 Dave Jones .. Copyright (c) 2023-2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ============== nobodd.server ============== .. module:: nobodd.server This module contains the server and handler classes which make up the main :program:`nobodd-tftpd` application, as well as the entry point for the application itself. Handler Classes =============== .. autoclass:: BootHandler Server Classes ============== .. autoclass:: BootServer Application Functions ===================== .. autofunction:: main .. autofunction:: request_loop .. autofunction:: get_parser Exceptions ========== .. autoexception:: ReloadRequest .. autoexception:: TerminateRequest nobodd-0.4/docs/api_systemd.rst000066400000000000000000000016031457216553300166400ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2024 Dave Jones .. Copyright (c) 2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 =============== nobodd.systemd =============== .. module:: nobodd.systemd This module contains a singleton class intended for communication with the :manpage:`systemd(1)` service manager. It includes facilities for running a service as ``Type=notify`` where the service can actively communicate to systemd that it is ready to handle requests, is reloading its configuration, is shutting down, or that it needs more time to handle certain operations. It also includes methods to ping the systemd watchdog, and to retrieve file-descriptors stored on behalf of the service (or provided as part of socket-activation). Systemd Class ============= .. autoclass:: Systemd .. autofunction:: get_systemd nobodd-0.4/docs/api_tftp.rst000066400000000000000000000032441457216553300161300ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2023-2024 Dave Jones .. Copyright (c) 2023-2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ============== nobodd.tftp ============== .. module:: nobodd.tftp Defines the data structures used by the `Trivial File Transfer Protocol`_ (TFTP). You should never need these directly; use the classes in :mod:`nobodd.tftpd` to construct a TFTP server instead. Enumerations ============ .. autoclass:: OpCode .. autoclass:: Error Constants ========= .. data:: TFTP_BLKSIZE .. data:: TFTP_MIN_BLKSIZE .. data:: TFTP_DEF_BLKSIZE .. data:: TFTP_MAX_BLKSIZE Constants defining the ``blksize`` TFTP option; the name of the option, its minimum, default, and maximum values. .. data:: TFTP_TIMEOUT .. data:: TFTP_UTIMEOUT .. data:: TFTP_MIN_TIMEOUT_NS .. data:: TFTP_DEF_TIMEOUT_NS .. data:: TFTP_MAX_TIMEOUT_NS Constants defining the ``timeout`` and ``utimeout`` TFTP options; the name of the options, the minimum, default, and maximum values, in units of nano-seconds. .. data:: TFTP_BINARY .. data:: TFTP_NETASCII .. data:: TFTP_MODES Constants defining the available transfer modes. .. data:: TFTP_TSIZE Constant defining the name of the ``tsize`` TFTP option. .. data:: TFTP_OPTIONS Constant defining the TFTP options available for negotiation. Packets ======= .. autoclass:: Packet .. autoclass:: RRQPacket .. autoclass:: WRQPacket .. autoclass:: DATAPacket .. autoclass:: ACKPacket .. autoclass:: ERRORPacket .. autoclass:: OACKPacket .. _Trivial File Transfer Protocol: https://en.wikipedia.org/wiki/Trivial_File_Transfer_Protocol nobodd-0.4/docs/api_tftpd.rst000066400000000000000000000054601457216553300162760ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2023-2024 Dave Jones .. Copyright (c) 2023-2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ============= nobodd.tftpd ============= .. module:: nobodd.tftpd Defines several classes for the purposes of constructing TFTP servers. The most useful are :class:`TFTPBaseHandler` and :class:`TFTPBaseServer` which are abstract base classes for the construction of a TFTP server with an arbitrary source of files (these are used by nobodd's :mod:`~nobodd.main` module). In addition, :class:`TFTPSimplerHandler` and :class:`TFTPSimplerServer` are provided as a trivial example implementation of a straight-forward TFTP file server. For example, to start a TFTP server which will serve files from the current directory on (unprivileged) port 1069: .. code-block:: pycon >>> from nobodd.tftpd import SimpleTFTPServer >>> server = SimpleTFTPServer(('0.0.0.0', 1069), '.') >>> server.serve_forever() Handler Classes =============== .. autoclass:: TFTPBaseHandler .. autoclass:: SimpleTFTPHandler Server Classes ============== .. autoclass:: TFTPBaseServer .. autoclass:: SimpleTFTPServer Command Line Use ================ Just as :mod:`http.server` can be invoked from the command line as a standalone server using the interpreter's :option:`-m` option, so :mod:`nobodd.tftpd` can too. To serve the current directory as a TFTP server:: python -m nobodd.tftpd The server listens to port 6969 by default. This is not the registered port 69 of TFTP, but as that port requires root privileges by default on UNIX platforms, a safer default was selected (the security provenance of this code is largely unknown, and certainly untested at higher privilege levels). The default port can be overridden by passed the desired port number as an argument:: python -m nobodd.tftpd 1069 By default, the server binds to all interfaces. The option ``-b/--bind`` specifies an address to which it should bind instead. Both IPv4 and IPv6 addresses are supported. For example, the following command causes the server to bind to localhost only:: python -m nobodd.tftpd --bind 127.0.0.1 By default, the server uses the current directory. The option ``-d/--directory`` specifies a directory from which it should serve files instead. For example:: python -m nobodd.tftpd --directory /tmp/ Internal Classes and Exceptions =============================== The following classes and exceptions are entirely for internal use and should never be needed (directly) by applications. .. autoclass:: TFTPClientState .. autoclass:: TFTPHandler .. autoclass:: TFTPSubHandler .. autoclass:: TFTPSubServer .. autoclass:: TFTPSubServers .. autoexception:: TransferDone .. autoexception:: AlreadyAcknowledged .. autoexception:: BadOptions nobodd-0.4/docs/api_tools.rst000066400000000000000000000016051457216553300163120ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2023-2024 Dave Jones .. Copyright (c) 2023-2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 =============== nobodd.tools =============== .. module:: nobodd.tools This module houses a series of miscellaneous functions which did not fit particularly well anywhere else and are needed across a variety of modules. They should never be needed by developers using nobodd as an application or a library, but are documented in case they are useful. .. autofunction:: labels .. autofunction:: formats .. autofunction:: get_best_family .. autofunction:: format_address .. autofunction:: pairwise .. autofunction:: decode_timestamp .. autofunction:: encode_timestamp .. autofunction:: any_match .. autofunction:: exclude .. autoclass:: BufferedTranscoder .. autoclass:: FrozenDict nobodd-0.4/docs/changelog.rst000066400000000000000000000016051457216553300162500ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2023-2024 Dave Jones .. Copyright (c) 2023-2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ================== Changelog ================== .. currentmodule:: nobodd Release 0.4 (2024-03-07) ======================== * Use absolute paths for output of nbd-server and tftpd server configurations * Include missing ``#cloud-config`` header in the tutorial Release 0.3 (2024-03-06) ======================== * Fix configuration reload when inheriting the TFTP socket from a service manager (`#8`_) .. _#8: https://github.com/waveform80/nobodd/issues/8 Prototype 0.2 (unreleased) ========================== * Add inheritance of the TFTP socket (`#3`_) .. _#3: https://github.com/waveform80/nobodd/issues/3 Prototype 0.1 (unreleased) ========================== * Initial tag nobodd-0.4/docs/cli.rst000066400000000000000000000006001457216553300150620ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2024 Dave Jones .. Copyright (c) 2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 =============== CLI Reference =============== The following chapters document the command line utilities included in nobodd: .. toctree:: :maxdepth: 1 cli_prep cli_server nobodd-0.4/docs/cli_prep.rst000066400000000000000000000251151457216553300161200ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2024 Dave Jones .. Copyright (c) 2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 .. include:: subst.rst ============= nobodd-prep ============= Customizes an OS image to prepare it for netbooting via TFTP. Specifically, this expands the image to a specified size (the assumption being the image is a copy of a minimally sized template image), then updates the kernel command line on the boot partition to point to an NBD server. Synopsis ======== .. code-block:: text usage: nobodd-prep [-h] [--version] [-s SIZE] [--nbd-host HOST] [--nbd-name NAME] [--cmdline NAME] [--boot-partition NUM] [--root-partition NUM] [-C PATH] [-R PATH] image Options ======= .. program:: nobodd-prep .. option:: image The target image to customize .. option:: -h, --help show the help message and exit .. option:: --version show program's version number and exit .. option:: -s SIZE, --size SIZE The size to expand the image to; default: 16GB .. option:: --nbd-host HOST The hostname of the nbd server to connect to for the root device; defaults to the local machine's FQDN .. option:: --nbd-name NAME The name of the nbd share to use as the root device; defaults to the stem of the *image* name .. option:: --cmdline NAME The name of the file containing the kernel command line on the boot partition; default: :file:`cmdline.txt` .. option:: --boot-partition NUM Which partition is the boot partition within the image; default is the first FAT partition (identified by partition type) found in the image .. option:: --root-partition NUM Which partition is the root partition within the image default is the first non-FAT partition (identified by partition type) found in the image .. option:: -C PATH, --copy PATH Copy the specified file or directory into the boot partition. This may be given multiple times to specify multiple items to copy .. option:: -R PATH, --remove PATH Delete the specified file or directory within the boot partition. This may be given multiple times to specify multiple items to delete .. option:: --serial HEX Defines the serial number of the Raspberry Pi that will be served this image. When this option is given, a board configuration compatible with :program:`nobodd-tftpd` may be output with :option:`--tftpd-conf` .. option:: --tftpd-conf FILE If specified, write a board configuration compatible with :program:`nobodd-tftpd` to the specified file; requires :option:`--serial` to be given. If "-" is given, output is written to stdout. .. option:: --nbd-conf FILE If specified, write a share configuration compatible with :manpage:`nbd-server(1)` to the specified file. If "-" is given, output is written to stdout. Usage ===== Typically :program:`nobodd-prep` is called with a base OS image. For example, if :file:`ubuntu-24.04-server.img.xz` is the Ubuntu 24.04 Server for Raspberry image, we would decompress it (we can only work on uncompressed images), use the tool to expand it to a reasonable disk size (e.g. 16GB like an SD card), and customize the kernel command line to look for the rootfs on our NBD server: .. code-block:: console $ ls -l ubuntu-24.04-server.img.xz -rw-rw-r-- 1 dave dave 1189280360 Oct 12 00:44 ubuntu-24.04-server.img.xz $ unxz ubuntu-24.04-server.img.xz $ ls -l ubuntu-24.04-server.img -rw-rw-r-- 1 dave dave 3727687680 Oct 12 00:44 ubuntu-24.04-server.img $ fdisk -l ubuntu-24.04-server.img Disk ubuntu-24.04-server.img: 3.47 GiB, 3727687680 bytes, 7280640 sectors Units: sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disklabel type: dos Disk identifier: 0x1634ec00 Device Boot Start End Sectors Size Id Type ubuntu-24.04-server.img1 * 2048 1050623 1048576 512M c W95 FAT32 (LBA) ubuntu-24.04-server.img2 1050624 7247259 6196636 3G 83 Linux $ mkdir mnt $ sudo mount -o loop,offset=$((2048*512)),sizelimit=$((1048576*512)) ubuntu-24.04-server.img mnt/ [sudo] Password: $ cat mnt/cmdline.txt console=serial0,115200 multipath=off dwc_otg.lpm_enable=0 console=tty1 root=LABEL=writable rootfstype=ext4 rootwait fixrtc $ sudo umount mnt/ $ nobodd-prep --size 16GB ubuntu-24.04-server.img $ ls -l ubuntu-24.04-server.img --nbd-host myserver --nbd-name ubuntu -rw-rw-r-- 1 dave dave 17179869184 Feb 27 13:11 ubuntu-24.04-server.img $ sudo mount -o loop,offset=$((2048*512)),sizelimit=$((1048576*512)) ubuntu-24.04-server.img mnt/ [sudo] Password: $ cat mnt/cmdline.txt ip=dhcp nbdroot=myserver/ubuntu root=/dev/nbd0p2 console=serial0,115200 multipath=off dwc_otg.lpm_enable=0 console=tty1 rootfstype=ext4 rootwait fixrtc $ sudo umount mnt/ Note, the only reason we are listing partitions and mounting the boot partition above is to demonstrate the change to the kernel command line in :file:`cmdline.txt`. Ordinarily, usage of :program:`nobodd-prep` is as simple as: .. code-block:: console $ unxz ubuntu-24.04-server.img.xz $ nobodd-prep --size 16GB ubuntu-24.04-server.img Typically :program:`nobodd-prep` will detect the boot and root partitions of the image automatically. The boot partition is defined as the first partition that has a FAT `partition type`_ (on `MBR-partitioned`_ images), or `Basic Data`_ or `EFI System`_ partition type (on `GPT-partitioned`_ images), which contains a valid FAT file-system (the script tries to determine the FAT-type of the contained file-system, and only counts those partitions on which it can determine a valid FAT-type). The root partition is the exact opposite; it is defined as the first partition that *doesn't* have a FAT `partition type`_ (on `MBR-partitioned`_ images), or `Basic Data`_ or `EFI System`_ partition type (on `GPT-partitioned`_ images), which contains something *other than* a valid FAT file-system (again, the script tries to determine the FAT-type of the contained file-system, and only counts those partitions on which it *cannot* determine a valid FAT-type). There may be images for which these simplistic definitions do not work. For example, images derived from a `NOOBS/PINN`_ install may well have several boot partitions for different installed OS'. In this case the boot or root partition (or both) may be specified manually on the command line: .. code-block:: console $ fdisk -l pinn-test.img Disk pinn-test.img: 29.72 GiB, 31914983424 bytes, 62333952 sectors Units: sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disklabel type: dos Disk identifier: 0x2e779525 Device Boot Start End Sectors Size Id Type pinn-test.img1 8192 137215 129024 63M e W95 FAT16 (LBA) pinn-test.img2 137216 62333951 62196736 29.7G 5 Extended pinn-test.img5 139264 204797 65534 32M 83 Linux pinn-test.img6 204800 464895 260096 127M c W95 FAT32 (LBA) pinn-test.img7 466944 4661247 4194304 2G 83 Linux pinn-test.img8 4669440 5193727 524288 256M 83 Linux pinn-test.img9 5201920 34480125 29278206 14G 83 Linux pinn-test.img10 34480128 34998271 518144 253M c W95 FAT32 (LBA) pinn-test.img11 35004416 62333951 27329536 13G 83 Linux $ nobodd-prep --boot-partition 10 --root-partition 11 pinn-test.img :program:`nobodd-prep` also includes several facilities for customizing the boot partition beyond re-writing the kernel's :file:`cmdline.txt`. Specifically, the :option:`--remove` and :option:`--copy` options. The :option:`--remove` option can be given multiple times, and tells :program:`nobodd-prep` to remove the specified files or directories from the boot partition. The :option:`--copy` option can also be given multiple times, and tells :program:`nobodd-prep` to copy the specified files or directories into the root of the boot partition. In both cases, directories that are specified are removed or copied recursively. The :option:`--copy` option is particularly useful for overwriting the `cloud-init`_ seeds on the boot partition of Ubuntu Server images, in case you want to provide an initial network configuration, user setup, or list of packages to install on first boot: .. code-block:: console $ cat user-data chpasswd: expire: true users: - name: ubuntu password: raspberry type: text ssh_pwauth: false package_update: true package_upgrade: true packages: - avahi-daemon $ nobodd-prep --copy user-data ubuntu-24.04-server.img There is no need to :option:`--remove` files you wish to :option:`--copy`; the latter option will overwrite where necessary. The exception to this is copying directories; if you are copying a directory that already exists in the boot partition, the new content will be merged with the existing content. Files under the directory that share a name will be overwritten, files that do not will be left in place. If you wish to replace the directory wholesale, specify it with :option:`--remove` as well. The ordering of options on the command line does *not* affect the order of operations in the utility. The order of operations in :program:`nobodd-prep` is strictly as follows: 1. Detect partitions, if necessary 2. Re-size the image, if necessary 3. Remove all items on the boot partition specified by :option:`--remove` 4. Copy all items specified by :option:`--copy` into the boot partition 5. Re-write the ``root=`` option in the :file:`cmdline.txt` file This ordering is deliberate, firstly to ensure directories can be replaced (as noted above), and secondly to ensure :file:`cmdline.txt` can be customized by :option:`--copy` prior to the customization performed by the utility. See Also ======== .. only:: not man :doc:`cli_server`, :manpage:`nbd-server(1)` .. only:: man :manpage:`nobodd-tftpd(1)`, :manpage:`nbd-server(1)` Bugs ==== |bug-link| .. _partition type: https://en.wikipedia.org/wiki/Partition_type .. _MBR-partitioned: https://en.wikipedia.org/wiki/Master_boot_record .. _GPT-partitioned: https://en.wikipedia.org/wiki/GUID_Partition_Table .. _Basic Data: https://en.wikipedia.org/wiki/Microsoft_basic_data_partition .. _EFI System: https://en.wikipedia.org/wiki/EFI_system_partition .. _NOOBS/PINN: https://github.com/procount/pinn .. _cloud-init: https://cloudinit.readthedocs.io/en/latest/ nobodd-0.4/docs/cli_server.rst000066400000000000000000000137751457216553300164710ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2024 Dave Jones .. Copyright (c) 2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 .. include:: subst.rst ============= nobodd-tftpd ============= A read-only TFTP server capable of reading FAT boot partitions from within image files or devices. Intended to be paired with a block-device service (e.g. NBD) for netbooting Raspberry Pis. Synopsis ======== .. code-block:: text usage: nobodd-tftpd [-h] [--version] [--listen ADDR] [--port PORT] [--board SERIAL,FILENAME[,PART[,IP]]] Options ======= .. program:: nobodd-tftpd .. option:: -h, --help show the help message and exit .. option:: --version show program's version number and exit .. option:: --board SERIAL,FILENAME[,PART[,IP]] can be specified multiple times to define boards which are to be served boot images over TFTP; if PART is omitted the default is 1; if IP is omitted the IP address will not be checked .. option:: --listen ADDR the address on which to listen for connections (default: "::" for all addresses) .. option:: --port PORT the port on which to listen for connections (default: "tftp" which is port 69) Configuration ============= :program:`nobodd-tftpd` can be configured via the command line, or from several configuration files. These are structured as INI-style files with bracketed ``[sections]`` containing ``key=value`` lines, and optionally #-prefixed comments. The configuration files which are read, and the order they are consulted is as follows: 1. :file:`/etc/nobodd/nobodd.conf` 2. :file:`/usr/local/etc/nobodd/nobodd.conf` 3. :file:`$XDG_CONFIG_HOME/nobodd/nobodd.conf` (where ``$XDG_CONFIG_HOME`` defaults to :file:`~/.config` if unset) Later files override settings from files earlier in this order. The configuration file may contain a ``[tftp]`` section which may contain the following values: listen This is equivalent to the :option:`--listen` parameter and specifies the address(es) on which the server will listen for incoming TFTP connections. port This is equivalent to the :option:`--port` parameter and specifies the UDP port on which the server will listen for incoming TFTP connections. Please note that only the *initial* TFTP packet will arrive on this port. Each "connection" is allocated its own `ephemeral port`_ on the server and all subsequent packets will use this ephemeral port. includedir If this is specified, it provides the name of a directory which will be scanned for files matching the pattern :file:`*.conf`. Any files found matching will be read as additional configuration files, in sorted filename order. For example: .. code-block:: ini [tftp] listen = 192.168.0.0/16 port = tftp includedir = /etc/nobodd/conf.d For each image the TFTP server is expected to serve to a Raspberry Pi, a ``[board:SERIAL]`` section should be defined. Here, "SERIAL" should be replaced by the serial number of the Raspberry Pi. The serial number can be found in the output of ``cat /proc/cpuinfo`` at runtime. For example: .. code-block:: console $ grep ^Serial /proc/cpuinfo Serial : 100000001234abcd If the serial number starts with 10000000 (as in the example above), exclude the initial one and all leading zeros. So the above Pi has a serial number of 1234abcd (in hexadecimal). Within the section the following values are valid: image Specifies the full path to the operating system image to serve to the specified Pi, presumably prepared with :program:`nobodd-prep`. partition Optionally specifies the number of the boot partition. If this is not specified it defaults to 1. ip Optionally limits serving any files from this image unless the IP address of the client matches. If this is not specified, any IP address may retrieve files from this share. For example: .. code-block:: ini [board:1234abcd] image = /srv/images/ubuntu-24.04-server.img partition = 1 ip = 192.168.0.5 In practice, what this means is that requests from a client with the IP address "192.168.0.5", for files under the path "1234abcd/", will be served from the FAT file-system on partition 1 of the image stored at :file:`/srv/images/ubuntu-24.04-server.img`. Such definitions can be produced by :program:`nobodd-prep` when it is provided with the :option:`nobodd-prep --serial` option. Boards may also be defined on the command-line with the :option:`--board` option. These definitions will augment (and override, where the serial number is identical) those definitions provided by the configuration files. Systemd/Inetd Usage =================== The server may inherit its listening socket from a managing process. In the case of :manpage:`inetd(8)` where the listening socket is traditionally passed as stdin (fd 0), pass "stdin" as the value of :option:`--listen` (or the ``listen`` option within the ``[tftp]`` section of the configuration file). In the case of :manpage:`systemd(1)`, where the listening socket(s) are passed via the environment, specify "systemd" as the value of :option:`--listen` (or the ``listen`` option within the ``[tftp]`` section of the configuration file) and the service will expect to find a single socket passed in :envvar:`LISTEN_FDS`. This will happen implicitly if the service is declared as socket-activated. However, the service must *not* use ``Accept=yes`` as the TFTP protocol is connection-less. The example units provided in the source code demonstrate using socket-activation with the server. In both cases, the service manager sets the port that the service will listen on, so the :option:`--port` option (and the ``port`` option in the ``[tftp]`` section of the configuration file) is silently ignored. See Also ======== .. only:: not man :doc:`cli_prep`, :manpage:`nbd-server(1)` .. only:: man :manpage:`nobodd-prep(1)`, :manpage:`nbd-server(1)` Bugs ==== |bug-link| .. _ephemeral port: https://en.wikipedia.org/wiki/Ephemeral_port nobodd-0.4/docs/conf.py000066400000000000000000000063651457216553300150760ustar00rootroot00000000000000#!/usr/bin/env python3 # vim: set fileencoding=utf-8: # # nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2023-2024 Dave Jones # Copyright (c) 2023-2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import sys import os import configparser from pathlib import Path from datetime import datetime from setuptools.config import read_configuration on_rtd = os.environ.get('READTHEDOCS', '').lower() == 'true' config = configparser.ConfigParser() config.read([Path(__file__).parent / '..' / 'setup.cfg']) info = config['metadata'] # -- Project information ----------------------------------------------------- project = info['name'] author = info['author'] now = datetime.now() copyright = ( f'2023-{now:%Y} {author}' if now.year > 2023 else f'2023 {author}') release = info['version'] version = release # -- General configuration ------------------------------------------------ needs_sphinx = '4.0' extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.viewcode', 'sphinx.ext.intersphinx', 'sphinx.ext.imgmath', ] if on_rtd: tags.add('rtd') root_doc = 'index' templates_path = ['_templates'] exclude_patterns = ['_build'] highlight_language = 'python3' pygments_style = 'sphinx' # -- Autodoc configuration ------------------------------------------------ autodoc_member_order = 'groupwise' autodoc_default_options = { 'members': True, } autodoc_mock_imports = [] # -- Intersphinx configuration -------------------------------------------- intersphinx_mapping = { 'python': ('https://docs.python.org/3.12', None), } # -- Options for HTML output ---------------------------------------------- html_theme = 'sphinx_rtd_theme' html_title = f'{project} {version} Documentation' html_static_path = ['_static'] manpages_url = 'https://manpages.ubuntu.com/manpages/noble/en/man{section}/{page}.{section}.html' # -- Options for LaTeX output --------------------------------------------- latex_engine = 'xelatex' latex_elements = { 'papersize': 'a4paper', 'pointsize': '10pt', 'preamble': r'\def\thempfootnote{\arabic{mpfootnote}}', # workaround sphinx issue #2530 } latex_documents = [ ( 'index', # source start file project + '.tex', # target filename html_title, # title author, # author 'manual', # documentclass True, # documents ref'd from toctree only ), ] latex_show_pagerefs = True latex_show_urls = 'footnote' # -- Options for epub output ---------------------------------------------- epub_basename = project epub_author = author epub_identifier = f'https://{info["name"]}.readthedocs.io/' epub_show_urls = 'no' # -- Options for manual page output --------------------------------------- man_pages = [ ( 'cli_server', 'nobodd-tftpd', 'nobodd-tftpd - serve boot partition files over TFTP', [info['author']], 1, ), ( 'cli_prep', 'nobodd-prep', 'nobodd-prep - prepare an OS image for NBD netboot', [info['author']], 1, ), ] man_show_urls = True # -- Options for linkcheck builder ---------------------------------------- linkcheck_retries = 3 linkcheck_workers = 20 linkcheck_anchors = True nobodd-0.4/docs/development.rst000066400000000000000000000101251457216553300166400ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2023-2024 Dave Jones .. Copyright (c) 2023-2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 =========== Development =========== .. currentmodule:: nobodd The main GitHub repository for the project can be found at: https://github.com/waveform80/nobodd .. _dev_install: Development installation ======================== If you wish to develop nobodd, obtain the source by cloning the GitHub repository and then use the "develop" target of the Makefile which will install the package as a link to the cloned repository allowing in-place development. The following example demonstrates this method within a virtual Python environment: .. code-block:: console $ sudo apt install build-essential git virtualenvwrapper After installing ``virtualenvwrapper`` you'll need to restart your shell before commands like :command:`mkvirtualenv` will operate correctly. Once you've restarted your shell, continue: .. code-block:: console $ cd $ mkvirtualenv nobodd $ workon nobodd (nobodd) $ git clone https://github.com/waveform80/nobodd.git (nobodd) $ cd nobodd (nobodd) $ make develop To pull the latest changes from git into your clone and update your installation: .. code-block:: console $ workon nobodd (nobodd) $ cd ~/nobodd (nobodd) $ git pull (nobodd) $ make develop To remove your installation, destroy the sandbox and the clone: .. code-block:: console (nobodd) $ deactivate $ rmvirtualenv nobodd $ rm -rf ~/nobodd Building the docs ================= If you wish to build the docs, you'll need a few more dependencies. Inkscape is used for conversion of SVGs to other formats, Graphviz is used for rendering certain charts, and TeX Live is required for building PDF output. The following command should install all required dependencies: .. code-block:: console $ sudo apt install texlive-latex-recommended texlive-latex-extra \ texlive-fonts-recommended texlive-xetex graphviz inkscape \ python3-sphinx python3-sphinx-rtd-theme latexmk xindy Once these are installed, you can use the "doc" target to build the documentation in all supported formats (HTML, ePub, and PDF): .. code-block:: console $ workon nobodd (nobodd) $ cd ~/nobodd (nobodd) $ make doc However, the easiest way to develop the documentation is with the "preview" target which will build the HTML version of the docs, and start a web-server to preview the output. The web-server will then watch for source changes (in both the documentation source, and the application's source) and rebuild the HTML automatically as required: .. code-block:: console $ workon nobodd (nobodd) $ cd ~/nobodd (nobodd) $ make preview The HTML output is written to :file:`build/html` while the PDF output goes to :file:`build/latex`. Test suite ========== If you wish to run the nobodd test suite, follow the instructions in :ref:`dev_install` above and then make the "test" target within the sandbox: .. code-block:: console $ workon nobodd (nobodd) $ cd ~/nobodd (nobodd) $ make test The test suite is also setup for usage with the :command:`tox` utility, in which case it will attempt to execute the test suite with all supported versions of Python. If you are developing under Ubuntu you may wish to look into the `Dead Snakes PPA`_ in order to install old/new versions of Python; the tox setup *should* work with the version of tox shipped with Ubuntu Focal, but more features (like parallel test execution) are available with later versions. For example, to execute the test suite under tox, skipping interpreter versions which are not installed: .. code-block:: console $ tox To execute the test suite under all installed interpreter versions in parallel, using as many parallel tasks as there are CPUs, then displaying a combined report of coverage from all environments: .. code-block:: console $ tox -p auto $ coverage combine .coverage.py* $ coverage report .. _Dead Snakes PPA: https://launchpad.net/~deadsnakes/%2Barchive/ubuntu/ppa nobodd-0.4/docs/explain-pi-netboot.rst000066400000000000000000000214211457216553300200350ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2024 Dave Jones .. Copyright (c) 2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ================= Netboot on the Pi ================= In order to understand nobodd, it is useful to understand the netboot procedure on the Raspberry Pi in general. At a high level, it consists of three phases which we'll cover in the following sections. DHCP ==== The first phase is quite simply a fairly typical `DHCP` phase, in which the bootloader attempts to obtain an IPv4 address from the local :abbr:`DHCP (Dynamic Host Configuration Protocol)` server. On the Pi 4 (and later models), the address obtained can be seen on the boot diagnostics screen. Near the top the line starting with "net:" indicates the current network status. Initially this will read:: net: down ip: 0.0.0.0 sn: 0.0.0.0 gw: 0.0.0.0 Shortly before attempting netboot, this line should change to something like the following:: net: up ip: 192.168.1.137 sn: 255.255.255.0 gw: 192.168.1.1 This indicates that the Pi has obtained the address "192.168.1.137" on a class D subnet ("192.168.1.0/24" in `CIDR`_ form), and knows the local network gateway is at "192.168.1.1". The bootloader also inspects certain DHCP options to locate the `TFTP`_ server for the next phase. Specifically: * DHCP option 66 (TFTP server) can specify the address directly * If DHCP option 43 (vendor options) specifies PXE string "Raspberry Pi Boot" [#pxe_id]_ then option 54 (server identifier) will be used * On the Pi 4 (and later), the EEPROM can override both of these with the `TFTP_IP`_ option With the network configured, and the TFTP server address obtained, we move onto the TFTP phase... TFTP ==== .. TODO Updated bootcode.bin on earlier models? Test on the 2+3 .. note:: Most of the notes under this section are specific, in some way, to the netboot sequence on the Pi 4. While older and newer models may broadly follow the same sequence, there will be differences. The bootloader's `TFTP`_ client first attempts to locate the :file:`start4.elf` file. By default, it looks for this in a directory named after the Pi's serial number. On the Pi 4 and later models, the EEPROM configuration can override this behaviour with the `TFTP_PREFIX`_ option, but we will only cover the default behaviour here. All subsequent files will be requested from within this serial number directory prefix [#no-prefix]_. Hence, when we say the bootloader requests :file:`SERIAL/vmlinuz`, we mean it requests the file :file:`vmlinuz` from within the virtual directory named after the Pi's serial number [#long-serial]_. The attempt to retrieve :file:`start4.elf` is immediately aborted when it is located, presumably because the intent is to determine the existence of the prefix directory, rather than the file itself. Next the bootloader attempts to read :file:`SERIAL/config.txt`, which will configure the rest of the boot sequence. Once :file:`SERIAL/config.txt` has been retrieved, the bootloader parses it to discover the name of the tertiary bootloader to load [#pi5-eeprom]_, and requests :file:`SERIAL/start.elf` or :file:`SERIAL/start4.elf` (depending on the model) and the corresponding fix-up file (:file:`SERIAL/fixup.dat` or :file:`SERIAL/fixup4.dat` respectively). The bootloader now executes the tertiary "start.elf" bootloader which requests :file:`SERIAL/config.txt` again. This is re-parsed [#sections]_ and the name of the base device-tree, kernel, kernel command line, (optional) initramfs, and any (optional) device-tree overlays are determined. These are then requested over TFTP, placed in RAM, and finally the bootloader hands over control to the kernel. TFTP Extensions --------------- A brief aside on the subject of :abbr:`TFTP (Trivial File Transfer Protocol)` extensions (as defined in :rfc:`2347`). The basic TFTP protocol is extremely simple (as the acronym would suggest) and also rather inefficient, being limited to 512-byte blocks, in-order, synchronously (each block must be acknowledged before another can be sent), with no retry mechanism. Various extensions have been proposed to the protocol over the years, including those in :rfc:`2347`, :rfc:`2348`, :rfc:`2349`, and :rfc:`7440`. The Pi bootloader implements *some* of these extensions. Specifically, it uses the "blocksize" extension (:rfc:`2348`) to negotiate a larger size of block to transfer, and the "tsize" extension (:rfc:`2349`) to attempt to determine the size of a transfer prior to it beginning. However, its use of "tsize" is slightly unusual in that, when it finds the server supports it, it frequently starts a transfer with "tsize=0" (requesting the size of the file), but when the server responds with, for example, "tsize=1234" in the OACK packet (indicating the file to be transferred is 1234 bytes large), the bootloader then terminates the transfer. In the case of the initial request for :file:`start4.elf` (detailed above), this is understandable as a test for the existence of a directory, rather than an actual attempt to retrieve a file. However, in later requests the bootloader terminates the transfer after the initial packet, *then immediately restarts it*. My best guess is that it allocates the RAM for the transfer after the termination, then restarts it (though why it does this is a bit of a mystery as it could allocate the space and continue the transfer, since the OACK packet doesn't contain any of the file data itself). Sadly, the "windowsize" extension (:rfc:`7440`) is not yet implemented which means the Pi's netboot, up to the kernel, is quite slow compared to other methods. Kernel ====== The kernel is now running with the configured command line, and (optionally) the address of an initial ramdisk (initramfs) as the root file-system. The initramfs is expected to contain the relevant kernel modules, and client binaries to talk to whatever network server will provide the root file-system. Traditionally on the Raspberry Pi, this has meant `NFS`_. However, it may also be `NBD`_ (as served by :manpage:`nbd-server(1)`) or `iSCSI`_ (as served by :manpage:`iscsid(8)`). Typically, the ``init`` process loaded from the kernel's initramfs will dissect the kernel's command line to determine the location of the root file-system, and mount it using the appropriate utilities. In the case of :manpage:`nbd-server(1)` the following items in the kernel command line are crucial: * ``ip=dhcp`` tells the kernel that it should request an IP address via DHCP (the Pi's bootloader cannot pass network state to the kernel, so this must be re-done) * ``nbdroot=HOST/SHARE`` tells the kernel that it should open "SHARE" on the NBD server at HOST. This will form the block device ``/dev/nbd0`` * ``root=/dev/nbd0p2`` tells the kernel that the root file-system is on the second partition of the block device .. _DHCP: https://en.wikipedia.org/wiki/Dynamic_Host_Configuration_Protocol .. _CIDR: https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing .. _TFTP: https://en.wikipedia.org/wiki/Trivial_File_Transfer_Protocol .. _TFTP_IP: https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#TFTP_IP .. _TFTP_PREFIX: https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#TFTP_IP .. _NFS: https://en.wikipedia.org/wiki/Network_File_System .. _NBD: https://en.wikipedia.org/wiki/Network_block_device .. _iSCSI: https://en.wikipedia.org/wiki/ISCSI .. [#pxe_id] In early versions of the Raspberry Pi bootloader, the string needed to include three trailing spaces, i.e. ``"Raspberry Pi Boot "``. Later versions of the bootloader perform a sub-string match. .. [#no-prefix] If :file:`start4.elf` is not found in the serial-number directory, the bootloader will attempt to lovate :file:`start4.elf` with no directory prefix. If this succeeds, all subsequent requests will have no serial-number directory prefix. .. [#long-serial] Some Pi serial numbers begin "10000000". This prefix is ignored for the purposes of constructing the serial-number directory prefix. For example, if the serial number is "10000000abcd1234", the :file:`config.txt` file would be requested as :file:`abcd1234/config.txt`. .. [#pi5-eeprom] This does not happen on the Pi 5, which loads the tertiary bootloader from its (larger) EEPROM. On all prior models, the tertiary bootloader (start*.elf) loads from the boot medium, and the specific file loaded may be customized by :file:`config.txt`. .. This does not happen *by default* on the Pi 5? Need to investigate further .. [#sections] The tertiary bootloader operates on all ``[sections]`` in the :file:`config.txt`. The secondary bootloader (:file:`bootcode.bin`) only operates on some of these and doesn't comprehend the full syntax that the tertiary bootloader does (for instance, the secondary bootloader won't handle includes). nobodd-0.4/docs/explanations.rst000066400000000000000000000006421457216553300170260ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2024 Dave Jones .. Copyright (c) 2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 =============== Explanations =============== The following chapter(s) contain explanations that may aid understanding of Raspberry Pi's netboot process in general. .. toctree:: :maxdepth: 1 explain-pi-netboot nobodd-0.4/docs/howto-firewall.rst000066400000000000000000000050041457216553300172610ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2024 Dave Jones .. Copyright (c) 2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 =================================== How to firewall your netboot server =================================== If you wish to add a netfilter (or iptables) firewall to your server running nobodd and nbd-server, there are a few things to be aware of. The `NBD`_ protocol is quite trivial to firewall; the protocol uses TCP and listens on a single port: 10809. Hence, adding a rule that allows "NEW" inbound TCP connections on port 10809, and a rule to permit traffic on "ESTABLISHED" connections is generally sufficient (where "NEW" and "ESTABLISHED" have their typical meanings in netfilter's connection state tracking). The `TFTP`_ protocol is, theoretically at least, a little harder. The TFTP protocol uses UDP (i.e. it's connectionless) and though it starts on the `privileged port`_ 69, this is only the case for the initial in-bound packet. All subsequent packets in a transfer take place on an ephemeral port on both the client *and the server* [#tid]_ . Hence, a typical transfer looks like this: .. image:: images/tftp-basic.* Thankfully, because the server sends the initial response from its ephemeral port, and the client replies to that ephemeral port, it will also count as "ESTABLISHED" traffic in netfilter's parlance. Hence, all that's required to successfully firewall the TFTP side is to permit "NEW" inbound packets on port 69, and to permit "ESTABLISHED" UDP packets. Putting this altogether, a typical :manpage:`iptables(8)` sequence might look like this: .. code-block:: console $ sudo -i [sudo] Password: # iptables -A INPUT -p tcp -m state --state ESTABLISHED -j ACCEPT # iptables -A INPUT -p tcp -m state --state NEW --dport 10809 -j ACCEPT # iptables -A INPUT -p udp -m state --state ESTABLISHED -j ACCEPT # iptables -A INPUT -p udp -m state --state NEW --dport 69 -j ACCEPT .. _TFTP: https://en.wikipedia.org/wiki/Trivial_File_Transfer_Protocol .. _NBD: https://en.wikipedia.org/wiki/Network_block_device .. _privileged port: https://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers#Well-known_ports .. [#tid] transfers are uniquely identified by the tuple of the client's ephemeral port, and the server's ephemeral port; this ensures a client may have multiple simultaneous transfers even in the case of a degenerate client that initiates multiple simultaneous transfers from a single port nobodd-0.4/docs/howto-jammy.rst000066400000000000000000000077541457216553300166070ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2024 Dave Jones .. Copyright (c) 2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 .. Once ubuntu-image works reliably, this should be re-written to just .. "generate your custom Ubuntu image, manually seeding nbd-client and .. modules-extra with this handy yaml!" =========================== How to netboot Ubuntu 22.04 =========================== The Ubuntu 22.04 (jammy) images are not compatible with NBD boot out of the box as they lack the ``nbd-client`` package in their seed. However, you can modify the image to make it compatible. On the Pi ========= Fire up `rpi-imager`_ and flash Ubuntu 22.04.4 server onto an SD card, then boot that SD card on your Pi (the model does not matter provided it can boot the image). .. warning:: Do *not* be tempted to upgrade packages at this point. Specifically, the kernel package must *not* be upgraded yet. Install the ``linux-modules-extra-raspi`` package for the currently running kernel version, and the ``nbd-client`` package. .. code-block:: console $ sudo apt install linux-modules-extra-$(uname -r) nbd-client On Ubuntu versions prior to 24.04, the ``nbd`` kernel module was moved out of the default ``linux-modules-raspi`` package for efficiency. We specifically need the version matching the running kernel version because installing this package will regenerate the initramfs (``initrd.img``). We'll be copying that regenerated file into the image we're going to netboot and it *must* match the kernel version in that image. This is why it was important not to upgrade any packages after the first boot. We also need to install the NBD client package to add the ``nbd-client`` executable to the initramfs, along with some scripts to call it if the kernel command line specifies an NBD device as root: We copy the regenerated ``initrd.img`` to the server, and shut down the Pi. Adjust the ``ubuntu@server`` reference below to fit your user on your server. .. code-block:: console $ scp -q /boot/firmware/initrd.img ubuntu@server: $ sudo poweroff On the Server ============= Download the same OS image to your server, verify its content, unpack it, and rename it to something more reasonable. .. code-block:: console $ wget http://cdimage.ubuntu.com/releases/22.04.4/release/ubuntu-22.04.4-preinstalled-server-arm64+raspi.img.xz ... $ wget http://cdimage.ubuntu.com/releases/22.04.4/release/SHA256SUMS ... $ sha256sum --check --ignore-missing SHA256SUMS ubuntu-22.04.4-preinstalled-server-arm64+raspi.img.xz: OK $ rm SHA256SUMS $ mv ubuntu-22.04.4-preinstalled-server-arm64+raspi.img jammy.img Next we need to create a cloud-init configuration which will perform the same steps we performed earlier on the first boot of our fresh image, namely to install ``nbd-client`` and ``linux-modules-extra-raspi``, alongside the usual user configuration. .. code-block:: console $ cat << EOF > user-data #cloud-config chpasswd: expire: true users: - name: ubuntu password: ubuntu type: text ssh_pwauth: false package_update: true packages: - nbd-client - linux-modules-extra-raspi EOF See the `cloud-init documentation`_, a `this series of blog posts `_ for more ideas on what can be done with the :file:`user-data` file. Preparing the Image =================== When preparing our image with :program:`nobodd-prep` we must remember to copy in our ``user-data`` and ``initrd.img`` files, overwriting the ones on the boot partition. .. code-block:: console $ nobodd-prep --size 16GB --copy initrd.img --copy user-data jammy.img At this point you should have a variant of the Ubuntu 22.04 image that is capable of being netbooted over NBD. .. _rpi-imager: https://www.raspberrypi.com/software/ .. _cloud-init documentation: https://cloudinit.readthedocs.io/ .. _waldorf-cloud-init: https://waldorf.waveform.org.uk/tag/cloud-init.html nobodd-0.4/docs/howtos.rst000066400000000000000000000006711457216553300156460ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2024 Dave Jones .. Copyright (c) 2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ============== How To Guides ============== The following guides cover specific, but commonly encountered, circumstances in operating a Raspberry Pi netboot server using NBD. .. toctree:: :maxdepth: 1 howto-jammy howto-firewall nobodd-0.4/docs/images/000077500000000000000000000000001457216553300150325ustar00rootroot00000000000000nobodd-0.4/docs/images/tftp-basic.mscgen000066400000000000000000000013221457216553300202620ustar00rootroot00000000000000msc { hscale = "0.8", arcgradient = "15"; "client:ephemeral","server:69","server:ephemeral"; "client:ephemeral"=>"server:69" [label="RRQ(filename)"]; "server:69"->"server:ephemeral" [label="alloc port"]; "server:ephemeral"=>"client:ephemeral" [label="OACK"]; "client:ephemeral"=>"server:ephemeral" [label="ACK(0)"]; "server:ephemeral"=>"client:ephemeral" [label="DATA(1)"]; "client:ephemeral"=>"server:ephemeral" [label="ACK(1)"]; "server:ephemeral"=>"client:ephemeral" [label="DATA(2)"]; "client:ephemeral"=>"server:ephemeral" [label="ACK(2)"]; ...; "server:ephemeral"=>"client:ephemeral" [label="DATA(n)"]; "client:ephemeral"=>"server:ephemeral" [label="ACK(n)"]; } nobodd-0.4/docs/images/tftp-basic.png000066400000000000000000001206111457216553300175750ustar00rootroot00000000000000PNG  IHDR  " pHYs&?tEXtSoftwarewww.inkscape.org< IDATxy\T?0€ ^ J)~lhRj?]sI-\EqOE+AE̙s=3Ι; 7p?H5vnٌFjFP'"vYN$Z Tq@BFIv#ʛpPNHJJK/(<m۶1bh}wضm,X`

dggyZVSRR5WI1Ce<(]t;;; jՒ/BD2aaa""w;gInnRg,2fqpp._R/<<\<==EHppV=,J˗eȐ!r o&rQNs֬Yb*sQ @5k&۷oWɑ?XKxx۷bO'`0+"/`RfM駟TɓGsβsNUѣGȑ#eR^=ZJD13ȅ 9T3TҥK2`1LwryUD6l2|h4J~$>>^UK.7ʕ+MҪU+y뭷n^<(2}tM:UvvvҾ}{ٶmjqI%..N5k&ʕ++ҹsgO>)b^xe=EEDD.PwΝ|ŋ-c ,Psi4iٲ[NU?8qB  ;vU< (lӦMʲ̙3G… %88Xn޼)@֯_`zR2x`qttT m6 ʠAD+iӦo*+:tPڴiSUuKڿߪz/l2U>x III*m5kXlg„ l[nw^U˛o);wڵk4iDy>&I/׮]ST2 O777ٳ%,,Lg}&"w777}x{{˧~*3gΔp #GT_^H.]$44Tϟ/͓F ٲeL>]ڵk'ZV&M$OOO iii"""M4^/׿dΜ92tPjҼys%/_\ 駟ʄ ~@[e/zU1BVX!'O$2d^zɬYd.ժU*¤v#ӦMKJ˖-HvdѢE2m4qqqjժIrr*{2k,OKL&ڵKףGyGWZh!/ILLh4iذ|W2o<=zhZ Wgp!ۋhc? oKN:"vvv2tP3g3Fz͛7㏋`.]ĉED$44T\\\T_EDƎ+￯|̜9S&N(j /M4FIӦMeРA"999@\]]ҲeK9|,_\6mڤ|> 4Hș3uM˗E6 o/^;1c4nX寿R͜9SH֭eڴiK N/_1bԩSGA /ȵkdĉ@~7v۶m@&L ""K?ӧK۶mL4IYҧOY_>,"" 4ʗ iٲˈ#dΜ92rH *_׭[ԩ#L8Q6l(/To^O &+V>Lz'=44Tԩ#jՒ3fȒ%Ky=44T/^,SNgggqqqQ}F 6LHϞ=e֬YG899z۷PGyDW7nҼys>}2dh4*3 )ܟyj7FVV4kLjժ%"e aaa:'O=ɡCDVpoڴ*ݻbF^/E9sFN<"駟 #~Py)""W^???qwwW:/,,N|#dذazG^/?5 111ϻN:N ȑ#h>}ZUAG@FF""o'O-ANwC ywTÇ$%%Ȝ9sů^+V2n8C?;c :'uUB3gDʓO>*ZR/矗lնN<)OVz zJ9҅QDYf 2ej}'NP~)}U/ݷo)A511QU&!!!rbbIZ_dGT:uJ4 >\9rhZ9I vڒΖۋr[h+"r99yrtԩ@,X7|o_nܸ!a#Gҙ,WU'))I/*_pPL0A(G% ׯKjjwF&$"* W( t7Hpp]ʥK,wfݻwNȬvҦM徇ZԻr2jƌ""_ e""CFlqqq%[D#vvv +VGʒ%KVZb4رc"`@@t:yweÆ 2c QƊ0m~=ӢTHtbl|@9rl֛OOO%?@&OlyrJ-Zt%"ҡC(ҥ oEӧ ex9:uJU ZiwGGGe*uUչqry>{b#"һwo1ʗۋ^Wfɪ_'ͺw^x^uwŅ MZ<ֵkWtڵL뉍`ǎR=f>… r}wuQ[l,[ ?ixx8@>voX٩ϛ>x rrrpԭ[iNɄX@\\v؁g9  >3gDڵѵkWt ={D&MzǎgXGjjEmذE5j <<WFvv6t:-Zш~)'B׫?z(D/^_s(;;;h4aڵjԨ3g믿#6mڄcĉV_|?>n=^ulڴ =6lG}hp}?k,,\Pӧh֬R^\{0zhl߾:uo+W`РA^{Q-{59u1h4bﹹeڿlp k.u۷ۿov: ܸq^^^:ud2999!!!/Çq l۶ pq:|f,gNXA0tPhBU^tyDYYYptt65k1P z JB ^__~ {{{%TֱcGtQVfy˭[gAbb"Zl͛W_Ů],>Љ÷~gy//7bر۷/,X{{{dffd2ۇz)8::ʊ{obƍKwz}Ӣ_ $$D-GGRF3|}}U_F ,, W.\KwwwDFF`0(;fh۶-;yaڵ={6O___X[VիWKnnnhٲ%<==2VkQꫯbԩ-ZիW^n/77bs@`` U%233^CTTT2ol:ZZm tnK.ҥŽnk֬Att4222~.~F[Q郻yvy:?oEw6x˽hceZ|8ӳ oooo-C^x{{#''_~ee1tP888`׮]Pʋ;@T^8uƏ G>W[޽{d2aj|2>ƍCRRq6m`4xbƎ V玞;wę3gTO^ǻᆱ*_`, 7߈FQN>-ˬ2SL2`9uꔤȢEQZnxf>9uÆ (222=9UѨf 777,ݻwlRv%iiiciܸjɩdr U{zmq/ɩ/dIMM8yg,]TY6,,L5kZ_rr.b>9533S7n,ժUKJJ9sF?Qѣԯ_ߢחZj)'˝;wN^z%51ϓSnHS?.F7o.񇤦'FL'&I4h $--M+[ѨrCO駟E?cҢE 1L2gIJJСCjsrrMuڥKEׯ/6mWիf͚\iҤ8;;˚5k$!!A~g:u'R #""Dʕ+rM`0O?ѣ_|!7nܐ˗/;c1ӈ#DՖؿA$881cy… rMo^z)ԂYTZj%nnn  ͚5;vHZZڵKQYT-O*0^TѓSKۿ/[LY644T3&$$yUq'IzM/^,rIyg-Nm߾2SD@eƫ8e UON 0}'ň̷v)3%ʘ1c/omڴGw'X\Ν;; g!Ul%t!:ndjUn֭t:0^GѿDDN{Q 6LΦM!"88إ'.G4jCt#FcG[ٿl'ܼ;Yۻnf~Gm(*};لMADDDq߻[!d=6BDDDDd܉@ U`~""*DONDDDDDDDDDDDDDDDDDDDDDDDDt)Q~NTU17"ω*?*w"""""NDDDDd܉l; `p'"""" DDDDD60w"""""`oO" Ih4nQy`pk/G[ G߾}ѨQ#<77ݻw/vjժ]vEDDDu7`ӦM`ooFW^x饗`4KlɓѰaCO?;wĠAзo_|ضmVZoO?_F)թ֮]+-""""*1*AD~+V'5jj*"-YLppVKߥ1 IDAT2l0)j(e˖-QQQ'^zraDD#ޒM8QHDD[NDD^|EqqqQ3FHnnn뭌f͚so7uU~NTEUUK1}ڴi F???[G-vcǎI͚5۷O)_Ndǎerss rEĉUe}___UÇe֭ED&M$mڴ)V}ZODUyēS&#k7߿?qyǗi bϜppp͛ZC .];Cz|ΝAtt<''bhM&Mйs25##gΜAfffusrrpyܶ޵kp2355OFVVŋrmMOOǙ3g,5ƞ={"""[^5= & `>Ix@( `0ʼjgΜѠAF-?͛J̙3ѱcGP^=l޼gϞEz0}tرct]~puu?L&x 9sFUUV/pBvڨV{1`|r4j5j@ݺup)uRRRP^=,\#G;G///J1SQNJu/. H|qsfC} fYzY8qcǎ-v0tIlmccď?ʗ{O?F0`Gǖ-[TJh7 Ď;oƌ +$&&"77٪޽{w%?W^Ӫ7h@ f=F-[y DDT)1ӽH(.;J~xҎsL<C Q]r&MɓQzu°b UYjj*6l؀#Fo߾իW\t/jרQCu˕Ull,裏Z<`qgݺu-d2!##qqĔxsH-/Ǐfggŋ2kVt;QeN9teq֡ kh1R̙3|rlذ"tb:t(6n܈իW#)) hժ~l۶n<\޾|fvv6`X@@L&Lӕ~ O?4Ѷm[!22{w}W.,xxxv3k]nm ""Hܩ)ס: ^eݰh/^zG  pvvO V &s7܇2`p'*W^EzzzqqɿyfйsgL::t?ĉիRSSqm=zPkժk׮ry>FBjлwo_W^ŶmгgOh;ƬiӦزe bbbX XnYc믿;;;DDD`͸z*~7...8pݻ֭[*w2?&>Uz}m۶9_*u\]]d2+"yyy-[vuu_-9YYY&oժU{ѿNiA*O^0@}mJ~NTWAU tq^0hoOoJDDU;oReN0:CuL><h0QERrWϦ|:p QNDCuP|ۡ: bUQ DT٥kN(gV15|1"*\P^҆x "*P?lK$>3.ADDDL n]*Ot;g[69NDU@DD+ ]#>EDD;Q `r[>w""۷h>ޞlٮx/ ^wv"JG܉lO6Hr[ap'"-E5jh""[Sp#ʋrϟ/dΝ""{n ?pǫtR9~xч\X WA*CDDDڵkJ0UN`- 'PNweÆ |}}ѡC̙3ֵl2t5kDz0h :uʢީS /nݺE޽{ۮ{޽?)SࡇB͚5ѵkWlڴɢs0dԫW5kDǎ??QQQؿ?-Z ?ĺu0byzMx&"|$!p2K+.{p[|rDEE}􁝝6oތ!C 33#G,>s[O>k׮aѢEXnOԯ_p ++ O<UKCزe BBB]||<-[7o?D^`ggף{Xl8{,ڴi7ngϞƯ#..~! )) ˖-O@xyy`0777h4{ycV5pwwGbbb׳j*dgg?:]tA=~z!==?3Ѽys?ƎR ~`` ƌqa۶m իѵkWCʕ+o(=իW/;w""**:_u&}NJ nrϩܴmصk=Xlٲ@֭˼G@˴nׯlj'pMG7nܸ2PCヌbҦM#GT兿уNDD"[iPr7@q3@p?]gÆ 2d鉇zHH(ˏȽ 55wwnsqV^DfQ:/w""zP:Tp͛7ubÆ +111.Q… W=?ŋ-?hٲmO5s)(ܖ._lU[:܉1 3T'11C!55FR<\xQ9]a)V«Zך5k@ׯkZqq%dʕ,Y@P???x{{cڵ2e ZRoժUJ=""zx:Q~^<:ݐ? Ng1si }ILL iڴigIKK___qww-[Hff+@ƍk@Llw޼yh_W'JVVdggˌ3DJHHR>2|pIOOm۶)$''f9s̳|ǜUc?'щ*?{cD`_j@LLs4hi޼FرDFF СCȟ)@jԨ!FQHddEDcǎ@SHV$))ޯ_?$NNN@ԩ#'NPfffO>>W4ir5,,Ƴ>Bf׭[+6={vZn'xbX``իW?7u۷O-SO=v^w}wO?\|СC3gԩŨ(sjǎ]3lM:uh4% y^U~yV6Ni'U'Z><'*'=YuR,Cdv"܉*UQA"JXf)+2w"""U6T,*m9̈;Qu BsNmN%\cp'""vNIA?@:DeNDDDw:eVuPr܉A(Yu/aC;U$SZP:w"""U=T)8T*0w""" *PW:t܉nP܉NYx"?{SvKXGK擭ap'"""\(a &n}/Z!?Tq1U n9x@mY@DDDD1@cmϳfcp'"""` wG~?gF0U, GDǸU=|yU[DY_IbINDDDd=Ceơ2DDDD 3׾t DDDD  ?[-d܉cD_YT DDDDq “Sl; `p'"""" DDDDD60w"""""NDDDDd܉l; `p'"""" DDDDD60{k7KnwDy^x[Dt߱U~Uw"""""NDDDDd܉l; `p'"""" DDDDD60w"""""NDDDDd"LDЦhyrrvٲe5;攞Kٳƍο&WWל=z(M <<|>|شv.\0g7h '[x\ҥKkxyyeufqޱcsuh׮]uDDDt'b nD*)bԩ&T7F#QQQr墋1 Pb0`Yl[6oެ){Ů(rYszm8ωvthp9rzH,Y"ru?dh^zrҥbɓ'Eшs[/''Gv*d_IBB9rDqss9zhMA̙3bp'ωvtpp_d]JZZZ{@zY|^Zt֭zӦM{/"-[d*.3YDU;:T87kL$>>0mֳgO xq%&Zb}yyyRNӢE [wqtt,)3'@8g 0&lKpܟW9<98y$:{5kּmaÆaڵXf {ѣ3f b֭Xd ^yÇqY3zݻرc"""PF lݺu)%t4\80CAkA _B.ڑ @2]A ap'"zo@۶mKطoDGGQFaѢE~P;7C;dc c.)3Pp;U~z UمϣU;^zu]]]q5,'An IDAT77/Fƍ Q°i&š~7n('N@ѴiS;w>z __߻Zm8]֠]\;UR>ۇBw"hm1=Rfee!;;NNNJ֭[=z௿Rʃi&,\|d2222'ODHH6n܈?8p 6mWaEC颏UGp;Uܑ鋸# nMd܉K{!ի̝;sεXfѢEJp]6 66Yz56l؀QFᡇR4h7ݻw/Yfaڴi3fL "Iǭ ]CE:zcJhKDLDVx"''^^^0L8ut4曘2e ֯_9sE>۷o޽{ѢE ͚5Þ={JNdd$֯_Ǐ#00YYY0 ޽;6nܨKMMEPP.\={I&W3UѼbrwP.^Օyq^;`oo#GO>'|?z3дiSlze$ӥKs5טzIN:fҥG1cǎ>tpvZw_|~G/7!!!f{QRp5ڵ3? ٭%u#&4=Qrw%} GL@ ?`BI;c}&GwןqT=.@q<B@z]J:uzjM4ҥK/߿jٳ<̗͚5vߴiS-Һs>4JHHH޹sŋ^z?<57onu5j+6˱c*8f~W7m ٳgkndɒK͛w&;;_~_jR Pqrjd[/@nPŝXQW)M.jIgP]y8WQ';~?+@Po@UErUrܵ("*qòހҖB|UVRgI/z"v*T%[MBQ%U0I<VRB=JRvmj*:w'LXIL̥"T;w9?}Y'$*T$_PQXﭪ )ɔ"å:P* ;QSKENI+<܃[YbBqRR":ޗ4H_zQZ'%q>;u#4t@LIYSg%{pj|VItTB܃ӏޖ4AR?:y 8I*%U=eq;I&' \-t(r(*P>PQg;JU>IN 8q&c# @p,X;`w @p,X;`w=wg=9P>B tρyT;`w @p,X;`aT7Ƙ;$+nmw FFFڋnۖ/_cǎ.]KNN.aǏQ~'On(I>xWݻwGv%%%Yf~޽;I&9!!!}"ԩsaf[ڱoaFdM櫋oa.wIf~]vy2ed-Zs^zH2=z0YYYۖ.]j:udv{5M4136?~a?_|A7:PɊ F2Lhhٳg2Lv̛oiV\if̘aFaBBBLFÇK_ywh?}ǶSN /Ь\ݖe.RnFa>SvmonwSvm܁сJ+gggxӱcGӿbx-Zd$sym7$_{5c|vڶmsI_m&< vk9CRs*ǵl2Gח$WK.բE I7|#IҥK5o޼m3gѣUfM-\P111>ZJuUfmwnWFvڥkޭ[7ZJ'N,v#l"Kаro@yvK:*)9͛\ 66d-]TgvÇK֭[י={>%''kǎzcn:uѣӒZjy]$ԩS;vԂ 2P:wT7^[RYEy¿mr| Xp +""Biii8$??0%3gFڵ&M7СW߽{kh3dHw%f9ܹsJOOׅ^XyJε3p\m D{ѺudԸqcmyyyھ}6lؠN:h~b7ߨo߾袋~ywW\ÇkÆ ###}%66V0W[\\GkC(*6J)--Mv]#GT˖-=m߾]ӧOל9sԩS'uM!!!ZjƏ_ĈҔ)S{iÆ ov-o۶&MGyD>^y1bbbִiSl6{iӦ1KKʋT+ %J /<(*TEEEiԩYǶ'Ojٚ3g׿*11Q_+ZdԾ}b_ŋꫯjݻ{[ztQKvZ֮]0o TmսT<ʗ ڼy %G_O>QϞ=OkժU曵tR]ve;I&kYf]v;yf%$$Hڴi9CSO=իW~zzVX뮻jРCpSU.rURTIҭZdCjJKKSϞ=աCM6Mwu:v~UV:uꔖ.]{W^zGJ5f͚饗^Ҙ1c4vX飹s̙3e.ƍӬY4tP=裊+"ݮɓ'{~z}*TJK.aÆkԿ֭[o:xܰ@~~v=uTo$iĈ[n{~`ҥQaaaƍ{wXcFm„ C7o1{.(5aDҮ*!H.k]: *TKOP3%+X*TJ^L>a%5tP<Bq{P!~`1>tGJp^bTRJ #|Thz u?LR+I%M'U"*U\__U΅{$͖#ԗv'G/U vH"iMYuU$Ngz";"TR@߀R!IoP;d7 dk2/94V#Ow@)X*TQ(By$I;WJ S*d690H;UJ\Tx򙑒3p"\(NP;Q>T;pק|nwPUFPhI58lKpGQ\usm%FXI5CSr$?AR @p,X;`wz`EƘ0IWz 80N/^#S)lfIUlb%q_=:Atf͚اO/իym۷שSJTvxbꚂsk5}x_W_~kl2I&8O@OzϮ`qƘI]mڴю;4h w,ڷo4i+We˖>Ԯ];IRdd~w̙3۷kӦM޽6lno޼}Y-[ԦMItժU+##Cmڴ~^aG̙3ܹsK5uT-yfX@X1&кukSn]Sn7ӦM3aaaK/5999^}1dRRR$3c \Lb$ o„ F2ddMVlp ^홙O?5{l&<<1z-ӲeKc o3с `J]"y~~iܸINN63!!!_~Rwn6mj4ib8`BCCMϞ=쟙ij֬i,XmԨQFQ\p?v옉4˖-+ib TGRra#i<=;P===݄xm[fdx c1\s 5EWn:#<czeBBB}7M͚5Mnn׶ sNsNӿb1 4בW*]ZJj-'t%\%M$--vJ: )Wv}<<2 Azk[jj$iذa}ze~2D̙zȫʕ+չsg{mKJJRRR$)&&׾ꪫ3(//Oaa|Ԡ TSRa;FRG%qzg\m ,9ljrIIgΟ$tڹ~ڹb^M<_k\͛7OW\qZh!I(--ٳ;w:tVZIn&w}JKK YFsO^+B'OԆ Ե+Ρ\G# ,^/H9Bȑ#\gX>RD{u_CAO㹰(;'xnҥtꫯڵkc]|~uQ=c$S}mqcǎ)33S5*oqƒ]v܃K}KP|*9L3(M&.r{O-r޽[{re˖wn8q=##CTvm_˗DIRzzzJQRvP?#+<6h.p8p@u333l2իWOqqq/bݻWiii~'OjJJJ$5k=n&nfB*HU-9 iQ*;SN1FC q͛7O999zg},ڧO^Z6mrߜ4ϟl=S?~kŊڰa:u$q(G( 24wDzK/}JIIqoKMMUHHn:tV^4{jjl6n"]b=!!Auў={튶w^I*S}yTQ"$JR#@T߾}gϞO?#GEUF IҾ}駟kUÆ }wM7iر3g&O\j͚5ꪫԤI}nF3F^|EwLϞ=W_kW_VZСCS*R80PZS*\P66V.&6-ɘ6Mڵ;׷oww+[jtm Kbb rM6mڴ}#111 mڴ~PR>oٲeqgE/ :thÍ7F:w#IW]uU7Zpխ[~VbIbm+VhҵkW._7vmIEͥv8k(ـꫴ¡ޒW*R#EA#` \()D[կR?^RIc"PFwX]U,e-K#KEr%&GxP` %DvI0o@YuHROc+sHU^'iPq>婉 t+n:@Uu+">,GHhԾ wVGIUU[U)諊TJApGIjKzJ}r5A+C12 E 4L˒x.p"E݀&"T;w*XTU.9.iJ>MU . UTD̔wH)RxB;*,"JE .S*=x,wu_Ae\}Ep>6I$QnLR3NJ:%WI8"}Ȕ\>|qew @ #GΕT ϟ-np˕CiFH\P eUo)UICpGq:K"s@pNU|J" BO IDATg(;||!8Tw*0\3@0;u"Fw @p,X;`w @p,X@OρJx!{$Px D `w @p,X;`w=n1 I:}tTkzmJbw^bRRR7|㫋۱cɓM^̥^jڴicj,Ywɒ%&%%lذX;v0C5#F0Gv!?|?OvZ_||EMG 9x_x#H2G.*כ$#ɴn 0tDFFI{?e#,Zk]v&Mhzjz8qy8qԬYӼ ^֮]k."=@T2SDpo۶]INN6ٳ^}8`֭kͪUZ>a#,^+}FYrϰnjԨavnc 3x`swzٳ$$$P1w5u5w#!NN NK:V`=Gq#zQ%;~>~ JRSS%IÆ Sll{9i>׿+R}9Nz4qD۷O ~KZxz_ŋGy^$ݻW.\Noe*E袋Mt*--ͽI&JOO/xA~ P<;PTR!z(*7%s~?rrr'2tP=3g~a;V-̙3u=Ct1}zgzIN}ᇚ3g ˵^^{WٰauV!e7FU-jX`ʕ f*Vhh-ZqiƌZrG͛kƌjݺuwyG]vƎZ5R>}4qDm۶M\rI*/Rcƌ)!(*3RȜtcLndddn֭Jʶl6f˴l6-'444v_6nxhhhNhhhNll.(l6ۙMU=ʆrss#oȓ'OfI:$>}ZI_F/Ԙ7o^<qJfRY+t῵T:;Fe *Ǒ%HERsan@"cg(VPiJ|)Om U_6I% 4IpEeߍR!TZ^t$}頪"PZ*TxgR sI@pB$5pS*ϠHw %@c3BpšTtJsA | RR|A߯`}?t*sbDJ'ƹ\v%8bPj9egSrd1G|q>UrXIS%X1$A(+sa$Kp (A 0^Y=5cS'kT @p,X;`w @p,X@O@O@}Tσ=4 T:@<Q*X;`w @p,X;`aX1]3gΌ?vXh~ׯ_V:uK3555СCa\rI_Up믿^ٳƨW^^.]$/A<֢E 8];v7Dz' o۲eKĶm“ڵkg/}OӧCi,z@9cL[6|>"""̓O>iMQ233Mddd."c=9~G.]wh߱cڵkEEEgyƣ߮]LzLzz'$zс +'&&;w۶m33:u2T.>ρ {ݺu}lӭ[7b6nO^LddYxdb)S$pbDDD%KI{)۶mɓ=,X`$g}֣=33ԭ[4ḳ}ƍ&,,믅4zс `n:#{^gBBBLJJ7\p[QJ1h@+dIK.IwH#ig$M%-ZI[%ɩpt]jҺu뼶nk 7߬SjպsQ~~.ͦZb KԸqc޽{~JNNzΞ={Tn]ըQã_~Z`&O\f )FR$E9ђΟq*WydI:#锤rOXGBpf/?-55UuQ%IC ԩSVڵkw/+--gp_jv޽{wuݫ;OֵkWM2E{*I!9efHv<|*]rQK:Wkq=<֙3WIܲe{;}Ժ[njܸ,X7|SQQQ~ֶm۴a=ZΝ;뢋.ҢEwmݺU#G,qcǎo+R'NӪU+Iҷ~KpGaQ*:@6h篢. '$y\hw8ON<8oSSS%I;wy㏵dZqkժ1__~E-ҭn?t萌1JLL,vܙ3g։'CgQdwfrQH*_G#%s.Cpܹsھ}ڴin3(--Ms|^ZZٳgK"-O8!IӺ[tR_//5kEU,tEA%K޽{>s޽[~z)SRsNO?o߾Zb=>2^NIRffW|wڵk5c q%5k\x)kʥ"'%x`eGpJ?Ownw5[_|QרQJz#G,rɓ'k޼y=z$)))Iaaa:|WŋkʕzK%)##C԰aRϻT@Ap rYZʽ~I}z뭷t[ }Νƍꫯ9+:;wN4h={9ɓ5jPΝ{3g$ ӦM5M6)44T]t)ռ+HEXJEH:TSw ۷W{-4m4p ˗ѣ5jBB|gÎ;/֚5ktA5hР9|G:|xӮ];]r%Zn{Yfy߱c$iܸq>Nj ׯ_ڵk5ժZ*r@P6%i0W_ϵm#~C.{g$rѣG_s? կ_=8&&lعs?c})IwuW[nٸqLIUVӽ{N0ax_}󫮺**;;;&''':??F^^^sv{ cLѣGE\pM64hPf^^^Mcǎ1&("`)q+n}V:Tv{XgUR<9,M>&LS* w:ٱRu%WYeKPJE>EccJFpY"wqQJS*&a%tkI!_6I&ГPW Ϣ"ETb{AIMz1@"lKj-i||dvUӒ~*sWDCpGiwIWJة Q%a\U(NIr\r"TSw*yQRϥ:RHOQ*EQX8TR<"Dc,RCdYLu,9%*=,&ARG/tS*'{Ij$iu|礤3qϽSrӒN))˹|8l#`Gp>FRC,>!JE*=8m&.  %})Gm9Z*r=o)U: ;$阤qu([ID @CpGaTAwU>E% gpϴ(sJw#C҂@O  @p,X;`w @p,X;`w=wJx!{IT:@<Q*X;`w @p,X;`aT7Ƙ'$5)ܞז.]HLLc ӯ_?+WΝ;_nݺ3Nr|xQB`9xbBBBLddԩkM7d$A+W;w><&&&ř]vv &11lذݶff6lh&OlMfh$޽{n1Ӧ~棏>"9xWp?q℉2=z0)))Fپ}W>}dƌ3/XH2#G,1?Evcy'M=ڮZm~;H2˗/wM0\s5#~ XyH!4A҃4DI$uRRcI$`}ƌF?aΝk$'z%ޖ-[u뚬,_cLrrIHHpq_OS=''ԫWϼ[|SF 3x`}d&Mnۺud6nHp?{cTS`I uJ:#I'%-鴤eJr.r>829+HMMUddnfըQCJKKSO=}vm۶MGVtttcܹך0a^|E 6L3gTh>իu! 8v9R={ꟛ+Iw]r%jѢt;'@܃ڢ8Y`zQˮGʲC#Gϑcxns-gHs@8p֬Yw;`͙3G~:v(I%ɽ^O>&O8>CM4QÆ mzBCCu{lڵVZUJv+x̒ivXN(ώqIG˜9saÆRRR4gѣG%Ik.OԳgOYF>^u} k׮1٣K+W_5oc{{̙3~K(?;*Ke }k\-) (O-SoBPMf͚jѢv%Ijٲ"""BBB)e)O3h„ ۷x 8P]wWUVEuY zeoh̘1^֭+ݮYfe7tqI*p\w|mKBa֭ڸq$GMxaӧ~=z .$ر1WZUVijݺ}z$IӧOW۶m5j(m޼(#Gs͛7kذaumSF|uq;T2;@PiQ*Tmڴ?/ԣGuM5jвeO9[o>|G{M4ѫ#Fh̘1z=^co߾]zRtt֮]5F\\\Gp*J%a IDAT 1FgVBB&Lʬjԩ7o^{5լYSC Qjj,Yxy!-[LM6-Env}4h vmmw4vXcgq%14hPb_@B UT//QFyvI 7ܠYfi4hhɒ%۔322l?^6[~[>]s50ޡC}}W^ZCUFF222kԨի^駟\d TR!zXK\e2zk}YfiΜ94h5o<|8pZlVZɓϕQFiѥCRRMnA#Gʕ+޽{kƌt~I͙3Gs9ޔ)Sׯ_޽{O(;ԥB? .cǒz̙k֬9q.<<}hڵ5+lْvښ5jȻKSRR{GPRRR[nYtt􏒎Nfp|Ξ,þ}>G=&b g!gyZqN$=)igkA D#rװmuG}(^$}Cvt3!1I'%M`[ 5  6@pl;`w  6@pl;`wY; 1c=Y8q((u1,kӭp*1c6ϵH/oVw@0cY8p>9 p>9|Ĭ2 [c8rg\s@ *t1c6289|sSl,q8<qqjɶ"q8<1$D= b8g\s@ ,q8<1 `t@A; b1|۳p.9|s p:|s1Yes"q85s1c1c8A*X̳p.9|oup>9|Ĭ2  6@pl;`w `GƘIVßE]-I/CV\[ ʳcLi7:,fȑ3I[[1bdfΜ٥}]oѣGIfڴi=_4s hJJJL~~ikk׼Rcy\Vw};oc$y;VZpos*++5x`UTT[Jvmr\ׯN<,_+{{iϞ={>l0-X~r\.'k}UWWk„ :p߉$iZxz \.׊ >ρ7t 3YYY~BWTT3tPsy,XH~lYH2=hѢ]vm?C}䷿:tijjܴʟ0|1 "cs#d233رc_|a7q 2Č3ՙ-:u0`ٹsg퍍رc477zlܸѼۦq͠ALiii')]0I&ifI*CI܁00A:oOޥmǎFYz1Ƙ3ge=^ H2+W43k,rG}1322LKKK핕Fٺu7a$e$o\p˱̙cf̘y d@$%Iʑ4B&I%͕HRI+$$?%m];Kz_ҧHjS״Hz@HKKSnn:ԥXqqq*,tORSPP h!fqq\.I̙;vhOe]v)//O{ jܹڳg e˖i'|þv-[F%&&0KYڭ4г>88~yS<;eIFRO<%kvyI<!w#:vXm/^Tyy 6Lt=GUptt.2={URR%,Y1GUV zuȑ.tMjhhо}4yb^ܡ91@TQR:zMuΏ[;C!@iiiQBBBmeeet钾kÇkرAVyy.^ḩ9smۦjܸqZ8]wqrreLד~999_Ajn4?]]'wɕtwm5Qw#'NPvvvmŒgyF+Wm?s$D?|Я=s=իW>>}Z1{ ]uUSNi$ vЏq] srׅFp>p1}'3go۩S{njԩ]ꫯD+WǚܹS#FдiӺoڴIZz$555u{`I-"s"g<B@p> ^(*Ijiiяc͟?s>3m߾ws@wSss/^K{MM;Ͻ /qc""tg?ƍ5e͚5V\\w}~h*)) *+>>^L%%%>h ]{:~x{Ւ}ka9^T:" ¤V#G=niiɓ'ڪɓ'_=zTf͚lJJJk_ǏWUUNeeeg֬YJIIku.׻gZ說* 2$ kۉR`LcwMƍ;M8wYj_#^YY?cK8p͛7GC?ۖ?,99#϶Jݛ5iҤq'On?y>|x>˻(I7|s͛oٙݿ)c T-w QIiuG"V,Ɖ"޳##e1R{AK|oROE@tC!>!R>Dp=F)l :PpJ/wv Ƕ?}II zBpG_ SIs%M3QGD;| g|b܀ oqGZ@,"6@pl;`w  6@pl;`w [c8AؓkuDp>y T;`w  6@pl;`Vw3\RU?~$I:pdkU@cf8z6m6mӟo?<&77$%%Ts뭷_Wþ6m{}ӧ|М;wC۱cLaaimm?^lݺ;1s 0Ё3gyH2LQQQx&))$$$o}[f6̬Y:5kI7r&==dffCui1cYvm!ݟlS[[K@l t ŒfrssMNN;v4hx?3p@3|pwhkll4Fy}ۻ 4݆reK̞=,^_X9$t2%X#a^UUe$zʬ\H2]<`sΙ$ݷ_p?t萹꫻ 3eOtZ̑#GLuuR7xäwnڟ@ W~Ig$}*}I%#iMSKVHZ*iI$]/I- .4ѣG$sww&!!L>0m16m2/\sp?|19rHR={n3tPw˗MjjyW@# fMJIɞ%zAIr$~YKYtV/ < }/k󮟓tQRs,Ң-[hر%Iƍ)==]TYYfM:uv竡A{ 7w %%%i„ _xQ'NPaa/_[nEO?"}}ﯼn={4a%$$ti;r6oެ|P4qD>|X6lٳgw<_^mmm Iw|c ~{C[uuF-YDFҘ1co_TfffcTQQtsWw^=@pC [dP_lU*tiڵK'N%wx^^tQ=Z?O4w\=*++P #I6lѣGUXX_?33S6lw߭ڹs\.ﵽƉ'!CXDP/N):'-~h˖-jiiϜ9sTUU=***[obM0AO<񄮿z={Veeeڸq233jժ0{l=#o~uiѢE)SWkkk۷O~{P5?-`D jJ.&%%.//OKOOΨ/{os=cǎg}N^}U1"eڵ?]4iRӆ .|+_% HS]]]$IҚ5k\{M_zu]wU7jԨ֩S/[,gn 08999^5R$zKkk\O{lJ+eVK{D^fg*)(i7FT)X8Oےf:!\W侦@<qS١T(CҀq\'Y~?$igڅs%mgRtq |P%=!iUŒ BUrߩ+oK fT7=;~V!7;8S T*n,}"2_q/T4 h'_ NKjг}qBI/;jYog T'.t9> M7I/Kr&Ep=c݈C A dB'A g|y bs K{ Rm. KB}\>8AqZ KB/;cR$R*J - t7ޔ v&H ]+UIgbX)KS% = yq3A\{p>9~%%{4I)rTZg[Z{%{ImזދE^s.u8䮯v(UgubTu tK5o~Bq^I[s/Dbg1Q-+bJeCY nqc=\ۋ2c28iu`/w  6@pl;`w  6@p>7IDATlx;>WkuDp>y "Ǟϭc8A6@pl;`w  6@pl;`w  6@pl;`w?<*g=IENDB`nobodd-0.4/docs/images/tftp-basic.svg000066400000000000000000000133711457216553300176140ustar00rootroot00000000000000 client:ephemeral server:69 server:ephemeral RRQ(filename) alloc port OACK ACK(0) DATA(1) ACK(1) DATA(2) ACK(2) DATA(n) ACK(n) nobodd-0.4/docs/index.rst000066400000000000000000000007311457216553300154270ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2023-2024 Dave Jones .. Copyright (c) 2023-2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 .. include:: ../README.rst Contents ======== .. toctree:: :maxdepth: 1 install tutorial howtos explanations cli api development changelog license Indices and tables ================== * :ref:`modindex` * :ref:`genindex` nobodd-0.4/docs/install.rst000066400000000000000000000030021457216553300157600ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2023-2024 Dave Jones .. Copyright (c) 2023-2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ============ Installation ============ nobodd is distributed in several formats. The following sections detail installation on a variety of platforms. Ubuntu PPA ========== For Ubuntu, it may be simplest to install from the `author's PPA`_ as follows: .. code-block:: console $ sudo add-apt-repository ppa:waveform/nobodd $ sudo apt install nobodd If you wish to remove nobodd: .. code-block:: console $ sudo apt remove nobodd The deb-packaging includes a full man-page, and systemd service definitions. Other Platforms =============== If your platform is *not* covered by one of the sections above, nobodd is available from PyPI and can therefore be installed with the Python setuptools "pip" tool: .. code-block:: console $ pip install nobodd On some platforms you may need to use a Python 3 specific alias of pip: .. code-block:: console $ pip3 install nobodd If you do not have either of these tools available, please install the Python `setuptools`_ package first. You can upgrade nobodd via pip: .. code-block:: console $ pip install --upgrade nobodd And removal can be performed as follows: .. code-block:: console $ pip uninstall nobodd .. _author's PPA: https://launchpad.net/~waveform/+archive/ubuntu/nobodd .. _setuptools: https://pypi.python.org/pypi/setuptools/ nobodd-0.4/docs/license.rst000066400000000000000000000011361457216553300157420ustar00rootroot00000000000000======= License ======= This file is part of nobodd. This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . nobodd-0.4/docs/subst.rst000066400000000000000000000004441457216553300154610ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2024 Dave Jones .. Copyright (c) 2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 .. |bug-link| replace:: Please report bugs at: https://github.com/waveform80/nobodd/issues nobodd-0.4/docs/tutorial.rst000066400000000000000000000327151457216553300161720ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2023-2024 Dave Jones .. Copyright (c) 2023-2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ======== Tutorial ======== nobodd is a confusingly named, but simple :abbr:`TFTP (Trivial File Transfer Protocol)` server intended for net-booting Raspberry Pis directly from OS images without having to loop-back mount or otherwise re-write those images. In order to get started you will need the following pre-requisites: * A Raspberry Pi you wish to netboot. This tutorial will be assuming a Pi 4, but the Pi 2B, 3B, 3B+, 4B, and 5 all support netboot. However, all have subtly different means of configuring their netboot support, so in the interests of brevity this tutorial will only cover the method for the Pi 4. * A micro-SD card. This is only required for the initial netboot configuration of the Pi 4, and for discovering the serial number of the board. * A server that will serve the OS image to be netbooted. This can be another Raspberry Pi, but if you eventually wish to scale to several netbooting clients you probably want something with a lot more I/O bandwidth. We will assume this server is running Ubuntu 24.04, and you have root authority to install new packages. * Ethernet networking connecting the two machines; netboot will *not* operate over WiFi. * The addressing details of your ethernet network, specifically the network address and mask (e.g. 192.168.1.0/24). Client Side ============ To configure your Pi 4 for netboot, use `rpi-imager`_ to flash Ubuntu Server 24.04 64-bit to your micro-SD card. Boot your Pi 4 with the micro-SD card and wait for `cloud-init`_ to finish the initial user configuration. Log in with the default user (username "ubuntu", password "ubuntu", unless you specified otherwise in rpi-imager), and follow the prompts to set a new password. Run :command:`sudo rpi-eeprom-config --edit`, and enter your password for "sudo". You will find yourself in an editor, with the Pi's boot configuration from the EEPROM, which will most likely look something like the following: .. code-block:: ini :emphasize-lines: 5 [all] BOOT_UART=0 WAKE_ON_GPIO=1 ENABLE_SELF_UPDATE=1 BOOT_ORDER=0xf41 .. note:: Do not be concerned if several other values appear, or the ordering differs. Various versions of the Raspberry Pi boot EEPROM have had differing defaults for their configuration, and some later ones include a lot more values. The value we are concerned with is ``BOOT_ORDER`` under the ``[all]`` section, which may be the only section in the file. This is a hexadecimal value (indicated by the "0x" prefix) in which each digit specifies another boot source in *reverse order*. The digits that may be specified include: == ========= ================================================================ # Mode Description == ========= ================================================================ 1 SD CARD Boot from the SD card 2 NETWORK Boot from TFTP over ethernet 4 USB-MSD Boot from a USB :abbr:`MSD (mass storage device)` e STOP Stop the boot and display an error pattern f RESTART Restart the boot from the first mode == ========= ================================================================ A `full listing `_ of valid digits can be found in the Raspberry Pi documentation. The current setting shown above is "0xf41". Remembering that this is in *reversed* order, we can interpret this as "try the SD card first (1), then try a USB mass storage device (4), then restart the sequence if neither worked (f)". We'd like to try network booting first, so we need to add the value 2 to the end, giving us: "0xf412". Change the "BOOT_ORDER" value to this, save and exit the editor. .. warning:: You may be tempted to remove values from the boot order to avoid delay (e.g. testing for the presence of an SD card). However, you are strongly advised to leave the value 1 (SD card booting) somewhere in your boot order to permit recovery from an SD card (or future re-configuration). Upon exiting, the :command:`rpi-eeprom-config` command should prompt you that you need to reboot in order to flash the new configuration onto the boot EEPROM. Enter :command:`sudo reboot` to do so, and let the boot complete fully. Once you are back at a login prompt, log back in with your username and password, and then run :command:`sudo rpi-eeprom-config` once more to query the boot configuration and make sure your change has taken effect. It should output something like: .. code-block:: ini :emphasize-lines: 5 [all] BOOT_UART=0 WAKE_ON_GPIO=1 ENABLE_SELF_UPDATE=1 BOOT_ORDER=0xf412 Finally, we need the serial number of your Raspberry Pi. This can be found with the following command. .. code-block:: console $ grep ^Serial /proc/cpuinfo Serial : 10000000abcd1234 Note this number down somewhere safe as we'll need it for the server configuration later. The Raspberry Pi side of the configuration is now complete, and we can move on to configuring our netboot server. Server Side =========== As mentioned in the pre-requisites, we will assume the server is running Ubuntu 24.04, and that you are logged in with a user that has root authority (via "sudo"). Firstly, install the packages which will provide our `TFTP`_, `NBD`_, and `DHCP`_ proxy servers, along with some tooling to customize images. .. code-block:: console $ sudo apt install nobodd-tftpd nobodd-tools nbd-server xz-utils dnsmasq The first thing to do is configure :manpage:`dnsmasq(8)` as a DHCP proxy server. Find the interface name of your server's primary ethernet interface (the one that will talk to the same network as the Raspberry Pi) within the output of the :command:`ip addr show up` command. It will probably look something like "enp2s0f0". .. code-block:: console :emphasize-lines: 8,10 $ ip addr show 1: lo: mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 2: enp2s0f0: mtu 1500 qdisc mq state UP group default qlen 1000 link/ether 0a:0b:0c:0d:0e:0f brd ff:ff:ff:ff:ff:ff inet 192.168.1.4/16 brd 192.168.1.255 scope global enp2s0f0 valid_lft forever preferred_lft forever inet6 fd00:abcd:1234::4/128 scope global noprefixroute valid_lft forever preferred_lft 53017sec inet6 fe80::beef:face:d00d:1234/64 scope link valid_lft forever preferred_lft forever 3: enp1s0f1: mtu 1500 qdisc mq master br0 state UP group default qlen 1000 link/ether 1a:0b:0c:0d:0e:0f brd ff:ff:ff:ff:ff:ff 4: br0: mtu 1500 qdisc noqueue state UP group default qlen 1000 link/ether 02:6c:fc:6f:56:5c brd ff:ff:ff:ff:ff:ff inet6 fe80::60d9:48ff:fee3:c955/64 scope link valid_lft forever preferred_lft forever ... Add the following configuration lines to :file:`/etc/dnsmasq.conf` adjusting the ethernet interface name, and the network mask on the highlighted lines to your particular setup. .. code-block:: text :emphasize-lines: 2,7 # Only listen on the primary ethernet interface interface=enp2s0f0 bind-interfaces # Perform DHCP proxying on the network, and advertise our # PXE-ish boot service dhcp-range=192.168.1.255,proxy pxe-service=0,"Raspberry Pi Boot" Restart dnsmasq to ensure it's listening for DHCP connections (unfortunately reload is not sufficient in this case). .. code-block:: console $ sudo systemctl restart dnsmasq.service Next, we need to obtain an image to boot on our Raspberry Pi. We'll be using the Ubuntu 24.04 Server for Raspberry Pi image as this is configured for NBD boot out of the box. We will place this image under a :file:`/srv/images` directory and unpack it so we can manipulate it. .. code-block:: console $ sudo mkdir /srv/images $ sudo chown ubuntu:ubuntu /srv/images $ cd /srv/images $ wget http://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04-preinstalled-server-arm64+raspi.img.xz ... $ wget http://cdimage.ubuntu.com/releases/24.04/release/SHA256SUMS ... $ sha256sum --check --ignore-missing SHA256SUMS $ rm SHA256SUMS $ unxz ubuntu-24.04-preinstalled-server-arm64+raspi.img.xz We'll use the :program:`nobodd-prep` command to adjust the image so that the kernel will try and find its root on our NBD server. At the same time, we'll have the utility generate the appropriate configurations for :manpage:`nbd-server(1)` and :program:`nobodd-tftpd`. :program:`nobodd-prep` needs to know several things in order to operate, but tries to use sensible defaults where it can: * The filename of the image to customize; we'll simply provide this on the command line. * The size we want to expand the image to; this will be size of the "disk" (or "SD card") that the Raspberry Pi sees. The default is 16GB, which is fine for our purposes here. * The number of the boot partition within the image; the default is the first FAT partition, which is fine in this case. * The name of the file containing the kernel command line on the boot partition; the default is :file:`cmdline.txt` which is correct for the Ubuntu images. * The number of the root partition within the image; the default is the first non-FAT partition, which is also fine here. * The host-name of the server; the default is the output of :command:`hostname --fqdn` but this can be specified manually with :option:`nobodd-prep --nbd-host`. * The name of the NBD share; the default is the stem of the image filename (the filename without its extensions) which in this case would be :file:`ubuntu-24.04-preinstalled-server-arm64+raspi`. That's a bit of a mouthful so we'll override it with :option:`nobodd-prep --nbd-name`. * The serial number of the Raspberry Pi; there is no default for this, so we'll provide it with :option:`nobodd-prep --serial`. * The path to write the two configuration files we want to produce; we'll specify these manually with :option:`nobodd-prep --tftpd-conf` and :option:`nobodd-prep --nbd-conf` Putting all this together we run, .. code-block:: console $ nobodd-prep --nbd-name ubuntu-noble --serial 10000000abcd1234 \ > --tftpd-conf tftpd-noble.conf --nbd-conf nbd-noble.conf \ > ubuntu-24.04-preinstalled-server-arm64+raspi.img Now we need to move the generated configuration files to their correct locations and ensure they're owned by root (so unprivileged users cannot modify them), ensure the modified image is owned by the "nbd" user (so the NBD service can read and write to it), and reload the configuration in the relevant services. .. code-block:: console $ sudo chown nbd:nbd ubuntu-24.04-preinstalled-server-arm64+raspi.img $ sudo chown root:root tftpd-noble.conf nbd-noble.conf $ sudo mv tftpd-noble.conf /etc/nobodd/conf.d/ $ sudo mv nbd-noble.conf /etc/nbd-server/conf.d/ $ sudo systemctl reload nobodd-tftpd.service $ sudo systemctl reload nbd-server.service Testing and Troubleshooting =========================== At this point your configuration should be ready to test. Ensure there is no SD card in the slot, and power it on. After a short delay you should see the "rainbow" boot screen appear. This will be followed by an uncharacteristically long delay on that screen. The reason is that your Pi is transferring the initramfs over TFTP which is not the most efficient protocol [#extensions]_. However, eventually you should be greeted by the typical Linux kernel log scrolling by, and reach a typical booted state the same as you would with a freshly flashed SD card. If you hit any snags here, the following things are worth checking: * Pay attention to any errors shown on the Pi's bootloader screen. In particular, you should be able to see the Pi obtaining an IP address via DHCP and various TFTP request attempts. * Run ``journalctl -f --unit nobodd-tftpd.service`` on your server to follow the TFTP log output. Again, if things are working, you should be seeing several TFTP requests here. If you see nothing, double check the network mask is specified correctly in the :manpage:`dnsmasq(8)` configuration, and that any firewall on your server is permitting inbound traffic to port 69 (the default TFTP port). * You *will* see numerous "Early terminate" TFTP errors in the journal output. This is normal, and appears to be how the Pi's bootloader operates [#tsize]_. .. _TFTP: https://en.wikipedia.org/wiki/Trivial_File_Transfer_Protocol .. _NBD: https://en.wikipedia.org/wiki/Network_block_device .. _DHCP: https://en.wikipedia.org/wiki/Dynamic_Host_Configuration_Protocol .. _rpi-imager: https://www.raspberrypi.com/software/ .. _BOOT_ORDER: https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#BOOT_ORDER .. _cloud-init: https://cloudinit.readthedocs.io/ .. [#extensions] absent certain extensions, which the Pi's bootloader doesn't implement. .. [#tsize] at a guess it's attempting to determine the size of a file with the ``tsize`` extension, terminating the transfer, allocating RAM for the file, then starting the transfer again. While not *strictly* necessary, remember that the bootloader operates with limited resources and simplicity of operation is the order of the day. nobodd-0.4/nobodd/000077500000000000000000000000001457216553300141025ustar00rootroot00000000000000nobodd-0.4/nobodd/__init__.py000066400000000000000000000000001457216553300162010ustar00rootroot00000000000000nobodd-0.4/nobodd/config.py000066400000000000000000000426541457216553300157340ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2023-2024 Dave Jones # Copyright (c) 2023-2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import os import re import socket import datetime as dt from pathlib import Path from decimal import Decimal from contextlib import suppress from fnmatch import fnmatchcase from collections import namedtuple from configparser import ConfigParser from argparse import ArgumentParser, SUPPRESS from ipaddress import ip_address from copy import deepcopy from . import lang # The locations to attempt to read the configuration from XDG_CONFIG_HOME = Path(os.environ.get('XDG_CONFIG_HOME', '~/.config')) CONFIG_LOCATIONS = ( Path('/etc/nobodd/nobodd.conf'), Path('/usr/local/etc/nobodd/nobodd.conf'), Path(XDG_CONFIG_HOME / 'nobodd/nobodd.conf'), ) class ConfigArgumentParser(ArgumentParser): """ A variant of :class:`~argparse.ArgumentParser` that links arguments to specified keys in a :class:`~configparser.ConfigParser` instance. Typical usage is to construct an instance of :class:`ConfigArgumentParser`, define the parameters and parameter groups on it, associating them with configuration section and key names as appropriate, then call :meth:`read_configs` to parse a set of configuration files. These will be checked against the (optional) *template* configuration passed to the initializer, which defines the set of valid sections and keys expected. The resulting :class:`~configparser.ConfigParser` forms the "base" configuration, prior to argument parsing. This can be optionally manipulated, before passing it to :meth:`set_defaults_from` to set the argument defaults. At this point, :meth:`~argparse.ArgumentParser.parse_args` may be called to parse the command line arguments, knowing that defaults in the help will be drawn from the "base" configuration. The resulting :class:`~argparse.Namespace` object is the application's runtime configuration. For example:: >>> from pathlib import Path >>> from nobodd.config import * >>> parser = ConfigArgumentParser() >>> tftp = parser.add_argument_group('tftp', section='tftp') >>> tftp.add_argument('--listen', type=str, key='listen', ... help="the address on which to listen for connections " ... "(default: %(default)s)") >>> Path('defaults.conf').write_text(''' ... [tftp] ... listen = 127.0.0.1 ... ''') >>> defaults = parser.read_configs(['defaults.conf']) >>> parser.set_defaults_from(defaults) >>> parser.get_default('listen') '127.0.0.1' >>> config = parser.parse_args(['--listen', '0.0.0.0']) >>> config.listen '0.0.0.0' Note that, after the call to :meth:`set_defaults_from`, the parser's idea of the defaults has been drawn from the file-based configuration (and thus will be reflected in printed ``--help``), but this is still overridden by the arguments passed to the command line. """ def __init__(self, *args, template=None, **kwargs): super().__init__(*args, **kwargs) if template is not None: self._template = self._get_config_parser() with template.open('r') as f: self._template.read_file(f) else: self._template = None self._config_map = {} def _get_config_parser(self): """ Generate and return a new :class:`~configparser.ConfigParser` with appropriate configuration (interpolation, delimiters, etc.) for the desired parsing behaviour. """ return ConfigParser( delimiters=('=',), empty_lines_in_values=False, interpolation=None, strict=False) def add_argument(self, *args, section=None, key=None, **kwargs): """ Adds *section* and *key* parameters. These link the new argument to the specified configuration entry. The default for the argument can be specified directly as usual, or can be read from the configuration (see :meth:`read_configs` and :meth:`set_defaults_from`). When arguments are parsed, the value assigned to this argument will be copied to the associated configuration entry. """ return self._add_config_action( *args, method=super().add_argument, section=section, key=key, **kwargs) def add_argument_group(self, title=None, description=None, section=None): """ Adds a new argument group object and returns it. The new argument group will likewise accept *section* and *key* parameters on its :meth:`add_argument` method. The *section* parameter will default to the value of the *section* parameter passed to this method (but may be explicitly overridden). """ group = super().add_argument_group(title=title, description=description) def add_argument(*args, section=section, key=None, _add_arg=group.add_argument, **kwargs): return self._add_config_action( *args, method=_add_arg, section=section, key=key, **kwargs) group.add_argument = add_argument return group def _add_config_action(self, *args, method, section, key, **kwargs): assert callable(method), 'method must be a callable' if (section is None) != (key is None): raise ValueError(lang._( 'section and key must be specified together')) try: if kwargs['action'] in ('store_true', 'store_false'): type = boolean except KeyError: type = kwargs.get('type', str) action = method(*args, **kwargs) if key is not None: with suppress(KeyError): if self._config_map[action.dest] != (section, key, type): raise ValueError(lang._( 'section and key must match for all equivalent dest ' 'values')) self._config_map[action.dest] = (section, key, type) return action def read_configs(self, paths): """ Constructs a :class:`~configparser.ConfigParser` instance, and reads the configuration files specified by *paths*, a list of :class:`~pathlib.Path`-like objects, into it. The method will check the configuration for valid section and key names, raising :exc:`ValueError` on invalid items. It will also resolve any configuration values that have the type :class:`~pathlib.Path` relative to the path of the configuration file in which they were defined. The return value is the configuration parser instance. """ # NOTE: We cheat in several places here to deal with the board:* # sections in the default.conf. If you use this class elsewhere, adjust # these accordingly if self._template is None: config = self._get_config_parser() else: config = deepcopy(self._template) valid = {config.default_section: set()} for section, keys in config.items(): for key in keys: valid.setdefault( 'board:*' if section.startswith('board:') else section, set() ).add(key) for section in {s for s in config if s.startswith('board:')}: del config[section] # Figure out which configuration items represent paths. These will need # special handling when loading configuration files as the values will # be resolved relative to the containing configuration file path_items = self.of_type(Path) path_items |= {('board:*', 'image')} # Attempt to load each of the specified locations; these are done # strictly in order to permit the customary hierarchy of configuration # files (/lib, /etc, ~) to override each other to_read = [Path(p) for p in paths] while to_read: path = to_read.pop(0).expanduser() config.read(path) # If a template was provided upon construction, validate sections # and keys against those in the template if self._template is not None: for section, keys in config.items(): try: section = { s for s in valid if fnmatchcase(section, s)}.pop() except KeyError: raise ValueError(lang._( '{path}: invalid section [{section}]' .format(path=path, section=section))) for key in set(keys) - valid[section]: raise ValueError(lang._( '{path}: invalid key {key} in [{section}]' .format(path=path, key=key, section=section))) # Resolve paths relative to the configuration file just loaded for glob, key in path_items: for section in {s for s in config if fnmatchcase(s, glob)}: if key in config[section]: value = Path(config[section][key]).expanduser() if not value.is_absolute(): value = (path.parent / value).resolve() config[section][key] = str(value) return config def set_defaults_from(self, config): """ Sets defaults for all arguments from their associated configuration entries in *config*. """ kwargs = { dest: config.getboolean(section, key) if type is boolean else config[section][key] for dest, (section, key, type) in self._config_map.items() if section in config and key in config[section] } return super().set_defaults(**kwargs) def update_config(self, config, namespace): """ Copy values from *namespace* (an :class:`argparse.Namespace`, presumably the result of calling something like :meth:`~argparse.ArgumentParser.parse_args`) to *config*, a :class:`~configparser.ConfigParser`. Note that namespace values will be converted to :class:`str` implicitly. """ for dest, (section, key, type) in self._config_map.items(): config[section][key] = str(getattr(namespace, dest)) def of_type(self, type): """ Return a set of (section, key) tuples listing all configuration items which were defined as being of the specified *type* (with the *type* keyword passed to :meth:`add_argument`. """ return { (section, key) for section, key, item_type in self._config_map.values() if item_type is type } def port(s): """ Convert the string *s* into a port number. The string may either contain an integer representation (in which case the conversion is trivial, or a port name, in which case :func:`socket.getservbyname` will be used to convert it to a port number (usually via NSS). """ try: return int(s) except ValueError: try: return socket.getservbyname(s) except OSError: raise ValueError(lang._('invalid service name or port number')) def boolean(s): """ Convert the string *s* to a :class:`bool`. A typical set of case insensitive strings are accepted: "yes", "y", "true", "t", and "1" are converted to :data:`True`, while "no", "n", "false", "f", and "0" convert to :data:`False`. Other values will result in :exc:`ValueError`. """ try: return { 'n': False, 'no': False, 'f': False, 'false': False, '0': False, 'y': True, 'yes': True, 't': True, 'true': True, '1': True, }[str(s).strip().lower()] except KeyError: raise ValueError(lang._('invalid boolean value: {s}'.format(s=s))) def size(s): """ Convert the string *s*, which must contain a number followed by an optional suffix (MB for mega-bytes, GB, for giga-bytes, etc.), and return the absolute integer value (scale the number in the string by the suffix given). """ for power, suffix in enumerate(['KB', 'MB', 'GB', 'TB'], start=1): if s.endswith(suffix): n = Decimal(s[:-len(suffix)]) result = int(n * 2 ** (10 * power)) break else: if s.endswith('B'): result = int(s[:-1]) else: # No recognized suffix; attempt straight conversion result = int(s) return result def serial(s): """ Convert the string *s*, which must contain a number in hexidecimal format to an :class:`int`. If *s* begins with either "10000000" or "00000000" then these prefixes will be discarded. This is intended to provide a representation of a Raspberry Pi's serial number that is consistent with that used by the TFTP client in the Pi's bootloader. """ s = s.strip() if len(s) >= 16 and (s.startswith('10000000') or s.startswith('00000000')): s = s[8:] value = int(s, base=16) if not 0 <= value <= 0xFFFFFFFF: raise ValueError(lang._( 'serial number is out of range: {value}'.format(value=value))) return value class Board(namedtuple('Board', ('serial', 'image', 'partition', 'ip'))): """ Represents a known board, recording its *serial* number, the *image* (filename) that the board should boot, the *partition* number within the *image* that contains the boot partition, and the IP address (if any) that the board should have. """ @classmethod def from_section(cls, config, section): """ Construct a new :class:`Board` from the specified *section* of the *config* (a mapping, e.g. a :class:`~configparser.ConfigParser` section). """ if not section.startswith('board:'): raise ValueError(lang._( 'invalid section name: {section}'.format(section=section))) values = config[section] sernum = serial(section[len('board:'):]) image = values['image'] part = int(values.get('partition', 1)) try: ip = ip_address(values['ip']) except KeyError: ip = None return cls(sernum, Path(image), part, ip) @classmethod def from_string(cls, s): """ Construct a new :class:`Board` from the string *s* which is expected to be a comma-separated list of serial number, filename, partition number, and IP address. The last two parts (partition number and IP address) are optional and default to 1 and :data:`None` respectively. """ sernum, image, *extra = s.split(',') sernum = serial(sernum) ip = part = None if len(extra) > 2: raise ValueError(lang._( 'expected serial,filename,[part],[ip] instead of {s}' .format(s=s))) elif len(extra) > 1: part = extra[0] ip = extra[1] elif len(extra) > 0: part = extra[0] if part: try: part = int(part) except ValueError: raise ValueError(lang._( 'invalid partition number {part!r}'.format(part=part))) else: part = 1 if ip is not None: ip = ip_address(ip) return cls(sernum, Path(image), part, ip) def __str__(self): return '\n'.join(( f"[board:{self.serial:x}]", f"image = {self.image}", f"partition = {self.partition:d}", ) + ((f"ip = {self.ip}",) if self.ip is not None else ())) _SPANS = { span: re.compile(fr'(?:(?P[+-]?\d+)\s*{suffix}\b)') for span, suffix in [ ('microseconds', '(micro|u|µ)s(ec(ond)?s?)?'), ('milliseconds', '(milli|m)s(ec(ond)?s?)?'), ('seconds', 's(ec(ond)?s?)?'), ('minutes', 'm(i(n(ute)?s?)?)?'), ('hours', 'h((ou)?rs?)?'), ] } def duration(s): """ Convert the string *s* to a :class:`~datetime.timedelta`. The string must consist of white-space and/or comma separated values which are a number followed by a suffix indicating duration. For example: >>> duration('1s') timedelta(seconds=1) >>> duration('5 minutes, 30 seconds') timedelta(seconds=330) The set of possible durations, and their recognized suffixes is as follows: * *Microseconds*: microseconds, microsecond, microsec, micros, micro, useconds, usecond, usecs, usec, us, µseconds, µsecond, µsecs, µsec, µs * *Milliseconds*: milliseconds, millisecond, millisec, millis, milli, mseconds, msecond, msecs, msec, ms * *Seconds*: seconds, second, secs, sec, s * *Minutes*: minutes, minute, mins, min, mi, m * *Hours*: hours, hour, hrs, hr, h If conversion fails, :exc:`ValueError` is raised. """ spans = {} t = s for span, regex in _SPANS.items(): m = regex.search(t) if m: spans[span] = spans.get(span, 0) + int(m.group('num')) t = (t[:m.start(0)] + t[m.end(0):]).strip(' \t\n,') if not t: break if t: raise ValueError(lang._('invalid duration {s}'.format(s=s))) return dt.timedelta(**spans) nobodd-0.4/nobodd/default.conf000066400000000000000000000042251457216553300164000ustar00rootroot00000000000000[tftp] # listen specifies the address(es) the TFTP server will listen on. The default # of "::1" means listen on localhost addresses. You will likely wish to change # this to "::" for all addresses, or "0.0.0.0" for all IPv4 addresses. # # The special values "stdin" and "systemd" may also be provided. For "stdin", # the server will attempt to use the stdin file descriptor (0) as the listening # socket (which is typically how inetd passes sockets to daemons). For # "systemd", the server will attempt to find the file descriptor passed in the # environment from the systemd service manager. # # The --listen parameter on the command line may override this setting. listen = ::1 # port specifies the port the TFTP server will listen to. The default value is # 69, the registered port of the TFTP protocol. You may also specify a port # name here (e.g. "tftp") to be resolved by /etc/services. # # Please note that, if listen is set to "stdin" or "systemd" then the port # setting is ignored, as the service manager has already opened and bound the # socket. # # The --port parameter on the command line may override this setting. port = 69 # includedir provides the path, etiher absolute or relative to the # configuration file, of a directory from which additional configuration files, # all of which must match the wildcard pattern "*.conf", will be read in sorted # order. includedir = /etc/nobodd/conf.d # All [board:SERIAL] sections must contain the "board:" prefix, followed by the # serial number of the Raspberry Pi they apply to. All files served from the # image specified within this section will be served as if they existed under a # directory named after the serial number. [board:0123abcd] # image specifies the path, either absolute or relative to the configuration # file, of the image that the identified Raspberry Pi will boot. image = /srv/images/ubuntu.img # partition optionally specifies the number of the boot partition within the # image. If not specified, it defaults to 1. partition = 1 # ip optionally limits the service of files from this image to clients matching # the specified address. If not specified, the share will not be ip-limited. ip = 192.168.0.5 nobodd-0.4/nobodd/disk.py000066400000000000000000000337451457216553300154220ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2023-2024 Dave Jones # Copyright (c) 2023-2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import os import mmap import uuid import warnings from binascii import crc32 from collections.abc import Mapping from . import lang from .mbr import MBRHeader, MBRPartition from .gpt import GPTHeader, GPTPartition class DiskImage: """ Represents a disk image, specified by *filename_or_obj* which must be a :class:`str` or :class:`~pathlib.Path` naming the file, or a file-like object. If a file-like object is provided, it *must* have a :attr:`~io.IOBase.fileno` method which returns a valid file-descriptor number (the class uses :class:`~mmap.mmap` internally which requires a "real" file). The disk image is expected to be partitioned with either an `MBR`_ partition table or a `GPT`_. The partitions within the image can be enumerated with the :attr:`partitions` attribute. The instance can (and should) be used as a context manager; exiting the context will call the :meth:`close` method implicitly. If specified, *sector_size* is the size of sectors (in bytes) within the disk image. This defaults to 512 bytes, and should almost always be left alone. The *access* parameter controls the access used when constructing the memory mapping. This defaults to :data:`mmap.ACCESS_READ` for read-only access. If you wish to write to file-systems within the disk image, change this to :data:`mmap.ACCESS_WRITE`. You may also use :data:`mmap.ACCESS_COPY` for read-write mappings that don't actually affect the underlying disk image. .. note:: Please note that this library provides no means to re-partition disk images, just the ability to re-write files within FAT partitions. .. _MBR: https://en.wikipedia.org/wiki/Master_boot_record .. _GPT: https://en.wikipedia.org/wiki/GUID_Partition_Table """ def __init__(self, filename_or_obj, sector_size=512, access=mmap.ACCESS_READ): self._ss = sector_size if isinstance(filename_or_obj, os.PathLike): filename_or_obj = filename_or_obj.__fspath__() self._opened = isinstance(filename_or_obj, str) if self._opened: self._file = open( filename_or_obj, 'r+b' if access == mmap.ACCESS_WRITE else 'rb') else: self._file = filename_or_obj self._map = mmap.mmap(self._file.fileno(), 0, access=access) self._mem = memoryview(self._map) self._partitions = None def __repr__(self): return ( f'<{self.__class__.__name__} file={self._file!r} ' f'style={self.style!r} signature={self.signature!r}>') def __enter__(self): return self def __exit__(self, *exc): self.close() def close(self): """ Destroys the memory mapping used on the file provided. If the file was opened by this class, it will also be closed. This method is idempotent and is implicitly called when the instance is used as a context manager. .. note:: All mappings derived from this one *must* be closed before calling this method. By far the easiest means of arranging this is to consistently use context managers with all instances derived from this. """ if self._map is not None: self._mem.release() self._map.close() if self._opened: self._file.close() self._partitions = None self._map = None self._mem = None self._file = None @property def style(self): """ The style of partition table in use on the disk image. Will be one of the strings, 'gpt' or 'mbr'. """ return self.partitions.style @property def signature(self): """ The identifying signature of the disk. In the case of a GPT partitioned disk, this is a :class:`~uuid.UUID`. In the case of MBR, this is a 32-bit integer number. """ return self.partitions.signature @property def partitions(self): """ Provides access to the partitions in the image as a :class:`~collections.abc.Mapping` of partition number to :class:`DiskPartition` instances. .. warning:: Disk partition numbers start from 1 and need not be contiguous, or ordered. For example, it is perfectly valid to have partition 1 occur later on disk than partition 2, for partition 3 to be undefined, and partition 4 to be defined between partition 1 and 2. The partition number is essentially little more than an arbitrary key. In the case of MBR partition tables, it is particularly common to have missing partition numbers as the primary layout only permits 4 partitions. Hence, the "extended partitions" scheme numbers partitions from 5. However, if not all primary partitions are defined, there will be a "jump" from, say, partition 2 to partition 5. """ # This is a bit hacky, but reliable enough for our purposes. We check # for the "EFI PART" signature at the start of sector 1 and, if we find # it, we assume we're dealing with GPT. We don't check for a protective # or hybrid MBR because we wouldn't use it in any case. Otherwise we, # check for a valid MBR boot-signature at the appropriate offset. # Failing both of these, we raise an error. if self._partitions is None: for cls in (DiskPartitionsGPT, DiskPartitionsMBR): try: self._partitions = cls(self._mem, self._ss) except ValueError: pass else: break else: raise ValueError(lang._( 'Unable to determine partitioning scheme in use by ' '{self._file}'.format(self=self))) return self._partitions class DiskPartition: """ Represents an individual disk partition within a :class:`DiskImage`. Instances of this class are returned as the values of the mapping provided by :attr:`DiskImage.partitions`. Instances can (and should) be used as a context manager to implicitly close references upon exiting the context. """ def __init__(self, mem, label, type): self._mem = mem self._label = label self._type = type def __repr__(self): return ( f'<{self.__class__.__name__} size={self._mem.nbytes} ' f'label={self._label!r} type={self._type!r}>') def __enter__(self): return self def __exit__(self, *exc): self.close() def close(self): """ Release the internal :class:`memoryview` reference. This method is idempotent and is implicitly called when the instance is used as a context manager. """ self._mem.release() @property def type(self): """ The type of the partition. For `GPT`_ partitions, this will be a :class:`uuid.UUID` instance. For `MBR`_ partitions, this will be an :class:`int`. """ return self._type @property def label(self): """ The label of the partition. `GPT`_ partitions may have a 36 character unicode label. `MBR`_ partitions do not have a label, so the string "Partition {num}" will be used instead (where *{num}* is the partition number). """ return self._label @property def data(self): """ Returns a buffer (specifically, a :class:`memoryview`) covering the contents of the partition in the owning :class:`DiskImage`. """ return self._mem class DiskPartitions(Mapping): """ Abstract base class for the classes that handle specific partition layouts. Provides common handlers for :func:`repr` amongst other things. """ def __repr__(self): partitions = '\n'.join(f'{key}: {part!r},' for key, part in self.items()) return f'{self.__class__.__name__}({{\n{partitions}\n}})' class DiskPartitionsGPT(DiskPartitions): """ Provides a :class:`~collections.abc.Mapping` from partition number to :class:`DiskPartition` instances for a `GPT`_. *mem* is the buffer covering the whole disk image. *sector_size* specifies the sector size of the disk image, which should almost always be left at the default of 512 bytes. """ style = 'gpt' def __init__(self, mem, sector_size=512): header = GPTHeader.from_buffer(mem, sector_size * 1) if header.signature != b'EFI PART': raise ValueError(lang._('Bad GPT signature')) if header.revision != 0x10000: raise ValueError(lang._('Unrecognized GPT version')) if header.header_size != GPTHeader._FORMAT.size: raise ValueError(lang._('Bad GPT header size')) if crc32(bytes(header._replace(header_crc32=0))) != header.header_crc32: raise ValueError(lang._('Bad GPT header CRC32')) self._mem = mem self._header = header self._ss = sector_size @property def signature(self): return uuid.UUID(bytes_le=self._header.disk_guid) def _get_table(self): start = self._header.part_table_lba table_sectors = (( (self._header.part_table_size * self._header.part_entry_size) + self._ss - 1) // self._ss) return self._mem[self._ss * start:self._ss * (start + table_sectors)] def __len__(self): with self._get_table() as table: count = 0 for offset in range(0, len(table), self._header.part_entry_size): entry = GPTPartition.from_buffer(table, offset) if entry.type_guid != b'\x00' * 16: count += 1 return count def __getitem__(self, index): if not 1 <= index <= self._header.part_table_size: raise KeyError(index) with self._get_table() as table: entry = GPTPartition.from_buffer( table, self._header.part_entry_size * (index - 1)) if entry.part_guid == b'\x00' * 16: raise KeyError(index) start = self._ss * entry.first_lba finish = self._ss * (entry.last_lba + 1) return DiskPartition( mem=self._mem[start:finish], type=uuid.UUID(bytes_le=entry.type_guid), label=entry.part_label.decode('utf-16-le').rstrip('\x00')) def __iter__(self): with self._get_table() as table: for index in range(self._header.part_table_size): entry = GPTPartition.from_buffer( table, self._header.part_entry_size * index) if entry.part_guid == b'\x00' * 16: continue yield index + 1 class DiskPartitionsMBR(DiskPartitions): """ Provides a :class:`~collections.abc.Mapping` from partition number to :class:`DiskPartition` instances for a `MBR`_. *mem* is the buffer covering the whole disk image. *sector_size* specifies the sector size of the disk image, which should almost always be left at the default of 512 bytes. """ style = 'mbr' def __init__(self, mem, sector_size=512): header = MBRHeader.from_buffer(mem, offset=0) if header.boot_sig != 0xAA55: raise ValueError(lang._('Bad MBR signature')) if header.zero != 0: raise ValueError(lang._('Bad MBR zero field')) self._mem = mem self._header = header self._ss = sector_size if len(self) == 1 and self[1].type == 0xEE: raise ValueError(lang._('Protective MBR; use GPT instead')) @property def signature(self): return self._header.disk_sig def _get_logical(self, ext_offset): logical_offset = ext_offset while True: ebr = MBRHeader.from_buffer(self._mem, logical_offset * self._ss) if ebr.boot_sig != 0xAA55: raise ValueError(lang._('Bad EBR signature')) # Yield the logical partition part = MBRPartition.from_bytes(ebr.partition_1) part = part._replace(first_lba=part.first_lba + logical_offset) yield part part = MBRPartition.from_bytes(ebr.partition_2) if part.part_type == 0x00 and part.first_lba == 0: break elif part.part_type not in (0x05, 0x0F): raise ValueError(lang._( 'Second partition in EBR at LBA {logical_offset} is not ' 'another EBR or a terminal' .format(logical_offset=logical_offset))) logical_offset = part.first_lba + ext_offset def _get_primary(self): mbr = self._header extended = False for num, buf in enumerate(mbr.partitions, start=1): part = MBRPartition.from_bytes(buf) if part.part_type in (0x05, 0x0F): if extended: warnings.warn(UserWarning(lang._( 'Multiple extended partitions found'))) extended = True yield from enumerate(self._get_logical(part.first_lba), start=5) elif part.part_type != 0x00: yield num, part def __len__(self): return sum(1 for num, part in self._get_primary()) def __getitem__(self, index): for num, part in self._get_primary(): if num == index: last_lba = part.first_lba + part.part_size return DiskPartition( mem=self._mem[self._ss * part.first_lba:self._ss * last_lba], type=part.part_type, label=f'Partition {num}') raise KeyError(index) def __iter__(self): for num, part in self._get_primary(): yield num nobodd-0.4/nobodd/fat.py000066400000000000000000000340071457216553300152320ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2023-2024 Dave Jones # Copyright (c) 2023-2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import re import struct from collections import namedtuple from .tools import labels, formats # Structures sourced from the indispensible Wikipedia page on the Design of the # FAT file system [1]. Note that we're using the DOS 3.31 BPB definition below # as it's used in all modern FAT-12/16/32 implementations (and we're not # interested in supporting ancient FAT images here). # # [1]: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system BIOS_PARAMETER_BLOCK = """ 3s jump_instruction 8s oem_name H bytes_per_sector B sectors_per_cluster H reserved_sectors B fat_count H max_root_entries H fat16_total_sectors B media_descriptor H sectors_per_fat H sectors_per_track H heads_per_disk I hidden_sectors I fat32_total_sectors """ class BIOSParameterBlock( namedtuple('BIOSParameterBlock', labels(BIOS_PARAMETER_BLOCK))): """ A :func:`~collections.namedtuple` representing the `BIOS Parameter Block`_ found at the very start of a FAT file system (of any type). This provides several (effectively unused) legacy fields, but also several fields still used exclusively in later FAT variants (like the count of FAT-32 sectors). .. _BIOS Parameter Block: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system#BIOS_Parameter_Block """ __slots__ = () _FORMAT = struct.Struct(formats(BIOS_PARAMETER_BLOCK)) def __bytes__(self): return self._FORMAT.pack(*self) @classmethod def from_bytes(cls, s): """ Construct a :class:`BIOSParameterBlock` from the byte-string *s*. """ return cls(*cls._FORMAT.unpack(s)) @classmethod def from_buffer(cls, buf, offset=0): """ Construct a :class:`BIOSParameterBlock` from the specified *offset* (which defaults to 0) in the buffer protocol object, *buf*. """ return cls(*cls._FORMAT.unpack_from(buf, offset)) def to_buffer(self, buf, offset=0): """ Write this :class:`BIOSParameterBlock` to *buf*, a buffer protocol object, at the specified *offset* (which defaults to 0). """ self._FORMAT.pack_into(buf, offset, *self) EXTENDED_BIOS_PARAMETER_BLOCK = """ B drive_number 1x reserved B extended_boot_sig 4s volume_id 11s volume_label 8s file_system """ class ExtendedBIOSParameterBlock( namedtuple('ExtendedBIOSParameterBlock', labels(EXTENDED_BIOS_PARAMETER_BLOCK))): """ A :func:`~collections.namedtuple` representing the `Extended BIOS Parameter Block`_ found either immediately after the `BIOS Parameter Block`_ (in FAT-12 and FAT-16 formats), or after the `FAT32 BIOS Parameter Block`_ (in FAT-32 formats). This provides several (effectively unused) legacy fields, but also provides the "file_system" field which is used as the primary means of distinguishing the different FAT types (see :func:`nobodd.fs.fat_type`), and the self-explanatory "volume_label" field. .. _BIOS Parameter Block: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system#BIOS_Parameter_Block .. _Extended BIOS Parameter Block: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system#Extended_BIOS_Parameter_Block .. _FAT32 BIOS Parameter Block: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system#FAT32_Extended_BIOS_Parameter_Block """ __slots__ = () _FORMAT = struct.Struct(formats(EXTENDED_BIOS_PARAMETER_BLOCK)) def __bytes__(self): return self._FORMAT.pack(*self) @classmethod def from_bytes(cls, s): """ Construct a :class:`ExtendedBIOSParameterBlock` from the byte-string *s*. """ return cls(*cls._FORMAT.unpack(s)) @classmethod def from_buffer(cls, buf, offset=0): """ Construct a :class:`ExtendedBIOSParameterBlock` from the specified *offset* (which defaults to 0) in the buffer protocol object, *buf*. """ return cls(*cls._FORMAT.unpack_from(buf, offset)) def to_buffer(self, buf, offset=0): """ Write this :class:`ExtendedBIOSParameterBlock` to *buf*, a buffer protocol object, at the specified *offset* (which defaults to 0). """ self._FORMAT.pack_into(buf, offset, *self) FAT32_BIOS_PARAMETER_BLOCK = """ I sectors_per_fat H mirror_flags H version I root_dir_cluster H info_sector H backup_sector 12x reserved """ class FAT32BIOSParameterBlock( namedtuple('FAT32BIOSParameterBlock', labels(FAT32_BIOS_PARAMETER_BLOCK))): """ A :func:`~collections.namedtuple` representing the `FAT32 BIOS Parameter Block`_ found immediately after the `BIOS Parameter Block`_ in FAT-32 formats. In FAT-12 and FAT-16 formats it should not occur. This crucially provides the cluster containing the root directory (which is structured as a normal sub-directory in FAT-32) as well as the number of sectors per FAT, specifically for FAT-32. All other fields are ignored by this implementation. .. _FAT32 BIOS Parameter Block: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system#FAT32_Extended_BIOS_Parameter_Block .. _BIOS Parameter Block: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system#BIOS_Parameter_Block """ __slots__ = () _FORMAT = struct.Struct(formats(FAT32_BIOS_PARAMETER_BLOCK)) def __bytes__(self): return self._FORMAT.pack(*self) @classmethod def from_bytes(cls, s): """ Construct a :class:`FAT32BIOSParameterBlock` from the byte-string *s*. """ return cls(*cls._FORMAT.unpack(s)) @classmethod def from_buffer(cls, buf, offset=0): """ Construct a :class:`FAT32BIOSParameterBlock` from the specified *offset* (which defaults to 0) in the buffer protocol object, *buf*. """ return cls(*cls._FORMAT.unpack_from(buf, offset)) def to_buffer(self, buf, offset=0): """ Write this :class:`FAT32BIOSParameterBlock` to *buf*, a buffer protocol object, at the specified *offset* (which defaults to 0). """ self._FORMAT.pack_into(buf, offset, *self) FAT32_INFO_SECTOR = """ 4s sig1 480s reserved1 4s sig2 I free_clusters I last_alloc 12s reserved2 4s sig3 """ class FAT32InfoSector(namedtuple('FAT32InfoSector', labels(FAT32_INFO_SECTOR))): """ A :func:`~collections.namedtuple` representing the `FAT32 Info Sector`_ typically found in the sector after the `BIOS Parameter Block`_ in FAT-32 formats. In FAT-12 and FAT-16 formats it is not present. This records the number of free clusters available, and the last allocated cluster, which can speed up the search for free clusters during allocation. Because this implementation is capable of writing, and thus allocating clusters, and because the reserved fields must be ignored but not re-written, they are represented as strings here (rather than "x" NULs) to ensure they are preserved when writing. .. _FAT32 Info Sector: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system#FS_Information_Sector .. _BIOS Parameter Block: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system#BIOS_Parameter_Block """ __slots__ = () _FORMAT = struct.Struct(formats(FAT32_INFO_SECTOR)) def __bytes__(self): return self._FORMAT.pack(*self) @classmethod def from_bytes(cls, s): """ Construct a :class:`FAT32InfoSector` from the byte-string *s*. """ return cls(*cls._FORMAT.unpack(s)) @classmethod def from_buffer(cls, buf, offset=0): """ Construct a :class:`FAT32InfoSector` from the specified *offset* (which defaults to 0) in the buffer protocol object, *buf*. """ return cls(*cls._FORMAT.unpack_from(buf, offset)) def to_buffer(self, buf, offset=0): """ Write this :class:`FAT32InfoSector` to *buf*, a buffer protocol object, at the specified *offset* (which defaults to 0). """ self._FORMAT.pack_into(buf, offset, *self) DIRECTORY_ENTRY = """ 8s filename 3s ext B attr B attr2 B ctime_cs H ctime H cdate H adate H first_cluster_hi H mtime H mdate H first_cluster_lo I size """ class DirectoryEntry(namedtuple('DirectoryEntry', labels(DIRECTORY_ENTRY))): """ A :func:`~collections.namedtuple` representing a FAT `directory entry`_. This is a fixed-size structure which repeats up to the size of a cluster within a FAT root or sub-directory. It contains the (8.3 sized) filename of an entry, the size in bytes, the cluster at which the entry's data starts, the entry's attributes (which determine whether the entry represents a file or another sub-directory), and (depending on the format), the creation, modification, and access timestamps. Entries may represent deleted items in which case the first character of the *filename* will be 0xE5. If the *attr* is 0x0F, the entry is actually a long-filename entry and should be converted to :class:`LongFilenameEntry`. If *attr* is 0x10, the entry represents a sub-directory. See `directory entry`_ for more details. .. _directory entry: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system#Directory_entry """ __slots__ = () _FORMAT = struct.Struct(formats(DIRECTORY_ENTRY)) def __bytes__(self): return self._FORMAT.pack(*self) @classmethod def eof(cls): """ Make a directory entry from NUL bytes; this is used to signify the end of the directory in indexes. """ return cls.from_bytes(b'\0' * cls._FORMAT.size) @classmethod def from_bytes(cls, s): """ Construct a :class:`DirectoryEntry` from the byte-string *s*. """ return cls(*cls._FORMAT.unpack(s)) @classmethod def from_buffer(cls, buf, offset=0): """ Construct a :class:`DirectoryEntry` from the specified *offset* (which defaults to 0) in the buffer protocol object, *buf*. """ return cls(*cls._FORMAT.unpack_from(buf, offset)) def to_buffer(self, buf, offset=0): """ Write this :class:`DirectoryEntry` to *buf*, a buffer protocol object, at the specified *offset* (which defaults to 0). """ self._FORMAT.pack_into(buf, offset, *self) @classmethod def iter_over(cls, buf): """ Iteratively yields successive :class:`DirectoryEntry` instances from the buffer protocol object, *buf*. .. note:: This method is entirely dumb and does not check whether the yielded instances are valid; it is up to the caller to determine the validity of entries. """ for i in cls._FORMAT.iter_unpack(buf): yield cls(*i) LONG_FILENAME_ENTRY = """ B sequence 10s name_1 B attr 1x reserved B checksum 12s name_2 H first_cluster 4s name_3 """ class LongFilenameEntry( namedtuple('LongFilenameEntry', labels(LONG_FILENAME_ENTRY))): """ A :func:`~collections.namedtuple` representing a FAT `long filename`_. This is a variant of the FAT `directory entry`_ where the *attr* field is 0x0F. Several of these entries will appear before their corresponding :class:`DirectoryEntry`, but will be in *reverse* order. A *checksum* is incorporated for additional verification, and a *sequence* number indicating the number of segments, and which one is "last" (first in the byte-stream, but last in character order). .. _directory entry: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system#Directory_entry .. _long filename: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system#VFAT_long_file_names """ __slots__ = () _FORMAT = struct.Struct(formats(LONG_FILENAME_ENTRY)) def __bytes__(self): return self._FORMAT.pack(*self) @classmethod def from_bytes(cls, s): """ Construct a :class:`LongFilenameEntry` from the byte-string *s*. """ return cls(*cls._FORMAT.unpack(s)) @classmethod def from_buffer(cls, buf, offset=0): """ Construct a :class:`LongFilenameEntry` from the specified *offset* (which defaults to 0) in the buffer protocol object, *buf*. """ return cls(*cls._FORMAT.unpack_from(buf, offset)) def to_buffer(self, buf, offset=0): """ Write this :class:`LongFilenameEntry` to *buf*, a buffer protocol object, at the specified *offset* (which defaults to 0). """ self._FORMAT.pack_into(buf, offset, *self) @classmethod def iter_over(cls, buf): """ Iteratively yields successive :class:`LongFilenameEntry` instances from the buffer protocol object, *buf*. .. note:: This method is entirely dumb and does not check whether the yielded instances are valid; it is up to the caller to determine the validity of entries. """ for i in cls._FORMAT.iter_unpack(buf): yield cls(*i) def lfn_checksum(sfn, ext): """ Calculate the expected long-filename checksum given the *filename* and *ext* byte-strings of the short filename (from the corresponding :class:`Directoryentry`). """ result = 0 for char in sfn + ext: result = (((result & 1) << 7) + (result >> 1) + char) & 0xFF return result def lfn_valid(s): """ Returns :data:`True` if :class:`str` *s* only contains characters valid in a VFAT long filename. Almost every Unicode character is permitted with a few exceptions (angle brackets, wildcards, etc). """ return ( not s.startswith(' ') and not s.endswith((' ', '.')) and bool(lfn_valid.regex.match(s)) ) lfn_valid.regex = re.compile(r"^[\w !#$%&'()@^_`{}~+.,;=[\]-]+$") nobodd-0.4/nobodd/fs.py000066400000000000000000002134121457216553300150670ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2023-2024 Dave Jones # Copyright (c) 2023-2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import io import os import re import errno import struct import weakref import warnings import datetime as dt from abc import abstractmethod from collections import abc from itertools import islice from . import lang from .fat import ( BIOSParameterBlock, ExtendedBIOSParameterBlock, FAT32BIOSParameterBlock, FAT32InfoSector, DirectoryEntry, LongFilenameEntry, lfn_valid, lfn_checksum, ) from .path import FatPath, get_cluster from .tools import ( pairwise, encode_timestamp, any_match, exclude, ) class FatWarning(Warning): """ Base class for warnings issued by :class:`FatFileSystem`. """ class DirtyFileSystem(FatWarning): """ Raised when opening a FAT file-system that has the "dirty" flag set in the second entry of the FAT. """ class DamagedFileSystem(FatWarning): """ Raised when opening a FAT file-system that has the I/O errors flag set in the second entry of the FAT. """ class OrphanedLongFilename(FatWarning): """ Raised when a :class:`~nobodd.fat.LongFilenameEntry` is found with a mismatched checksum, terminal flag, out of order index, etc. This usually indicates an orphaned entry as the result of a non-LFN aware file-system driver manipulating a directory. """ class BadLongFilename(FatWarning): """ Raised when a :class:`~nobodd.fat.LongFilenameEntry` is unambiguously corrupted, e.g. including a non-zero cluster number, in a way that would not be caused by a non-LFN aware file-system driver. """ # The following references were invaluable in constructing this implementation; # the wikipedia page on the Design of the FAT File system [1], Jonathan # de Boyne Pollard's notes on determination of FAT widths [2], the # Microsoft Extensible Firmware Initiative FAT32 File System Specification [3], # and Electronic Lives Mfg.'s notes on the FAT File system [4]. # # [1]: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system # [2]: http://homepage.ntlworld.com/jonathan.deboynepollard/FGA/determining-fat-widths.html # [3]: http://download.microsoft.com/download/1/6/1/161ba512-40e2-4cc9-843a-923143f3456c/fatgen103.doc # [4]: http://elm-chan.org/docs/fat_e.html # # Future maintainers, please note [2] is a dead link at the time of writing; # use archive.org to retrieve. [1] is the best starting point although it does # attempt to drown the casual reader in detail, a lot of which can be ignored # (I have no interest in supporting, for example, DR-DOS' DELWATCH mechanism, # or CP/M-86's user attributes). # # [3] is extremely useful in some places, though you have to put up with the # slighly condescending tone as the author argues that everyone else habitually # gets it wrong, and Microsoft's detection algorithms are The One True Way # (reading [2] provides a good antidote to this). # # Unfortunately, in other places [3] is dreadfully vague for a spec (e.g. valid # SFN / LFN characters). Refer back to [1] for these. [4] is obviously partly # drawn from [3], but adds some extremely important notes that others have # omitted (or not noticed), such as the fact that volume labels can # legitimately duplicate the name of a later file in the root directory. class FatFileSystem: """ Represents a `FAT`_ file-system, contained at the start of the buffer object *mem*. This class supports the FAT-12, FAT-16, and FAT-32 formats, and will automatically determine which to use from the headers found at the start of *mem*. The type in use may be queried from :attr:`fat_type`. Of primary use is the :attr:`root` attribute which provides a :class:`~nobodd.path.FatPath` instance representing the root directory of the file-system. Instances can (and should) be used as a context manager; exiting the context will call the :meth:`close` method implicitly. If certain header bits are set, :exc:`DamagedFileSystem` and :exc:`DirtyFileSystem` warnings may be generated upon opening. If *atime* is :data:`False`, the default, then accesses to files will *not* update the atime field in file meta-data (when the underlying *mem* mapping is writable). Finally, *encoding* specifies the character set used for decoding and encoding DOS short filenames. .. _FAT: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system """ def __init__(self, mem, atime=False, encoding='iso-8859-1'): self._fat = None self._data = None self._root = None mem = memoryview(mem) try: self._fat_type, bpb, ebpb, ebpb_fat32 = fat_type(mem) self._atime = atime self._encoding = encoding # TODO: Replace with root volume label if == b'NO NAME ' self._label = ebpb.volume_label.decode( encoding, 'replace').rstrip(' ') total_sectors = bpb.fat16_total_sectors or bpb.fat32_total_sectors if total_sectors == 0 and ebpb.extended_boot_sig == 0x29: # FAT32 with >2**32 sectors uses file-system label as an 8-byte # int total_sectors, = struct.unpack('') def __enter__(self): return self def __exit__(self, *exc): self.close() def close(self): """ Releases the memory references derived from the buffer the instance was constructed with. This method is idempotent. """ if self._fat is not None: self._fat.close() self._fat = None if self._data is not None: self._data.close() self._data = None if self._root is not None: if self._fat_type != 'fat32': self._root.release() self._root = None @property def readonly(self): """ Returns :data:`True` if the underlying buffer is read-only. """ return self._data.readonly def open_dir(self, cluster): """ Opens the sub-directory in the specified *cluster*, returning a :class:`FatDirectory` instance representing it. .. warning:: This method is intended for internal use by the :class:`~nobodd.path.FatPath` class. """ if cluster == 0: if self._fat_type == 'fat32': return Fat32Root(self, self._root, self._encoding) elif self._fat_type == 'fat16': return Fat16Root(self._root, self._encoding) else: return Fat12Root(self._root, self._encoding) else: return FatSubDirectory(self, cluster, self._encoding) def open_file(self, cluster, mode='rb'): """ Opens the file at the specified *cluster*, returning a :class:`FatFile` instance representing it with the specified *mode*. Note that the :class:`FatFile` instance returned by this method has no directory entry associated with it. .. warning:: This method is intended for internal use by the :class:`~nobodd.path.FatPath` class, specifically for "files" underlying the sub-directory structure which do not have an associated size (other than that dictated by their FAT chain of clusters). """ return FatFile.from_cluster(self, cluster, mode) def open_entry(self, index, entry, mode='rb'): """ Opens the specified *entry*, which must be a :class:`~nobodd.fat.DirectoryEntry` instance, which must be a member of *index*, an instance of :class:`FatDirectory`. Returns a :class:`FatFile` instance associated with the specified *entry*. This permits writes to the file to be properly recorded in the corresponding directory entry. .. warning:: This method is intended for internal use by the :class:`~nobodd.path.FatPath` class. """ return FatFile.from_entry(self, index, entry, mode) @property def fat(self): """ A :class:`FatTable` sequence representing the FAT table itself. .. warning:: This attribute is intended for internal use by the :class:`FatFile` class, but may be useful for low-level exploration or manipulation of FAT file-systems. """ return self._fat @property def clusters(self): """ A :class:`FatClusters` sequence representing the clusters containing the data stored in the file-system. .. warning:: This attribute is intended for internal use by the :class:`FatFile` class, but may be useful for low-level exploration or manipulation of FAT file-systems. """ return self._data @property def fat_type(self): """ Returns a :class:`str` indicating the type of `FAT`_ file-system present. Returns one of "fat12", "fat16", or "fat32". """ return self._fat_type @property def label(self): """ Returns the label from the header of the file-system. This is an ASCII string up to 11 characters long. """ return self._label @property def sfn_encoding(self): """ The encoding used for short (8.3) filenames. This defaults to "iso-8859-1" but unfortunately there's no way of determining the correct codepage for these. """ return self._encoding @property def atime(self): """ If the underlying mapping is writable, then atime (last access time) will be updated upon reading the content of files, when this property is :data:`True` (the default is :data:`False`). """ return self._atime @property def root(self): """ Returns a :class:`~nobodd.path.FatPath` instance (a :class:`~pathlib.Path`-like object) representing the root directory of the FAT file-system. For example:: from nobodd.disk import DiskImage from nobodd.fs import FatFileSystem with DiskImage('test.img') as img: with FatFileSystem(img.partitions[1].data) as fs: print('ls /') for p in fs.root.iterdir(): print(p.name) .. note:: This is intended to be the primary entry-point for querying and manipulating the file-system at the high level. Only use the :attr:`fat` and :attr:`clusters` attributes, and the various "open" methods if you want to explore or manipulate the file-system at a low level. """ return FatPath._from_index(self, self.open_dir(0)) def fat_type(mem): """ Given a `FAT`_ file-system at the start of the buffer *mem*, determine its type, and decode its headers. Returns a four-tuple containing: * one of the strings "fat12", "fat16", or "fat32" * a :class:`~nobodd.fat.BIOSParameterBlock` instance * a :class:`~nobodd.fat.ExtendedBIOSParameterBlock` instance * a :class:`~nobodd.fat.FAT32BIOSParameterBlock`, if one is present, or :data:`None` otherwise """ fat_types = { b'FAT ': None, b'FAT12 ': 'fat12', b'FAT16 ': 'fat16', b'FAT32 ': 'fat32', } bpb = BIOSParameterBlock.from_buffer(mem) ebpb = ExtendedBIOSParameterBlock.from_buffer( mem, BIOSParameterBlock._FORMAT.size) try: fat_type = fat_types[ebpb.file_system] if fat_type is not None: return fat_type, bpb, ebpb, None except KeyError: pass if ebpb.extended_boot_sig in (0x28, 0x29): fat_type = fat_type_from_count(bpb, ebpb, None) return fat_type, bpb, ebpb, None ebpb_fat32 = FAT32BIOSParameterBlock.from_buffer( mem, BIOSParameterBlock._FORMAT.size) ebpb = ExtendedBIOSParameterBlock.from_buffer( mem, BIOSParameterBlock._FORMAT.size + FAT32BIOSParameterBlock._FORMAT.size) try: fat_type = fat_types[ebpb.file_system] if fat_type is not None: return fat_type, bpb, ebpb, ebpb_fat32 except KeyError: pass if ebpb.extended_boot_sig in (0x28, 0x29): fat_type = fat_type_from_count(bpb, ebpb, ebpb_fat32) return fat_type, bpb, ebpb, ebpb_fat32 raise ValueError(lang._( 'Could not find FAT file-system type or extended boot signature')) def fat_type_from_count(bpb, ebpb, ebpb_fat32): """ Derives the type of the `FAT`_ file-system when it cannot be determined directly from the *bpb* and *ebpb* headers (the :class:`~nobodd.fat.BIOSParameterBlock`, and :class:`~nobodd.fat.ExtendedBIOSParameterBlock` respectively). Uses `known limits`_ on the number of clusters to derive the type of FAT in use. Returns one of the strings "fat12", "fat16", or "fat32". .. _known limits: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system#Size_limits """ total_sectors = bpb.fat16_total_sectors or bpb.fat32_total_sectors if total_sectors == 0 and ebpb.extended_boot_sig == 0x29: # FAT32 with >2**32 sectors uses file-system label as an 8-byte int total_sectors, = struct.unpack('= self.max_valid: break # If we reach this point without the caller having broken out of their # loop, we've run out of space so raise the appropriate exception raise OSError(errno.ENOSPC, os.strerror(errno.ENOSPC)) class Fat12Table(FatTable): """ Concrete child of :class:`FatTable` for FAT-12 file-systems. .. autoattribute:: min_valid .. autoattribute:: max_valid .. autoattribute:: end_mark """ min_valid = 0x002 max_valid = 0xFEF end_mark = 0xFFF def __init__(self, mem, fat_size, info_mem=None): super().__init__() assert info_mem is None self._tables = tuple( mem[offset:offset + fat_size] for offset in range(0, len(mem), fat_size) ) def __len__(self): return (super().__len__() * 2) // 3 def get_all(self, cluster): try: offset = cluster + (cluster >> 1) if cluster % 2: return tuple( struct.unpack_from('> 4 for t in self._tables ) else: return tuple( struct.unpack_from('> 1) if cluster % 2: return struct.unpack_from( '> 4 else: return struct.unpack_from( '> 1) if cluster % 2: value <<= 4 value |= struct.unpack_from( '= self.max_valid: break yield from super().free() def get_all(self, cluster): return tuple(t[cluster] & 0x0FFFFFFF for t in self._tables) def __getitem__(self, cluster): return self._tables[0][cluster] & 0x0FFFFFFF def __setitem__(self, cluster, value): if not 0x00000000 <= value <= 0x0FFFFFFF: raise ValueError(lang._( '{value} is outside range 0x00000000..0x0FFFFFFF' .format(value=value))) old_value = self._tables[0][cluster] if not old_value and value: self._alloc(cluster) elif old_value and not value: self._dealloc(cluster) for table in self._tables: table[cluster] = (old_value & 0xF0000000) | (value & 0x0FFFFFFF) class FatClusters(abc.MutableSequence): """ :class:`~collections.abc.MutableSequence` representing the clusters of the file-system itself. While the sequence is mutable, clusters cannot be deleted or inserted, only read and (if the underlying buffer is writable) re-written. """ def __init__(self, mem, cluster_size): self._mem = mem self._cs = cluster_size def __enter__(self): return self def __exit__(self, *exc): self.close() def close(self): if self._mem is not None: self._mem.release() self._mem = None @property def size(self): """ Returns the size (in bytes) of clusters in the file-system. """ return self._cs @property def readonly(self): """ Returns :data:`True` if the underlying buffer is read-only. """ return self._mem.readonly def __len__(self): return len(self._mem) // self._cs def __getitem__(self, cluster): # The first data cluster is numbered 2, hence the offset below. # Clusters 0 and 1 are special and don't exist in the data portion of # the file-system if not 2 <= cluster < len(self) + 2: raise IndexError(cluster) offset = (cluster - 2) * self._cs return self._mem[offset:offset + self._cs] def __setitem__(self, cluster, value): # See above if not 2 <= cluster < len(self) + 2: raise IndexError(cluster) offset = (cluster - 2) * self._cs self._mem[offset:offset + self._cs] = value def __delitem__(self, cluster): raise TypeError(lang._('FS length is immutable')) def insert(self, cluster, value): """ Raises :exc:`TypeError`; the FS length is immutable. """ raise TypeError(lang._('FS length is immutable')) class FatDirectory(abc.MutableMapping): """ An abstract :class:`~collections.abc.MutableMapping` representing a `FAT directory`_. The mapping is ostensibly from filename to :class:`~nobodd.fat.DirectoryEntry` instances, but there are several oddities to be aware of. In VFAT, many files effectively have *two* filenames: the original DOS "short" filename (SFN hereafter) and the VFAT "long" filename (LFN hereafter). All files have an SFN; any file may optionally have an LFN. The SFN is stored in the :class:`~nobodd.fat.DirectoryEntry` which records details of the file (mode, size, cluster, etc). The optional LFN is stored in leading :class:`~nobodd.fat.LongFilenameEntry` records. Even when :class:`~nobodd.fat.LongFilenameEntry` records do *not* precede a :class:`~nobodd.fat.DirectoryEntry`, the file may still have an LFN that differs from the SFN in case only, recorded by flags in the :class:`~nobodd.fat.DirectoryEntry`. Naturally, some files still only have one filename because the LFN doesn't vary in case from the SFN, e.g. the special directory entries "." and "..", and anything which conforms to original DOS naming rules like "README.TXT". For the purposes of listing files, most FAT implementations (including this one) ignore the SFNs. Hence, iterating over this mapping will *not* yield the SFNs as keys (unless the SFN is equal to the LFN), and they are *not* counted in the length of the mapping. However, for the purposes of testing existence, opening, etc., FAT implementations allow the use of SFNs. Hence, testing for membership, or manipulating entries via the SFN will work with this mapping, and will implicitly manipulate the associated LFNs (e.g. deleting an entry via a SFN key will also delete the associated LFN key). In other words, if a file has a distinct LFN and SFN, it has *two* entries in the mapping (a "visible" LFN entry, and an "invisible" SFN entry). Further, note that FAT is case retentive (for LFNs; SFNs are folded uppercase), but not case sensitive. Hence, membership tests and retrieval from this mapping are case insensitive with regard to keys. Finally, note that the values in the mapping are always instances of :class:`~nobodd.fat.DirectoryEntry`. :class:`~nobodd.fat.LongFilenameEntry` instances are neither accepted nor returned; these are managed internally. .. _FAT directory: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system#Directory_table .. autoattribute:: MAX_SFN_SUFFIX """ MAX_SFN_SUFFIX = 0xFFFF SFN_VALID = re.compile(b"[^A-Z0-9 !#$%&'()@^_`{}~\x80-\xFF-]") __slots__ = ('_encoding',) @abstractmethod def _get_cluster(self): raise NotImplementedError @abstractmethod def _iter_entries(self): """ Abstract generator that is expected to yield successive offsets and the entries at those offsets as :class:`~nobodd.fat.DirectoryEntry` instances or :class:`~nobodd.fat.LongFilenameEntry` instances, as appropriate. All instances must be yielded, in the order they appear on disk, regardless of whether they represent deleted, orphaned, corrupted, terminal, or post-terminal entries. """ raise NotImplementedError @abstractmethod def _update_entry(self, offset, entry): """ Abstract method which is expected to (re-)write *entry* (a :class:`~nobodd.fat.DirectoryEntry` or :class:`~nobodd.fat.LongFilenameEntry` instance) at the specified *offset* in the directory. """ raise NotImplementedError def _split_entries(self, entries): """ Given *entries*, a sequence of :class:`~nobodd.fat.LongFilenameEntry` instances, ending with a single :class:`~nobodd.fat.DirectoryEntry` (as would typically be found in a FAT directory index), return the decoded long filename, short filename, and the directory entry record as a 3-tuple. If no long filename entries are present, the long filename will be equal to the short filename (but may have lower-case parts). .. note:: This function also carries out several checks, including the filename checksum, that all checksums match, that the number of entries is valid, etc. Any violations found may raise warnings including :exc:`OrphanedLongFilename` and :exc:`BadLongFilename`. """ # The extration of the long filename could be simpler, but let's do all # the checks we can (the structure includes a *lot* of redundancy for # checking things!) assert entries *lfn_entries, entry = entries assert isinstance(entry, DirectoryEntry) checksum = lfn_checksum(entry.filename, entry.ext) lfn = self._join_lfn_entries(lfn_entries, checksum) if lfn is not None: lfn = lfn.decode('utf-16le').rstrip('\uffff') # There may be one trailing NUL char, but there may not if the # filename fits perfectly in a LFN structure if lfn[-1:] == '\x00': lfn = lfn[:-1] if not lfn: warnings.warn(BadLongFilename(lang._( 'empty LongFilenameEntry decoded'))) lfn = None sfn = entry.filename.rstrip(b' ') # If initial char of the filename is 0xE5 (which is reserved to # indicate a deleted entry) then it's encoded as 0x05 (since DOS 3.0) if sfn[0] == 0x05: sfn = b'\xE5' + sfn[1:] sfn = sfn.decode(self._encoding) ext = entry.ext.rstrip(b' ').decode(self._encoding) # Bits 3 & 4 of attr2 are used by Windows NT (basically any modern # Windows) to indicate if the short filename (in the absence of long # filename entries) has upper / lower-case portions if lfn is None: lfn = sfn.lower() if entry.attr2 & 0b1000 else sfn if ext: lfn = lfn + '.' + (ext.lower() if entry.attr2 & 0b10000 else ext) if ext: sfn = sfn + '.' + ext return lfn, sfn, entry def _join_lfn_entries(self, entries, checksum, sequence=0, lfn=b''): """ Given *entries*, a sequence of :class:`~nobodd.fat.LongFilenameEntry` instances, decode the long filename encoded within them, ensuring that all the invariants (sequence number, checksums, terminal flag, etc.) are obeyed. Returns the decoded (:class:`str`) long filename, or :data:`None` if no valid long filename can be found. Emits various warnings if invalid entries are encountered during decoding, including :exc:`OrphanedLongFilename` and :exc:`BadLongFilename`. """ if not entries: return None head, *entries = entries if head.first_cluster != 0: warnings.warn(BadLongFilename(lang._( 'LongFilenameEntry.first_cluster is non-zero: ' '{head.first_cluster}'.format(head=head)))) return self._join_lfn_entries(entries, checksum) if head.checksum != checksum: warnings.warn(OrphanedLongFilename(lang._( 'mismatched LongFilenameEntry.checksum: {checksum} != ' '{head.checksum}'.format(checksum=checksum, head=head)))) return self._join_lfn_entries(entries, checksum) if head.sequence & 0x40: if lfn: # NOTE: Add the new terminal back onto the list to be # processed. All other failures (below) don't need to do this # because they're definitely non-terminal and thus can't start # a valid LongFilenameEntry run warnings.warn(OrphanedLongFilename(lang._( 'new terminal LongFilenameEntry'))) return self._join_lfn_entries([head] + entries, checksum) sequence = head.sequence & 0b11111 if not sequence: warnings.warn(BadLongFilename(lang._( 'LongFilenameEntry.sequence is zero'))) return self._join_lfn_entries(entries, checksum) elif head.sequence != sequence: warnings.warn(OrphanedLongFilename(lang._( 'unexpected LongFilenameEntry.sequence: {sequence} != ' '{head.sequence}'.format(sequence=sequence, head=head)))) return self._join_lfn_entries(entries, checksum) lfn = head.name_1 + head.name_2 + head.name_3 + lfn if sequence == 1: if entries: warnings.warn(OrphanedLongFilename(lang._( 'more LongFilenameEntry after sequence: 1'))) return self._join_lfn_entries(entries, checksum) return lfn else: if not entries: warnings.warn(OrphanedLongFilename(lang._( 'missing LongFilenameEntry after sequence: {sequence}' .format(sequence=sequence)))) return self._join_lfn_entries(entries, checksum, sequence - 1, lfn) def _prefix_entries(self, filename, entry): """ Given *entry*, a :class:`~nobodd.fat.DirectoryEntry`, generate the necessary :class:`~nobodd.fat.LongFilenameEntry` instances (if any), that are necessary to associate *entry* with the specified *filename*. This function merely constructs the instances, ensuring the (many, convoluted!) rules are followed, including that the short filename, if one is generated, is unique in this directory, and the long filename is encoded and check-summed appropriately. .. note:: The *filename* and *ext* fields of *entry* are ignored by this method. The only filename that is considered is the one explicitly passed in which becomes the basis for the long filename entries *and* the short filename stored within the *entry* itself. The return value is the sequence of long filename entries and the modified directory entry in the order they should appear on disk. """ lfn, sfn, ext, attr2 = self._get_names(filename) if lfn: checksum = lfn_checksum(sfn, ext) entries = [ LongFilenameEntry( sequence=part, name_1=lfn[offset:offset + 10], attr=0xF, checksum=checksum, name_2=lfn[offset + 10:offset + 22], first_cluster=0, name_3=lfn[offset + 22:offset + 26] ) for part, offset in enumerate(range(0, len(lfn), 26), start=1) ] entries.reverse() # Add terminal marker to "last" entry entries[0] = entries[0]._replace( sequence=0x40 | entries[0].sequence) else: entries = [] entries.append(entry._replace(filename=sfn, ext=ext, attr2=attr2)) return entries def _get_names(self, filename): """ Given a *filename*, generate an appropriately encoded long filename (encoded in little-endian UCS-2), short filename (encoded in the file-system's SFN encoding), extension, and the case attributes. The result is a 4-tuple: ``lfn, sfn, ext, attr``. ``lfn``, ``sfn``, and ``ext`` will be :class:`bytes` strings, and ``attr`` will be an :class:`int`. If *filename* is capable of being represented as a short filename only (potentially with non-zero case attributes), ``lfn`` in the result will be zero-length. """ # sfn == short filename, lfn == long filename, ext == extension if filename in ('.', '..'): sfn, ext = filename.encode(self._encoding), b'' else: sfn = filename.lstrip('.').upper().encode(self._encoding, 'replace') sfn = sfn.replace(b' ', b'') if b'.' in sfn: sfn, ext = sfn.rsplit(b'.', 1) else: sfn, ext = sfn, b'' sfn = self.SFN_VALID.sub(b'_', sfn) ext = self.SFN_VALID.sub(b'_', ext) if len(sfn) <= 8 and len(ext) <= 3: # NOTE: Huh, a place where match..case might actually be # useful! Why isn't this a dict? It was originally, but in # purely symbolic cases (e.g. "." and "..") the transformed SFN # can be equivalent in all cases and we want to explicitly prefer # the case where attr is 0. sfn_only = True lfn = filename.encode(self._encoding, 'replace') make_sfn = lambda s, e: (s + b'.' + e) if e else s if lfn == make_sfn(sfn, ext): attr = 0 elif lfn == make_sfn(sfn, ext.lower()): attr = 0b10000 elif lfn == make_sfn(sfn.lower(), ext): attr = 0b01000 elif lfn == make_sfn(sfn.lower(), ext.lower()): attr = 0b11000 else: sfn_only = False attr = 0 else: sfn_only = False attr = 0 if sfn_only: lfn = b'' else: lfn = filename.encode('utf-16le') if len(lfn) > 255 * 2: raise ValueError(lang._( '{filename} is too long (more than 255 UCS-2 characters)' .format(filename=filename))) # NUL terminate if len(result) mod 26 # result fits perfectly in a LongFilenameEntry) if len(lfn) % 26: lfn += b'\0\0' if len(lfn) % 26: pad = ((len(lfn) + 25) // 26) * 26 lfn = lfn.ljust(pad, b'\xff') assert len(lfn) % 26 == 0 ext = ext[:3] sfn = self._get_unique_sfn( sfn.decode(self._encoding), ext.decode(self._encoding)).encode(self._encoding, 'replace') sfn = sfn.ljust(8, b' ') ext = ext.ljust(3, b' ') return lfn, sfn, ext, attr def _get_unique_sfn(self, prefix, ext): """ Given *prefix* and *ext*, which are :class:`str`, of the short filename prefix and extension, find a suffix that is unique in the directory (amongst both long *and* short filenames, because these are still in the same namespace). For example, in a directory containing ``default.config`` (which has shortname ``DEFAUL~1.CON``), given the filename and extension ``default.conf``, this function will return the :class:`str` ``DEFAUL~2.CON``. Because the search requires enumeration of the whole directory, which is expensive, an artificial limit of :data:`MAX_SFN_SUFFIX` is enforced. If this is reached, the search will terminate with an :exc:`OSError` with code ENOSPC (out of space). """ ranges = [range(1, self.MAX_SFN_SUFFIX)] regexes = [ re.compile( f'{re.escape(prefix[:7 - i])}~([0-9]{{{i}}})\\.{re.escape(ext)}' if ext else f'{re.escape(prefix[:7 - i])}~([0-9]{{{i}}})', re.IGNORECASE) for i in range(1, len(str(self.MAX_SFN_SUFFIX)) + 1) ] for offset, entries in self._group_entries(): lfn, sfn, entry = self._split_entries(entries) m = any_match(sfn, regexes) if m: exclude(ranges, int(m.group(1))) m = any_match(lfn, regexes) if m: exclude(ranges, int(m.group(1))) for r in ranges: return f'{prefix[:7 - len(str(r.start))]}~{r.start}' # We cannot create any shortnames that aren't already taken. Given the # limit on entries in a dir (MAX_SFN_SUFFIX, roughly) report ENOSPC raise OSError(errno.ENOSPC, os.strerror(errno.ENOSPC)) def _group_entries(self): """ Generator which yields an offset, and a sequence of either :class:`~nobodd.fat.LongFilenameEntry` and :class:`~nobodd.fat.DirectoryEntry` instances. Each tuple yielded represents a single (extant, non-deleted) file or directory with its long-filename entries at the start, and the directory entry as the final element. The offset associated with the sequence is the offset of the *directory entry* (not its preceding long filename entries). In other words, for a file with three long-filename entries, the following might be yielded:: (160, [ ), ), ), ) ]) This indicates that the directory entry is at offset 160, preceded by long filename entries at offsets 128, 96, and 64. """ entries = [] for offset, entry in self._iter_entries(): if isinstance(entry, LongFilenameEntry): if entry.sequence == 0xE5: # deleted entry continue entries.append(entry) if isinstance(entry, DirectoryEntry): if entry.filename[0] == 0: # end of valid entries break elif entry.attr & 0x8: # volume label pass elif entry.filename[0] != 0xE5: # deleted entry yield offset, entries entries = [] def _clean_entries(self): """ Find and remove all deleted entries from the directory. The method scans the directory for all directory entries and long filename entries which start with 0xE5, indicating a deleted entry, and overwrites them with later (not deleted) entries. Trailing entries are then zeroed out. The return value is the new offset of the terminal entry. """ write_offset = 0 for read_offset, entry in self._iter_entries(): if isinstance(entry, DirectoryEntry): if entry.filename[0] == 0: # end of valid entries break elif entry.filename[0] == 0xE5: # deleted entry continue if isinstance(entry, LongFilenameEntry): if entry.sequence == 0xE5: # deleted entry continue if read_offset > write_offset: self._update_entry(write_offset, entry) write_offset += DirectoryEntry._FORMAT.size else: # If we exit the loop without a break, the source has no EOF marker # which is strictly invalid; advance the read_offset to force one read_offset += DirectoryEntry._FORMAT.size eof = write_offset empty = DirectoryEntry.eof() while write_offset < read_offset: self._update_entry(write_offset, empty) write_offset += DirectoryEntry._FORMAT.size return eof def __len__(self): return sum(1 for lfn in self) def __iter__(self): for offset, entries in self._group_entries(): lfn, sfn, entry = self._split_entries(entries) yield lfn def items(self): # NOTE: Overridden to avoid quadratic behaviour of inherited method for offset, entries in self._group_entries(): lfn, sfn, entry = self._split_entries(entries) yield lfn, entry def values(self): # NOTE: Overridden to avoid quadratic behaviour of inherited method for offset, entries in self._group_entries(): lfn, sfn, entry = self._split_entries(entries) yield entry def __contains__(self, name): uname = name.upper() for offset, entries in self._group_entries(): lfn, sfn, entry = self._split_entries(entries) if lfn.upper() == uname or sfn == name: return True return False def __getitem__(self, name): uname = name.upper() for offset, entries in self._group_entries(): lfn, sfn, entry = self._split_entries(entries) if lfn.upper() == uname or sfn == uname: return entry raise KeyError(name) def __setitem__(self, name, entry): # NOTE: For the purposes of setting entries, the filename and ext # within *entry* are ignored. For new entries, these will be generated # from *name*. For existing entries, the existing values will be # re-used uname = name.upper() offset = -DirectoryEntry._FORMAT.size for offset, entries in self._group_entries(): lfn, sfn, old_entry = self._split_entries(entries) if lfn.upper() == uname or sfn == uname: self._update_entry(offset, entry._replace( filename=old_entry.filename, ext=old_entry.ext)) return # This isn't *necessarily* the actual EOF. It could be orphaned or # deleted entries that _group_entries isn't yielding, but that doesn't # matter for our purposes. All that matters is that we can safely # overwrite these entries eof_offset = offset + DirectoryEntry._FORMAT.size entries = self._prefix_entries(name, entry) entries.append(DirectoryEntry.eof()) for cleaned in (False, True): # We write the entries in reverse order to make it more likely that # anything scanning the directory simultaneously sees the append as # "atomic" (because the last item written overwrites the old # terminal marker entry) offsets = range( eof_offset, eof_offset + len(entries) * DirectoryEntry._FORMAT.size, DirectoryEntry._FORMAT.size) try: for offset, entry in reversed(list(zip(offsets, entries))): self._update_entry(offset, entry) except OSError as e: # If the directory structure runs out of space (which is more # likely under FAT-12 and FAT-16 where the root directory is # fixed in size), then all deleted entries will be expunged, # and the method will attempt to append the new entries once # more if e.errno == errno.ENOSPC and not cleaned: eof_offset = self._clean_entries() else: raise else: return def __delitem__(self, name): uname = name.upper() for offset, entries in self._group_entries(): lfn, sfn, entry = self._split_entries(entries) if lfn.upper() == uname or sfn == uname: # NOTE: We update the DirectoryEntry first then work backwards, # marking the long filename entries. This ensures anything # simultaneously scanning the directory shouldn't find a "live" # directory entry preceded by "dead" long filenames for entry in reversed(entries): if isinstance(entry, DirectoryEntry): self._update_entry(offset, entry._replace( filename=b'\xE5' + entry.filename[1:])) else: # LongFilenameEntry self._update_entry(offset, entry._replace( sequence=0xE5)) offset -= DirectoryEntry._FORMAT.size return raise KeyError(name) cluster = property(lambda self: self._get_cluster()) class FatRoot(FatDirectory): """ An abstract derivative of :class:`FatDirectory` representing the (fixed-size) root directory of a FAT-12 or FAT-16 file-system. Must be constructed with *mem*, which is a buffer object covering the root directory clusters, and *encoding*, which is taken from :attr:`FatFileSystem.sfn_encoding`. The :class:`Fat12Root` and :class:`Fat16Root` classes are (trivial) concrete derivatives of this. """ __slots__ = ('_mem',) def __init__(self, mem, encoding): self._encoding = encoding self._mem = mem def _get_cluster(self): return 0 def _update_entry(self, offset, entry): if offset >= len(self._mem): raise OSError(errno.ENOSPC, os.strerror(errno.ENOSPC)) entry.to_buffer(self._mem, offset) def _iter_entries(self): for offset in range(0, len(self._mem), DirectoryEntry._FORMAT.size): entry = DirectoryEntry.from_buffer(self._mem, offset) if entry.attr == 0x0F: entry = LongFilenameEntry.from_buffer(self._mem, offset) yield offset, entry class FatSubDirectory(FatDirectory): """ A concrete derivative of :class:`FatDirectory` representing a sub-directory in a FAT file-system (of any type). Must be constructed with *fs* (a :class:`FatFileSystem` instance), *start* (the first cluster of the sub-directory), and *encoding*, which is taken from :attr:`FatFileSystem.sfn_encoding`. """ __slots__ = ('_cs', '_file', 'fat_type') def __init__(self, fs, start, encoding): self._encoding = encoding self._cs = fs.clusters.size # NOTE: We always open sub-directories with a writable mode when # possible; this simply parallels the state in FAT-12/16 root # directories which are always writable (if the underlying mapping is) self._file = fs.open_file(start, mode='rb' if fs.readonly else 'r+b') self.fat_type = fs.fat_type def _get_cluster(self): return self._file._map[0] def _update_entry(self, offset, entry): pos = self._file.tell() try: self._file.seek(offset) self._file.write(bytes(entry)) finally: self._file.seek(pos) def _iter_entries(self): buf = bytearray(self._cs) buf_offset = offset = 0 self._file.seek(0) while self._file.readinto(buf): buf_offset += offset offset = 0 for entry in DirectoryEntry.iter_over(buf): if entry.attr == 0x0F: entry = LongFilenameEntry.from_buffer(buf, offset) yield buf_offset + offset, entry offset += DirectoryEntry._FORMAT.size class Fat12Root(FatRoot): """ Concrete, trivial derivative of :class:`FatRoot` which simply declares the root as belonging to a FAT-12 file-system. .. autoattribute:: fat_type """ fat_type = 'fat12' class Fat16Root(FatRoot): """ Concrete, trivial derivative of :class:`FatRoot` which simply declares the root as belonging to a FAT-16 file-system. .. autoattribute:: fat_type """ fat_type = 'fat16' class Fat32Root(FatSubDirectory): """ This is a trivial derivative of :class:`FatSubDirectory` because, in FAT-32, the root directory is represented by the same structure as a regular sub-directory. """ class FatFile(io.RawIOBase): """ Represents an open file from a :class:`FatFileSystem`. You should never need to construct this instance directly. Instead it (or wrapped variants of it) is returned by the :meth:`~nobodd.path.FatPath.open` method of :class:`~nobodd.path.FatPath` instances. For example:: from nobodd.disk import DiskImage from nobodd.fs import FatFileSystem with DiskImage('test.img') as img: with FatFileSystem(img.partitions[1].data) as fs: path = fs.root / 'bar.txt' with path.open('r', encoding='utf-8') as f: print(f.read()) Instances can (and should) be used as context managers to implicitly close references upon exiting the context. Instances are readable and seekable, and writable, depending on their opening mode and the nature of the underlying :class:`FatFileSystem`. As a derivative of :class:`io.RawIOBase`, all the usual I/O methods should be available. """ __slots__ = ('_fs', '_map', '_index', '_entry', '_pos', '_mode') def __init__(self, fs, start, mode='rb', index=None, entry=None): super().__init__() if 'b' not in mode: raise ValueError(lang._( 'non-binary mode {mode!r} not supported'.format(mode=mode))) self._fs = weakref.ref(fs) if start: self._map = list(fs.fat.chain(start)) else: self._map = [] self._index = index self._entry = entry self._pos = 0 if 'w' in mode: self._mode = '+' if '+' in mode else 'w' self.truncate() elif 'a' in mode: self._mode = '+' if '+' in mode else 'w' self.seek(0, os.SEEK_END) else: self._mode = '+' if '+' in mode else 'r' @classmethod def from_cluster(cls, fs, start, mode='rb'): """ Construct a :class:`FatFile` from a :class:`FatFileSystem`, *fs*, and a *start* cluster. The optional *mode* is equivalent to the built-in :func:`open` function. Files constructed via this method do not have an associated directory entry. As a result, their size is assumed to be the full size of their cluster chain. This is typically used for the "file" backing a :class:`FatSubDirectory`. .. warning:: This method is intended for internal use by the :class:`~nobodd.path.FatPath` class. """ return cls(fs, start, mode) @classmethod def from_entry(cls, fs, index, entry, mode='rb'): """ Construct a :class:`FatFile` from a :class:`FatFileSystem`, *fs*, a :class:`FatDirectory`, *index*, and a :class:`~nobodd.fat.DirectoryEntry`, *entry*. The optional *mode* is equivalent to the built-in :func:`open` function. Files constructed via this method have an associated directory entry which will be updated if/when reads or writes occur (updating atime, mtime, and size fields). .. warning:: This method is intended for internal use by the :class:`~nobodd.path.FatPath` class. """ return cls(fs, get_cluster(entry, fs.fat_type), mode, index, entry) def _get_fs(self): """ Check the weak reference to the FatFileSystem. If it's still live, return the strong reference result. If it's disappeared, raise an :exc:`OSError` exception indicating the file-system has been closed. """ fs = self._fs() if fs is None: raise ValueError(lang._( 'FatFileSystem containing {self!r} is closed' .format(self=self))) return fs def _get_size(self): """ Returns the current size of the file. If the file has an associated directory entry, we simply return the size recorded there. Otherwise, the size is full size of all clusters in the file's chain. """ fs = self._get_fs() if self._entry is None: return fs.clusters.size * len(self._map) else: return self._entry.size def _get_key(self): """ Returns the short filename (SFN) key for the associated directory entry. This is used by various internal methods to locate the entry for updating in the associated directory index. """ if self._entry is None: raise ValueError(lang._('no key for entry-less FatFile')) fs = self._get_fs() filename = self._entry.filename.rstrip(b' ') assert filename != b'\0' * 8 ext = self._entry.ext.rstrip(b' ') assert ext != b'\0' * 3 return ( filename + b'.' + ext if ext else filename ).decode(fs.sfn_encoding) def _set_size(self, new_size): """ Update the size of the file in the associated directory entry, if any. If the file has no associated directory entry, this is a no-op. """ if self._entry is not None: try: first_cluster = self._map[0] except IndexError: # Only set first_cluster to 0 if the map is actually empty; # we ignore size here because we allow size to be 0 with a # cluster allocated while the file is open so that the file # doesn't "move cluster" while it's opened, even if it's # truncated. Only on close() do we remove the last cluster first_cluster = 0 entry = self._entry._replace( size=new_size, first_cluster_hi=first_cluster >> 16, first_cluster_lo=first_cluster & 0xFFFF) self._index[self._get_key()] = entry self._entry = entry def _set_atime(self, ts=None): """ Update the access timestamp of the file in the associated directory entry, if any, to the :class:`~datetime.datetime` *ts*. If the file has no associated directory entry, this is a no-op. """ if self._entry is not None: if ts is None: ts = dt.datetime.now() adate, _, _ = encode_timestamp(ts) # This slightly convoluted logic is because assigning to _index # causes writes to the underlying media and can fail for a variety # of reasons (including no more space in the dir). Hence, don't # re-write self._entry until it's actually written to disk. entry = self._entry._replace(adate=adate) self._index[self._get_key()] = entry self._entry = entry def _set_mtime(self, ts=None): """ Update the last-modified timestamp of the file in the associated directory entry, if any, to the :class:`~datetime.datetime` *ts*. If the file has no associated directory entry, this is a no-op. """ if self._entry is not None: if ts is None: ts = dt.datetime.now() mdate, mtime, _ = encode_timestamp(ts) # See note above entry = self._entry._replace(mdate=mdate, mtime=mtime) self._index[self._get_key()] = entry self._entry = entry def _check_closed(self): if self.closed: raise ValueError(lang._('I/O operation on closed file')) def close(self): if not self.closed: if self._entry is not None and self._entry.size == 0 and self._map: # See note in _set_size assert len(self._map) == 1 fs = self._get_fs() fs.fat.mark_free(self._map[0]) self._map = [] self._set_size(0) super().close() def readable(self): return self._mode in 'r+' def seekable(self): return True def writable(self): return self._mode in 'w+' def readall(self): self._check_closed() if not self.readable(): raise io.UnsupportedOperation() size = self._get_size() buf = bytearray(max(0, size - self._pos)) mem = memoryview(buf) pos = 0 while self._pos < size: pos += self.readinto(mem[pos:]) return bytes(buf) def readinto(self, buf): self._check_closed() if not self.readable(): raise io.UnsupportedOperation() fs = self._get_fs() cs = fs.clusters.size size = self._get_size() # index is which cluster of the file we wish to read; i.e. index 0 # represents the first cluster of the file; left and right are the byte # offsets within the cluster to return; read is the number of bytes to # return index = self._pos // cs left = self._pos - (index * cs) right = min(cs, left + len(buf), size - (index * cs)) read = max(right - left, 0) if read > 0: buf[:read] = fs.clusters[self._map[index]][left:right] self._pos += read if fs.atime and not fs.readonly: self._set_atime() return read def write(self, buf): self._check_closed() if not self.writable(): raise io.UnsupportedOperation() mem = memoryview(buf) fs = self._get_fs() size = self._get_size() if self._pos > size: # Pad the file to the current position. Note that this does *not* # count towards written self.truncate() written = 0 try: while mem: # Alternate between filling a cluster with _write1, and # allocating a new cluster. This is far from the most efficient # method (we're not taking account of whether any clusters are # actually contiguous), but it is simple! w = self._write1(mem, fs) if w: written += w mem = mem[w:] else: # TODO In event of ENOSPC, raise or return written so far? for cluster in fs.fat.free(): fs.fat.mark_end(cluster) if self._map: fs.fat[self._map[-1]] = cluster self._map.append(cluster) break finally: if self._pos > size: self._set_size(self._pos) self._set_mtime() return written def _write1(self, buf, fs=None): """ Write as much of *buf* to the file at the current position as will fit in the current cluster, returning the number of bytes written, and advancing the position of the file-pointer. If the current position is beyond the end of the file, this method writes nothing and return 0. """ self._check_closed() if fs is None: fs = self._get_fs() mem = memoryview(buf) cs = fs.clusters.size index = self._pos // cs left = self._pos - (index * cs) right = min(cs, left + len(mem)) written = max(right - left, 0) if written > 0: try: fs.clusters[self._map[index]][left:right] = mem[:written] except IndexError: return 0 self._pos += written return written def seek(self, pos, whence=io.SEEK_SET): self._check_closed() if whence == io.SEEK_SET: pos = pos elif whence == io.SEEK_CUR: pos = self._pos + pos elif whence == io.SEEK_END: pos = self._get_size() + pos else: raise ValueError(lang._( 'invalid whence: {whence}'.format(whence=whence))) if pos < 0: raise OSError(errno.EINVAL, os.strerror(errno.EINVAL)) self._pos = pos return self._pos def truncate(self, size=None): self._check_closed() if not self.writable(): raise io.UnsupportedOperation() fs = self._get_fs() cs = fs.clusters.size old_size = self._get_size() if size is None: size = self._pos if size == old_size: return size clusters = max(1, (size + cs - 1) // cs) if size > old_size: # If we're expanding the size of the file, zero the tail of the # current final cluster; this is necessary whether or not we're # expanding the actual number of clusters in the file. Note we # don't bother calculating exactly how many bytes to zero; we just # zero everything from the current size up to the end of the # cluster because that's fine in either case tail = len(self._map) * cs - old_size if tail: fs.clusters[self._map[-1]][-tail:] = b'\0' * tail if clusters > len(self._map): # Pre-calculate the clusters we're going to append. We don't want # to add any if we can't add them all. We then mark the clusters # in the FAT in reverse order, zeroing new blocks as we go so that # the final extension of the file is effectively atomic (from a # concurrent reader's perspective) to_append = list(islice(fs.fat.free(), clusters - len(self._map))) fs.fat.mark_end(to_append[-1]) zeros = b'\0' * cs for next_c, this_c in pairwise(reversed([self._map[-1]] + to_append)): fs.clusters[next_c] = zeros fs.fat[this_c] = next_c self._map.extend(to_append) elif clusters < len(self._map): # We start by marking the new end cluster, which atomically # shortens the FAT chain for the file, then proceed to mark all the # removed clusters as free to_remove = self._map[len(self._map) - clusters:] fs.fat.mark_end(self._map[clusters - 1]) del self._map[clusters:] for cluster in to_remove: fs.fat.mark_free(cluster) # Finally, correct the directory entry to reflect the new size self._set_size(size) self._set_mtime() return size nobodd-0.4/nobodd/gpt.py000066400000000000000000000050061457216553300152470ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2023-2024 Dave Jones # Copyright (c) 2023-2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import struct from collections import namedtuple from .tools import labels, formats # Structures sourced from the Wikipedia page on the GUID Partition Table [1]. # # [1]: https://en.wikipedia.org/wiki/GUID_Partition_Table GPT_HEADER = """ 8s signature I revision I header_size I header_crc32 4x reserved Q current_lba Q backup_lba Q first_usable_lba Q last_usable_lba 16s disk_guid Q part_table_lba I part_table_size I part_entry_size I part_table_crc32 """ class GPTHeader(namedtuple('GPTHeader', labels(GPT_HEADER))): """ A :func:`~collections.namedtuple` representing the fields of the `GPT header`_. .. _GPT header: https://en.wikipedia.org/wiki/GUID_Partition_Table#Partition_table_header_(LBA_1) """ __slots__ = () _FORMAT = struct.Struct(formats(GPT_HEADER)) def __bytes__(self): return self._FORMAT.pack(*self) @classmethod def from_bytes(cls, s): """ Construct a :class:`GPTHeader` from the byte-string *s*. """ return cls(*cls._FORMAT.unpack(s)) @classmethod def from_buffer(cls, buf, offset=0): """ Construct a :class:`GPTHeader` from the specified *offset* (which defaults to 0) in the buffer protocol object, *buf*. """ return cls(*cls._FORMAT.unpack_from(buf, offset)) GPT_PARTITION = """ 16s type_guid 16s part_guid Q first_lba Q last_lba Q flags 72s part_label """ class GPTPartition(namedtuple('GPTPartition', labels(GPT_PARTITION))): """ A :func:`~collections.namedtuple` representing the fields of a `GPT entry`_. .. _GPT entry: https://en.wikipedia.org/wiki/GUID_Partition_Table#Partition_entries_(LBA_2%E2%80%9333) """ __slots__ = () _FORMAT = struct.Struct(formats(GPT_PARTITION)) def __bytes__(self): return self._FORMAT.pack(*self) @classmethod def from_bytes(cls, s): """ Construct a :class:`GPTPartition` from the byte-string *s*. """ return cls(*cls._FORMAT.unpack(s)) @classmethod def from_buffer(cls, buf, offset=0): """ Construct a :class:`GPTPartition` from the specified *offset* (which defaults to 0) in the buffer protocol object, *buf*. """ return cls(*cls._FORMAT.unpack_from(buf, offset)) nobodd-0.4/nobodd/lang.py000066400000000000000000000006401457216553300153750ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2024 Dave Jones # Copyright (c) 2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import locale import gettext _ = gettext.gettext def init(): try: locale.setlocale(locale.LC_ALL, '') except locale.Error: locale.setlocale(locale.LC_ALL, 'C') gettext.textdomain(__package__) nobodd-0.4/nobodd/mbr.py000066400000000000000000000061001457216553300152310ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2023-2024 Dave Jones # Copyright (c) 2023-2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import struct from collections import namedtuple from .tools import labels, formats # Structures sourced from the Wikipedia page on the Master Boot Record [1], # specifically the "structure of a classic generic MBR". We don't bother with # of the later more complicated variants because the only thing we care about # is the four primary partitions anyway. # # [1]: https://en.wikipedia.org/wiki/Master_boot_record MBR_HEADER = """ 218x bootstrap_code H zero B physical_drive B seconds B minutes B hours 216x bootstrap_code I disk_sig H copy_protect 16s partition_1 16s partition_2 16s partition_3 16s partition_4 H boot_sig """ class MBRHeader(namedtuple('MBRHeader', labels(MBR_HEADER))): """ A :func:`~collections.namedtuple` representing the fields of the `MBR header`_. .. _MBR header: https://en.wikipedia.org/wiki/Master_boot_record#Sector_layout """ __slots__ = () _FORMAT = struct.Struct(formats(MBR_HEADER)) def __bytes__(self): return self._FORMAT.pack(*self) @classmethod def from_bytes(cls, s): """ Construct a :class:`MBRHeader` from the byte-string *s*. """ return cls(*cls._FORMAT.unpack(s)) @classmethod def from_buffer(cls, buf, offset=0): """ Construct a :class:`MBRHeader` from the specified *offset* (which defaults to 0) in the buffer protocol object, *buf*. """ return cls(*cls._FORMAT.unpack_from(buf, offset)) @property def partitions(self): """ Returns a sequence of the partitions defined by the header. This is always 4 elements long, and not all elements are guaranteed to be valid, or in order on the disk. """ return ( self.partition_1, self.partition_2, self.partition_3, self.partition_4, ) MBR_PARTITION = """ B status 3s first_chs B part_type 3s last_chs I first_lba I part_size """ class MBRPartition(namedtuple('MBRPartition', labels(MBR_PARTITION))): """ A :func:`~collections.namedtuple` representing the fields of an `MBR partition entry`_. .. _MBR partition entry: https://en.wikipedia.org/wiki/Master_boot_record#Partition_table_entries """ __slots__ = () _FORMAT = struct.Struct(formats(MBR_PARTITION)) def __bytes__(self): return self._FORMAT.pack(*self) @classmethod def from_bytes(cls, s): """ Construct a :class:`MBRPartition` from the byte-string *s*. """ return cls(*cls._FORMAT.unpack(s)) @classmethod def from_buffer(cls, buf, offset=0): """ Construct a :class:`MBRPartition` from the specified *offset* (which defaults to 0) in the buffer protocol object, *buf*. """ return cls(*cls._FORMAT.unpack_from(buf, offset)) nobodd-0.4/nobodd/netascii.py000066400000000000000000000173231457216553300162610ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2023-2024 Dave Jones # Copyright (c) 2023-2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import os import codecs from . import lang # The following references were essential in constructing this module; the # original TELNET specification [RFC764], and the wikipedia page documenting # the TFTP protocol [1]. # # [1]: https://en.wikipedia.org/wiki/Trivial_File_Transfer_Protocol # [RFC764]: https://datatracker.ietf.org/doc/html/rfc764 # [RFC1350]: https://datatracker.ietf.org/doc/html/rfc1350 _netascii_linesep = os.linesep.encode('ascii') def encode(s, errors='strict', final=False): """ Encodes the :class:`str` *s*, which must only contain valid ASCII characters, to the netascii :class:`bytes` representation. The *errors* parameter specifies the handling of encoding errors in the typical manner ('strict', 'ignore', 'replace', etc). The *final* parameter indicates whether this is the end of the input. This only matters on the Windows platform where the line separator is '\\r\\n' in which case a trailing '\\r' character *may* be the start of a newline sequence. The return value is a tuple of the encoded :class:`bytes` string, and the number of characters consumed from *s* (this may be less than the length of *s* when *final* is :data:`False`). """ # We can pre-allocate the output array as the transform guarantees the # length of output <= 2 * length of the input (largest transform in all # cases is b'\r' -> b'\r\0') buf_in = s.encode('ascii', errors=errors) buf_out = bytearray(len(buf_in) * 2) pos_in = pos_out = 0 def encode_newline(): nonlocal buf_out, pos_out, pos_in buf_out[pos_out:pos_out + 2] = b'\r\n' pos_out += 2 pos_in += len(_netascii_linesep) def encode_cr(): nonlocal buf_out, pos_out, pos_in buf_out[pos_out:pos_out + 2] = b'\r\0' pos_out += 2 pos_in += 1 while pos_in < len(buf_in): i = min( len(buf_in) if j == -1 else j for j in ( buf_in.find(_netascii_linesep[0], pos_in), buf_in.find(b'\r', pos_in), ) ) if i > pos_in: buf_out[pos_out:pos_out + i - pos_in] = buf_in[pos_in:i] pos_out += i - pos_in pos_in = i elif len(_netascii_linesep) == 1: # Non-windows case if buf_in[i] == _netascii_linesep[0]: encode_newline() else: # buf_in[i] == b'\r'[0] encode_cr() else: # Windows case if len(buf_in) > pos_in + 1: if buf_in[i + 1] == _netascii_linesep[1]: encode_newline() else: encode_cr() else: if final: encode_cr() break return bytes(buf_out[:pos_out]), pos_in def decode(s, errors='strict', final=False): """ Decodes the :class:`bytes` string *s*, which must contain a netascii encoded string, to the :class:`str` representation (which can only contain ASCII characters). The *errors* parameter specifies the handling of encoding errors in the typical manner ('strict', 'ignore', 'replace', etc). The *final* parameter indicates whether this is the end of the input. This matters as a trailing '\\r' in the input is the beginning of a newline sequence, an encoded '\\r', or an error (in other cases). The return value is a tuple of the decoded :class:`str`, and the number of characters consumed from *s* (this may be less than the length of *s* when *final* is :data:`False`). """ # We can pre-allocate the output array as the transform guarantees the # length of output <= length of the input buf_in = bytes(s) buf_out = bytearray(len(buf_in)) pos_in = pos_out = 0 while pos_in < len(buf_in): i = buf_in.find(b'\r', pos_in) if i == -1: i = len(buf_in) if i > pos_in: buf_out[pos_out:pos_out + i - pos_in] = buf_in[pos_in:i] pos_out += i - pos_in pos_in = i elif len(buf_in) > pos_in + 1: if buf_in[i + 1] == 0x0: # b'\0' buf_out[pos_out] = 0xD # b'\r' pos_out += 1 pos_in += 2 elif buf_in[i + 1] == 0xA: # b'\n' buf_out[pos_out:pos_out + len(_netascii_linesep)] = _netascii_linesep pos_out += len(_netascii_linesep) pos_in += 2 else: err_out = handle_error(errors) buf_out[pos_out:pos_out + len(err_out)] = err_out pos_out += len(err_out) pos_in += 1 else: if final: err_out = handle_error(errors) buf_out[pos_out:pos_out + len(err_out)] = err_out pos_out += len(err_out) pos_in += 1 break return buf_out[:pos_out].decode('ascii', errors=errors), pos_in def handle_error(errors): if errors == 'strict': raise UnicodeError(lang._('invalid netascii')) elif errors == 'ignore': return b'' elif errors == 'replace': return b'?' else: raise ValueError(lang._('invalid errors setting for netascii')) class IncrementalEncoder(codecs.BufferedIncrementalEncoder): r""" Use :func:`codecs.iterencode` to utilize this class for encoding: .. code-block:: pycon >>> import os >>> os.linesep '\n' >>> import nobodd.netascii >>> import codecs >>> it = ['foo', '\n', 'bar\r'] >>> b''.join(codecs.iterencode(it, 'netascii')) b'foo\r\nbar\r\0' """ @staticmethod def _buffer_encode(s, errors, final=False): return encode(s, errors, final) class IncrementalDecoder(codecs.BufferedIncrementalDecoder): r""" Use :func:`codecs.iterdecode` to utilize this class for encoding: .. code-block:: pycon >>> import os >>> os.linesep '\n' >>> import nobodd.netascii >>> import codecs >>> it = [b'foo\r', b'\n', b'bar\r', b'\0'] >>> ''.join(codecs.iterdecode(it, 'netascii')) 'foo\nbar\r' """ @staticmethod def _buffer_decode(s, errors, final=False): return decode(s, errors, final) class StreamWriter(codecs.StreamWriter): def __init__(self, stream, errors='strict'): super().__init__(stream, errors) self._final = False self.reset() def encode(self, s, errors='strict'): encoded, consumed = encode(self._buf + s, errors, final=self._final) self._buf = (self._buf + s)[consumed:] return encoded, consumed def flush(self): self._final = True try: self.write('') self.stream.flush() finally: self._final = False def reset(self): super().reset() self._buf = '' class StreamReader(codecs.StreamReader): def decode(self, s, errors='strict', final=False): return decode(s, errors, final) def stateless_encode(s, errors='strict'): return encode(s, errors, final=True) def stateless_decode(s, errors='strict'): return decode(s, errors, final=True) def find_netascii(name): if name.lower() == 'netascii': return codecs.CodecInfo( name='netascii', encode=stateless_encode, decode=stateless_decode, incrementalencoder=IncrementalEncoder, incrementaldecoder=IncrementalDecoder, streamreader=StreamReader, streamwriter=StreamWriter, ) codecs.register(find_netascii) nobodd-0.4/nobodd/path.py000066400000000000000000001245611457216553300154210ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2023-2024 Dave Jones # Copyright (c) 2023-2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import io import os import re import stat import errno import fnmatch import weakref import warnings import datetime as dt from urllib.parse import quote_from_bytes as urlquote_from_bytes from itertools import zip_longest from . import lang from .fat import DirectoryEntry, LongFilenameEntry, lfn_checksum, lfn_valid from .tools import encode_timestamp, decode_timestamp class FatPath: """ A :class:`~pathlib.Path`-like object representing a filepath within an associated :class:`~nobodd.fs.FatFileSystem`. There is rarely a need to construct this class directly. Instead, instances should be obtained via the :attr:`~nobodd.fs.FatFileSystem.root` property of a :class:`~nobodd.fs.FatFileSystem`. If constructed directly, *fs* is a :class:`~nobodd.fs.FatFileSystem` instance, and *pathsegments* is the sequence of strings to be joined with a path separator into the path. Instances provide almost all the facilities of the :class:`pathlib.Path` class they are modeled after, including the crucial :meth:`open` method, :meth:`iterdir`, :meth:`glob`, and :meth:`rglob` for enumerating directories, :meth:`stat`, :meth:`is_dir`, and :meth:`is_file` for querying information about files, division for construction of new paths, and all the usual :attr:`name`, :attr:`parent`, :attr:`stem`, and :attr:`suffix` attributes. When the :class:`~nobodd.fs.FatFileSystem` is writable, then :meth:`unlink`, :meth:`touch`, :meth:`mkdir`, :meth:`rmdir`, and :meth:`rename` may also be used. Instances are also comparable for the purposes of sorting, but only within the same :class:`~nobodd.fs.FatFileSystem` instance (comparisons across file-system instances raise :exc:`TypeError`). """ __slots__ = ('_fs', '_index', '_entry', '_parts', '_resolved') sep = '/' def __init__(self, fs, *pathsegments): self._fs = weakref.ref(fs) self._index = None self._entry = None self._parts = get_parts(*pathsegments) for index, part in enumerate(self._parts): if index == 0 and not part: continue # ignore root marker elif part in ('.', '..'): continue # ignore path components elif not lfn_valid(part): raise ValueError(lang._( 'invalid name {str_self!r}' .format(str_self=str(self)))) self._resolved = False def __repr__(self): return f'{self.__class__.__name__}(, {str(self)!r})' def __str__(self): if self._parts == ('',): return self.sep else: return self.sep.join(self._parts) def _get_fs(self): """ Check the weak reference to the FatFileSystem. If it's still live, return the strong reference result. If it's disappeared, raise an :exc:`OSError` exception indicating the file-system has been closed. """ fs = self._fs() if fs is None: raise OSError(lang._( 'FatFileSystem containing {self!s} is closed' .format(self=self))) return fs @classmethod def _from_index(cls, fs, index, path=sep): """ Internal class method for constructing an instance from *fs* (a :class:`~nobodd.fs.FatFileSystem` instance), *index* (a :class:`~nobodd.fs.FatDirectory` instance), and a *path*. This is only used in the construction of directory instances. """ self = cls(fs, path) self._index = index self._resolved = True return self @classmethod def _from_entry(cls, fs, index, entry, path=sep): """ Internal class method for constructing an instance from *fs* (a :class:`~nobodd.fs.FatFileSystem` instance), *index* (a :class:`~nobodd.fs.FatDirectory instance), *entries* (a sequence of associated :class:`~nobodd.fat.LongFilenameEntry` and :class:`~nobodd.fat.DirectoryEntry` instances which must exist within *index*), and a *path*. """ if entry.attr & 0x10: # directory cluster = get_cluster(entry, fs.fat_type) self = cls._from_index(fs, fs.open_dir(cluster), path) else: self = cls(fs, path) self._index = index self._entry = entry self._resolved = True return self def _resolve(self): """ Internal method which "resolves" a constructed path to find the corresponding structures on disk (if the path exists). """ if self._resolved: return assert self._index is None assert self._entry is None try: head, *parts = self.parts if head != self.sep: raise ValueError(lang._('relative FatPath cannot be resolved')) fs = self._get_fs() path = fs.root while parts: path._must_exist() path._must_be_dir() head, *parts = parts try: path = FatPath._from_entry( fs, path._index, path._index[head], str(path / head)) except KeyError: # Path doesn't exist return self._index = path._index self._entry = path._entry finally: self._resolved = True def _refresh(self): """ Internal method which "refreshes" the _entry field to ensure that, if the first cluster of the file has changed (because the file was emptied and then re-filled) we have the correct value. This method should be called before using the cluster of the _entry field, unless you are certain the cluster cannot be wrong (e.g. the file backing sub-directories can never be emptied due to the "." and ".." entries so it never changes). The entry will be refreshed by searching for an entry in the _index with a matching name, i.e. this is no good if the calling method has renamed the entry. """ if self._resolved: assert self._index assert self._entry assert not self._entry.attr & 0x10, 'no need for _refresh on dirs' try: self._entry = self._index[self.name] except KeyError: raise FileNotFoundError(lang._( 'Directory entry for {self} disappeared' .format(self=self))) else: self._resolve() def _must_exist(self): """ Internal method which is called to check that a constructed path actually exists in the file-system. Calls :meth:`_resolve` implicitly. """ self._resolve() if not self.exists(): raise FileNotFoundError(lang._( 'No such file or directory: {self}'.format(self=self))) def _must_not_exist(self): """ Internal method which is called to check that a constructed path does not exist in the file-system. Calls :meth:`_resolve` implicitly. """ self._resolve() if self.exists(): raise FileExistsError(lang._( 'File exists: {self}'.format(self=self))) def _must_be_dir(self): """ Internal method which is called to check that a constructed path is a directory. Calls :meth:`_resolve` implicitly. """ self._resolve() if not self.is_dir(): raise NotADirectoryError(lang._( 'Not a directory: {self}'.format(self=self))) def _must_not_be_dir(self): """ Internal method which is called to check that a constructed path is not a directory. Calls :meth:`_resolve` implicitly. """ self._resolve() if self.is_dir(): raise IsADirectoryError(lang._( 'Is a directory: {self}'.format(self=self))) def open(self, mode='r', buffering=-1, encoding=None, errors=None, newline=None): """ Open the file pointed to by the path, like the built-in :func:`~io.open` function does. The *mode*, *buffering*, *encoding*, *errors* and *newline* options are as for the :func:`~io.open` function. If successful, a :class:`~nobodd.fs.FatFile` instance is returned. .. note:: This implementation is read-only, so any modes other than "r" and "rb" will fail with :exc:`PermissionError`. """ fs = self._get_fs() # Check the mode is valid and matches our expectations (can't open a # directory, can't read a non-existent file, etc.) if set(mode) - set('rwaxb+'): raise ValueError(lang._( 'invalid file mode {mode!r}'.format(mode=mode))) if len(set(mode) & set('rwax')) != 1: raise ValueError(lang._( 'must have exactly one of read, write, append, exclusive ' 'creation mode')) if fs.readonly and set(mode) & set('wax+'): raise PermissionError(lang._('fs is read-only')) if 'r' in mode: self._must_exist() elif 'x' in mode: self._must_not_exist() mode = mode.replace('x', 'w') self._must_not_be_dir() # If self._entry is None at this point, we must be creating a file # so get the containing index and make an appropriate DirectoryEntry if self._entry is None: date, time, cs = encode_timestamp(dt.datetime.now()) parent = self.parent parent._must_exist() parent._must_be_dir() entry = DirectoryEntry( # filename and ext of the entry will be ignored; generated # SFN will be used instead filename=b'\0' * 8, ext=b'\0' * 3, # Set DOS "Archive" bit and nothing else attr=0x20, attr2=0, cdate=date, ctime=time, ctime_cs=cs, mdate=date, mtime=time, adate=date, first_cluster_lo=0, first_cluster_hi=0, size=0) parent._index[self.name] = entry self._index = parent._index # Look up entry to get generated SFN; this is required so the entry # we pass to FatFile below has a valid key for later lookups self._entry = parent._index[self.name] else: self._refresh() # Sanity check the buffering parameter and create the underlying # FatFile instance with an appropriate mode if 'b' in mode: if buffering == 1: warnings.warn( RuntimeWarning( "line buffering (buffering=1) isn't supported in " "binary mode, the default buffer size will be used")) buffering = -1 if encoding is not None: raise ValueError(lang._( "binary mode doesn't take an encoding argument")) if errors is not None: raise ValueError(lang._( "binary mode doesn't take an errors argument")) if newline is not None: raise ValueError(lang._( "binary mode doesn't take a newline argument")) f = fs.open_entry(self._index, self._entry, mode) else: if buffering == 0: raise ValueError(lang._("can't have unbuffered text I/O")) else: line_buffering = buffering == 1 f = fs.open_entry(self._index, self._entry, mode + 'b') # Wrap the underlying FatFile instance in whatever's necessary to make # it text-mode / buffered if buffering: if buffering in (-1, 1): buffering = fs.clusters.size f = { (True, False): io.BufferedReader, (False, True): io.BufferedWriter, (True, True): io.BufferedRandom, }[(f.readable(), f.writable())](f, buffering) if 'b' not in mode: f = io.TextIOWrapper( f, encoding=encoding, errors=errors, newline=newline, line_buffering=line_buffering) return f def unlink(self, missing_ok=False): """ Remove this file. If the path points to a directory, use :meth:`rmdir` instead. If *missing_ok* is :data:`False` (the default), :exc:`FileNotFoundError` is raised if the path does not exist. If *missing_ok* is :data:`True`, :exc:`FileNotFoundError` exceptions will be ignored (same behaviour as the POSIX ``rm -f`` command). """ fs = self._get_fs() try: self._must_exist() except FileNotFoundError: if missing_ok: return else: raise self._must_not_be_dir() self._refresh() del self._index[self.name] for cluster in fs.fat.chain(get_cluster(self._entry, fs.fat_type)): fs.fat.mark_free(cluster) self._index = None self._entry = None def rename(self, target): """ Rename this file or directory to the given *target*, and return a new :class:`FatPath` instance pointing to target. If *target* exists and is a file, it will be replaced silently. *target* can be either a string or another path object:: >>> p = fs.root / 'foo' >>> p.open('w').write('some text') 9 >>> target = fs.root / 'bar' >>> p.rename(target) FatPath(, '/bar') >>> target.read_text() 'some text' The target path must be absolute. There are no guarantees of atomic behaviour (in contrast to :func:`os.rename`). .. note:: :meth:`pathlib.Path.rename` permits relative paths, but interprets them relative to the working directory which is a concept :class:`FatPath` does not support. """ fs = self._get_fs() if not isinstance(target, FatPath): target = FatPath(fs, target) target_fs = target._get_fs() if fs is not target_fs: raise ValueError(lang._( 'Cannot rename between FatFileSystem instances')) if target.exists(): target._must_not_be_dir() target._refresh() target_cluster = get_cluster(target._entry, fs.fat_type) else: target.touch() target_cluster = 0 self._refresh() target._index[target.name] = self._entry del self._index[self.name] if target_cluster: for cluster in fs.fat.chain(target_cluster): fs.fat.mark_free(cluster) self._index = None self._entry = None # The entry is mutated by the FatDirectory when setting (specifically # the SFN is re-written); in case the *target* is not ephemeral, update # its internal _entry to match to mutated one target._entry = target._index[target.name] return target def mkdir(self, mode=0o777, parents=False, exist_ok=False): """ Create a new directory at this given path. The *mode* parameter exists only for compatibility with :class:`pathlib.Path` and is otherwise ignored. If the path already exists, :exc:`FileExistsError` is raised. If *parents* is true, any missing parents of this path are created as needed. If *parents* is false (the default), a missing parent raises :exc:`FileNotFoundError`. If *exist_ok* is false (the default), :exc:`FileExistsError` is raised if the target directory already exists. If *exist_ok* is true, :exc:`FileExistsError` exceptions will be ignored (same behavior as the POSIX ``mkdir -p`` command), but only if the last path component is not an existing non-directory file. """ fs = self._get_fs() try: self._must_not_exist() except FileExistsError: if exist_ok and self.is_dir(): return else: raise parent = self.parent try: parent._must_exist() except FileNotFoundError: if parents: parent.mkdir(mode, parents, exist_ok) else: raise parent._must_be_dir() date, time, cs = encode_timestamp(dt.datetime.now()) cluster = next(fs.fat.free()) fs.fat.mark_end(cluster) entry = DirectoryEntry( # filename and ext of the entry will be ignored and overwritten # with SFN generated from the associated name filename=b'\0' * 8, ext=b'\0' * 3, # Set DOS "Directory" bit and nothing else attr=0x10, attr2=0, cdate=date, ctime=time, ctime_cs=cs, mdate=date, mtime=time, adate=date, first_cluster_lo=cluster & 0xFFFF, first_cluster_hi=cluster >> 16 if fs.fat_type == 'fat32' else 0, size=0) parent._index[self.name] = entry self._index = fs.open_dir(cluster) self._entry = entry # Write the minimum entries that all sub-dirs must have: the "." and # ".." entries, and a terminal EOF entry self._index['.'] = self._entry if parent._entry is None: # Parent is the root self._index['..'] = self._entry._replace( first_cluster_hi=0, first_cluster_lo=0) else: self._index['..'] = parent._entry def rmdir(self): """ Remove this directory. The directory must be empty. """ fs = self._get_fs() self._must_exist() self._must_be_dir() if self._entry is not None: cluster = get_cluster(self._entry, fs.fat_type) else: cluster = 0 if cluster == 0: raise OSError(errno.EACCES, lang._( 'Cannot remove the root directory')) for item in self.iterdir(): print(repr(item)) raise OSError(errno.ENOTEMPTY, os.strerror(errno.ENOTEMPTY)) parent = self.resolve(strict=False).parent # NOTE: We already know parent must exist and be a dir parent._resolve() del parent._index[self.name] for cluster in fs.fat.chain(cluster): fs.fat.mark_free(cluster) self._index = None self._entry = None def resolve(self, strict=False): """ Make the path absolute, resolving any symlinks. A new :class:`FatPath` object is returned. ``".."`` components are also eliminated (this is the only method to do so). If the path doesn't exist and *strict* is :data:`True`, :exc:`FileNotFoundError` is raised. If *strict* is :data:`False`, the path is resolved as far as possible and any remainder is appended without checking whether it exists. Note that as there is no concept of the "current" directory within :class:`~nobodd.fs.FatFileSystem`, relative paths cannot be resolved by this function, only absolute. """ fs = self._get_fs() if not self.is_absolute(): raise ValueError(lang._( 'Cannot resolve relative path {self!r}'.format(self=self))) parts = [p for p in self._parts if p != '.'] while '..' in parts: i = parts.index('..') if i == 1: del parts[1] else: del parts[i - 1:i] result = FatPath(fs, *parts) if strict: result._must_exist() return result def iterdir(self): """ When the path points to a directory, yield path objects of the directory contents:: >>> fs >>> for child in fs.root.iterdir(): child ... FatPath(, '/foo') FatPath(, '/bar.txt') FatPath(, '/setup.cfg') FatPath(, '/baz') FatPath(, '/adir') FatPath(, '/BDIR') The children are yielded in arbitrary order (the order they are found in the file-system), and the special entries ``'.'`` and ``'..'`` are not included. """ fs = self._get_fs() self._must_exist() self._must_be_dir() for name, entry in self._index.items(): if name not in ('.', '..'): yield FatPath._from_entry( fs, self._index, entry, str(self / name)) def match(self, pattern): """ Match this path against the provided glob-style pattern. Returns a :class:`bool` indicating if the match is successful. If *pattern* is relative, the path may be either relative or absolute, and matching is done from the right:: >>> fs >>> f = fs / 'nobodd' / 'mbr.py' >>> f FatPath(, '/nobodd/mbr.py') >>> f.match('*.py') True >>> f.match('nobodd/*.py') True >>> f.match('/*.py') False As FAT file-systems are case-insensitive, all matches are likewise case-insensitive. """ if not pattern: raise ValueError(lang._('empty pattern')) pat_parts = get_parts(pattern.lower()) parts = self.parts if len(pat_parts) > len(parts): return False for part, pat in zip(reversed(parts), reversed(pat_parts)): if not fnmatch.fnmatchcase(part.lower(), pat): return False return True def _search(self, parent, parts): """ Internal generator function for the implementation of :meth:`glob` and :meth:`rglob`. Called with *parent*, the containing :class:`FatPath`, and *parts*, the sequence of path components (in the form of strings) to match against. """ def recursive(parent, parts): yield from self._search(parent, parts) for path in parent.iterdir(): if path.is_dir(): yield from recursive(path, parts) def wildcard(parent, part, parts): part_re = re.compile(fnmatch.translate(part), re.IGNORECASE) for path in parent.iterdir(): if part_re.match(path.name): yield from self._search(path, parts) def precise(parent, part, parts): path = parent / part.lower() if path.exists(): yield from self._search(path, parts) if not parts: yield parent elif parent.is_dir(): part, *parts = parts if part == '**': yielded = set() for path in recursive(parent, parts): if path._parts not in yielded: yielded.add(path._parts) yield path elif '**' in part: raise ValueError(lang._( 'invalid pattern: ** can only be an entire component')) elif '*' in part or '?' in part or '[' in part: yield from wildcard(parent, part, parts) else: yield from precise(parent, part, parts) def glob(self, pattern): """ Glob the given relative *pattern* in the directory represented by this path, yielding matching files (of any kind):: >>> fs >>> sorted((fs.root / 'nobodd').glob('*.py')) [FatPath(, '/nobodd/__init__.py'), FatPath(, '/nobodd/disk.py'), FatPath(, '/nobodd/fat.py'), FatPath(, '/nobodd/fs.py'), FatPath(, '/nobodd/gpt.py'), FatPath(, '/nobodd/main.py'), FatPath(, '/nobodd/mbr.py'), FatPath(, '/nobodd/tftp.py'), FatPath(, '/nobodd/tools.py')] Patterns are the same as for :func:`~fnmatch.fnmatch`, with the addition of "``**``" which means "this directory and all subdirectories, recursively". In other words, it enables recurisve globbing. .. warning:: Using the "``**``" pattern in large directory trees may consume an inordinate amount of time. """ self._must_exist() if not pattern: raise ValueError(lang._('Unacceptable pattern')) pat_parts = get_parts(pattern) if pat_parts[:1] == ('',): raise ValueError(lang._('Non-relative patterns are not supported')) yield from self._search(self, pat_parts) def rglob(self, pattern): """ This is like calling :meth:`glob` with a prefix of "``**/``" to the specified *pattern*. """ self._must_exist() if not pattern: raise ValueError(lang._('Unacceptable pattern')) pat_parts = get_parts(pattern) if pat_parts[:1] == ('',): raise ValueError(lang._('Non-relative patterns are not supported')) yield from self._search(self, ('**',) + pat_parts) def stat(self, *, follow_symlinks=True): """ Return a :class:`os.stat_result` object containing information about this path:: >>> fs >>> p = (fs.root / 'nobodd' / 'main.py') >>> p.stat().st_size 388 >>> p.stat().st_ctime 1696606672.02 .. note:: In a FAT file-system, ``atime`` has day resolution, ``mtime`` has 2-second resolution, and ``ctime`` has either 2-second or millisecond resolution depending on the driver that created it. Directories have no timestamp information. The *follow_symlinks* parameter is included purely for compatibility with :meth:`pathlib.Path.stat`; it is ignored as symlinks are not supported. """ fs = self._get_fs() self._must_exist() if self._entry is not None and not (self._entry.attr & 0x10): self._refresh() return os.stat_result(( 0o444, # mode get_cluster(self._entry, fs.fat_type), # inode id(fs), # dev 1, # nlink 0, # uid 0, # gid self._entry.size, # size decode_timestamp( # atime self._entry.adate, 0).timestamp(), decode_timestamp( # mtime self._entry.mdate, self._entry.mtime).timestamp(), decode_timestamp( # ctime self._entry.cdate, self._entry.ctime, self._entry.ctime_cs).timestamp())) else: # self._index is not None is guaranteed by _must_exist # NOTE: No need to _refresh as the cluster of a sub-directory can # never change return os.stat_result(( stat.S_IFDIR | 0o555, # mode self._index.cluster, # inode id(fs), # dev 0, # nlink 0, # uid 0, # gid 0, # size 0, # atime 0, # mtime 0)) # ctime @property def fs(self): """ Returns the :class:`~nobodd.fs.FatFileSystem` instance that this instance was constructed with. """ return self._get_fs() @property def root(self): """ Returns a string representing the root. This is always "/". """ return self.sep @property def anchor(self): """ Returns the concatenation of the drive and root. This is always "/". """ return self.sep @property def name(self): """ A string representing the final path component, excluding the root:: >>> fs >>> p = (fs.root / 'nobodd' / 'main.py') >>> p.name 'main.py' """ try: return self._parts[-1] except IndexError: return '' @property def suffix(self): """ The file extension of the final component, if any:: >>> fs >>> p = (fs.root / 'nobodd' / 'main.py') >>> p.suffix '.py' """ name = self.name try: return name[name.rindex('.'):] except ValueError: return '' @property def suffixes(self): """ A list of the path's file extensions:: >>> fs >>> p = (fs.root / 'nobodd.tar.gz') >>> p.suffixes ['.tar', '.gz'] """ name = self.name return ['.' + s for s in name.lstrip('.').split('.')[1:]] @property def stem(self): """ The final path component, without its suffix:: >>> fs >>> p = (fs.root / 'nobodd' / 'main.py') >>> p.stem 'main' """ name = self.name try: return name[:name.rindex('.')] except ValueError: return name @property def parts(self): """ A tuple giving access to the path's various components:: >>> fs >>> p = (fs.root / 'nobodd' / 'main.py') >>> p.parts ['/', 'nobodd', 'main.py'] """ return tuple( self.sep if index == 0 and part == '' else part for index, part in enumerate(self._parts)) @property def parent(self): """ The logical parent of the path:: >>> fs >>> p = (fs.root / 'nobodd' / 'main.py') >>> p.parent FatPath(, '/nobodd') You cannot go past an anchor:: >>> p = (fs.root / 'nobodd' / 'main.py') >>> p.parent.parent.parent FatPath(, '/') """ fs = self._get_fs() if len(self._parts) > 1: return type(self)(fs, *self._parts[:-1]) elif self._parts == ('',) or self._parts == ('.',): return self else: return type(self)(fs, '.') @property def parents(self): """ An immutable sequence providing access to the logical ancestors of the path:: >>> fs >>> p = (fs.root / 'nobodd' / 'main.py') >>> p.parents (FatPath(, '/nobodd'), FatPath(, '/')) """ result = [self.parent] self = result[-1] while self.parent is not self: result.append(self.parent) self = result[-1] return tuple(result) def read_text(self, encoding=None, errors=None): """ Return the decoded contents of the pointed-to file as a string:: >>> fs >>> (fs.root / 'foo').read_text() 'foo\\n' """ with self.open(encoding=encoding, errors=errors) as f: return f.read() def write_text(self, data, encoding=None, errors=None, newline=None): """ Open the file pointed to in text mode, write *data* to it, and close the file:: >>> p = fs.root / 'my_text_file' >>> p.write_text('Text file contents') 18 >>> p.read_text() 'Text file contents' An existing file of the same name is overwritten. The optional parameters have the same meaning as in :meth:`open`. """ with self.open(mode='w', encoding=encoding, errors=errors, newline=newline) as f: return f.write(data) def read_bytes(self): """ Return the binary contents of the pointed-to file as a bytes object:: >>> fs >>> (fs.root / 'foo').read_text() b'foo\\n' """ with self.open(mode='rb') as f: return f.read() def write_bytes(self, data): """ Open the file pointed to in bytes mode, write *data* to it, and close the file:: >>> p = fs.root / 'my_binary_file' >>> p.write_bytes(b'Binary file contents') 20 >>> p.read_bytes() b'Binary file contents' An existing file of the same name is overwritten. """ with self.open(mode='wb') as f: return f.write(data) def touch(self, mode=0o666, exist_ok=True): """ Create a file at this given path. The *mode* parameter is only present for compatibility with :class:`pathlib.Path` and is otherwise ignored. If the file already exists, the function succeeds if *exist_ok* is :data:`True` (and its modification time is updated to the current time), otherwise :exc:`FileExistsError` is raised. """ if exist_ok: with self.open('ab', buffering=0) as f: f._set_mtime() else: with self.open('xb', buffering=0) as f: pass def exists(self): """ Whether the path points to an existing file or directory:: >>> fs >>> (fs.root / 'foo').exists() True >>> (fs.root / 'fooo').exists() False """ self._resolve() return self._index is not None and ( self._entry is not None or self._parts == ('',)) def is_dir(self): """ Return a :class:`bool` indicating whether the path points to a directory. :data:`False` is also returned if the path doesn't exist. """ self._resolve() return self._index is not None and ( self._entry is None or bool(self._entry.attr & 0x10)) def is_file(self): """ Returns a :class:`bool` indicating whether the path points to a regular file. :data:`False` is also returned if the path doesn't exist. """ self._resolve() return self._entry is not None and not (self._entry.attr & 0x10) def is_mount(self): """ Returns a :class:`bool` indicating whether the path is a *mount point*. In this implementation, this is only :data:`True` for the root path. """ return self._parts == ('',) def is_absolute(self): """ Return whether the path is absolute or not. A path is considered absolute if it has a "/" prefix. """ return self._parts[:1] == ('',) def is_relative_to(self, *other): """ Return whether or not this path is relative to the *other* path. """ try: self.relative_to(*other) except ValueError: return False else: return True def relative_to(self, *other): """ Compute a version of this path relative to the path represented by *other*. If it's impossible, :exc:`ValueError` is raised. """ if not other: raise TypeError(lang._('need at least one argument')) fs = self._get_fs() to = type(self)(fs, *other) n = len(to._parts) if self._parts[:n] != to._parts: raise ValueError(lang._( '{self!r} is not in the subpath of {to!r} OR one path is ' 'relative and the other is absolute' .format(self=self, to=to))) return type(self)(fs, *self._parts[n:]) def joinpath(self, *other): """ Calling this method is equivalent to combining the path with each of the *other* arguments in turn:: >>> fs >>> fs.root FatPath(, '/') >>> fs.root.joinpath('nobodd') FatPath(, '/nobodd') >>> fs.root.joinpath('nobodd', 'main.py') FatPath(, '/nobodd/main.py') """ fs = self._get_fs() other = get_parts(*other) if other[:1] == ('',): return type(self)(fs, *other) else: return type(self)(fs, *self._parts, *other) def with_name(self, name): """ Return a new path with the :attr:`name` changed. If the original path doesn't have a name, :exc:`ValueError` is raised. """ fs = self._get_fs() if not self.name: raise ValueError(lang._( '{self!r} has an empty name'.format(self=self))) if not name: raise ValueError(lang._( 'invalid name {name!r}'.format(name=name))) return type(self)(fs, *self._parts[:-1], name) def with_stem(self, stem): """ Return a new path with the :attr:`stem` changed. If the original path doesn't have a name, :exc:`ValueError` is raised. """ return self.with_name(stem + self.suffix) def with_suffix(self, suffix): """ Return a new path with the :attr:`suffix` changed. If the original path doesn't have a suffix, the new *suffix* is appended instead. If the *suffix* is an empty string, the original suffix is removed. """ if self.sep in suffix: raise ValueError(lang._( 'Invalid suffix {suffix!r}'.format(suffix=suffix))) if suffix and not suffix.startswith('.') or suffix == '.': raise ValueError(lang._( 'Invalid suffix {suffix!r}'.format(suffix=suffix))) name = self.name old_suffix = self.suffix if not old_suffix: name = name + suffix else: name = name[:-len(old_suffix)] + suffix return self.with_name(name) __truediv__ = joinpath def __eq__(self, other): if not isinstance(other, FatPath): return NotImplemented self_fs = self._get_fs() other_fs = other._get_fs() if self_fs is not other_fs: raise TypeError(lang._( 'comparison is not supported between instances of ' '{self.__class__.__name__} with different file-systems' .format(self=self))) return len(self._parts) == len(other._parts) and all( sp.lower() == op.lower() for sp, op in zip_longest(self._parts, other._parts, fillvalue='')) def __le__(self, other): if not isinstance(other, FatPath): return NotImplemented self_fs = self._get_fs() other_fs = other._get_fs() if self_fs is not other_fs: raise TypeError(lang._( 'comparison is not supported between instances of ' '{self.__class__.__name__} with different file-systems' .format(self=self))) return all( sp.lower() <= op.lower() for sp, op in zip_longest(self.parts, other.parts, fillvalue='')) def __ne__(self, other): if not isinstance(other, FatPath): return NotImplemented return not self.__eq__(other) def __lt__(self, other): if not isinstance(other, FatPath): return NotImplemented return self.__le__(other) and not self.__eq__(other) def __gt__(self, other): if not isinstance(other, FatPath): return NotImplemented return not self.__le__(other) def __ge__(self, other): if not isinstance(other, FatPath): return NotImplemented return self.__eq__(other) or self.__gt__(other) def get_cluster(entry, fat_type): """ Given *entry*, a :class:`~nobodd.fat.DirectoryEntry`, and the *fat_type* indicating the size of FAT clusters, return the first cluster of the file associated with the directory entry. """ return entry.first_cluster_lo | ( entry.first_cluster_hi << 16 if fat_type == 'fat32' else 0) def get_parts(*pathsegments): """ Given *pathsegments*, split them on the "/" path separator, and return a :class:`tuple` containing each path segment. If the path segments refer to an absolute path (beginning with "/") the first element of the returned :class:`tuple` will be an empty string. """ return tuple( part for i1, segment in enumerate(pathsegments) for i2, part in enumerate(str(segment).split('/')) if i1 == i2 == 0 or part) nobodd-0.4/nobodd/prep.py000066400000000000000000000327021457216553300154260ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2023-2024 Dave Jones # Copyright (c) 2023-2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 """ Customizes an OS image to prepare it for netbooting via TFTP. Specifically, this expands the image to a specified size (the assumption being the image is a copy of a minimally sized template image), then updates the kernel command line on the boot partition to point to an NBD server. """ import os import sys import mmap import socket import logging import argparse from pathlib import Path from uuid import UUID from shutil import copyfileobj from . import lang from .disk import DiskImage from .fs import FatFileSystem from .config import ( CONFIG_LOCATIONS, ConfigArgumentParser, size, serial, Board, ) # NOTE: The fallback comes first here as Python 3.7 incorporates # importlib.resources but at a version incompatible with our requirements. # Ultimately the try clause should be removed in favour of the except clause # once compatibility moves beyond Python 3.9 try: import importlib_resources as resources except ImportError: from importlib import resources # NOTE: Remove except when compatibility moves beyond Python 3.8 try: from importlib.metadata import version except ImportError: from importlib_metadata import version def get_parser(): """ Returns the command line parser for the application, pre-configured with defaults from the application's configuration file(s). See :func:`~nobodd.config.ConfigArgumentParser` for more information. """ parser = ConfigArgumentParser( description=__doc__, template=resources.files('nobodd') / 'default.conf') parser.add_argument( '--version', action='version', version=version('nobodd')) parser.add_argument( '-v', '--verbose', dest='log_level', action='store_const', const=logging.INFO, help=lang._("Print more output")) parser.add_argument( '-q', '--quiet', dest='log_level', action='store_const', const=logging.CRITICAL, help=lang._("Print no output")) parser.add_argument( 'image', type=Path, help=lang._("The target image to customize")) parser.add_argument( '-s', '--size', type=size, default='16GB', help=lang._("The size to expand the image to; default: %(default)s")) parser.add_argument( '--nbd-host', type=str, metavar='HOST', default=socket.getfqdn(), help=lang._( "The hostname of the nbd server to connect to for the root " "device; defaults to the local machine's FQDN")) parser.add_argument( '--nbd-name', type=str, metavar='NAME', default=None, help=lang._( "The name of the nbd share to use as the root device; defaults " "to the stem of the *image* name")) parser.add_argument( '--cmdline', type=str, metavar='NAME', default='cmdline.txt', help=lang._( "The name of the file containing the kernel command line on the " "boot partition; default: %(default)s")) parser.add_argument( '--boot-partition', type=int, metavar='NUM', default=None, help=lang._( "Which partition is the boot partition within the image; " "default is the first FAT partition (identified by partition " "type) found in the image")) parser.add_argument( '--root-partition', type=int, metavar='NUM', default=None, help=lang._( "Which partition is the root partition within the image " "default is the first non-FAT partition (identified by partition " "type) found in the image")) parser.add_argument( '-C', '--copy', type=Path, metavar='PATH', action='append', default=[], help=lang._( "Copy the specified file or directory into the boot partition. " "This may be given multiple times to specify multiple items to " "copy")) parser.add_argument( '-R', '--remove', type=Path, metavar='PATH', action='append', default=[], help=lang._( "Remove the specified file or directory from the boot " "partition. This may be given multiple times to specify multiple " "items to delete")) parser.add_argument( '--serial', type=serial, metavar='HEX', default=None, help=lang._( "Defines the serial number of the Raspberry Pi that will be " "served this image. When this option is given, a board " "configuration compatible with nobodd-tftpd may be output with " "--tftpd-conf")) parser.add_argument( '--tftpd-conf', type=argparse.FileType('w'), metavar='FILE', default=None, help=lang._( "If specified, write a board configuration compatible with " "nobodd-tftpd to the specified file; requires --serial to be " "given")) parser.add_argument( '--nbd-conf', type=argparse.FileType('w'), metavar='FILE', default=None, help=lang._( "If specified, write a share configuration compatible with " "nbd-server to the specified file")) defaults = parser.read_configs(CONFIG_LOCATIONS) parser.set_defaults(log_level=logging.WARNING) parser.set_defaults_from(defaults) return parser def prepare_image(conf): """ Given the script's configuration in *conf*, an :class:`argparse.Namespace`, resize the target image, and re-write the kernel command line within its boot partition to point to the configured NBD server and share. """ with conf.image.open('ab') as f: size = f.seek(0, os.SEEK_END) if size < conf.size: conf.logger.info( lang._('Resizing %s to %d bytes'), conf.image, conf.size) f.seek(conf.size) f.truncate() else: conf.logger.info( lang._('Skipping resize; %s is already %d bytes or larger'), conf.image, conf.size) with \ DiskImage(conf.image, access=mmap.ACCESS_WRITE) as img, \ FatFileSystem(img.partitions[conf.boot_partition].data) as fs: remove_items(fs, conf) copy_items(fs, conf) rewrite_cmdline(fs, conf) def remove_items(fs, conf): """ In *fs*, a :class:`~nobodd.fs.FatFileSystem`, remove all items in the :class:`list` *conf.remove*, where *conf* is the script's configuration. If any item is a directory, it and all files under it will be removed recursively. If an item in *to_remove* does not exist, a warning will be printed, but no error is raised. """ for item in conf.remove: item = fs.root / str(item) if item.exists(): conf.logger.info( lang._('Removing %s from partition %d'), item, conf.boot_partition) if item.is_dir(): dirs = [] for subitem in item.rglob('*'): if subitem.is_dir(): dirs.append(subitem) else: subitem.unlink() for subitem in dirs: subitem.rmdir() item.rmdir() else: item.unlink() else: conf.logger.warning( lang._("No such file/dir %s in partition %d"), item, conf.boot_partition) def copy_items(fs, conf): """ Copy all :class:`~pathlib.Path` items in the :class:`list` *conf.copy* into *fs*, a :class:`~nobodd.fs.FatFileSystem`, where *conf* is the script's configuration. If an item is a directory, it and all files under it will be copied recursively. If an item is a hard-link or a sym-link it will be copied as a regular file (since FAT does not support links). If an item does not exist, an :exc:`OSError` will be raised. This is in contrast to :func:`to_remove` since it is assumed that control over the source file-system is under the caller's control, which is not the case in :func:`to_remove`. """ for item in conf.copy: conf.logger.info( lang._('Copying %s into partition %d'), item, conf.boot_partition) if item.is_dir(): copy_root = fs.root / item.name copy_root.mkdir(exist_ok=True) for subitem in item.rglob('*'): name = subitem.relative_to(item) if subitem.is_dir(): (copy_root / str(name)).mkdir(exist_ok=True) else: with \ subitem.open('rb') as source, \ (copy_root / str(name)).open('wb') as target: copyfileobj(source, target) else: with \ item.open('rb') as source, \ (fs.root / item.name).open('wb') as target: copyfileobj(source, target) def rewrite_cmdline(fs, conf): """ Given the script's configuration *conf*, find the file *conf.cmdline* containing the kernel command-line in the :class:`~nobodd.fs.FatFileSystem` *fs*, and re-write it to point the NBD share specified. """ cmdline = fs.root / conf.cmdline conf.logger.info( lang._('Re-writing %s in partition %d'), conf.cmdline, conf.boot_partition) params = cmdline.read_text() try: params = params[:params.index('\n')] except ValueError: pass # no newline in the file params = [ param for param in params.split() if not param.startswith('root=') ] params[:0] = [ 'ip=dhcp', f'nbdroot={conf.nbd_host}/{conf.nbd_name}', f'root=/dev/nbd0p{conf.root_partition}', ] cmdline.write_text(' '.join(params)) def detect_partitions(conf): """ Given the script's configuration in *conf*, an :class:`argparse.Namespace`, open the target image, and attempt to detect the root and/or boot partition. """ conf.logger.info(lang._('Detecting partitions')) with \ conf.image.open('rb') as img_file, \ DiskImage(img_file) as img: fat_types = ( {UUID('ebd0a0a2-b9e5-4433-87c0-68b6b72699c7'), UUID('c12a7328-f81f-11d2-ba4b-00a0c93ec93b')} if img.partitions.style == 'gpt' else {0x01, 0x06, 0x0B, 0x0C, 0x0E, 0xEF} ) for num, part in img.partitions.items(): with part: if part.type in fat_types: if conf.boot_partition is None: try: fs = FatFileSystem(part.data) except ValueError: continue else: conf.boot_partition = num conf.logger.info( lang._('Boot partition is %d (%s)'), conf.boot_partition, fs.fat_type) fs.close() else: if conf.root_partition is None: try: fs = FatFileSystem(part.data) except ValueError: conf.root_partition = num conf.logger.info( lang._('Root partition is %d'), conf.root_partition) else: fs.close() continue if conf.boot_partition is not None: if conf.root_partition is not None: break if conf.boot_partition is None: raise ValueError(lang._('Unable to detect boot partition')) if conf.root_partition is None: raise ValueError(lang._('Unable to detect root partition')) def main(args=None): """ The main entry point for the :program:`nobodd-prep` application. Takes *args*, the sequence of command line arguments to parse. Returns the exit code of the application (0 for a normal exit, and non-zero otherwise). If ``DEBUG=1`` is found in the application's environment, top-level exceptions will be printed with a full back-trace. ``DEBUG=2`` will launch PDB in port-mortem mode. """ try: debug = int(os.environ['DEBUG']) except (KeyError, ValueError): debug = 0 lang.init() try: conf = get_parser().parse_args(args) conf.image = conf.image.resolve() conf.logger = logging.getLogger('prep') conf.logger.addHandler(logging.StreamHandler(sys.stderr)) conf.logger.setLevel(logging.DEBUG if debug else conf.log_level) if conf.boot_partition is None or conf.root_partition is None: detect_partitions(conf) if conf.nbd_name is None: conf.nbd_name = conf.image.stem prepare_image(conf) if conf.tftpd_conf is not None and conf.serial is not None: board = Board(conf.serial, conf.image, conf.boot_partition, None) conf.tftpd_conf.write(str(board)) conf.tftpd_conf.write('\n') if conf.nbd_conf is not None: conf.nbd_conf.write(f"[{conf.nbd_name}]\n") conf.nbd_conf.write(f"exportname = {conf.image}\n") except Exception as e: if not debug: print(str(e), file=sys.stderr) return 1 elif debug == 1: raise else: import pdb pdb.post_mortem() else: return 0 nobodd-0.4/nobodd/server.py000066400000000000000000000326741457216553300157760ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2023-2024 Dave Jones # Copyright (c) 2023-2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 """ A read-only TFTP server capable of reading FAT boot partitions from within image files or devices. Intended to be paired with a block-device service (e.g. NBD) for netbooting Raspberry Pis. """ import os import sys import stat import signal import socket import logging import argparse from pathlib import Path from selectors import DefaultSelector, EVENT_READ from . import lang from .disk import DiskImage from .fs import FatFileSystem from .systemd import get_systemd from .tftpd import TFTPBaseHandler, TFTPBaseServer from .tools import get_best_family from .config import ( CONFIG_LOCATIONS, ConfigArgumentParser, Board, port, ) # NOTE: The fallback comes first here as Python 3.7 incorporates # importlib.resources but at a version incompatible with our requirements. # Ultimately the try clause should be removed in favour of the except clause # once compatibility moves beyond Python 3.9 try: import importlib_resources as resources except ImportError: from importlib import resources # NOTE: Remove except when compatibility moves beyond Python 3.8 try: from importlib.metadata import version except ImportError: from importlib_metadata import version class BootHandler(TFTPBaseHandler): """ A descendent of :class:`~nobodd.tftpd.TFTPBaseHandler` that resolves paths relative to the FAT file-system in the OS image associated with the Pi serial number which forms the initial directory. """ def resolve_path(self, filename): """ Resolves *filename* relative to the OS image associated with the initial directory. In other words, if the request is for :file:`1234abcd/config.txt`, the handler will look up the board with serial number ``1234abcd`` in :class:`BootServer.boards`, find the associated OS image, the FAT file-system within that image, and resolve :file:`config.txt` within that file-system. """ p = Path(filename) if not p.parts: raise FileNotFoundError() try: serial = int(p.parts[0], base=16) board = self.server.boards[serial] except (ValueError, KeyError): raise FileNotFoundError(filename) if board.ip is not None and self.client_address[0] != board.ip: raise PermissionError(lang._('IP does not match')) boot_filename = Path('').joinpath(*p.parts[1:]) try: image, fs = self.server.images[serial] except KeyError: image = DiskImage(board.image) fs = FatFileSystem(image.partitions[board.partition].data) self.server.images[serial] = (image, fs) return fs.root / boot_filename class BootServer(TFTPBaseServer): """ A descendent of :class:`~nobodd.tftpd.TFTPBaseServer` that is configured with *boards*, a mapping of Pi serial numbers to :class:`~nobodd.config.Board` instances, and uses :class:`BootHandler` as the handler class. .. attribute:: boards The mapping of Pi serial numbers to :class:`~nobodd.config.Board` instances. """ def __init__(self, server_address, boards): if isinstance(server_address, int): fd = server_address # We're being passed an fd directly. In this case, we don't # actually want the super-class to go allocating a socket but we # can't avoid it so we allocate an ephemeral localhost socket, then # close it and overwrite self.socket. However, we need to remember # we don't *own* the socket, so self.server_close doesn't go # closing it self._own_sock = False if not stat.S_ISSOCK(os.fstat(fd).st_mode): raise RuntimeError(lang._( 'inherited fd {fd} is not a socket').format(fd=fd)) super().__init__( ('127.0.0.1', 0), BootHandler, bind_and_activate=False) self.socket.close() try: # XXX Using socket's fileno argument in this way isn't # guaranteed to work on all platforms (though it should on # Linux); see https://bugs.python.org/issue28134 for more # details self.socket = socket.socket(fileno=fd) self.socket_type = self.socket.type if self.socket_type != socket.SOCK_DGRAM: raise RuntimeError(lang._( 'inherited fd {fd} is not a datagram socket') .format(fd=fd)) # Setting self.address_family is required because TFTPSubServer # uses this to figure out the family of the ephemeral socket to # allocate for client connections self.address_family = self.socket.family if self.address_family not in (socket.AF_INET, socket.AF_INET6): raise RuntimeError(lang._( 'inherited fd {fd} is not an INET or INET6 socket') .format(fd=fd)) self.server_address = self.socket.getsockname() except: # The server's initialization creates the TFTPSubServers thread # which must be terminated if we abort the initialization at # this point self.server_close() raise else: self._own_sock = True super().__init__(server_address, BootHandler) self.boards = boards self.images = {} def server_close(self): if not self._own_sock: # We're intending to close the server, but we don't actually own # the socket's fd; detach it to make sure it stays alive in case # we're reloading and want to re-create a socket from it again self.socket.detach() super().server_close() try: for image, fs in self.images.values(): fs.close() image.close() self.images.clear() except AttributeError: # Ignore AttributeError in the case of early termination pass def get_parser(): """ Returns the command line parser for the application, pre-configured with defaults from the application's configuration file(s). See :func:`~nobodd.config.ConfigArgumentParser` for more information. """ parser = ConfigArgumentParser( description=__doc__, template=resources.files('nobodd') / 'default.conf') parser.add_argument( '--version', action='version', version=version('nobodd')) tftp_section = parser.add_argument_group('tftp', section='tftp') tftp_section.add_argument( '--listen', key='listen', type=str, metavar='ADDR', help=lang._( "the address on which to listen for connections (default: " "%(default)s)")) tftp_section.add_argument( '--port', key='port', type=port, metavar='PORT', help=lang._( "the port on which to listen for connections (default: " "%(default)s)")) tftp_section.add_argument( '--includedir', key='includedir', type=Path, metavar='PATH', help=argparse.SUPPRESS) parser.add_argument( '--board', dest='boards', type=Board.from_string, action='append', metavar='SERIAL,FILENAME[,PART[,IP]]', default=[], help=lang._( "can be specified multiple times to define boards which are to be " "served boot images over TFTP; if PART is omitted the default is " "1; if IP is omitted the IP address will not be checked")) # Reading the config twice is ... inelegant, but it's the simplest way to # handle the include path and avoid double-parsing values. The first pass # reads the default locations; the second pass re-reads the default # locations and whatever includes are found defaults = parser.read_configs(CONFIG_LOCATIONS) defaults = parser.read_configs(CONFIG_LOCATIONS + tuple(sorted( p for p in Path(defaults['tftp'].pop('includedir')).glob('*.conf') ))) # Fix-up defaults for [board:*] sections parser.set_defaults_from(defaults) parser.set_defaults(boards=parser.get_default('boards') + [ Board.from_section(defaults, section) for section in defaults if section.startswith('board:') ]) return parser # Signal handling; this stuff is declared globally primarily for testing # purposes. The exit_write and exit_read sockets can be used by the test suite # to simulate signals to the application, and the signals are registered # outside of main to ensure this occurs in the Python main thread # (signal.signal cannot be called from a subordinate thread) exit_write, exit_read = socket.socketpair() def on_sigint(signal, frame): exit_write.send(b'INT ') signal.signal(signal.SIGINT, on_sigint) def on_sigterm(signal, frame): exit_write.send(b'TERM') signal.signal(signal.SIGTERM, on_sigterm) def on_sighup(signal, frame): exit_write.send(b'HUP ') signal.signal(signal.SIGHUP, on_sighup) class ReloadRequest(Exception): """ Exception class raised in :func:`request_loop` to cause a reload. Handled in :func:`main`. """ class TerminateRequest(Exception): """ Exception class raised in :func:`request_loop` to cause service termination. Handled in :func:`main`. Takes the return code of the application as the first argument. """ def __init__(self, returncode, message=''): super().__init__(message) self.returncode = returncode def request_loop(server_address, boards): """ The application's request loop. Takes the *server_address* to bind to, which may be a ``(address, port)`` tuple, or an :class:`int` file-descriptor passed by a service manager, and the *boards* configuration, a :class:`dict` mapping serial numbers to :class:`~nobodd.config.Board` instances. Raises :exc:`ReloadRequest` or :exc:`TerminateRequest` in response to certain signals, but is an infinite loop otherwise. """ sd = get_systemd() with \ BootServer(server_address, boards) as server, \ DefaultSelector() as selector: selector.register(exit_read, EVENT_READ) selector.register(server, EVENT_READ) sd.ready() server.logger.info(lang._('Ready')) while True: for key, events in selector.select(): if key.fileobj == exit_read: code = exit_read.recv(4) if code == b'INT ': sd.stopping() server.logger.warning(lang._('Interrupted')) raise TerminateRequest(returncode=2) elif code == b'TERM': sd.stopping() server.logger.warning(lang._('Terminated')) raise TerminateRequest(returncode=0) elif code == b'HUP ': sd.reloading() server.logger.info(lang._('Reloading configuration')) raise ReloadRequest() else: assert False, f'internal error' elif key.fileobj == server: server.handle_request() else: assert False, 'internal error' def main(args=None): """ The main entry point for the :program:`nobodd-tftpd` application. Takes *args*, the sequence of command line arguments to parse. Returns the exit code of the application (0 for a normal exit, and non-zero otherwise). If ``DEBUG=1`` is found in the application's environment, top-level exceptions will be printed with a full back-trace. ``DEBUG=2`` will launch PDB in port-mortem mode. """ try: debug = int(os.environ['DEBUG']) except (KeyError, ValueError): debug = 0 lang.init() sd = get_systemd() BootServer.logger.addHandler(logging.StreamHandler(sys.stderr)) BootServer.logger.setLevel(logging.DEBUG if debug else logging.INFO) while True: try: conf = get_parser().parse_args(args) boards = { board.serial: board for board in conf.boards } if conf.listen == 'stdin': # Yes, this should always be zero but ... just in case server_address = sys.stdin.fileno() elif conf.listen == 'systemd': fds = sd.listen_fds() if len(fds) != 1: raise RuntimeError(lang._( 'Expected 1 fd from systemd but got {fds}' ).format(fds=len(fds))) server_address, name = fds.popitem() else: (BootServer.address_family, server_address ) = get_best_family(conf.listen, conf.port) request_loop(server_address, boards) except ReloadRequest: continue except TerminateRequest as err: return err.returncode except Exception as err: sd.stopping() if not debug: print(str(err), file=sys.stderr) return 1 elif debug == 1: raise else: import pdb pdb.post_mortem() return 1 nobodd-0.4/nobodd/systemd.py000066400000000000000000000146541457216553300161560ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2024 Dave Jones # Copyright (c) 2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 """ Provides a simple interface to systemd's notification and watchdog services. .. autoclass:: Systemd """ import os import socket from . import lang class Systemd: """ Provides a simple interface to systemd's notification and watchdog services. It is suggested applications obtain a single, top-level instance of this class via :func:`get_systemd` and use it to communicate with systemd. """ __slots__ = ('_socket',) LISTEN_FDS_START = 3 def __init__(self, address=None): # Remove NOTIFY_SOCKET implicitly so child processes don't inherit it self._socket = None if address is None: address = os.environ.pop('NOTIFY_SOCKET', None) if address is not None: if len(address) <= 1 or address[0] not in ('@', '/'): return None if address[0] == '@': address = '\0' + address[1:] # abstract namespace socket self._socket = socket.socket( socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC) try: self._socket.connect(address) except IOError: self._socket = None def available(self): """ If systemd's notification socket is not available, raises :exc:`RuntimeError`. Services expecting systemd notifications to be available can call this to assert that notifications will be noticed. """ if self._socket is None: raise RuntimeError(lang._( 'systemd notification socket unavailable')) def notify(self, state): """ Send a notification to systemd. *state* is a string type (if it is a unicode string it will be encoded with the 'ascii' codec). """ if self._socket is not None: if isinstance(state, str): state = state.encode('ascii') self._socket.sendall(state) def ready(self): """ Notify systemd that service startup is complete. """ self.notify('READY=1') def reloading(self): """ Notify systemd that the service is reloading its configuration. Call :func:`ready` when reload is complete. """ self.notify('RELOADING=1') def stopping(self): """ Notify systemd that the service is stopping. """ self.notify('STOPPING=1') def extend_timeout(self, timeout): """ Notify systemd to extend the start / stop timeout by *timeout* seconds. A timeout will occur if the service does not call :func:`ready` or terminate within *timeout* seconds but *only* if the original timeout (set in the systemd configuration) has already been exceeded. For example, if the stopping timeout is configured as 90s, and the service calls :func:`stopping`, systemd expects the service to terminate within 90s. After 10s the service calls :func:`extend_timeout` with a *timeout* of 10s. 20s later the service has not yet terminated but systemd does *not* consider the timeout expired as only 30s have elapsed of the original 90s timeout. """ self.notify(f'EXTEND_TIMEOUT_USEC={timeout * 1000000:d}') def watchdog_ping(self): """ Ping the systemd watchdog. This must be done periodically if :func:`watchdog_period` returns a value other than ``None``. """ self.notify('WATCHDOG=1') def watchdog_reset(self, timeout): """ Reset the systemd watchdog timer to *timeout* seconds. """ self.notify(f'WATCHDOG_USEC={timeout * 1000000:d}') def watchdog_period(self): """ Returns the time (in seconds) before which systemd expects the process to call :func:`watchdog_ping`. If a watchdog timeout is not set, the function returns ``None``. """ timeout = os.environ.get('WATCHDOG_USEC') if timeout is not None: pid = os.environ.get('WATCHDOG_PID') if pid is None or int(pid) == os.getpid(): return int(timeout) / 1000000 return None def watchdog_clean(self): """ Unsets the watchdog environment variables so that no future child processes will inherit them. .. warning:: After calling this function, :func:`watchdog_period` will return ``None`` but systemd will continue expecting :func:`watchdog_ping` to be called periodically. In other words, you should call :func:`watchdog_period` first and store its result somewhere before calling this function. """ os.environ.pop('WATCHDOG_USEC', None) os.environ.pop('WATCHDOG_PID', None) def main_pid(self, pid=None): """ Report the main PID of the process to systemd (for services that confuse systemd with their forking behaviour). If *pid* is None, :func:`os.getpid` is called to determine the calling process' PID. """ if pid is None: pid = os.getpid() self.notify(f'MAINPID={pid:d}') def listen_fds(self): """ Return file-descriptors passed to the service by systemd, e.g. as part of socket activation or file descriptor stores. It returns a :class:`dict` mapping each file-descriptor to its name, or the string "unknown" if no name was given. """ try: if int(os.environ['LISTEN_PID']) != os.getpid(): raise ValueError(lang._('wrong LISTEN_PID')) fds = int(os.environ['LISTEN_FDS']) except (ValueError, KeyError): return {} try: names = os.environ['LISTEN_FDNAMES'].split(':') except KeyError: names = ['unknown'] * fds if len(names) != fds: return {} return { fd: name for fd, name in zip( range(self.LISTEN_FDS_START, self.LISTEN_FDS_START + fds), names) } _SYSTEMD = None def get_systemd(): """ Return a single top-level instance of :class:`Systemd`; repeated calls will return the same instance. """ global _SYSTEMD if _SYSTEMD is None: _SYSTEMD = Systemd() return _SYSTEMD nobodd-0.4/nobodd/tftp.py000066400000000000000000000266151457216553300154430ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2023-2024 Dave Jones # Copyright (c) 2023-2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import re import struct from enum import IntEnum, auto from . import lang from .tools import labels, formats, FrozenDict # The following references were essential in constructing this module; the # original TFTP version 2 [RFC1350], the TFTP option extension [RFC2347], and # the wikipedia page documenting the protocol [1]. # # [1]: https://en.wikipedia.org/wiki/Trivial_File_Transfer_Protocol # [RFC1350]: https://datatracker.ietf.org/doc/html/rfc1350 # [RFC2347]: https://datatracker.ietf.org/doc/html/rfc2347 TFTP_BLKSIZE = 'blksize' TFTP_MIN_BLKSIZE = 8 TFTP_DEF_BLKSIZE = 512 TFTP_MAX_BLKSIZE = 65464 TFTP_TIMEOUT = 'timeout' TFTP_UTIMEOUT = 'utimeout' TFTP_MIN_TIMEOUT_NS = 10_000_000 # 10ms TFTP_MAX_TIMEOUT_NS = 255_000_000_000 # 255s TFTP_DEF_TIMEOUT_NS = 1_000_000_000 # 1s TFTP_BINARY = 'octet' TFTP_NETASCII = 'netascii' TFTP_MODES = frozenset({TFTP_BINARY, TFTP_NETASCII}) TFTP_TSIZE = 'tsize' TFTP_OPTIONS = frozenset({TFTP_TSIZE, TFTP_BLKSIZE, TFTP_TIMEOUT, TFTP_UTIMEOUT}) class OpCode(IntEnum): """ Enumeration of op-codes for the `Trivial File Transfer Protocol`_ (TFTP). These appear at the start of any TFTP packet to indicate what sort of packet it is. .. _Trivial File Transfer Protocol: https://en.wikipedia.org/wiki/Trivial_File_Transfer_Protocol """ RRQ = 1 WRQ = auto() DATA = auto() ACK = auto() ERROR = auto() OACK = auto() class Error(IntEnum): """ Enumeration of error status for the `Trivial File Transfer Protocol`_ (TFTP). These are used in packets with :class:`OpCode` ``ERROR`` to indicate the sort of error that has occurred. .. _Trivial File Transfer Protocol: https://en.wikipedia.org/wiki/Trivial_File_Transfer_Protocol """ UNDEFINED = 0 NOT_FOUND = auto() NOT_AUTH = auto() DISK_FULL = auto() BAD_OP = auto() UNKNOWN_ID = auto() EXISTS = auto() UNKNOWN_USER = auto() INVALID_OPT = auto() class Packet: """ Abstract base class for all TFTP packets. This provides the class method :meth:`Packet.from_bytes` which constructs and returns the appropriate concrete sub-class for the :class:`OpCode` found at the beginning of the packet's data. Instances of the concrete classes may be converted back to :class:`bytes` simply by calling :class:`bytes` on them:: >>> b = b'\\x00\\x01config.txt\\0octet\\0' >>> r = Packet.from_bytes(b) >>> r RRQPacket(filename='config.txt', mode='octet', options=FrozenDict({})) >>> bytes(r) b'\\x00\\x01config.txt\\x00octet\\x00' Concrete classes can also be constructed directly, for conversion into :class:`bytes` during transfer:: >>> bytes(ACKPacket(block=10)) b'\\x00\\x04\\x00\\n' >>> bytes(RRQPacket('foo', 'netascii', {'tsize': 0})) b'\\x00\\x01foo.txt\\x00netascii\\x00tsize\\x000\\x00' """ __slots__ = () opcode = None def __repr__(self): fields = ', '.join( f'{field}={getattr(self, field)!r}' for field in self.__class__.__slots__) return f'{self.__class__.__name__}({fields})' @classmethod def from_bytes(cls, s): """ Given a :class:`bytes`-string *s*, checks the :class:`OpCode` at the front, and constructs one of the concrete packet types defined below, returning (instead of :class:`Packet` which is abstract):: >>> Packet.from_bytes(b'\\x00\\x01config.txt\\0octet\\0') RRQPacket(filename='config.txt', mode='octet', options=FrozenDict({})) """ opcode, = struct.unpack_from('!H', s) try: cls = { OpCode.RRQ: RRQPacket, OpCode.WRQ: WRQPacket, OpCode.DATA: DATAPacket, OpCode.ACK: ACKPacket, OpCode.ERROR: ERRORPacket, OpCode.OACK: OACKPacket, }[opcode] except KeyError: raise ValueError(lang._( 'invalid packet opcode {opcode}'.format(opcode=opcode))) else: return cls.from_data(s[2:]) @classmethod def from_data(cls, data): """ Constructs an instance of the packet class with the specified *data* (which is everything in the :class:`bytes`-string passed to :meth:`from_bytes` minus the header). This method is not implemented in :class:`Packet` but is expected to be implemented in any concrete descendant. """ raise NotImplementedError() class RRQPacket(Packet): """ Concrete type for ``RRQ`` (read request) packets. These packets are sent by a client to initiate a transfer. They include the *filename* to be sent, the *mode* to send it (one of the strings "octet" or "netascii"), and any *options* the client wishes to negotiate. """ __slots__ = ('filename', 'mode', 'options') opcode = OpCode.RRQ options_re = re.compile( rb'(?P[\x20-\xFF]+)\0(?P[\x01-\xFF]*)\0') packet_re = re.compile( rb'^' rb'(?P[\x20-\xFF]+)\0' rb'(?P[a-zA-Z]+)\0' rb'(?P(?:[\x20-\xFF]+\0[\x01-\xFF]*\0)*)' rb'.*') def __init__(self, filename, mode, options=None): self.filename = str(filename) self.mode = str(mode).lower() if options is None: options = () self.options = FrozenDict(options) def __bytes__(self): return b''.join(( struct.pack('!H', self.opcode), self.filename.encode('ascii'), b'\0', self.mode.encode('ascii'), b'\0', b''.join(tuple( s for name, value in self.options.items() for s in ( name.encode('ascii'), b'\0', str(value).encode('ascii'), b'\0', ) )), )) @classmethod def from_data(cls, data): try: filename, mode, suffix = cls.packet_re.match(data).groups() except AttributeError: raise ValueError(lang._('badly formed RRQ/WRQ packet')) # Technically the filename must be in ASCII format (7-bit chars in an # 8-bit field), but given ASCII is a strict subset of UTF-8, and that # UTF-8 cannot include NUL chars, I see no harm in permitting UTF-8 # encoded filenames filename = filename.decode('utf-8') mode = mode.decode('ascii').lower() if mode not in TFTP_MODES: raise ValueError(lang._('unsupported file mode')) options = { match.group('name').decode('ascii').lower(): match.group('value').decode('ascii').lower() for match in cls.options_re.finditer(suffix) } return cls(filename, mode, options) class WRQPacket(RRQPacket): """ Concrete type for ``WRQ`` (write request) packets. These packets are sent by a client to initiate a transfer to the server. They include the *filename* to be sent, the *mode* to send it (one of the strings "octet" or "netascii"), and any *options* the client wishes to negotiate. """ __slots__ = () opcode = OpCode.WRQ class DATAPacket(Packet): """ Concrete type for ``DATA`` packets. These are sent in response to ``RRQ``, ``WRQ``, or ``ACK`` packets and each contains a block of the file to transfer, *data* (by default, 512 bytes long unless this is the final ``DATA`` packet), and the *block* number. """ __slots__ = ('block', 'data') opcode = OpCode.DATA def __init__(self, block, data): self.block = int(block) if not 1 <= self.block <= 65535: raise ValueError(f'invalid block (1..65535): {block}') self.data = bytes(data) def __bytes__(self): return struct.pack( f'!HH{len(self.data)}s', self.opcode, self.block, self.data) @classmethod def from_data(cls, data): block, = struct.unpack_from('!H', data) return cls(block, data[2:]) class ACKPacket(Packet): """ Concrete type for ``ACK`` packets. These are sent in response to ``DATA`` packets, and acknowledge the successful receipt of the specified *block*. """ __slots__ = ('block',) opcode = OpCode.ACK def __init__(self, block): self.block = int(block) if not 0 <= self.block <= 65535: raise ValueError(f'invalid block (0..65535): {block}') def __bytes__(self): return struct.pack(f'!HH', self.opcode, self.block) @classmethod def from_data(cls, data): block, = struct.unpack_from('!H', data) return cls(block) class ERRORPacket(Packet): """ Concrete type for ``ERROR`` packets. These are sent by either end of a transfer to indicate a fatal error condition. Receipt of an ``ERROR`` packet immediately terminates a transfer without further acknowledgment. The ``ERROR`` packet contains the *error* code (an :class:`Error` value) and a descriptive *message*. """ __slots__ = ('error', 'message') opcode = OpCode.ERROR def __init__(self, error, message=None): self.error = Error(int(error)) if message is None: self.message = { # NOTE: These messages are deliberately *not* marked for # translation as they are sent to the client Error.UNDEFINED: 'Undefined error', Error.NOT_FOUND: 'File not found', Error.NOT_AUTH: 'Access violation', Error.DISK_FULL: 'Disk full or allocation exceeded', Error.BAD_OP: 'Illegal TFTP operation', Error.UNKNOWN_ID: 'Unknown transfer ID', Error.EXISTS: 'File already exists', Error.UNKNOWN_USER: 'No such user', }[self.error] else: self.message = str(message) def __bytes__(self): return struct.pack( f'!HH{len(self.message)}sx', self.opcode, self.error, self.message.encode('ascii')) @classmethod def from_data(cls, data): error, = struct.unpack_from('!H', data) return cls(error, data[2:].rstrip(b'\0').decode('ascii', 'replace')) class OACKPacket(Packet): """ Concrete type for ``OACK`` packets. This is sent by the server instead of an initial ``DATA`` packet, when the client includes options in the ``RRQ`` packet. The content of the packet is all the *options* the server accepts, and their (potentially revised) values. """ __slots__ = ('options',) opcode = OpCode.OACK options_re = RRQPacket.options_re def __init__(self, options): self.options = FrozenDict(options) def __bytes__(self): return struct.pack('!H', self.opcode) + b''.join(tuple( s for name, value in self.options.items() for s in ( name.encode('ascii'), b'\0', str(value).encode('ascii'), b'\0', ) )) @classmethod def from_data(cls, data): options = { match.group('name').decode('ascii').lower(): match.group('value').decode('ascii').lower() for match in cls.options_re.finditer(data) } return cls(options) nobodd-0.4/nobodd/tftpd.py000066400000000000000000000725311457216553300156050ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2023-2024 Dave Jones # Copyright (c) 2023-2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import io import os import sys import socket import logging from pathlib import Path from contextlib import suppress from threading import Thread, Lock, Event from socketserver import BaseRequestHandler, UDPServer from time import monotonic_ns as time_ns from . import netascii, lang from .tools import BufferedTranscoder, format_address from .tftp import ( TFTP_BINARY, TFTP_NETASCII, TFTP_BLKSIZE, TFTP_TSIZE, TFTP_TIMEOUT, TFTP_UTIMEOUT, TFTP_MIN_BLKSIZE, TFTP_DEF_BLKSIZE, TFTP_MAX_BLKSIZE, TFTP_DEF_TIMEOUT_NS, TFTP_MIN_TIMEOUT_NS, TFTP_MAX_TIMEOUT_NS, TFTP_OPTIONS, Packet, RRQPacket, WRQPacket, DATAPacket, ACKPacket, ERRORPacket, OACKPacket, Error, ) # The following references were essential in constructing this module; the # various TFTP RFCs covering the protocol version 2 and its negotiated options # [RFC1350], [RFC2347], [RFC2348], [RFC2349], the wikipedia page documenting # the protocol [1], and Thiadmer Riemersma's notes on the protocol [2] and the # various options commonly found in other implementations. Wireshark [3] was # also extremely useful in analyzing bugs in the implementation. # # [1]: https://en.wikipedia.org/wiki/Trivial_File_Transfer_Protocol # [2]: https://www.compuphase.com/tftp.htm # [3]: https://www.wireshark.org/ # [RFC1350]: https://datatracker.ietf.org/doc/html/rfc1350 # [RFC2347]: https://datatracker.ietf.org/doc/html/rfc2347 # [RFC2348]: https://datatracker.ietf.org/doc/html/rfc2348 # [RFC2349]: https://datatracker.ietf.org/doc/html/rfc2349 class TransferDone(Exception): """ Exception raised internally to signal that a transfer has been completed. """ class AlreadyAcknowledged(ValueError): """ Exception raised internally to indicate that a particular data packet was already acknowledged, and does not require repeated acknowlegement. """ class BadOptions(ValueError): """ Exception raised when a client passes invalid options in a :class:`~nobodd.tftp.RRQPacket`. """ class TFTPClientState: """ Represents the state of a single transfer with a client. Constructed with the client's *address* (format varies according to family), the *path* of the file to transfer (must be a :class:`~pathlib.Path`-like object, specifically one with a functioning :meth:`~pathlib.Path.open` method), and the *mode* of the transfer (must be either :data:`~nobodd.tftp.TFTP_BINARY` or :data:`~nobodd.tftp.TFTP_NETASCII`). .. attribute:: address The address of the client. .. attribute:: blocks An internal mapping of block numbers to blocks. This caches blocks that have been read, transmitted, but not yet acknowledged. As ``ACK`` packets are received, blocks are removed from this cache. .. attribute:: block_size The size, in bytes, of blocks to transfer to the client. .. attribute:: mode The transfer mode. One of :data:`~nobodd.tftp.TFTP_BINARY` or :data:`~nobodd.tftp.TFTP_NETASCII`. .. attribute:: source The file-like object opened from the specified *path*. .. attribute:: timeout The timeout, in nano-seconds, to use before re-transmitting packets to the client. """ def __init__(self, address, path, mode=TFTP_BINARY): self.address = address self.source = path.open('rb') if mode == TFTP_NETASCII: self.source = BufferedTranscoder( self.source, TFTP_NETASCII, 'ascii', errors='replace') self.mode = mode self.blocks = {} self.blocks_read = 0 self.block_size = TFTP_DEF_BLKSIZE self.last_ack_size = None self.timeout = TFTP_DEF_TIMEOUT_NS self.started = self.last_recv = time_ns() self.last_send = None def close(self): """ Closes the source file associated with the client state. This method is idempotent. """ if self.source is not None: self.source.close() self.source = None def negotiate(self, options): """ Called with *options*, a mapping of option names to values (both :class:`str`) that the client wishes to negotiate. Currently supported options are defined in :data:`nobodd.tftp.TFTP_OPTIONS`. The original *options* mapping is left unchanged. Returns a new options mapping containing only those options that we understand and accept, and with values adjusted to those that we can support. Raises :exc:`BadOptions` in the case that the client requests pathologically silly or dangerous options. """ # Strip out any options we don't support, but maintain the original # order of them (in accordance with RFC2347); this also ensures the # local options dict is distinct from the one passed in (so we can't # mutate it) options = { name: value for name, value in options.items() if name in TFTP_OPTIONS } # Reject stupid block sizes (less than 8 according to RFC2348, though # I'm sorely tempted to set this to 512!) if TFTP_BLKSIZE in options: self.block_size = min(TFTP_MAX_BLKSIZE, int(options[TFTP_BLKSIZE])) if self.block_size < TFTP_MIN_BLKSIZE: raise BadOptions('silly block size') options[TFTP_BLKSIZE] = self.block_size # There may be implementations or transfer modes where we cannot # (cheaply) determine the transfer size (netascii). In this case we # simply remove it from the negotiated options if TFTP_TSIZE in options: try: options[TFTP_TSIZE] = self.get_size() except OSError: del options[TFTP_TSIZE] # Accept timeout and utimeout with the latter taking precedence # regardless of its order in the options. If both are present, timeout # is removed from the returned options to indicate we accept utimeout if TFTP_TIMEOUT in options: try: self.timeout = int(options[TFTP_TIMEOUT]) * 1_000_000_000 except ValueError: self.timeout = int(float(options[TFTP_TIMEOUT]) * 1_000_000_000) if TFTP_UTIMEOUT in options: self.timeout = int(options[TFTP_UTIMEOUT]) * 1_000 with suppress(KeyError): del options[TFTP_TIMEOUT] if not TFTP_MIN_TIMEOUT_NS <= self.timeout <= TFTP_MAX_TIMEOUT_NS: raise BadOptions('silly timeout') return options def ack(self, block_num): """ Specifies that *block_num* has been acknowledged by the client and can be removed from :attr:`blocks`, the internal block cache. """ with suppress(KeyError): self.last_ack_size = len(self.blocks.pop(block_num)) def get_block(self, block_num): """ Returns the :class:`bytes` of the specified *block_num*. If the *block_num* has not been read yet, this will cause the :attr:`source` to be read. Otherwise, it will be returned from the as-yet unacknowledged block cache (in :attr:`blocks`). If the block has already been acknowledged, which may happen asynchronously, this will raise :exc:`AlreadyAcknowledged`. A :exc:`ValueError` is raised if an invalid block is requested. """ if self.blocks_read + 1 == block_num: if self.finished: raise TransferDone('transfer completed') self.blocks[block_num] = self.source.read(self.block_size) self.blocks_read += 1 return self.blocks[block_num] try: # Re-transmit unacknowledged block (because DATA packet was # presumably lost). In this case blocks_read is not updated return self.blocks[block_num] except KeyError: if block_num <= self.blocks_read: # The block was already transmitted and acknowledged # (re-transmit of ACK in case of timeout); ignore this raise AlreadyAcknowledged('no re-transmit necessary') else: # A "future" block number beyond those already ACKed is # requested; this is invalid raise ValueError('invalid block number requested') def get_size(self): """ Attempts to calculate the size of the transfer. This is used when negotiating the ``tsize`` option. At first, :func:`os.fstat` is attempted on the open file; if this fails (e.g. because there's no valid ``fileno``), the routine will attempt to :meth:`~io.IOBase.seek` to the end of the file briefly to determine its size. Raises :exc:`OSError` in the case that the size cannot be determined. """ try: # The most reliable method of determining size is to stat the # open fd (guarantees we're talking about the same file even if # that filename got re-written since we opened it) return os.fstat(self.source.fileno()).st_size except (OSError, AttributeError): # If the source doesn't have a fileno() attribute, fall back to # seeking to the end of the file (temporarily) to determine its # size. Again, this guarantees we're looking at the right file pos = self.source.tell() size = self.source.seek(0, io.SEEK_END) self.source.seek(pos) return size # Note that both these methods fail in the case of the netascii mode as # BufferedTranscoder has no fileno and is not seekable, but that's # entirely deliberate. We don't want to incur the potential expense of # determining the transfer size of a netascii transfer so we'll fail # with an OSError there (which in turn means the tsize negotation # will fail and the option will be excluded from OACK) @property def transferred(self): """ Returns the number of bytes transferred to client and successfully acknowledged. """ if self.last_ack_size is None: return 0 else: return (self.blocks_read - 1) * self.block_size + self.last_ack_size @property def finished(self): """ Indicates whether the transfer has completed or not. A transfer is considered complete when the final (under-sized) block has been sent to the client *and acknowledged*. """ return ( self.last_ack_size is not None and self.last_ack_size < self.block_size) class TFTPHandler(BaseRequestHandler): """ Abstract base handler for TFTP transfers. This handles decoding TFTP packets with the classes defined in :mod:`nobodd.tftp`. If the decoding is successful, it attempts to call a corresponding ``do_`` method (e.g. :meth:`~TFTPBaseHandler.do_RRQ`, :meth:`~TFTPSubHandler.do_ACK`) with the decoded packet. The handler must return a :class:`nobodd.tftp.Packet` in response. This base class defines no ``do_`` methods itself; see :class:`TFTPBaseHandler` and :class:`TFTPSubHandler`. """ def setup(self): """ Overridden to set up the :attr:`rfile` and :attr:`wfile` objects. """ self.packet, self.socket = self.request self.rfile = io.BytesIO(self.packet) self.wfile = io.BytesIO() def handle(self): """ Attempts to decode the incoming :class:`~nobodd.tftp.Packet` and dispatch it to an appropriately named ``do_`` method. If the method returns another :class:`~nobodd.tftp.Packet`, it will be sent as the response. """ try: packet = Packet.from_bytes(self.rfile.read()) self.server.logger.debug( '%s -> %s - %r', format_address(self.client_address), format_address(self.server.server_address), packet) response = getattr(self, 'do_' + packet.opcode.name)(packet) except AttributeError as exc: self.server.logger.warning( lang._('%s - ERROR - unsupported operation; %s'), format_address(self.client_address), exc) response = ERRORPacket( Error.UNDEFINED, f'Unsupported operation, {exc!s}') except ValueError as exc: self.server.logger.warning( lang._('%s - ERROR - invalid request; %s'), format_address(self.client_address), exc) response = ERRORPacket(Error.UNDEFINED, f'Invalid request, {exc!s}') except Exception as exc: self.server.logger.exception( lang._('%s - ERROR - unexpected error; %s'), format_address(self.client_address), exc, exc_info=exc) response = ERRORPacket(Error.UNDEFINED, 'Server error') finally: if response is not None: self.server.logger.debug( '%s <- %s - %r', format_address(self.client_address), format_address(self.server.server_address), response) self.wfile.write(bytes(response)) def finish(self): """ Overridden to send the response written to :attr:`wfile`. Returns the number of bytes written. .. note:: In contrast to the usual DatagramRequestHandler, this method does *not* send an empty packet in the event that :attr:`wfile` has no content, as that confused several TFTP clients. """ buf = self.wfile.getvalue() if buf: # Return the number of bytes written; this is used in descendents # to track when we've *actually* written something return self.socket.sendto(buf, self.client_address) class TFTPBaseHandler(TFTPHandler): """ A abstract base handler for building TFTP servers. Implements :meth:`do_RRQ` to handle the initial :class:`~nobodd.tftp.RRQPacket` of a transfer. This calls the abstract :meth:`resolve_path` to obtain the :class:`~pathlib.Path`-like object representing the requested file. Descendents must (at a minimum) override :meth:`resolve_path` to implement a TFTP server. """ def resolve_path(self, filename): """ Given *filename*, as requested by a TFTP client, returns a :class:`~pathlib.Path`-like object. In the base class, this is an abstract method which raises :exc:`NotImplementedError`. Descendents must override this method to return a :class:`~pathlib.Path`-like object, specifically one with a working :meth:`~pathlib.Path.open` method, representing the file requested, or raise an :exc:`OSError` (e.g. :exc:`FileNotFoundError`) if the requested *filename* is invalid. """ raise NotImplementedError def do_RRQ(self, packet): """ Handles *packet*, the initial :class:`~nobodd.tftp.RRQPacket` of a connection. If option negotiation succeeds, and :meth:`resolve_path` returns a valid :class:`~pathlib.Path`-like object, this method will spin up a :class:`TFTPSubServer` instance in a background thread (see :class:`TFTPSubServers`) on an ephemeral port to handle all further interaction with this client. """ try: self.server.logger.info( '%s - RRQ (%s) %s', format_address(self.client_address), packet.mode, packet.filename) state = TFTPClientState( self.client_address, self.resolve_path(packet.filename), packet.mode) options = state.negotiate(packet.options) if options: packet = OACKPacket(options) else: packet = DATAPacket(1, state.get_block(1)) except BadOptions as exc: self.server.logger.info( lang._('%s - ERROR - bad options; %s'), format_address(self.client_address), exc) return ERRORPacket(Error.INVALID_OPT, str(exc)) except PermissionError: self.server.logger.info( lang._('%s - ERROR - permission denied'), format_address(self.client_address)) return ERRORPacket(Error.NOT_AUTH) except FileNotFoundError: self.server.logger.info( lang._('%s - ERROR - not found'), format_address(self.client_address)) return ERRORPacket(Error.NOT_FOUND) except OSError as exc: self.server.logger.info( lang._('%s - ERROR - %s'), format_address(self.client_address), exc) return ERRORPacket(Error.UNDEFINED, str(exc)) else: # Construct a new sub-server with an ephemeral port to handler all # further packets from this connection sub_server = TFTPSubServer(self.server, state) self.server.subs.add(sub_server) self.server.logger.debug( '%s <- %s - %r', format_address(self.client_address), format_address(sub_server.server_address), packet) # We cause the sub-server to send the first packet instead of # returning it for the main server to send, as it must originate # from the ephemeral port of the sub-server, not port 69 sub_server.socket.sendto(bytes(packet), self.client_address) state.last_send = time_ns() return None def do_ERROR(self, packet): """ Handles :class:`~nobodd.tftp.ERRORPacket` by ignoring it. The only way this should appear on the main port is at the start of a transfer, which would imply we're not going to start a transfer anyway. """ return None class TFTPSubHandler(TFTPHandler): """ Handler for all client interaction after the initial :class:`~nobodd.tftp.RRQPacket`. Only the initial packet goes to the "main" TFTP port (69). After that, each transfer communicates between the client's original port (presumably in the ephemeral range) and an ephemeral server port, specific to that transfer. This handler is spawned by the main handler (a descendent of :class:`TFTPBaseHandler`) and deals with all further client communication. In practice this means it only handles :class:`~nobodd.tftp.ACKPacket` and :class:`~nobodd.tftp.ERRORPacket`. """ def handle(self): """ Overridden to verify that the incoming packet came from the address (and port) that originally spawned this sub-handler. Logs and otherwise ignores all packets that do not meet this criteria. """ if self.client_address != self.server.client_state.address: self.server.logger.warning( lang._('%s - IGNORE - bad client for %s'), format_address(self.client_address), format_address(self.server.server_address)) return None else: self.server.client_state.last_recv = time_ns() return super().handle() def finish(self): """ Overridden to note the last time we communicated with this client. This is used by the re-transmit algorithm. """ written = super().finish() if written is not None: self.server.client_state.last_send = time_ns() def do_ACK(self, packet): """ Handles :class:`~nobodd.tftp.ACKPacket` by calling :meth:`TFTPClientState.ack`. Terminates the thread for this sub-handler if the transfer is complete, and otherwise sends the next :class:`~nobodd.tftp.DATAPacket` in response. """ state = self.server.client_state try: state.ack(packet.block) return DATAPacket(packet.block + 1, state.get_block(packet.block + 1)) except AlreadyAcknowledged: pass except (ValueError, OSError) as exc: self.server.done = True raise except TransferDone: self.server.done = True now = time_ns() duration = (now - state.started) / 1_000_000_000 self.server.logger.info( lang._('%s - DONE - %.1f secs, %d bytes, ~%.1f Kb/s'), format_address(self.client_address), duration, state.transferred, state.transferred / duration / 1024) def do_ERROR(self, packet): """ Handles :class:`~nobodd.tftp.ERRORPacket` by terminating the transfer (in accordance with the spec.) """ self.server.done = True class TFTPBaseServer(UDPServer): """ A abstract base for building TFTP servers. To build a concrete TFTP server, make a descendent of :class:`TFTPBaseHandler` that overrides :meth:`~TFTPBaseHandler.resolve_path`, then make a descendent of this class that calls ``super().__init__`` with the overridden handler class. See :class:`SimpleTFTPHandler` and :class:`SimpleTFTPServer` for examples. .. note:: While it is common to combine classes like :class:`~socketserver.UDPServer` and :class:`~socketserver.TCPServer` with the threading or fork-based mixins there is little point in doing so with :class:`TFTPBaseServer`. Only the initial packet of a TFTP transaction arrives on the "main" port; every packet after this is handled by a background thread with its own ephemeral port. Thus, multi-threading or multi-processing of the initial connection only applies to a single (minimal) packet. """ allow_reuse_address = True allow_reuse_port = True logger = logging.getLogger('tftpd') def __init__(self, address, handler_class, bind_and_activate=True): assert issubclass(handler_class, TFTPBaseHandler) super().__init__(address, handler_class, bind_and_activate) self.subs = TFTPSubServers() def server_close(self): super().server_close() self.subs.close() class TFTPSubServer(UDPServer): """ The server class associated with :class:`TFTPSubHandler`. You should never need to instantiate this class yourself. The base handler should create an instance of this to handle all communication with the client after the initial ``RRQ`` packet. """ allow_reuse_address = True # NOTE: allow_reuse_port is left False as the sub-server is restricted to # ephemeral ports logger = TFTPBaseServer.logger def __init__(self, main_server, client_state): self.done = False self.address_family = main_server.address_family host, _, *suffix = main_server.server_address address = (host, 0) + tuple(suffix) super().__init__(address, TFTPSubHandler) self.client_state = client_state def service_actions(self): """ Overridden to handle re-transmission after a timeout. """ super().service_actions() now = time_ns() state = self.client_state if now - state.last_recv > state.timeout: if state.last_send is None: # TODO: Not sure this code can be reached? self.logger.error( lang._('internal error; timeout without send')) self.done = True elif state.last_send - state.last_recv > state.timeout * 5: self.logger.warning( lang._('%s - timed out to %s'), format_address(self.client_state.address), format_address(self.server_address)) self.done = True elif now - state.last_send > state.timeout: for block, data in state.blocks.items(): packet = DATAPacket(block, data) self.socket.sendto(bytes(packet), state.address) state.last_send = time_ns() class TFTPSubServers(Thread): """ Manager class for the threads running :class:`TFTPSubServer`. :class:`TFTPBaseServer` creates an instance of this to keep track of the background threads that are running transfers with :class:`TFTPSubServer`. """ logger = TFTPBaseServer.logger def __init__(self): super().__init__() self._done = Event() self._lock = Lock() self._alive = {} self.start() def close(self): self._done.set() self.join(timeout=10) def add(self, server): """ Add *server*, a :class:`TFTPSubServer` instance, as a new background thread to be tracked. """ # Transfers are uniquely identified by TID (transfer ID) which consists # of the ephemeral server and client ports involved in the transfer. We # actually use the full ephemeral server and client address and port # combination (as we could be serving distinct networks on multiple # interfaces) tid = (server.server_address, server.client_state.address) # Override default poll_interval on serve_forever to permit # finer-grained timeouts (as supported by the utimeout extension) thread = Thread( target=server.serve_forever, kwargs={'poll_interval': 0.01}) self.logger.debug( lang._('%s - starting server on %s'), format_address(server.client_state.address), format_address(server.server_address)) with self._lock: with suppress(KeyError): self._remove(tid) self._alive[tid] = (server, thread) thread.start() def _remove(self, tid): """ Shutdown the server and join the background thread responsible for the transfer with *tid*. """ server, thread = self._alive.pop(tid) self.logger.debug( lang._('%s - shutting down server on %s'), format_address(server.client_state.address), format_address(server.server_address)) server.shutdown() thread.join(timeout=10) if thread.is_alive(): raise RuntimeError(lang._( 'failed to shutdown thread for {server.server_address}' .format(server=server))) server.client_state.close() def run(self): """ Watches background threads for completed or otherwise terminated transfers. Shuts down all remaining servers (and their corresponding threads) at termination. """ while not self._done.wait(0.01): with self._lock: to_remove = { tid for tid, (server, thread) in self._alive.items() if server.done } for tid in to_remove: self._remove(tid) with self._lock: while self._alive: self._remove(next(iter(self._alive))) class SimpleTFTPHandler(TFTPBaseHandler): """ An implementation of :class:`TFTPBaseHandler` that overrides uses :attr:`SimpleTFTPServer.base_path` for :meth:`resolve_path`. """ def resolve_path(self, filename): """ Resolves *filename* against :attr:`SimpleTFTPServer.base_path`. """ p = (self.server.base_path / filename).resolve() if self.server.base_path in p.parents: return p else: raise PermissionError(lang._( '{filename} is outside {self.server.base_path}' .format(filename=filename, self=self))) class SimpleTFTPServer(TFTPBaseServer): """ A trivial (pun intended) implementation of :class:`TFTPBaseServer` that resolves requested paths against *base_path* (a :class:`str` or :class:`~pathlib.Path`). .. attribute:: base_path The *base_path* specified in the constructor. """ def __init__(self, server_address, base_path): self.base_path = Path(base_path).resolve() super().__init__(server_address, SimpleTFTPHandler) if __name__ == '__main__': import argparse from .config import port from .tools import get_best_family parser = argparse.ArgumentParser() parser.add_argument( '--bind', '-b', metavar='ADDR', help="Specify alternate bind address (default: all interfaces)") parser.add_argument( '--directory', '-d', default=os.getcwd(), help="Specify alternate directory (default: current directory)") parser.add_argument( 'port', action='store', default=6969, type=port, nargs='?', help="Specify alternate port (default: %(default)s)") conf = parser.parse_args() SimpleTFTPServer.address_family, address = get_best_family( conf.bind, conf.port) SimpleTFTPServer.logger.addHandler(logging.StreamHandler(sys.stdout)) SimpleTFTPServer.logger.setLevel(logging.INFO) with SimpleTFTPServer(address, conf.directory) as server: host, port = server.socket.getsockname()[:2] url_host = f'[{host}]' if ':' in host else host print( f'Serving TFTP on {host} port {port} ' f'(tftp://{url_host}:{port}/) ...') try: server.serve_forever() except KeyboardInterrupt: print('\nKeyboard interrupt received, exiting.') sys.exit(0) nobodd-0.4/nobodd/tools.py000066400000000000000000000235521457216553300156230ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2023-2024 Dave Jones # Copyright (c) 2023-2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import io import codecs import socket import datetime as dt from itertools import tee from collections.abc import Mapping # TODO Remove except when compatibility moves beyond Python 3.10 try: from itertools import pairwise except ImportError: pairwise = None from . import lang def labels(desc): """ Given the description of a C structure in *desc*, returns a tuple of the labels. The :class:`str` *desc* must contain one entry per line (blank lines are ignored) where each entry consists of whitespace separated type (in Python :mod:`struct` format) and label. For example:: >>> EBPB = ''' B drive_number 1x reserved B extended_boot_sig 4s volume_id 11s volume_label 8s file_system ''' >>> labels(EBPB) ('drive_number', 'extended_boot_sig', 'volume_id', 'volume_label', 'file_system') Note the amount of whitespace is arbitrary, and further that any entries with the type "x" (which is used to indicate padding) will be excluded from the result ("reserved" is missing from the result tuple above). The corresponding function :func:`formats` can be used to obtain a tuple of the types. """ return tuple( label for line in desc.splitlines() if line for fmt, label in (line.split(None, 1),) if not fmt.endswith('x') ) def formats(desc, prefix='<'): """ Given the description of a C structure in *desc*, returns a concatenated :class:`str` of the types with an optional *prefix* (for endianness). The :class:`str` *desc* must contain one entry per line (blank lines are ignored) where each entry consists of whitespace separated type (in Python :mod:`struct` format) and label. For example:: >>> EBPB = ''' B drive_number 1x reserved B extended_boot_sig 4s volume_id 11s volume_label 8s file_system ''' >>> formats(EBPB) '>> import io, codecs >>> latin1_stream = io.BytesIO('abcdé'.encode('latin-1')) >>> utf8_stream = codecs.StreamRecoder(latin1_stream, ... codecs.getencoder('utf-8'), codecs.getdecoder('utf-8'), ... codecs.getreader('latin-1'), codecs.getwriter('latin-1')) >>> utf8_stream.read(3) b'abc' >>> utf8_stream.read(1) b'd' >>> utf8_stream.read(1) b'\\xc3\\xa9' This is alluded to in the documentation of :class:`StreamReader.read` so it probably isn't a bug, but it is rather inconvenient when the caller is looking to fill a network packet of a specific size, and thus expects not to over-run. This class implements a rather simpler recoder, which is read-only, does not permit seeking, but by use of an internal buffer, guarantees that the :meth:`read` method (and associated methods like :meth:`readinto`) will not return more bytes than requested. It is constructed with the underlying *stream*, the name of the *output_encoding*, the name of the *input_encoding* (which defaults to the *output_encoding* when not specified), and the *errors* mode to use with the codecs. For example:: >>> import io >>> from nobodd.tools import BufferedTranscoder >>> latin1_stream = io.BytesIO('abcdé'.encode('latin-1')) >>> utf8_stream = BufferedTranscoder(latin1_stream, 'utf-8', 'latin-1') >>> utf8_stream.read(4) b'abcd' >>> utf8_stream.read(1) b'\\xc3' >>> utf8_stream.read(1) b'\\xa9' """ def __init__(self, stream, output_encoding, input_encoding=None, errors='strict'): if input_encoding is None: input_encoding = output_encoding self._source = codecs.getreader(input_encoding)(stream, errors) self._encode = codecs.getencoder(output_encoding) self._buffer = bytearray() def readable(self): return True def readall(self): result = self._buffer + self._encode(self._source.read())[0] del self._buffer[:] return result def readinto(self, b): while len(self._buffer) < len(b): s = self._source.read(4096) if not s: break self._buffer.extend(self._encode(s)[0]) to_read = min(len(b), len(self._buffer)) b[:to_read] = self._buffer[:to_read] del self._buffer[:to_read] return to_read class FrozenDict(Mapping): """ A hashable, immutable mapping type. The arguments to :class:`FrozenDict` are processed just like those to :class:`dict`. """ def __init__(self, *args): self._d = dict(*args) self._hash = None def __iter__(self): return iter(self._d) def __len__(self): return len(self._d) def __getitem__(self, key): return self._d[key] def __repr__(self): return f'{self.__class__.__name__}({self._d})' def __hash__(self): if self._hash is None: self._hash = hash((frozenset(self), frozenset(self.values()))) return self._hash # TODO Remove except when compatibility moves beyond Python 3.10 if pairwise is None: def pairwise(it): """ Return successive overlapping pairs taken from the input iterable. The number of 2-tuples in the output iterator will be one fewer than the number of inputs. It will be empty if the input iterable has fewer than two values. """ a, b = tee(it) next(b, None) return zip(a, b) def decode_timestamp(date, time, cs=0): """ Given the integers *date*, *time*, and optionally *cs* (from various fields in :class:`~nobodd.fat.DirectoryEntry`), return a :class:`~datetime.datetime` with the decoded timestamp. """ ms = cs * 10 return dt.datetime( year=1980 + ((date & 0xFE00) >> 9), month=(date & 0x1E0) >> 5, day=(date & 0x1F), hour=(time & 0xF800) >> 11, minute=(time & 0x7E0) >> 5, second=(time & 0x1F) * 2 + (ms // 1000), microsecond=(ms % 1000) * 1000 ) def encode_timestamp(ts): """ Given a :class:`~datetime.datetime`, encode it as a FAT-compatible triple of three 16-bit integers representing (date, time, 1/100th seconds). """ if not dt.datetime(1980, 1, 1) <= ts < dt.datetime(2100, 1, 1): raise ValueError(f'{ts} is outside the valid range for FAT timestamps') return ( ((ts.year - 1980) << 9) | (ts.month << 5) | ts.day, (ts.hour << 11) | (ts.minute << 5) | (ts.second // 2), ((ts.second % 2) * 1000 + (ts.microsecond // 1000)) // 10 ) def exclude(ranges, value): """ Given a list non-overlapping of *ranges*, sorted in ascending order, this function modifies the range containing *value* (an integer, which must belong to one and only one range in the list) to exclude it. """ for i, r in enumerate(ranges): if value in r: break else: return ranges[i:i + 1] = [ r for r in (range(r.start, value), range(value + 1, r.stop)) if r] def any_match(s, expressions): """ Given a :class:`str` *s*, and *expressions*, a sequence of compiled regexes, return the :class:`re.Match` object from the first regex that matches *s*. If no regexes match, return :data:`None`. """ for exp in expressions: m = exp.match(s) if m: return m return None nobodd-0.4/po/000077500000000000000000000000001457216553300132535ustar00rootroot00000000000000nobodd-0.4/po/en_GB.po000066400000000000000000000470711457216553300145760ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: nobodd 0.1\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2024-03-06 00:40+0000\n" "PO-Revision-Date: 2024-03-06 00:43+0000\n" "Last-Translator: Dave Jones \n" "Language-Team: \n" "Language: en_GB\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 3.0.1\n" "X-Poedit-SourceCharset: UTF-8\n" #: nobodd/config.py:137 msgid "section and key must be specified together" msgstr "section and key must be specified together" #: nobodd/config.py:148 msgid "section and key must match for all equivalent dest values" msgstr "section and key must match for all equivalent dest values" #: nobodd/config.py:206 #, python-brace-format msgid "{path}: invalid section [{section}]" msgstr "{path}: invalid section [{section}]" #: nobodd/config.py:210 #, python-brace-format msgid "{path}: invalid key {key} in [{section}]" msgstr "{path}: invalid key {key} in [{section}]" #: nobodd/config.py:275 msgid "invalid service name or port number" msgstr "invalid service name or port number" #: nobodd/config.py:299 #, python-brace-format msgid "invalid boolean value: {s}" msgstr "invalid boolean value: {s}" #: nobodd/config.py:337 #, python-brace-format msgid "serial number is out of range: {value}" msgstr "serial number is out of range: {value}" #: nobodd/config.py:358 #, python-brace-format msgid "invalid section name: {section}" msgstr "invalid section name: {section}" #: nobodd/config.py:382 #, python-brace-format msgid "expected serial,filename,[part],[ip] instead of {s}" msgstr "expected serial,filename,[part],[ip] instead of {s}" #: nobodd/config.py:394 msgid "invalid partition number {part!r}" msgstr "invalid partition number {part!r}" #: nobodd/config.py:456 #, python-brace-format msgid "invalid duration {s}" msgstr "invalid duration {s}" #: nobodd/disk.py:160 #, python-brace-format msgid "Unable to determine partitioning scheme in use by {self._file}" msgstr "Unable to determine partitioning scheme in use by {self._file}" #: nobodd/disk.py:249 msgid "Bad GPT signature" msgstr "Bad GPT signature" #: nobodd/disk.py:251 msgid "Unrecognized GPT version" msgstr "Unrecognised GPT version" #: nobodd/disk.py:253 msgid "Bad GPT header size" msgstr "Bad GPT header size" #: nobodd/disk.py:255 msgid "Bad GPT header CRC32" msgstr "Bad GPT header CRC32" #: nobodd/disk.py:319 msgid "Bad MBR signature" msgstr "Bad MBR signature" #: nobodd/disk.py:321 msgid "Bad MBR zero field" msgstr "Bad MBR zero field" #: nobodd/disk.py:326 msgid "Protective MBR; use GPT instead" msgstr "Protective MBR; use GPT instead" #: nobodd/disk.py:337 msgid "Bad EBR signature" msgstr "Bad EBR signature" #: nobodd/disk.py:347 #, python-brace-format msgid "" "Second partition in EBR at LBA {logical_offset} is not another EBR or a " "terminal" msgstr "" "Second partition in EBR at LBA {logical_offset} is not another EBR or a " "terminal" #: nobodd/disk.py:360 msgid "Multiple extended partitions found" msgstr "Multiple extended partitions found" #: nobodd/fs.py:148 #, python-brace-format msgid "{fat_type} sectors per FAT is 0" msgstr "{fat_type} sectors per FAT is 0" #: nobodd/fs.py:153 #, python-brace-format msgid "" "Max. root entries, {bpb.max_root_entries} creates a root directory region " "that is not a multiple of sector size, {bpb.bytes_per_sector}" msgstr "" "Max. root entries, {bpb.max_root_entries} creates a root directory region " "that is not a multiple of sector size, {bpb.bytes_per_sector}" #: nobodd/fs.py:180 msgid "File-system claims to be FAT32 but has no FAT32 EBPB" msgstr "File-system claims to be FAT32 but has no FAT32 EBPB" #: nobodd/fs.py:194 #, python-brace-format msgid "Max. root entries must be 0 for {fat_type}" msgstr "Max. root entries must be 0 for {fat_type}" #: nobodd/fs.py:198 #, python-brace-format msgid "Max. root entries must be non-zero for {fat_type}" msgstr "Max. root entries must be non-zero for {fat_type}" #: nobodd/fs.py:211 msgid "File-system has the dirty bit set" msgstr "File-system has the dirty bit set" #: nobodd/fs.py:214 msgid "File-system has the I/O errors bit set" msgstr "File-system has the I/O errors bit set" #: nobodd/fs.py:438 msgid "Could not find FAT file-system type or extended boot signature" msgstr "Could not find FAT file-system type or extended boot signature" #: nobodd/fs.py:512 nobodd/fs.py:531 msgid "FAT length is immutable" msgstr "FAT length is immutable" #: nobodd/fs.py:612 nobodd/fs.py:625 nobodd/fs.py:644 #, python-brace-format msgid "{offset} out of bounds" msgstr "{offset} out of bounds" #: nobodd/fs.py:630 #, python-brace-format msgid "{value} is outside range 0x000..0xFFF" msgstr "{value} is outside range 0x000..0xFFF" #: nobodd/fs.py:678 #, python-brace-format msgid "{value} is outside range 0x0000..0xFFFF" msgstr "{value} is outside range 0x0000..0xFFFF" #: nobodd/fs.py:758 #, python-brace-format msgid "{value} is outside range 0x00000000..0x0FFFFFFF" msgstr "{value} is outside range 0x00000000..0x0FFFFFFF" #: nobodd/fs.py:826 nobodd/fs.py:832 msgid "FS length is immutable" msgstr "FS length is immutable" #: nobodd/fs.py:949 msgid "empty LongFilenameEntry decoded" msgstr "empty LongFilenameEntry decoded" #: nobodd/fs.py:989 #, python-brace-format msgid "LongFilenameEntry.first_cluster is non-zero: {head.first_cluster}" msgstr "LongFilenameEntry.first_cluster is non-zero: {head.first_cluster}" #: nobodd/fs.py:994 #, python-brace-format msgid "mismatched LongFilenameEntry.checksum: {checksum} != {head.checksum}" msgstr "mismatched LongFilenameEntry.checksum: {checksum} != {head.checksum}" #: nobodd/fs.py:1004 msgid "new terminal LongFilenameEntry" msgstr "new terminal LongFilenameEntry" #: nobodd/fs.py:1009 msgid "LongFilenameEntry.sequence is zero" msgstr "LongFilenameEntry.sequence is zero" #: nobodd/fs.py:1013 #, python-brace-format msgid "unexpected LongFilenameEntry.sequence: {sequence} != {head.sequence}" msgstr "unexpected LongFilenameEntry.sequence: {sequence} != {head.sequence}" #: nobodd/fs.py:1020 msgid "more LongFilenameEntry after sequence: 1" msgstr "more LongFilenameEntry after sequence: 1" #: nobodd/fs.py:1026 #, python-brace-format msgid "missing LongFilenameEntry after sequence: {sequence}" msgstr "missing LongFilenameEntry after sequence: {sequence}" #: nobodd/fs.py:1132 #, python-brace-format msgid "{filename} is too long (more than 255 UCS-2 characters)" msgstr "{filename} is too long (more than 255 UCS-2 characters)" #: nobodd/fs.py:1501 msgid "non-binary mode {mode!r} not supported" msgstr "non-binary mode {mode!r} not supported" #: nobodd/fs.py:1566 msgid "FatFileSystem containing {self!r} is closed" msgstr "FatFileSystem containing {self!r} is closed" #: nobodd/fs.py:1589 msgid "no key for entry-less FatFile" msgstr "no key for entry-less FatFile" #: nobodd/fs.py:1656 msgid "I/O operation on closed file" msgstr "I/O operation on closed file" #: nobodd/fs.py:1782 #, python-brace-format msgid "invalid whence: {whence}" msgstr "invalid whence: {whence}" #: nobodd/netascii.py:145 msgid "invalid netascii" msgstr "invalid netascii" #: nobodd/netascii.py:151 msgid "invalid errors setting for netascii" msgstr "invalid errors setting for netascii" #: nobodd/path.py:65 msgid "invalid name {str_self!r}" msgstr "invalid name {str_self!r}" #: nobodd/path.py:87 msgid "FatFileSystem containing {self!s} is closed" msgstr "FatFileSystem containing {self!s} is closed" #: nobodd/path.py:137 msgid "relative FatPath cannot be resolved" msgstr "relative FatPath cannot be resolved" #: nobodd/path.py:179 #, python-brace-format msgid "Directory entry for {self} disappeared" msgstr "Directory entry for {self} disappeared" #: nobodd/path.py:192 #, python-brace-format msgid "No such file or directory: {self}" msgstr "No such file or directory: {self}" #: nobodd/path.py:202 #, python-brace-format msgid "File exists: {self}" msgstr "File exists: {self}" #: nobodd/path.py:212 #, python-brace-format msgid "Not a directory: {self}" msgstr "Not a directory: {self}" #: nobodd/path.py:222 #, python-brace-format msgid "Is a directory: {self}" msgstr "Is a directory: {self}" #: nobodd/path.py:243 msgid "invalid file mode {mode!r}" msgstr "invalid file mode {mode!r}" #: nobodd/path.py:246 msgid "must have exactly one of read, write, append, exclusive creation mode" msgstr "must have exactly one of read, write, append, exclusive creation mode" #: nobodd/path.py:249 msgid "fs is read-only" msgstr "fs is read-only" #: nobodd/path.py:293 msgid "binary mode doesn't take an encoding argument" msgstr "binary mode doesn't take an encoding argument" #: nobodd/path.py:296 msgid "binary mode doesn't take an errors argument" msgstr "binary mode doesn't take an errors argument" #: nobodd/path.py:299 msgid "binary mode doesn't take a newline argument" msgstr "binary mode doesn't take a newline argument" #: nobodd/path.py:303 msgid "can't have unbuffered text I/O" msgstr "can't have unbuffered text I/O" #: nobodd/path.py:382 msgid "Cannot rename between FatFileSystem instances" msgstr "Cannot rename between FatFileSystem instances" #: nobodd/path.py:485 msgid "Cannot remove the root directory" msgstr "Cannot remove the root directory" #: nobodd/path.py:517 msgid "Cannot resolve relative path {self!r}" msgstr "Cannot resolve relative path {self!r}" #: nobodd/path.py:582 msgid "empty pattern" msgstr "empty pattern" #: nobodd/path.py:629 msgid "invalid pattern: ** can only be an entire component" msgstr "invalid pattern: ** can only be an entire component" #: nobodd/path.py:665 nobodd/path.py:678 msgid "Unacceptable pattern" msgstr "Unacceptable pattern" #: nobodd/path.py:668 nobodd/path.py:681 msgid "Non-relative patterns are not supported" msgstr "Non-relative patterns are not supported" #: nobodd/path.py:1024 msgid "need at least one argument" msgstr "need at least one argument" #: nobodd/path.py:1030 msgid "" "{self!r} is not in the subpath of {to!r} OR one path is relative and the " "other is absolute" msgstr "" "{self!r} is not in the subpath of {to!r} OR one path is relative and the " "other is absolute" #: nobodd/path.py:1064 msgid "{self!r} has an empty name" msgstr "{self!r} has an empty name" #: nobodd/path.py:1067 msgid "invalid name {name!r}" msgstr "invalid name {name!r}" #: nobodd/path.py:1085 nobodd/path.py:1088 msgid "Invalid suffix {suffix!r}" msgstr "Invalid suffix {suffix!r}" #: nobodd/path.py:1106 nobodd/path.py:1120 #, python-brace-format msgid "" "comparison is not supported between instances of {self.__class__.__name__} " "with different file-systems" msgstr "" "comparison is not supported between instances of {self.__class__.__name__} " "with different file-systems" #: nobodd/prep.py:66 msgid "Print more output" msgstr "Print more output" #: nobodd/prep.py:70 msgid "Print no output" msgstr "Print no output" #: nobodd/prep.py:74 msgid "The target image to customize" msgstr "The target image to customise" #: nobodd/prep.py:77 #, python-format msgid "The size to expand the image to; default: %(default)s" msgstr "The size to expand the image to; default: %(default)s" #: nobodd/prep.py:81 msgid "" "The hostname of the nbd server to connect to for the root device; defaults " "to the local machine's FQDN" msgstr "" "The hostname of the nbd server to connect to for the root device; defaults " "to the local machine's FQDN" #: nobodd/prep.py:86 msgid "" "The name of the nbd share to use as the root device; defaults to the stem of " "the *image* name" msgstr "" "The name of the nbd share to use as the root device; defaults to the stem of " "the *image* name" #: nobodd/prep.py:91 #, python-format msgid "" "The name of the file containing the kernel command line on the boot " "partition; default: %(default)s" msgstr "" "The name of the file containing the kernel command line on the boot " "partition; default: %(default)s" #: nobodd/prep.py:96 msgid "" "Which partition is the boot partition within the image; default is the first " "FAT partition (identified by partition type) found in the image" msgstr "" "Which partition is the boot partition within the image; default is the first " "FAT partition (identified by partition type) found in the image" #: nobodd/prep.py:102 msgid "" "Which partition is the root partition within the image default is the first " "non-FAT partition (identified by partition type) found in the image" msgstr "" "Which partition is the root partition within the image default is the first " "non-FAT partition (identified by partition type) found in the image" #: nobodd/prep.py:108 msgid "" "Copy the specified file or directory into the boot partition. This may be " "given multiple times to specify multiple items to copy" msgstr "" "Copy the specified file or directory into the boot partition. This may be " "given multiple times to specify multiple items to copy" #: nobodd/prep.py:114 msgid "" "Remove the specified file or directory from the boot partition. This may be " "given multiple times to specify multiple items to delete" msgstr "" "Remove the specified file or directory from the boot partition. This may be " "given multiple times to specify multiple items to delete" #: nobodd/prep.py:120 msgid "" "Defines the serial number of the Raspberry Pi that will be served this " "image. When this option is given, a board configuration compatible with " "nobodd-tftpd may be output with --tftpd-conf" msgstr "" "Defines the serial number of the Raspberry Pi that will be served this " "image. When this option is given, a board configuration compatible with " "nobodd-tftpd may be output with --tftpd-conf" #: nobodd/prep.py:127 msgid "" "If specified, write a board configuration compatible with nobodd-tftpd to " "the specified file; requires --serial to be given" msgstr "" "If specified, write a board configuration compatible with nobodd-tftpd to " "the specified file; requires --serial to be given" #: nobodd/prep.py:133 msgid "" "If specified, write a share configuration compatible with nbd-server to the " "specified file" msgstr "" "If specified, write a share configuration compatible with nbd-server to the " "specified file" #: nobodd/prep.py:152 #, python-format msgid "Resizing %s to %d bytes" msgstr "Resizing %s to %d bytes" #: nobodd/prep.py:157 #, python-format msgid "Skipping resize; %s is already %d bytes or larger" msgstr "Skipping resize; %s is already %d bytes or larger" #: nobodd/prep.py:181 #, python-format msgid "Removing %s from partition %d" msgstr "Removing %s from partition %d" #: nobodd/prep.py:197 #, python-format msgid "No such file/dir %s in partition %d" msgstr "No such file/dir %s in partition %d" #: nobodd/prep.py:216 #, python-format msgid "Copying %s into partition %d" msgstr "Copying %s into partition %d" #: nobodd/prep.py:247 #, python-format msgid "Re-writing %s in partition %d" msgstr "Re-writing %s in partition %d" #: nobodd/prep.py:273 msgid "Detecting partitions" msgstr "Detecting partitions" #: nobodd/prep.py:295 #, python-format msgid "Boot partition is %d (%s)" msgstr "Boot partition is %d (%s)" #: nobodd/prep.py:305 #, python-format msgid "Root partition is %d" msgstr "Root partition is %d" #: nobodd/prep.py:314 msgid "Unable to detect boot partition" msgstr "Unable to detect boot partition" #: nobodd/prep.py:316 msgid "Unable to detect root partition" msgstr "Unable to detect root partition" #: nobodd/server.py:80 msgid "IP does not match" msgstr "IP does not match" #: nobodd/server.py:115 #, python-brace-format msgid "inherited fd {fd} is not a socket" msgstr "inherited fd {fd} is not a socket" #: nobodd/server.py:128 #, python-brace-format msgid "inherited fd {fd} is not a datagram socket" msgstr "inherited fd {fd} is not a datagram socket" #: nobodd/server.py:136 #, python-brace-format msgid "inherited fd {fd} is not an INET or INET6 socket" msgstr "inherited fd {fd} is not an INET or INET6 socket" #: nobodd/server.py:185 #, python-format msgid "the address on which to listen for connections (default: %(default)s)" msgstr "the address on which to listen for connections (default: %(default)s)" #: nobodd/server.py:191 #, python-format msgid "the port on which to listen for connections (default: %(default)s)" msgstr "the port on which to listen for connections (default: %(default)s)" #: nobodd/server.py:202 msgid "" "can be specified multiple times to define boards which are to be served boot " "images over TFTP; if PART is omitted the default is 1; if IP is omitted the " "IP address will not be checked" msgstr "" "can be specified multiple times to define boards which are to be served boot " "images over TFTP; if PART is omitted the default is 1; if IP is omitted the " "IP address will not be checked" #: nobodd/server.py:285 msgid "Ready" msgstr "Ready" #: nobodd/server.py:292 msgid "Interrupted" msgstr "Interrupted" #: nobodd/server.py:296 msgid "Terminated" msgstr "Terminated" #: nobodd/server.py:300 msgid "Reloading configuration" msgstr "Reloading configuration" #: nobodd/server.py:345 #, python-brace-format msgid "Expected 1 fd from systemd but got {fds}" msgstr "Expected 1 fd from systemd but got {fds}" #: nobodd/systemd.py:55 msgid "systemd notification socket unavailable" msgstr "systemd notification socket unavailable" #: nobodd/systemd.py:163 msgid "wrong LISTEN_PID" msgstr "wrong LISTEN_PID" #: nobodd/tftp.py:136 #, python-brace-format msgid "invalid packet opcode {opcode}" msgstr "invalid packet opcode {opcode}" #: nobodd/tftp.py:198 msgid "badly formed RRQ/WRQ packet" msgstr "badly formed RRQ/WRQ packet" #: nobodd/tftp.py:206 msgid "unsupported file mode" msgstr "unsupported file mode" #: nobodd/tftpd.py:326 #, python-format msgid "%s - ERROR - unsupported operation; %s" msgstr "%s - ERROR - unsupported operation; %s" #: nobodd/tftpd.py:332 #, python-format msgid "%s - ERROR - invalid request; %s" msgstr "%s - ERROR - invalid request; %s" #: nobodd/tftpd.py:337 #, python-format msgid "%s - ERROR - unexpected error; %s" msgstr "%s - ERROR - unexpected error; %s" #: nobodd/tftpd.py:418 #, python-format msgid "%s - ERROR - bad options; %s" msgstr "%s - ERROR - bad options; %s" #: nobodd/tftpd.py:423 #, python-format msgid "%s - ERROR - permission denied" msgstr "%s - ERROR - permission denied" #: nobodd/tftpd.py:428 #, python-format msgid "%s - ERROR - not found" msgstr "%s - ERROR - not found" #: nobodd/tftpd.py:433 #, python-format msgid "%s - ERROR - %s" msgstr "%s - ERROR - %s" #: nobodd/tftpd.py:483 #, python-format msgid "%s - IGNORE - bad client for %s" msgstr "%s - IGNORE - bad client for %s" #: nobodd/tftpd.py:521 #, python-format msgid "%s - DONE - %.1f secs, %d bytes, ~%.1f Kb/s" msgstr "%s - DONE - %.1f secs, %d bytes, ~%.1f Kb/s" #: nobodd/tftpd.py:602 msgid "internal error; timeout without send" msgstr "internal error; timeout without send" #: nobodd/tftpd.py:606 #, python-format msgid "%s - timed out to %s" msgstr "%s - timed out to %s" #: nobodd/tftpd.py:653 #, python-format msgid "%s - starting server on %s" msgstr "%s - starting server on %s" #: nobodd/tftpd.py:669 #, python-format msgid "%s - shutting down server on %s" msgstr "%s - shutting down server on %s" #: nobodd/tftpd.py:676 #, python-brace-format msgid "failed to shutdown thread for {server.server_address}" msgstr "failed to shutdown thread for {server.server_address}" #: nobodd/tftpd.py:715 #, python-brace-format msgid "{filename} is outside {self.server.base_path}" msgstr "{filename} is outside {self.server.base_path}" #: nobodd/tools.py:106 nobodd/tools.py:109 msgid "invalid host and port combination" msgstr "invalid host and port combination" #~ msgid "No boards defined" #~ msgstr "No boards defined" nobodd-0.4/po/nobodd.pot000066400000000000000000000330171457216553300152500ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2024-03-06 00:40+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" #: nobodd/config.py:137 msgid "section and key must be specified together" msgstr "" #: nobodd/config.py:148 msgid "section and key must match for all equivalent dest values" msgstr "" #: nobodd/config.py:206 #, python-brace-format msgid "{path}: invalid section [{section}]" msgstr "" #: nobodd/config.py:210 #, python-brace-format msgid "{path}: invalid key {key} in [{section}]" msgstr "" #: nobodd/config.py:275 msgid "invalid service name or port number" msgstr "" #: nobodd/config.py:299 #, python-brace-format msgid "invalid boolean value: {s}" msgstr "" #: nobodd/config.py:337 #, python-brace-format msgid "serial number is out of range: {value}" msgstr "" #: nobodd/config.py:358 #, python-brace-format msgid "invalid section name: {section}" msgstr "" #: nobodd/config.py:382 #, python-brace-format msgid "expected serial,filename,[part],[ip] instead of {s}" msgstr "" #: nobodd/config.py:394 msgid "invalid partition number {part!r}" msgstr "" #: nobodd/config.py:456 #, python-brace-format msgid "invalid duration {s}" msgstr "" #: nobodd/disk.py:160 #, python-brace-format msgid "Unable to determine partitioning scheme in use by {self._file}" msgstr "" #: nobodd/disk.py:249 msgid "Bad GPT signature" msgstr "" #: nobodd/disk.py:251 msgid "Unrecognized GPT version" msgstr "" #: nobodd/disk.py:253 msgid "Bad GPT header size" msgstr "" #: nobodd/disk.py:255 msgid "Bad GPT header CRC32" msgstr "" #: nobodd/disk.py:319 msgid "Bad MBR signature" msgstr "" #: nobodd/disk.py:321 msgid "Bad MBR zero field" msgstr "" #: nobodd/disk.py:326 msgid "Protective MBR; use GPT instead" msgstr "" #: nobodd/disk.py:337 msgid "Bad EBR signature" msgstr "" #: nobodd/disk.py:347 #, python-brace-format msgid "" "Second partition in EBR at LBA {logical_offset} is not another EBR or a " "terminal" msgstr "" #: nobodd/disk.py:360 msgid "Multiple extended partitions found" msgstr "" #: nobodd/fs.py:148 #, python-brace-format msgid "{fat_type} sectors per FAT is 0" msgstr "" #: nobodd/fs.py:153 #, python-brace-format msgid "" "Max. root entries, {bpb.max_root_entries} creates a root directory region " "that is not a multiple of sector size, {bpb.bytes_per_sector}" msgstr "" #: nobodd/fs.py:180 msgid "File-system claims to be FAT32 but has no FAT32 EBPB" msgstr "" #: nobodd/fs.py:194 #, python-brace-format msgid "Max. root entries must be 0 for {fat_type}" msgstr "" #: nobodd/fs.py:198 #, python-brace-format msgid "Max. root entries must be non-zero for {fat_type}" msgstr "" #: nobodd/fs.py:211 msgid "File-system has the dirty bit set" msgstr "" #: nobodd/fs.py:214 msgid "File-system has the I/O errors bit set" msgstr "" #: nobodd/fs.py:438 msgid "Could not find FAT file-system type or extended boot signature" msgstr "" #: nobodd/fs.py:512 nobodd/fs.py:531 msgid "FAT length is immutable" msgstr "" #: nobodd/fs.py:612 nobodd/fs.py:625 nobodd/fs.py:644 #, python-brace-format msgid "{offset} out of bounds" msgstr "" #: nobodd/fs.py:630 #, python-brace-format msgid "{value} is outside range 0x000..0xFFF" msgstr "" #: nobodd/fs.py:678 #, python-brace-format msgid "{value} is outside range 0x0000..0xFFFF" msgstr "" #: nobodd/fs.py:758 #, python-brace-format msgid "{value} is outside range 0x00000000..0x0FFFFFFF" msgstr "" #: nobodd/fs.py:826 nobodd/fs.py:832 msgid "FS length is immutable" msgstr "" #: nobodd/fs.py:949 msgid "empty LongFilenameEntry decoded" msgstr "" #: nobodd/fs.py:989 #, python-brace-format msgid "LongFilenameEntry.first_cluster is non-zero: {head.first_cluster}" msgstr "" #: nobodd/fs.py:994 #, python-brace-format msgid "mismatched LongFilenameEntry.checksum: {checksum} != {head.checksum}" msgstr "" #: nobodd/fs.py:1004 msgid "new terminal LongFilenameEntry" msgstr "" #: nobodd/fs.py:1009 msgid "LongFilenameEntry.sequence is zero" msgstr "" #: nobodd/fs.py:1013 #, python-brace-format msgid "unexpected LongFilenameEntry.sequence: {sequence} != {head.sequence}" msgstr "" #: nobodd/fs.py:1020 msgid "more LongFilenameEntry after sequence: 1" msgstr "" #: nobodd/fs.py:1026 #, python-brace-format msgid "missing LongFilenameEntry after sequence: {sequence}" msgstr "" #: nobodd/fs.py:1132 #, python-brace-format msgid "{filename} is too long (more than 255 UCS-2 characters)" msgstr "" #: nobodd/fs.py:1501 msgid "non-binary mode {mode!r} not supported" msgstr "" #: nobodd/fs.py:1566 msgid "FatFileSystem containing {self!r} is closed" msgstr "" #: nobodd/fs.py:1589 msgid "no key for entry-less FatFile" msgstr "" #: nobodd/fs.py:1656 msgid "I/O operation on closed file" msgstr "" #: nobodd/fs.py:1782 #, python-brace-format msgid "invalid whence: {whence}" msgstr "" #: nobodd/netascii.py:145 msgid "invalid netascii" msgstr "" #: nobodd/netascii.py:151 msgid "invalid errors setting for netascii" msgstr "" #: nobodd/path.py:65 msgid "invalid name {str_self!r}" msgstr "" #: nobodd/path.py:87 msgid "FatFileSystem containing {self!s} is closed" msgstr "" #: nobodd/path.py:137 msgid "relative FatPath cannot be resolved" msgstr "" #: nobodd/path.py:179 #, python-brace-format msgid "Directory entry for {self} disappeared" msgstr "" #: nobodd/path.py:192 #, python-brace-format msgid "No such file or directory: {self}" msgstr "" #: nobodd/path.py:202 #, python-brace-format msgid "File exists: {self}" msgstr "" #: nobodd/path.py:212 #, python-brace-format msgid "Not a directory: {self}" msgstr "" #: nobodd/path.py:222 #, python-brace-format msgid "Is a directory: {self}" msgstr "" #: nobodd/path.py:243 msgid "invalid file mode {mode!r}" msgstr "" #: nobodd/path.py:246 msgid "must have exactly one of read, write, append, exclusive creation mode" msgstr "" #: nobodd/path.py:249 msgid "fs is read-only" msgstr "" #: nobodd/path.py:293 msgid "binary mode doesn't take an encoding argument" msgstr "" #: nobodd/path.py:296 msgid "binary mode doesn't take an errors argument" msgstr "" #: nobodd/path.py:299 msgid "binary mode doesn't take a newline argument" msgstr "" #: nobodd/path.py:303 msgid "can't have unbuffered text I/O" msgstr "" #: nobodd/path.py:382 msgid "Cannot rename between FatFileSystem instances" msgstr "" #: nobodd/path.py:485 msgid "Cannot remove the root directory" msgstr "" #: nobodd/path.py:517 msgid "Cannot resolve relative path {self!r}" msgstr "" #: nobodd/path.py:582 msgid "empty pattern" msgstr "" #: nobodd/path.py:629 msgid "invalid pattern: ** can only be an entire component" msgstr "" #: nobodd/path.py:665 nobodd/path.py:678 msgid "Unacceptable pattern" msgstr "" #: nobodd/path.py:668 nobodd/path.py:681 msgid "Non-relative patterns are not supported" msgstr "" #: nobodd/path.py:1024 msgid "need at least one argument" msgstr "" #: nobodd/path.py:1030 msgid "" "{self!r} is not in the subpath of {to!r} OR one path is relative and the " "other is absolute" msgstr "" #: nobodd/path.py:1064 msgid "{self!r} has an empty name" msgstr "" #: nobodd/path.py:1067 msgid "invalid name {name!r}" msgstr "" #: nobodd/path.py:1085 nobodd/path.py:1088 msgid "Invalid suffix {suffix!r}" msgstr "" #: nobodd/path.py:1106 nobodd/path.py:1120 #, python-brace-format msgid "" "comparison is not supported between instances of {self.__class__.__name__} " "with different file-systems" msgstr "" #: nobodd/prep.py:66 msgid "Print more output" msgstr "" #: nobodd/prep.py:70 msgid "Print no output" msgstr "" #: nobodd/prep.py:74 msgid "The target image to customize" msgstr "" #: nobodd/prep.py:77 #, python-format msgid "The size to expand the image to; default: %(default)s" msgstr "" #: nobodd/prep.py:81 msgid "" "The hostname of the nbd server to connect to for the root device; defaults " "to the local machine's FQDN" msgstr "" #: nobodd/prep.py:86 msgid "" "The name of the nbd share to use as the root device; defaults to the stem of " "the *image* name" msgstr "" #: nobodd/prep.py:91 #, python-format msgid "" "The name of the file containing the kernel command line on the boot " "partition; default: %(default)s" msgstr "" #: nobodd/prep.py:96 msgid "" "Which partition is the boot partition within the image; default is the first " "FAT partition (identified by partition type) found in the image" msgstr "" #: nobodd/prep.py:102 msgid "" "Which partition is the root partition within the image default is the first " "non-FAT partition (identified by partition type) found in the image" msgstr "" #: nobodd/prep.py:108 msgid "" "Copy the specified file or directory into the boot partition. This may be " "given multiple times to specify multiple items to copy" msgstr "" #: nobodd/prep.py:114 msgid "" "Remove the specified file or directory from the boot partition. This may be " "given multiple times to specify multiple items to delete" msgstr "" #: nobodd/prep.py:120 msgid "" "Defines the serial number of the Raspberry Pi that will be served this " "image. When this option is given, a board configuration compatible with " "nobodd-tftpd may be output with --tftpd-conf" msgstr "" #: nobodd/prep.py:127 msgid "" "If specified, write a board configuration compatible with nobodd-tftpd to " "the specified file; requires --serial to be given" msgstr "" #: nobodd/prep.py:133 msgid "" "If specified, write a share configuration compatible with nbd-server to the " "specified file" msgstr "" #: nobodd/prep.py:152 #, python-format msgid "Resizing %s to %d bytes" msgstr "" #: nobodd/prep.py:157 #, python-format msgid "Skipping resize; %s is already %d bytes or larger" msgstr "" #: nobodd/prep.py:181 #, python-format msgid "Removing %s from partition %d" msgstr "" #: nobodd/prep.py:197 #, python-format msgid "No such file/dir %s in partition %d" msgstr "" #: nobodd/prep.py:216 #, python-format msgid "Copying %s into partition %d" msgstr "" #: nobodd/prep.py:247 #, python-format msgid "Re-writing %s in partition %d" msgstr "" #: nobodd/prep.py:273 msgid "Detecting partitions" msgstr "" #: nobodd/prep.py:295 #, python-format msgid "Boot partition is %d (%s)" msgstr "" #: nobodd/prep.py:305 #, python-format msgid "Root partition is %d" msgstr "" #: nobodd/prep.py:314 msgid "Unable to detect boot partition" msgstr "" #: nobodd/prep.py:316 msgid "Unable to detect root partition" msgstr "" #: nobodd/server.py:80 msgid "IP does not match" msgstr "" #: nobodd/server.py:115 #, python-brace-format msgid "inherited fd {fd} is not a socket" msgstr "" #: nobodd/server.py:128 #, python-brace-format msgid "inherited fd {fd} is not a datagram socket" msgstr "" #: nobodd/server.py:136 #, python-brace-format msgid "inherited fd {fd} is not an INET or INET6 socket" msgstr "" #: nobodd/server.py:185 #, python-format msgid "the address on which to listen for connections (default: %(default)s)" msgstr "" #: nobodd/server.py:191 #, python-format msgid "the port on which to listen for connections (default: %(default)s)" msgstr "" #: nobodd/server.py:202 msgid "" "can be specified multiple times to define boards which are to be served boot " "images over TFTP; if PART is omitted the default is 1; if IP is omitted the " "IP address will not be checked" msgstr "" #: nobodd/server.py:285 msgid "Ready" msgstr "" #: nobodd/server.py:292 msgid "Interrupted" msgstr "" #: nobodd/server.py:296 msgid "Terminated" msgstr "" #: nobodd/server.py:300 msgid "Reloading configuration" msgstr "" #: nobodd/server.py:345 #, python-brace-format msgid "Expected 1 fd from systemd but got {fds}" msgstr "" #: nobodd/systemd.py:55 msgid "systemd notification socket unavailable" msgstr "" #: nobodd/systemd.py:163 msgid "wrong LISTEN_PID" msgstr "" #: nobodd/tftp.py:136 #, python-brace-format msgid "invalid packet opcode {opcode}" msgstr "" #: nobodd/tftp.py:198 msgid "badly formed RRQ/WRQ packet" msgstr "" #: nobodd/tftp.py:206 msgid "unsupported file mode" msgstr "" #: nobodd/tftpd.py:326 #, python-format msgid "%s - ERROR - unsupported operation; %s" msgstr "" #: nobodd/tftpd.py:332 #, python-format msgid "%s - ERROR - invalid request; %s" msgstr "" #: nobodd/tftpd.py:337 #, python-format msgid "%s - ERROR - unexpected error; %s" msgstr "" #: nobodd/tftpd.py:418 #, python-format msgid "%s - ERROR - bad options; %s" msgstr "" #: nobodd/tftpd.py:423 #, python-format msgid "%s - ERROR - permission denied" msgstr "" #: nobodd/tftpd.py:428 #, python-format msgid "%s - ERROR - not found" msgstr "" #: nobodd/tftpd.py:433 #, python-format msgid "%s - ERROR - %s" msgstr "" #: nobodd/tftpd.py:483 #, python-format msgid "%s - IGNORE - bad client for %s" msgstr "" #: nobodd/tftpd.py:521 #, python-format msgid "%s - DONE - %.1f secs, %d bytes, ~%.1f Kb/s" msgstr "" #: nobodd/tftpd.py:602 msgid "internal error; timeout without send" msgstr "" #: nobodd/tftpd.py:606 #, python-format msgid "%s - timed out to %s" msgstr "" #: nobodd/tftpd.py:653 #, python-format msgid "%s - starting server on %s" msgstr "" #: nobodd/tftpd.py:669 #, python-format msgid "%s - shutting down server on %s" msgstr "" #: nobodd/tftpd.py:676 #, python-brace-format msgid "failed to shutdown thread for {server.server_address}" msgstr "" #: nobodd/tftpd.py:715 #, python-brace-format msgid "{filename} is outside {self.server.base_path}" msgstr "" #: nobodd/tools.py:106 nobodd/tools.py:109 msgid "invalid host and port combination" msgstr "" nobodd-0.4/scripts/000077500000000000000000000000001457216553300143245ustar00rootroot00000000000000nobodd-0.4/scripts/class_graph000077500000000000000000000200631457216553300165410ustar00rootroot00000000000000#!/usr/bin/python3 """ This script generates Graphviz-compatible dot scripts from the class definitions of the containing project. Specify the root class to generate with the -i (multiple roots can be specified). Specify parts of the hierarchy to exclude with -x. Default configurations can be specified in the containing project's setup.cfg under [{SETUP_SECTION}] """ from __future__ import annotations import re import sys assert sys.version_info >= (3, 6), 'Script requires Python 3.6+' import typing as t from pathlib import Path from configparser import ConfigParser from argparse import ArgumentParser, Namespace, FileType PROJECT_ROOT = (Path(__file__).parent / '..').resolve() SETUP_SECTION = str(Path(__file__).name) + ':settings' def main(args: t.List[str] = None): if args is None: args = sys.argv[1:] config = get_config(args) m = make_class_map(config.source, config.omit) if config.include or config.exclude: m = filter_map(m, include_roots=config.include, exclude_roots=config.exclude) config.output.write(render_map(m, config.abstract)) def get_config(args: t.List[str]) -> Namespace: config = ConfigParser( defaults={ 'source': '', 'include': '', 'exclude': '', 'abstract': '', 'omit': '', 'output': '-', }, delimiters=('=',), default_section=SETUP_SECTION, empty_lines_in_values=False, interpolation=None, converters={'list': lambda s: s.strip().splitlines()}) config.read(PROJECT_ROOT / 'setup.cfg') sect = config[SETUP_SECTION] # Resolve source and output defaults relative to setup.cfg if sect['source']: sect['source'] = '\n'.join( str(PROJECT_ROOT / source) for source in sect.getlist('source') ) if sect['output'] and sect['output'] != '-': sect['output'] = str(PROJECT_ROOT / sect['output']) parser = ArgumentParser(description=__doc__.format(**globals())) parser.add_argument( '-s', '--source', action='append', metavar='PATH', default=sect.getlist('source'), help="the pattern(s) of files to search for classes; can be specified " "multiple times. Default: %(default)r") parser.add_argument( '-i', '--include', action='append', metavar='CLASS', default=sect.getlist('exclude'), help="only include classes which have BASE somewhere in their " "ancestry; can be specified multiple times. Default: %(default)r") parser.add_argument( '-x', '--exclude', action='append', metavar='CLASS', default=sect.getlist('exclude'), help="exclude any classes which have BASE somewhere in their " "ancestry; can be specified multiple times. Default: %(default)r") parser.add_argument( '-o', '--omit', action='append', metavar='CLASS', default=sect.getlist('omit'), help="omit the specified class, but not its descendents from the " "chart; can be specified multiple times. Default: %(default)r") parser.add_argument( '-a', '--abstract', action='append', metavar='CLASS', default=sect.getlist('abstract'), help="mark the specified class as abstract, rendering it in a " "different color; can be specified multiple times. Default: " "%(default)r") parser.add_argument( 'output', nargs='?', type=FileType('w'), default=sect['output'], help="the file to write the output to; defaults to stdout") ns = parser.parse_args(args) ns.abstract = set(ns.abstract) ns.include = set(ns.include) ns.exclude = set(ns.exclude) ns.omit = set(ns.omit) if not ns.source: ns.source = [str(PROJECT_ROOT)] ns.source = set(ns.source) return ns def make_class_map(search_paths: t.List[str], omit: t.Set[str])\ -> t.Dict[str, t.Set[str]]: """ Find all Python source files under *search_paths*, extract (via a crude regex) all class definitions and return a mapping of class-name to the list of base classes. All classes listed in *omit* will be excluded from the result, but not their descendents (useful for excluding "object" etc.) """ def find_classes() -> t.Iterator[t.Tuple[str, t.Set[str]]]: class_re = re.compile( r'^class\s+(?P\w+)\s*(?:\((?P.*)\))?:', re.MULTILINE) for path in search_paths: p = Path(path) for py_file in p.parent.glob(p.name): with py_file.open() as f: for match in class_re.finditer(f.read()): if match.group('name') not in omit: yield match.group('name'), { base.strip() for base in ( match.group('bases') or 'object' ).split(',') if base.strip() not in omit } return { name: bases for name, bases in find_classes() } def filter_map(class_map: t.Dict[str, t.Set[str]], include_roots: t.Set[str], exclude_roots: t.Set[str]) -> t.Dict[str, t.Set[str]]: """ Returns *class_map* (which is a mapping such as that returned by :func:`make_class_map`), with only those classes which have at least one of the *include_roots* in their ancestry, and none of the *exclude_roots*. """ def has_parent(cls: str, parent: str) -> bool: return cls == parent or any( has_parent(base, parent) for base in class_map.get(cls, ())) filtered = { name: bases for name, bases in class_map.items() if (not include_roots or any(has_parent(name, root) for root in include_roots)) and not any(has_parent(name, root) for root in exclude_roots) } pure_bases = { base for name, bases in filtered.items() for base in bases } - set(filtered) # Make a second pass to fill in missing links between classes that are # only included as bases of other classes for base in pure_bases: filtered[base] = pure_bases & class_map[base] return filtered def render_map(class_map: t.Dict[str, t.Set[str]], abstract: t.Set[str]) -> str: """ Renders *class_map* (which is a mapping such as that returned by :func:`make_class_map`) to graphviz's dot language. The *abstract* sequence determines which classes will be rendered lighter to indicate their abstract nature. All classes with names ending "Mixin" will be implicitly rendered in a different style. """ def all_names(class_map: t.Dict[str, t.Set[str]]) -> t.Iterator[str]: for name, bases in class_map.items(): yield name for base in bases: yield base template = """\ digraph classes {{ graph [rankdir=RL]; node [shape=rect, style=filled, fontname=Sans, fontsize=10]; edge []; /* Mixin classes */ node [color="#c69ee0", fontcolor="#000000"] {mixin_nodes} /* Abstract classes */ node [color="#9ec6e0", fontcolor="#000000"] {abstract_nodes} /* Concrete classes */ node [color="#2980b9", fontcolor="#ffffff"]; {concrete_nodes} /* Edges */ {edges} }} """ return template.format( mixin_nodes='\n '.join( '{name};'.format(name=name) for name in sorted(set(all_names(class_map))) if name.endswith('Mixin') ), abstract_nodes='\n '.join( '{name};'.format(name=name) for name in sorted(abstract & set(all_names(class_map))) ), concrete_nodes='\n '.join( '{name};'.format(name=name) for name in sorted(set(all_names(class_map))) if not name.endswith('Mixin') and not name in abstract ), edges='\n '.join( '{name}->{base};'.format(name=name, base=base) for name, bases in sorted(class_map.items()) for base in sorted(bases) ), ) if __name__ == '__main__': sys.exit(main()) nobodd-0.4/scripts/copyrights000077500000000000000000000407421457216553300164540ustar00rootroot00000000000000#!/usr/bin/python3 """ This script updates the copyright headers on all files project-wide. It derives the authorship and copyright years information from the git history of the project; hence, this script must be run within a git clone of the project's repository. Options are available to specify the license text file, the files to edit, and files to exclude. Default options can be specified in the containing project's setup.cfg under [{SETUP_SECTION}] """ from __future__ import annotations import os import re import sys assert sys.version_info >= (3, 6), 'Script requires Python 3.6+' import tempfile import typing as t from argparse import ArgumentParser, Namespace from configparser import ConfigParser from operator import attrgetter from itertools import groupby from datetime import datetime from subprocess import Popen, PIPE, DEVNULL from pathlib import Path from fnmatch import fnmatch PROJECT_ROOT: Path = (Path(__file__).parent / '..').resolve() SETUP_SECTION: str = str(Path(__file__).name) + ':settings' SPDX_PREFIX: str = 'SPDX-License-Identifier:' COPYRIGHT_PREFIX: str = 'Copyright (c)' def main(args: t.Optional[t.List[str]]=None): if args is None: args = sys.argv[1:] config = get_config(args) writer = CopyWriter.from_config(config) for path, copyrights in get_copyrights( config.include, config.exclude, config.additional ): print(f'Re-writing {path}...') copyrights = sorted( copyrights, reverse=True, key=lambda c: (max(c.years), c.author)) with ( AtomicReplaceFile(path, encoding='utf-8') as target, path.open('r') as source ): for chunk in writer.transform(source, copyrights): target.write(chunk) def get_config(args: t.List[str]) -> Namespace: config = ConfigParser( defaults={ 'additional': '', 'include': '**/*', 'exclude': '', 'license': 'LICENSE.txt', 'preamble': '', 'strip_preamble': 'false', 'spdx_prefix': SPDX_PREFIX, 'copy_prefix': COPYRIGHT_PREFIX, }, delimiters=('=',), default_section=SETUP_SECTION, empty_lines_in_values=False, interpolation=None, converters={'list': lambda s: s.strip().splitlines()}) config.read(PROJECT_ROOT / 'setup.cfg') sect = config[SETUP_SECTION] # Resolve license default relative to setup.cfg if sect['license']: sect['license'] = str(PROJECT_ROOT / sect['license']) parser = ArgumentParser(description=__doc__.format(**globals())) parser.add_argument( '-a', '--additional', action='append', metavar='STR', default=sect.getlist('additional'), help="An additional copyright owner to add to all files. This is " "typically used to specify a corporate owner which never appears " "directly in commits, but which ultimately owns a project. If an " "e-mail contact should be included, format the string as " '"name "') parser.add_argument( '-i', '--include', action='append', metavar='GLOB', default=sect.getlist('include'), help="The set of patterns that a file must match to be included in " "the set of files to re-write. Can be specified multiple times to " "add several patterns. Default: %(default)r") parser.add_argument( '-e', '--exclude', action='append', metavar='GLOB', default=sect.getlist('exclude'), help="The set of patterns that a file must *not* match to be included " "in the set of files to re-write. Can be specified multiple times to " "add several patterns. Default: %(default)r") parser.add_argument( '-l', '--license', action='store', type=Path, metavar='PATH', default=sect['license'], help="The file containing the project's license text. If this file " "contains a SPDX-License-Identifier line (in addition to the license " "text itself), then matching license text found in source files will " "be replaced by the SPDX-License-Identifier line (appropriately " "commented). Default: %(default)s") parser.add_argument( '-p', '--preamble', action='append', metavar='STR', default=sect.getlist('preamble'), help="The line(s) of text to insert before the copyright attributions " "in source files. This is typically a brief description of the " "project. Can be specified multiple times to add several lines. " "Default: %(default)r") parser.add_argument( '-S', '--spdx-prefix', action='store', metavar='STR', default=sect['spdx_prefix'], help="The prefix on the line in the license file, and within comments " "of source files that identifies the appropriate license from the " "SPDX list. Default: %(default)r") parser.add_argument( '-C', '--copy-prefix', action='store', metavar='STR', default=sect['copy_prefix'], help="The prefix before copyright attributions in source files. " "Default: %(default)r") parser.add_argument( '--no-strip-preamble', action='store_false', dest='strip_preamble') parser.add_argument( '--strip-preamble', action='store_true', default=sect.getboolean('strip-preamble'), help="If enabled, any existing preamble matching that specified " "by --preamble will be removed. This can be used to change the " "preamble text in files by first specifying the old preamble with " "this option, then running a second time with the new preamble") ns = parser.parse_args(args) ns.include = set(ns.include) ns.exclude = set(ns.exclude) ns.additional = [name_and_email(s) for s in ns.additional] return ns def name_and_email(s: str) -> t.Tuple[str, str]: """ Parse the :class:`str` *s* into a :class:`tuple` of ``(name, email)`` when *s* is formatted as "name ", or ``(name, '')`` otherwise. """ m = re.match(r'^(?P.*) +<(?P.+)>$', s) if m: return m.group('name'), m.group('email') else: return s, '' class Copyright(t.NamedTuple): author: str email: str years: t.Set[int] def __str__(self): if len(self.years) > 1: years = f'{min(self.years)}-{max(self.years)}' else: years = f'{min(self.years)}' if self.email: return f'{years} {self.author} <{self.email}>' else: return f'{years} {self.author}' def get_copyrights(include: t.Set[str], exclude: t.Set[str], additional: t.Set[t.Tuple[str, str]] ) -> t.Iterator[t.Tuple[Path, t.Iterable[Copyright]]]: sorted_blame = sorted( get_contributions(include, exclude), key=lambda c: (c.path, c.author, c.email) ) blame_by_file = { path: list(file_contributions) for path, file_contributions in groupby( sorted_blame, key=attrgetter('path') ) } for path, file_contributors in blame_by_file.items(): it = groupby(file_contributors, key=lambda c: (c.author, c.email)) copyrights = [ Copyright(author, email, {y.year for y in years}) for (author, email), years in it ] years = {year for c in copyrights for year in c.years} copyrights.extend([ Copyright(author, email, years) for author, email in additional ]) yield path, copyrights class Contribution(t.NamedTuple): author: str email: str year: int path: Path def get_contributions(include: t.Set[str], exclude: t.Set[str])\ -> t.Iterator[Contribution]: for path in get_source_paths(include, exclude): blame = Popen( ['git', 'blame', '--line-porcelain', 'HEAD', '--', str(path)], stdout=PIPE, stderr=PIPE, universal_newlines=True ) author = email = year = None if blame.stdout is not None: for line in blame.stdout: if line.startswith('author '): author = line.split(' ', 1)[1].rstrip() elif line.startswith('author-mail '): email = line.split(' ', 1)[1].rstrip() email = email.lstrip('<').rstrip('>') elif line.startswith('author-time '): # Forget the timezone; we only want the year anyway timestamp = int(line.split(' ', 1)[1].strip()) year = datetime.fromtimestamp(timestamp).year elif line.startswith('filename '): assert author is not None assert email is not None assert year is not None yield Contribution( author=author, email=email, year=year, path=path) author = email = year = None blame.wait() assert blame.returncode == 0 def get_source_paths(include: t.Set[str], exclude: t.Set[str])\ -> t.Iterator[Path]: ls_tree = Popen( ['git', 'ls-tree', '-r', '--name-only', 'HEAD'], stdout=PIPE, stderr=DEVNULL, universal_newlines=True) if not include: include = {'*'} if ls_tree.stdout is not None: for filename in ls_tree.stdout: filename = filename.strip() if any(fnmatch(filename, pattern) for pattern in exclude): continue if any(fnmatch(filename, pattern) for pattern in include): yield Path(filename) ls_tree.wait() assert ls_tree.returncode == 0 class License(t.NamedTuple): ident: t.Optional[str] text: t.List[str] def get_license(path: Path, *, spdx_prefix: str = SPDX_PREFIX) -> License: with open(path, 'r') as f: lines = f.read().splitlines() idents = [ line.rstrip() for line in lines if line.startswith(spdx_prefix) ] ident = None if len(idents) > 1: raise RuntimeError(f'More than one {spdx_prefix} line in {path}!') elif len(idents) == 1: ident = idents[0] body = [ line.rstrip() for line in lines if not line.startswith(spdx_prefix) ] while not body[0]: del body[0] while not body[-1]: del body[-1] return License(ident, body) class CopyWriter: """ Transformer for the copyright header in source files. The :meth:`transform` method can be called with a file-like object as the *source* and will yield chunks of replacement data to be written to the replacement. """ # The script's kinda dumb at this point - only handles straight-forward # line-based comments, not multi-line delimited styles like /*..*/ COMMENTS = { '': '#', '.c': '//', '.cpp': '//', '.js': '//', '.py': '#', '.rst': '..', '.sh': '#', '.sql': '--', } def __init__(self, license: Path=Path('LICENSE.txt'), preamble: t.Optional[t.List[str]]=None, spdx_prefix: str=SPDX_PREFIX, copy_prefix: str=COPYRIGHT_PREFIX): if preamble is None: preamble = [] self.license = get_license(license, spdx_prefix=spdx_prefix) self.preamble = preamble self.spdx_prefix = spdx_prefix self.copy_prefix = copy_prefix @classmethod def from_config(cls, config: Namespace) -> CopyWriter: return cls( config.license, config.preamble, config.spdx_prefix, config.copy_prefix) def transform(self, source: t.TextIO, copyrights: t.List[Copyright], *, comment_prefix: t.Optional[str]=None) -> t.Iterator[str]: if comment_prefix is None: comment_prefix = self.COMMENTS[Path(source.name).suffix] license_start = self.license.text[0] license_end = self.license.text[-1] state = 'header' empty = True for linenum, line in enumerate(source, start=1): if state == 'header': if linenum == 1 and line.startswith('#!'): yield line empty = False elif linenum < 3 and ( 'fileencoding=' in line or '-*- coding:' in line): yield line empty = False elif line.rstrip() == comment_prefix: pass # skip blank comment lines elif line.startswith(f'{comment_prefix} {self.spdx_prefix}'): pass # skip existing SPDX ident elif line.startswith(f'{comment_prefix} {self.copy_prefix}'): pass # skip existing copyright lines elif any(line.startswith(f'{comment_prefix} {pre_line}') for pre_line in self.preamble): pass # skip existing preamble elif line.startswith(f'{comment_prefix} {license_start}'): state = 'license' # skip existing license lines else: yield from self._generate_header( copyrights, comment_prefix, empty) state = 'blank' elif state == 'license': if line.startswith(f'{comment_prefix} {license_end}'): yield from self._generate_header( copyrights, comment_prefix, empty) state = 'blank' continue if state == 'blank': # Ensure there's a blank line between license and start of the # source body if line.strip(): yield '\n' yield line state = 'body' elif state == 'body': yield line def _generate_header(self, copyrights: t.Iterable[Copyright], comment_prefix: str, empty: bool) -> t.Iterator[str]: if not empty: yield comment_prefix + '\n' for line in self.preamble: yield f'{comment_prefix} {line}\n' if self.preamble: yield comment_prefix + '\n' for copyright in copyrights: yield f'{comment_prefix} {self.copy_prefix} {copyright!s}\n' yield comment_prefix + '\n' if self.license.ident: yield f'{comment_prefix} {self.license.ident}\n' else: for line in self.license.text: if line: yield f'{comment_prefix} {line}\n' else: yield comment_prefix + '\n' class AtomicReplaceFile: """ A context manager for atomically replacing a target file. Uses :class:`tempfile.NamedTemporaryFile` to construct a temporary file in the same directory as the target file. The associated file-like object is returned as the context manager's variable; you should write the content you wish to this object. When the context manager exits, if no exception has occurred, the temporary file will be renamed over the target file atomically (after copying permissions from the target file). If an exception occurs during the context manager's block, the temporary file will be deleted leaving the original target file unaffected and the exception will be re-raised. :param pathlib.Path path: The full path and filename of the target file. This is expected to be an absolute path. :param str encoding: If ``None`` (the default), the temporary file will be opened in binary mode. Otherwise, this specifies the encoding to use with text mode. """ def __init__(self, path: t.Union[str, Path], encoding: t.Optional[str]=None): if isinstance(path, str): path = Path(path) self._path = path self._tempfile = tempfile.NamedTemporaryFile( mode='wb' if encoding is None else 'w', dir=str(self._path.parent), encoding=encoding, delete=False) self._withfile = None def __enter__(self): self._withfile = self._tempfile.__enter__() return self._withfile def __exit__(self, exc_type, exc_value, exc_tb): os.fchmod(self._withfile.file.fileno(), self._path.stat().st_mode) result = self._tempfile.__exit__(exc_type, exc_value, exc_tb) if exc_type is None: os.rename(self._withfile.name, str(self._path)) else: os.unlink(self._withfile.name) return result if __name__ == '__main__': sys.exit(main()) nobodd-0.4/scripts/previewer000077500000000000000000000166441457216553300162750ustar00rootroot00000000000000#!/usr/bin/python3 """ This script builds the HTML documentation of the containing project, and serves it from a trivial built-in web-server. It then watches the project source code for changes, and rebuilds the documentation as necessary. Options are available to specify the build output directory, the build command, and the paths to watch for changes. Default options can be specified in the containing project's setup.cfg under [{SETUP_SECTION}] """ from __future__ import annotations import os import sys assert sys.version_info >= (3, 6), 'Script requires Python 3.6+' import time import shlex import socket import traceback import typing as t import subprocess as sp import multiprocessing as mp from pathlib import Path from functools import partial from configparser import ConfigParser from argparse import ArgumentParser, Namespace from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler PROJECT_ROOT: Path = Path(__file__).parent / '..' SETUP_SECTION: str = str(Path(__file__).name) + ':settings' def main(args: t.Optional[t.List[str]]=None): if args is None: args = sys.argv[1:] config = get_config(args) queue: mp.Queue = mp.Queue() builder_proc = mp.Process(target=builder, args=(config, queue), daemon=True) server_proc = mp.Process(target=server, args=(config, queue), daemon=True) builder_proc.start() server_proc.start() exc, value, tb = queue.get() server_proc.terminate() builder_proc.terminate() traceback.print_exception(exc, value, tb) def get_config(args: t.List[str]) -> Namespace: config = ConfigParser( defaults={ 'command': 'make doc', 'html': 'build/html', 'watch': '', 'ignore': '\n'.join(['*.swp', '*.bak', '*~', '.*']), 'bind': '0.0.0.0', 'port': '8000', }, delimiters=('=',), default_section=SETUP_SECTION, empty_lines_in_values=False, interpolation=None, converters={'list': lambda s: s.strip().splitlines()}) config.read(PROJECT_ROOT / 'setup.cfg') sect = config[SETUP_SECTION] # Resolve html and watch defaults relative to setup.cfg if sect['html']: sect['html'] = str(PROJECT_ROOT / sect['html']) if sect['watch']: sect['watch'] = '\n'.join( str(PROJECT_ROOT / watch) for watch in sect.getlist('watch') ) parser = ArgumentParser(description=__doc__.format(**globals())) parser.add_argument( 'html', default=sect['html'], type=Path, nargs='?', help="The base directory (relative to the project's root) which you " "wish to server over HTTP. Default: %(default)s") parser.add_argument( '-c', '--command', default=sect['command'], help="The command to run (relative to the project root) to regenerate " "the HTML documentation. Default: %(default)s") parser.add_argument( '-w', '--watch', action='append', default=sect.getlist('watch'), help="Can be specified multiple times to append to the list of source " "patterns (relative to the project's root) to watch for changes. " "Default: %(default)s") parser.add_argument( '-i', '--ignore', action='append', default=sect.getlist('ignore'), help="Can be specified multiple times to append to the list of " "patterns to ignore. Default: %(default)s") parser.add_argument( '--bind', metavar='ADDR', default=sect['bind'], help="The address to listen on. Default: %(default)s") parser.add_argument( '--port', metavar='PORT', default=sect['port'], help="The port to listen on. Default: %(default)s") ns = parser.parse_args(args) ns.command = shlex.split(ns.command) if not ns.watch: parser.error('You must specify at least one --watch') ns.watch = [ str(Path(watch).relative_to(Path.cwd())) for watch in ns.watch ] return ns class DevRequestHandler(SimpleHTTPRequestHandler): server_version = 'DocsPreview/1.0' protocol_version = 'HTTP/1.0' class DevServer(ThreadingHTTPServer): allow_reuse_address = True base_path = None def get_best_family(host: t.Optional[str], port: t.Union[str, int, None])\ -> t.Tuple[ socket.AddressFamily, t.Union[t.Tuple[str, int], t.Tuple[str, int, int, int]] ]: infos = socket.getaddrinfo( host, port, type=socket.SOCK_STREAM, flags=socket.AI_PASSIVE) for family, type, proto, canonname, sockaddr in infos: return family, sockaddr raise ValueError(f'No family found for {host}:{port}') def server(config: Namespace, queue: t.Optional[mp.Queue]=None): try: DevServer.address_family, addr = get_best_family(config.bind, config.port) handler = partial(DevRequestHandler, directory=str(config.html)) with DevServer(addr[:2], handler) as httpd: host, port = httpd.socket.getsockname()[:2] hostname = socket.gethostname() print(f'Serving {config.html} HTTP on {host} port {port}') print(f'http://{hostname}:{port}/ ...') # XXX Wait for queue message to indicate time to start? httpd.serve_forever() except: if queue is not None: queue.put(sys.exc_info()) raise def get_stats(config: Namespace) -> t.Dict[Path, os.stat_result]: return { path: path.stat() for watch_pattern in config.watch for path in Path('.').glob(watch_pattern) if not any(path.match(ignore_pattern) for ignore_pattern in config.ignore) } def get_changes(old_stats: t.Dict[Path, os.stat_result], new_stats: t.Dict[Path, os.stat_result])\ -> t.Tuple[t.Set[Path], t.Set[Path], t.Set[Path]]: # Yes, this is crude and could be more efficient but it's fast enough on a # Pi so it'll be fast enough on anything else return ( new_stats.keys() - old_stats.keys(), # new old_stats.keys() - new_stats.keys(), # deleted { # modified filepath for filepath in old_stats.keys() & new_stats.keys() if new_stats[filepath].st_mtime > old_stats[filepath].st_mtime } ) def rebuild(config: Namespace) -> t.Dict[Path, os.stat_result]: print('Rebuilding...') sp.run(config.command, cwd=PROJECT_ROOT) return get_stats(config) def builder(config: Namespace, queue: t.Optional[mp.Queue]=None): try: old_stats = rebuild(config) print('Watching for changes in:') print('\n'.join(config.watch)) # XXX Add some message to the queue to indicate first build done and # webserver can start? And maybe launch webbrowser too? while True: new_stats = get_stats(config) created, deleted, modified = get_changes(old_stats, new_stats) if created or deleted or modified: for filepath in created: print(f'New file, {filepath}') for filepath in deleted: print(f'Deleted file, {filepath}') for filepath in modified: print(f'Changed detected in {filepath}') old_stats = rebuild(config) else: time.sleep(0.5) # make sure we're not a busy loop except: if queue is not None: queue.put(sys.exc_info()) raise if __name__ == '__main__': sys.exit(main()) nobodd-0.4/setup.cfg000066400000000000000000000040201457216553300144520ustar00rootroot00000000000000# coding: utf-8 [metadata] name = nobodd version = 0.4 description = A simple TFTP boot server for the Raspberry Pi long_description = file: README.rst author = Dave Jones author_email = dave.jones@canonical.com project_urls = Documentation = https://nobodd.readthedocs.io/ Source Code = https://github.com/waveform80/nobodd Issue Tracker = https://github.com/waveform80/nobodd/issues keywords = raspberry pi boot nbd tftp classifiers = Development Status :: 4 - Beta Environment :: Console Intended Audience :: System Administrators License :: OSI Approved :: GNU General Public License v3 (GPLv3) Operating System :: POSIX :: Linux Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 Topic :: System :: Boot [options] packages = find: python_requires = >=3.7 install_requires = setuptools importlib_resources~=1.3;python_version<'3.9' importlib_metadata~=1.4;python_version<'3.8' [options.package_data] nobodd = *.conf [options.extras_require] test = pytest pytest-cov doc = sphinx sphinx-rtd-theme [options.entry_points] console_scripts = nobodd-tftpd = nobodd.server:main nobodd-prep = nobodd.prep:main [tool:pytest] addopts = -rsx --cov --tb=short testpaths = tests [coverage:run] source = nobodd branch = true [coverage:report] show_missing = true exclude_lines = raise NotImplementedError assert False [copyrights:settings] include = **/*.py **/*.rst exclude = docs/examples/*.py docs/license.rst additional = Canonical Ltd. license = LICENSE.txt preamble = nobodd: a boot configuration tool for the Raspberry Pi strip-preamble = false [previewer:settings] command = make -C docs html html = build/html watch = nobodd/*.py docs/*.rst docs/images/*.mscgen README.rst nobodd-0.4/setup.py000066400000000000000000000000461457216553300143470ustar00rootroot00000000000000from setuptools import setup setup() nobodd-0.4/systemd/000077500000000000000000000000001457216553300143255ustar00rootroot00000000000000nobodd-0.4/systemd/README.rst000066400000000000000000000016151457216553300160170ustar00rootroot00000000000000.. nobodd: a boot configuration tool for the Raspberry Pi .. .. Copyright (c) 2024 Dave Jones .. Copyright (c) 2024 Canonical Ltd. .. .. SPDX-License-Identifier: GPL-3.0 ================= Systemd Examples ================= The example units and configuration file in this directory configure nobodd-tftpd to be run as a socket-activated service by systemd. They are suggested for use by distribution packagers. The configuration specifies ``listen=systemd``, and the :file:`nobodd-tftpd.service` unit has a corresponding :file:`nobodd-tftpd.socket` unit to define the UDP socket on port 69. This method is chosen rather than forcing the config via ``--listen systemd`` on the command line to permit the system administrator to disable the socket and easily re-configure the server to use its own socket via the configuration file rather than having to override the service file. nobodd-0.4/systemd/nobodd-tftpd.service000066400000000000000000000004061457216553300202730ustar00rootroot00000000000000[Unit] Description=TFTP Server for OS images, geared towards the Raspberry Pi Documentation=man:nobodd-tftp(1) After=local-fs.target network.target [Service] Type=notify User=nbd Group=nbd Restart=on-failure ExecStart=nobodd-tftpd ExecReload=kill -HUP $MAINPID nobodd-0.4/systemd/nobodd-tftpd.socket000066400000000000000000000002151457216553300201210ustar00rootroot00000000000000[Unit] Description=TFTP Server for OS images, geared towards the Raspberry Pi [Socket] ListenDatagram=69 [Install] WantedBy=sockets.target nobodd-0.4/systemd/nobodd.conf000066400000000000000000000010051457216553300164350ustar00rootroot00000000000000[tftp] # This unit configures nobodd-tftpd to accept a listening socket from systemd. # For this reason, the port option is disabled by default because this is # ignored when the socket is passed by the service manager. listen = systemd #port = 69 # includedir provides the path, etiher absolute or relative to the # configuration file, of a directory from which additional configuration files, # all of which must match the wildcard pattern "*.conf", will be read in sorted # order. includedir = /etc/nobodd/conf.d nobodd-0.4/tests/000077500000000000000000000000001457216553300137775ustar00rootroot00000000000000nobodd-0.4/tests/conftest.py000066400000000000000000000107011457216553300161750ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2024 Dave Jones # Copyright (c) 2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import gzip from shutil import copyfileobj import pytest def make_disk(output, *, part_style='mbr', part_map={1: 'fat16', 5: 'ext2'}): disk, parts = { # Both layouts define the following partitions in a 32MB disk: # 1 -- 8MB -- FAT32 / Basic Data # 2 -- 200KB -- FAT32 / Basic Data # 5 -- 4MB -- Linux # 6 -- 200KB -- FAT32 / Basic Data 'gpt': ('tests/gpt_disk.img.gz', {1: 2048, 2: 18432, 5: 20480, 6: 28672}), 'mbr': ('tests/mbr_disk.img.gz', {1: 2048, 2: 18432, 5: 22528, 6: 32768}), }[part_style] fs = { # The fat12 image fits in any of the partitions (160KB unpacked). The # fat16 and ext2 images will only fit in partitions 1 and 5 (4MB # unpacked). The fat32 image will only fit in partition 1 (and, yes, # it's undersized according to the "spec", but that just goes to show # how ridiculous the spec is in certain places) 'fat12': 'tests/fat12.img.gz', 'fat16': 'tests/fat16.img.gz', 'fat32': 'tests/fat32.img.gz', 'ext2': 'tests/ext2.img.gz', } output.seek(0) with gzip.open(disk) as src: copyfileobj(src, output) for part_num, fat_type in part_map.items(): output.seek(parts[part_num] * 512) with gzip.open(fs[fat_type]) as src: copyfileobj(src, output) output.seek(0) @pytest.fixture(scope='session') def gpt_disk(request, tmp_path_factory): tmp = tmp_path_factory.mktemp('gpt_disk') path = tmp / 'gpt.img' with path.open('wb') as output: make_disk(output, part_style='gpt') path.chmod(0o444) return path @pytest.fixture() def gpt_disk_w(request, tmp_path, gpt_disk): path = tmp_path / 'gpt-mutable.img' with gpt_disk.open('rb') as source, path.open('w+b') as output: copyfileobj(source, output) return path @pytest.fixture(scope='session') def mbr_disk(request, tmp_path_factory): tmp = tmp_path_factory.mktemp('mbr_disk') path = tmp / 'mbr.img' with path.open('wb') as output: make_disk(output, part_style='mbr') path.chmod(0o444) return path @pytest.fixture() def mbr_disk_w(request, tmp_path, mbr_disk): path = tmp_path / 'mbr-mutable.img' with mbr_disk.open('rb') as source, path.open('w+b') as output: copyfileobj(source, output) return path @pytest.fixture(scope='session') def fat12_disk(request, tmp_path_factory): tmp = tmp_path_factory.mktemp('fat12_disk') path = tmp / 'fat12.img' with path.open('wb') as output: make_disk(output, part_style='mbr', part_map={1: 'fat12'}) path.chmod(0o444) return path @pytest.fixture() def fat12_disk_w(request, tmp_path, fat12_disk): path = tmp_path / 'fat12-mutable.img' with fat12_disk.open('rb') as source, path.open('w+b') as output: copyfileobj(source, output) return path @pytest.fixture(scope='session') def fat16_disk(request, tmp_path_factory): tmp = tmp_path_factory.mktemp('fat16_disk') path = tmp / 'fat16.img' with path.open('wb') as output: make_disk(output, part_style='mbr', part_map={1: 'fat16'}) path.chmod(0o444) return path @pytest.fixture() def fat16_disk_w(request, tmp_path, fat16_disk): path = tmp_path / 'fat16-mutable.img' with fat16_disk.open('rb') as source, path.open('w+b') as output: copyfileobj(source, output) return path @pytest.fixture(scope='session') def fat32_disk(request, tmp_path_factory): tmp = tmp_path_factory.mktemp('fat32_disk') path = tmp / 'fat32.img' with path.open('wb') as output: make_disk(output, part_style='gpt', part_map={1: 'fat32'}) path.chmod(0o444) return path @pytest.fixture() def fat32_disk_w(request, tmp_path, fat32_disk): path = tmp_path / 'fat32-mutable.img' with fat32_disk.open('rb') as source, path.open('w+b') as output: copyfileobj(source, output) return path @pytest.fixture(scope='session') def fat_disks(request, fat12_disk, fat16_disk, fat32_disk): yield { 'fat12': fat12_disk, 'fat16': fat16_disk, 'fat32': fat32_disk, } @pytest.fixture() def fat_disks_w(request, fat12_disk_w, fat16_disk_w, fat32_disk_w): yield { 'fat12': fat12_disk_w, 'fat16': fat16_disk_w, 'fat32': fat32_disk_w, } nobodd-0.4/tests/ext2.img.gz000066400000000000000000000521521457216553300160030ustar00rootroot00000000000000A\eext2.img dwA I8 X;ўErN0f& 鮞)]TUϤ1\,+!**"+.rǪ\wWDD%^ ~w[~~ՎvsQ?xf{ߝ ?gΣu֎¯>wsUrpE9G<~8O}ߋ_cC8>xC>8'+ ;O.G o?8>X\g?uE;^7f·\wu 3>S3Ή/}_">;g;۱aOxɿǎ{s[?|ǿ7\xOd?gŅXܢ?.np|ʿ& Gn;{~u-~/dvG1UwjW1 o|{9O*ờ]DypW?WQ3oڱ?iǣ[zcu'ݾk诸W}Bg9y?G9/߸߽v{÷s߷s/l?f;/۶s?m+Z9Ǘk|}W_.<9k{s{ߴKe?\{~sOmɟv[ۿn_~vWn刺9}b;?{zo׷s Wo87{۶sC;v;ͻ>p?߿޲g޺n㋶svg޾ë~mm}?nou+|l=OzSO ?uh;~v{;5}⯷sC9ozۯco_~v9wv_ngx;y'nvk9;=x~׼vݾK=9yvcN>U9O?9oy׶KzS?|ǯmso?;{}#9's_vWvOo_꿚ٟ:g;;s9;m?۞yo/?{yp3|_}˿y!O  /18~bav8녟#h9vW__?c:j/|ί|?'yl78s>{P}q_>7q9|T=>88oW}÷>8[=q888888qߥqeCs.ssV]Y]U]]]S=>{D}88o_}÷>᛫sssnsGUǗ=>;PXs9|9|Gq߱[9|9|s_=>{b}'9|Os:q8o>[sqR}w>;Y?,W=>ử>[sV~}7sV7|e<K 8su։K4mGy1n[uW?|`%{/3YVԾ~ܑ7]{`~_;<:ǃQoo_2ӾaG/p}aq{׾ tgm_7X/vgOǫ+/`iw0X-VbzNVgJ -!h]x51bO':N8nbkC[S'DgpbYٳ'h%}$ܮaw aLxx^3R>y/|2~uq<*ƢU1,•GN܍|!(&F̉1鏯v') ~:S]gt OMBӃxMi-tlwǛQy˻LxŔ!/brBH73B|DRS\ս{W!-jdZu ٥d.)gQ{J)W&)ܱĿiG&dEŒEu1i̔/Ij~\ URJT?b^_^)Qިv0bWRk0?/TJ΅A8WL%)tk8}:{ʯDOwDx\7b&T+tB?% cHө LHn# Q7fP;^m/ŻMEbp|ZHܣ0bX.˘G|[U!ٮq!;-xxƌjPHkٕ"NWn*W2FTjx j?9v͊r}'Bn9|i%QHeC/_N݊tk]-ͽ 76C?$W'5^}z1V|-OP108_Gݝ7rc =y?Ωn@Br;C1xAlrvJ(rr:^UT]6DZ Uy)y$~FknRTF})Iw [F3zZg(+WSDZ+˥7i7[tpqzӞ?jm|\Snd/O$#jPʻқk4:C<3 or|*[?~,shni>꪿ѐi!Xbr7/ˈ}I 82>{vFd:\wHr7r6 `4E*eU#.=f@j?V`ũATmb1v?WCqh+vk'G&_/VzcUٲW:Sej!S_NT giXҎR48K[|r vp:tOtsZ4RVli S\?&Iz|}Onp44rv*Xеsq]gL[N՜w 8:UpC4iN Mio;+yl4Y&q(dT~BpmУX^AxTS %c5ǵb 5rj58ЪND|(^vְg)*Ttw.<6XpZ%yj* ODx$ :=Y^)w<ҮUcPf҃▇F+:y,:ICΩ$Xw9߯\Lyu_neg_ Vb? V<[/Z9q&y40!q>.FzLN.0 b!$yGőKr){N^g6V{ qxq<8y* YxC[wcb7cd<8+зiI5c^Z[r7!'Q>;CvEUtCc9C6/?$=ag5 oR*TY 駑ĩd(s=UFKtYOwbBȖ9pR"dPlT뵧LR cOc0'|.PCuK[T)3G4Web|a9ΠŘE4Re8VP}MۏkD"=6M6ɏ{S\Kbթ馵Nt\ڗԚ5PpU+&M\Z0]\GCⅇu1MÓa8]Kɘq3ZuQN NsӍ鯷J<9U,{gaa0|2]_x.tsٱ^]q"=XԠ8^ȼ'^&5?(+I6oJlH!'Ob7 NLք \l7Vm29Ѿ!5G75TKkcXjPk"۪խ}V9Lsu4RYU2Ȯ3tݩGbel1ȸeWJqKB[ff׋N'YS!U:US Qͨ"v70a% 'k6l"B7{_*Ry0%Ŷbb0uGJdl q(!3qCU_9o, st3UBݬ>JǬLљrNtqIlkELy> )USXvT>X3-jcdgOFhr쇮M/fIJvbrҲ,uCsy^-qE}ᓝ]LJUn=ɏt3N\Ӧ/MU^T"TLe Yt:;"ŢHw8|0 mrW)WfqA\MOCol4Qj8M;j} !`Ө~5CZ JYI=:?;O VrJ0dȪ)ИbvvN:N(E~ytLjﭬvj\)[nϭN{qmϪ,\z 4F;YR%/țT_8wr6~nδS[{MqZgmw;Sjp37BX/cBڨɼ(<b3QCH::J8+bnKHǞY7<[3qM^>JqgkD'߽XM^&5`5s\OyyyJ<O*ȸl0vVn_hn=ʲδkKkih2n`TNΩAZZ6T )j,ݞΩZW ٫^F2rg4j1aX7^mpvDz@:ɂ mwaP[Խ=⧚-e<^3)c eoyuHAZvAbk:'UE/ONm/zebHҠf6U UqI+AO%UjyPk\ ,fbX.ƉXY޾7|ɹwSD녺-wMyg*ju6}~'tt]w*xqT5ZK28baOhiN# VUJRybyU$y(+/ISǻ?=seS/MM7͡ت/=x<\TަfN5Zͭ7'1suة.xˊ䥟qYKYYݨ]Y_u{iʮ-7C8Jc8 -᧗֖UXvi(xm~LX|L 5G둞4 ~pz^˓B|6unyOxZW/7kR+'uǑC7-5G)WuZNQ=eU]vLѫ٣Vr^Q~3;ylt6RRfJVjmJ:7>IcEQw:OP4,rdi*Tz!E5˲n^ jTOsS](To^K[[K;qTƓUu`4j,Ѹg! gUk8 5=|xI1ܖ AGH@ioX3|í J5]9BzW0~:8$TRXi4(Jud^XPG|lm.y4YvlYߨ/n~gحɔց(oՕ2T-sbtGjF /M^72Mszs kj-F.xpc}yRM2<]Romqd>U{8w"JN&I~}iyH\<+*[ b`VKcb4:e߀*J(>We֗;R,ҀOJΐKJUfuehp#]ĕ=+rt +L6!ˉR(Δ2.zLsRaCrje8&Jڐz{CTZױn.JyRM{ݴ@~Zs˂bzRdcYz8k2QIU˙F.MEm UYMB_7Mݖ᩺~Sn<5ޑ!]LM$eƵIbˣ;۽$ _y 9{MN$ϖO޸=u\&VF N0LH~i攛]7ÈwVEN}* uwe91dKlV Cg&F/c,ݏ4RZDsYZ)}F.5sZ^O-ĤIulk}VJhoh.Ui_o~ԜyЖ_6 AZ^ PK 66IuvX,Cz)hjĸ'1TsD{UN/\LkJ{y}tJղF}5oYإ7:mMk'l7NZy!@M(W[D7~e)cޘoO;ozf>-s"Աߖְƒw,9Sc);b0L}V*ExL3iHHtʠ¦;D~ ʟcNqfq!֦ɎrƟݴ 6>Q3U,Y/ÓWn 6r=#<3jtj1E%e!T)WM^?G4}7F&{AmsXBcIMb5ܡq㽑ɯ֪wOd%wz鷬%ݩ]WO|sB*JrzƍBuK߭*.C74{\cާݓ~\kcp'/, Y#g8@Iȩ&OeL!eGؚJ:)RDcl6?{zp:WX^fO_:wwOOoZo?XײSck&ѿRih>!Ѷujdsr$]]PQ*U@CZ4yqN^]yaOM2 ѩB7 >xyS*Q tTjitjXR'rzV5H^s5ny;;g2MI.{Ws"ozt,HRaM`s*}cKyGJcztqѪ/TuswgUjת5ugb;Ss+ytZH~˩Y4y)Kv|} Fl3dggn7g9|f%WnMmUg^ug |D9RvǵU 똫U[ؕeyh7l5 -nyI\[ ۉ<7s-y}#bz2äIЋb }HZc绲yf *8Q׌B]؜im}Z?ް1qy寮KKqզfsoǒg.Ԩy+s^ɏU~zL 驭!Jռ䉍2S6h,ooM[LZun r~mM-NK{k8MWJPI>;Qu[z^̂Z)+ydecڎ\?^IʨWsSx§VEmQpgfC1>Snz'`nJ1n1';pr*h7EjB fx߫!d3fYXMs+TީDCvzPAjP{;'n4:i\~ z8\^jեΞgTQOg3ظh--Һx/˃W-Sy7o8`P/3+5(nݦ]s̛eoNu"g2?{rwvg;ܧ`Zܐ[eV *k4_fOjӆkF%:MAoqqjPm18M6qd^|%^iUkC]IUi9Io2qpzA q qcqwrB8x"wS2}mdS~ e0lX4ޓz r3BNw6V9HNjNbdz\؂,H';q 8r'KNR5Y_I r])4j$p=//ʆ=x64k.IqMOG5:^Kjf~ڝts0&TV/-wvsb=;Wq"#☗xtkڣLL[vs;?}3`jm/Q,}ڻan- 3I`wW‡B,VSnv!!cu[ oNkf իeS}OL^ig!jM8R>Rlo0KRҦɜxv] =JՏKM̺xf_fQ6,,ocu)gb*.xhXƇ8ά֨M9nhܪ#SD3iL{չ`/ ͥrY=bKjRNhU2vhLe\]^-eqx.n;2מ?>x}#ھБ#?2{Lءt|`~foߜ}soa`P<-GG??ؾק;t#puá玤o \=acsGc8n? S{[pcu[ 'gsDs=|dpC/;p k1 ;vhV>[=&ƹ#nVc%Rᛎ>ttno;'a8IH#G1( f qྐྵxF[6o=tS"BJPss;6Ldћn+}X8ik}!Gnm;rGБ#,lt޼8PZ%n㦃bJ{M!1sI' Rogo~h#gkʷlϴ7#>ǐ:x45$pbuSxb˃A~y%,\7R8.b{:-\r?7P˙:E⮜˃&h| V:>,ɹSFط3i& I X0ם gš߸BL @8qC^ٔDy9ױji;% R^8=Vj9C2CRq"8 5̿6[:SfFF㼇Q\w2@˼XoܚJ_FILoͪҨXY#6Uw`Z:U5izYQ!zphQ-"2}-olWlj8)q:S:EzvKݟ \=ԩ 9Kkޮlc۴dcrxkQz]3(zs2@eWkqw+S {|_klcv_YMp7];Eymiha"~kuݛ{6{NI<:tRo<̻h\5Hd~67,*vL uۭ_q~x5ծQq>+|ⲯ\c/IZ`qC}xut>}zz B4ŷn{]DrwM@pЏF/ ƥ+!r͚r-ˬ-3U1W}J'&p*i:pv6n̻6w;הwV.}(nʈyo攨=tcsnmveJ7x=Ч|?}6>Г#x<29|3ǹ~+Jy N4_8*|) _ X}]}Ua0nZJ-zf{RhVn[:W'qHZK 흡;녓%ii\k `=.i(֓Aү;ܝun(9iR.F<~{bҏI's{%s-CVOw>S}ZvahޠwX㛩_oK4JN\"6ęnb]7ҔNkrHPv~{r2Y4.[sɫ V#. wW+Wb gߋ넺-k:靛~8|{q{O.=9^Y7g|Ūxܽf9p梋. ۋ<ػ\خ>;_k^;wyZmi[to?4į>/vetR =;~Y^xͷ֋?ugo=қ.{7_񺋾k__y?w~w_r+ w~W{<\o\'o W>cU?|Ŏ|O9<wٽ5zgws{Áد /?ӗ{n[>o?#]W<3 wkzgf>/ȃuy3_=K_׹a_Z:?ӯ?fv^x=?~=YyK?x;ws{dz>ooO ?w~?'y߷՞W7y^?x}oyo<`»vo^sw}{~S_7?S~+9eWsw o{WO|gywԷ_k֋_~u=cG_}|KzgW-?|_G>pG|}k>8粃_uOݿԗ~k?+^fK|cΫ~t۟+?OoG/8kӟ}Ӊ;:xϷ~ 5^pϼO__^yAoλ^U~7_o|ΫY|X^??~q<\s? '#~cg^Nox~=wm?^O>>rO/z{.~oO~˯=}[g5~;~׵'o'|sC_o;jwKaOsXX=gWg._:_̽+OsxuOny/qko]_篺ڇ~?ϼ׽ُ?^ G^zY?Пz/~|;} ?q{~>qg+]2`y>nobodd-0.4/tests/fat12.img.gz000066400000000000000000000356451457216553300160460ustar00rootroot00000000000000[efat12.img} t$Wy<6-KfDZѼck$GF$@pZ*OwUSU-Mn}g-FIrr#kQvz6 (´{Z?kZieǎ:y)D?66?Rۿwx<{]G{{ЦozU~ݦ-ly㦳om6tΫ}{o9?Ϟe G_84W\{ړ}ܴtrɿ7v}ѥS>Dc(ʇѡޖ 7 C)?/-<E30175_W ]KS3 /Mnr~7P( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP(-6%7^{ړCCK~C,kCPght~/RБSGnm"~aKu B7O>ؾ};tr>ؾ}|;s{{wܿ@cv ߎ1~;o@sv9~;o@sv߮5~o@]kv ߮5~o@{v=~o@=g ߞ3~{o@=w;~{o@w߾7~o@}o ߾7~o@?~oP(=Kٝf^Z'μ\P( D,:9:U8 GgjaG(L/A> Scs cgg&}}pNr?h46m4_4 ˍZZ laA?^|Yl70 s;zzN%W?ŵ 1Ӊk~3rϻ4QA/k-̢8y@<8 SX-i"=U%…2?؁ydy80:4=z==q hL'YI(4ġgmx+ AOas 2Hg~;`'8y'sɒF0=V zWwd5|0FdH D$rFՄx*i 7l%` IBpb5u D+ Npv< 痆0MrB rJaN<4eKW[ ֘Erv"o[efhGul_ՑP 5|VZ: Vœ6-_Wm:6ʣFb?=gAomtvk I<yX͙Ieq4lH d\NqAvZ~NSR)͇#+ヸ(^5Ls@)ZQ!dJ.*}TGEudzۃa]F-Bs$3AՀv5]]-v]czhؕX v $=d~qkhТb W@+k@zR^`(+lkv77ؘ(<Xh!m; 4&Z_pf^KȆ=:`cwW/hTh!k!0UUn"/l}SrX$vDŽJ6Enp'!/Db"f dRn!Zᑒ( eal3u ~6cM Bv]8, AXPK0$'c 1x zNit#6 0(2~n<ŷx[=!Ipl[h{pϞi4( E4U;9=cc[MS2΃eKj^T )[;L$v#Ѷ&_;X`H0z14xpi$YE&5Z$j9 P v\GYJO!+4BG_!B rA\tBHp=aU۰uP*&t@{ǗG@Ck(HcX^ XD eO0L8`<8(쳢Ba]RF1,l[.NUashcF^/5lk D؅eҀy ϺyU% %{[#djd 2WU.F@"h.*-6yib6ġIqMID6HsJr rB,JbzQL61bDO-r=W$ q p^ka- ͞3nɼJDv2$a#\~P0"'LPn*ޜctU'2Jx-yc]~YOQ,Fv%b/dyؽ8T5&d,]'jtFcX-k/[zfA(*QMMD+982+1 &YJ(sdрYf+]f l& 7w;;X[[q|0FƋH!a7rB 6Pǀ"4?#cmzGMh,vZ-|ѡ.f`+ՀA FԌr Ȗ^OSpZ~UcRC*_AZ0* (i3K(8\lŽupWBgDup#iI c-h~ .w>!-0?LnRh #;a9pm1Ƶg0Gy1:%)\1PNҌF;d\ xӁ&l˂Ghg&`\$хBJDW ,#SMt2ƕ쀬ۉMi= J#DC0=q Itٕ# f;1w]T95E!%`K-A^M1'1DJ62͍CN[8#4Z1qXZZMIK7 v<{5J&h*EGb7<&N$``E2aȈ/Ճ vc9.p8yvxBL!c[8U)2iukVdB̰8i$+L (Y h LxEv<aF9:f Z;S@2>S^]* 1*!}$ 3x FR,+9 !pE#9Mf/L(> *i5"G=LyuoQV603"\T)28USջnl?1JWr"A횸dTxsN5e.]E)3Y4?5iv9Ϛ䀚duJD?oJ& +τ#@<,Q_LY ~NR~ca@b^:FNSE5F?q1OlΦ&{P&1E-Js @,Z Yvtl c6oNDVOb2do&\ Q#zYK 'MK9 g5$(k\܏&w`*,8I'#]ޥtR,X/?JDеd^D *q8V,3vG1 ,p%؊+%:nGLec Y. 6xcy8=qiJ[ky=Fشd QjgϹQq"rsjN9:I <2qI/%i$a&ˢ4ʆ+x{GHC Q1owH"6Hx%B.'_vEB":ugrkP8jKѠ8e!u %7,#*윯WdohMp4BD- 2uMfk wM₭0LGd_PG1 8RQ ӮO&&CK@xy9di['!$jS#Q ߈툉!v6rO$$[ 7G)RneȮ)=)C`Q0OWƼSu\)Iw ҀkF[qf>LR-= 'yª6tA߱{6Un q47;L"6PT& _zCxvlX~18@ ( >䳃\(&l%n%B<5+v%_0=t ?*FdX rBbEr!*Q\@޾uEoR2ISf.1类|):iUSb8ʨz!.Ŵ=rNAQ.Sǔ$7i/09/:&#ƤL9 t9dn{pmLXMmI׬*8gNٲN2 Իq{Ԉd\MNI9<v}uyNWIJDun 'n7\`R\Z!:Btl/s6  KTj#8r*qM M w%n\t`ad1Ai>s) '+ f\*+f\`bL"rJ4]9e۵Ry3»ڢ Mq.cnhwDsrPvac0`p1/r]El&1<ʵa {D-F kT$NڳW( kv}&Rlѳ!:Cۤ6:;>6; e5|T~X ,Iʻiʙy*s3jۨ=k[(hl&}94_B60SJFTa!ur0X%z%C=YIwTTR.ȭSdl sEݼLW4hAbGut͞)Z99Mc@e1m9 .BV;Fɷy)ܑx jT/W>e-Zh%Tn/`> r[Z-q}vxG&b.p;#9ꄪ @bD_`xijF('%2L5pV}ҩŸ=Y`ڀUҸ'.9K~  ]x!޳ݯUSe%O&8\/_jZT lEZtt = 1F%}xOu0nr.9kU@A`)RERqq" ޖ[rH|2% ɜ4ax&;L TrpyACn2Jjޑ~kλar=GpFK>g7a0ޱG=L+R@v(=r_'GĘJցb`4SB/TVlp֪U)iW#2/3Qk-pYV/z>6"*vEű'>Ֆ;N 16C flΉᣚ|{%`kPU\u .y*m+9}_$6ڮAu̦j+{g'e$Kϝ+ ag]&Xkܢ<7 8F(9 CIN"@HA{(vn3sS*6LԹSМbb9gU}XcU,6J>.Tf2or>ʧsҥ!)I'hH,qJV1oagM23WT,LQ>OwJ6J0}bx1B`׊,Ht{АT$Q/R8(|S%cQsr.c›.ҙ>n|v.+sdn )QantGm߳gHJOd,ʷ?{xšsG aۆ+ER7 b"eSkh <҂Ί \=L׻…|FZbDxdyOtXd~#}WPqKE2i &*ޱrCv=b ^KJ5tii>Y1kôQ[;sJ.#LqTk)i/}[)ap2Y'Vzbpy,$ xh()NG9|$TؕJ ɻV{\lZb䤏5%OwOô" Rq[Lsk7mϼ k٩~UN]+^S 㞡) S]\Q.#gJknҜ n' x%fhxg+Eto͆VqG.k[~?MsL;32}6$Rpl!j|0h!ּ!Kzϕ"c$Haih^-I L.mN9X݌Z\)+.) ۬n N3Qj|^nh?c=Om/sՒߺEM)k0pb# jQՖ.:~; $[Ɔ6~w0? FДU|hY5ۍ<0߉JJ!sE9) z񚨗7k"Rȉu*u+TG&=i-g[ `J1唊|cli~D%S&LSV>^RWxd"V,[at |sph\j>!=+U6E (i7ts&ԅHn'dh7Ԇ㺘'6^,h+<[\.5%BDW.Z KX>Z|t|ב/v) +)vx68ܦʌг#LͽlN(% -6; 2E1 cd)L#k, p̙a~A7d9XLgK&rW9Z-pbF1Iu]52K j-; LjIHs=ߩe.e)NTeRܺ4-Pbl^JLn- gV:!,O/L3ܼbbaabnh~._0q/ߧO.M-G,-MOOyGLNg'NNNN]O303XY?zjaK٩B6^N,,L/8LMc'aǖC)?=C M<0gǙcS0Z_gg`f|Y:?20y:qpfv腟:44]&xf'/NLBh0x3>؄m mƾ9{L8]1T0٩QPɥ|YT:,;qp s2C pݦ&L\=pW+ 0ˤ[?H#1 8gs軗)E@ojbi§N s@(c`f9^ /m)l2C3{bĀJ#ߟ9]MeK[4<61u|#s1~rb!WyJBϞ%F.%\i[|я FYXE q).$ 9+\gUZ aIP9- s@s'ig]gK+r V)n@%/^l614ߓ(|Y2|Ij*XTH00_)kCgA#o7`Qi *"r{-Y鋘_tn5,_JL-QyPo WoA6Cr8`nϩݮ)[M.Gr7g[2:N$M/QX J`/'ؕr+NtWBDb].ZMFi//-r?,gi%Yɿ?: >~|t׳ObU^0tgoEԘkAv4rM ~Š]u[^@܍ /0q<nR&[0l.Wmܰ+EVВA`v|W]mJKҕmcە0 S7&x O { IFB\MrQ)nіsk+$=٤Wƛaso;uośkx+0"LD88?{lizEpu-cEs$Ñx;S6pCK)_X|Ќ`4w:o uDlfkIKWYwh@m 6`1_,'gm M֒z ac',iuI0.tpAr(W `] #o{яC6^8ig绒l?[mw7~zv7xr}ͽ. Ą@|wejC朖p$s%biP>Ցv|/MJ`ME< eIGIH&iT#K~T Af,(uBaP듯Ku Ӂ,,뫝Qhc[͛ X!BP(~@U4+2:s2>{~m$_k^7ygO$bvxj8ZWg]?wI6r/EVU* BcX<4{kO +F+ BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( B.'o=~o=g|Oӫ?ʕz+ͼ蕧\;K_l[С'z=/]./>;Й=W-7|sɾ#} ~c~%]{v~n>qpԷ}{nÿ'c~:gލ_z;ߺtCr߭ ?ޟyw>zs޼=ӵo^[p{g_?'|zi^4/gګdvۃ7O`s_9Q_?s7?4ñz>wyg?6nƮ|ody^hwhW&wISk>pl};nG?ݰǿrux;N}oem~St)/z+O`5{e}K8;M:~ϛ/֣)[FMu߽|g}י9z'ya鋗<?<5=7${3_?zݓ?x_{y^]7C_~%b/<޻.A>]zC7U?8~y#+O6;Fzj_>?|磞?zҥw—?y^ub;w}S}wNԧlu{w+O|s Ϯ9|=tך{sW^|o]>屧?!ͫ |ګ s'ҿg&[y}WN^1~~/z|O_z?ϻ<++ͳohgߛ/ۆ>ퟸ~qOOo7{_h~{ͽ| y{Ssntgxߎ{?? ̻Wy[0W~/~7n>t7GKEOu:_|́ mzל/Ͽ%}`'{#|}}Ǜ>ԻT* BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( BP( ſ a nobodd-0.4/tests/fat16.img.gz000066400000000000000000000467271457216553300160550ustar00rootroot00000000000000[efat16.img\s$Y] ^e\NcFNb4kIݕ]ve4c4@@oD˾B#38 #)2w/kkߜ\ Pg~V-)xV8Xu|rduu}-|ůmO/Ň/}[?>qb$/Kd?KOs&i2Ɍ.Ƈi>ΤigBd˲J2&D7z.qno?/^//~@!a#Gŏ+ ?)~JqC+gωXkqKĺbKl53gŮ'ľ8sP|^|AXSsxS%w/__"~U( -b$" D.ˢFJ<357Smu;w Pc'O7ş?R JA $H A $H A $H A $H A $H A $H A $H A $ȷK A $H A $H A $H A $H A $H A $H A $H A /'NWWW{ _xwy|W>|A{(އ98?7OE.JaĪxQu.BrW{8{L pxB$H A5YKb [[{8O 8x3D&f_R"R`grvtwp|vp1}ܺu>ny=Mg|3~3~3u~={[o݃ߺu~6^~zg{ģo|۫G@gF:+aW%ڭռXMU(K7w,}M]$FWBi*rl~/Ίd4.ͽ_h-ay -Ul&_Լ+C3\ˋ+.f9̚9$)K2^,qb"Wl&`M$,fʈ8T+y9ld#8}Ri~I%9-SO5æ3]TV}XM3y¾qe0/"X"b_ŅYsط &')w%$LV xJ, R,' if}Vʔݨ8J|NJ ~׋+FVўh 5Y]&8BOTOt NFVfQ')w:i1ѬanV0e✕toT O6lPrيB`gREd_Wj$.l#`"8OmSƸwVOe v PGapC3љiULqhaOF3r<$!2F#-aS=BY}n!,!_tٚe pr(rڠR^Y.u`b\98EIM8f+`1 f i<:?xL˽Ó3|a~缕c:LO|m~Dee D+UV UW63k* -`z H̀|zk(5E@F봧i/偂#\1#;r;TǾMӐh6Ckh8je%o0duc\L UvT;|';WfG Cz.rеM'D`"f%6þ))"ao%)B~ S}]0<X(tb5a#ˣp 2z^9&3*#eu W-(Ra ǎ0M&dmX;%OCSHd4'3g0sUi{ &q8D $:Dsq&m۔5 uTeMۙBV3K:,̧\~\4O0ԋ~>oYB :-kQ @q RD^\EL|ȥ"U'H%*' jwS{ɴm= l֬sy04q8 )D&"v=<+f˨0$4kB*"/ꕹ]Qݘ[FuFT!q9fQz:5XkMKmq` KFy kJ%D_كM?R9ȴ,!$阃rkT[ue`!fhME,F U{Xziգ[*l A\eY(1)qƷ{ʑT3y$#ƠP2m+ jкEtA- մߵr C B;7NhN. Wcמ[.垮˼.=@x5yOLuAe.ԅ<[x "tÎa͵FkS?Y 2q9+QwOtZn}lW@ު5`ӃU&ar)-TጒeԮ!`gpPLyHk(.QʣXRVWP4,rDC(BIqRϦ? hZSu-%2v R_Hٳpwޤv.hNicIy}wfmh29VtPQNzQDU"~m8X;1y) xlu6;{AR AAƎoD Nc``"AbI5y r vvQ5# !;}g}`Ⱥq>wxJDuGZpq Ԗ1ZM!p{9R) lxd>^ϥOOZ>CYK=7~K >F ^,LęmS'q{`/(*J RnY¡QEO ,.3t 1Wd9m-  K#q}SG.ʩ^RAÝG4MY\@fU..dNFW*"!#nS\&]vwS-koX0j4ZM:͵߾Z;]mfpr\?5ΒTa:3S(R6^`0clzS]w xbV|Lwbj"8Od;(\w6`t`SOrhv@|by ]7{^$݃зFhWYnd>$sO0tc+Fa@M6Yff}ڝkDղX]s')νd{/)ֶZ"tTSw,^si{M3_4US+D^~ZMƥFYP4A}vXAum`ݶGq vfg-V4mGmUH#cj r@ '8Nnt|#8,~E] :q?|m !W2"7F%j&/LJQnR}-6b3]EҺ>lafjkl8NkGL3nSK>+\0 k6Zm}8fξаqfP$hۉfRq}a4(}\R+EX\R=ċ/%ŕδ,x]ɫpΦ)hu aXsyg{w$YZp@6 e@U1Yn̩f{zC.YFTKQPVRE.Af`D0A9}ǻfXc "|{eYpVNb7W4Ql8Z XPؑ8_ΐ:fVB}RB1tvåT%pȄZӑeS1ż?~Ą<{'+R*%&-ސZ+j?+j.聘gmXJ ȫOj迯V`K+ݍA5 6>jS٘j8J9 a|$}H@H.?{cqN('1.I1~HM˔(u =4s(mL?s@<ʱ4:rCvR |/nH'FyvQ:ڔw{[fȿx/4CT)oO#C<]Hz4gcGֿ~cˣ\mӌ*W7SjXnOPV("{UaDE&=K>zczaǻ"ҏ+ꀛvZ,-c4Px\fÉ91μYcrN.zhM ]_m7p8[!A4l6e #gSN ƒ*2<5".z!o,^^#ֻ 6Ro>vY.4:A/4N\ﴹNC o~Z:ںE7h^C`ɓk+0X_hnV,%Âa ]KER1uМ8yds0~=֚D[TT6bB?ϥifTα6u[5ZiV7&1Su/$我czOlfkɑՍ:jŵ]I=}f Gr,TUXvq(Ƞ~wg,,_Hj'tJ½^ F;Z\ &Bcolɼ:[)p`j'|Twݽ [jեowQ>eÕ]vQ'rs?*VRz9+u6%Oꠡ:@N'A9ubi*TcU E9ʲA U,T?Tå/^R ًk8GCt\QuѨyq'B*ek85='>6)BKqNq@n8וQ}NG9 <;-(aJɏ]9 )&02ŲQI@xè{0=s$1Iji춬^_U_sr^hJjGew?\-7ͶƃrN6%} -~l/gtGx"T}V\+Uγ[QQnP]KiEkЩlo=a҉KYQܜ|4p1p2+~y'>zy v0¥a%dբ3./1GFXy-|"Ex\`LQǚ9-Lx/+il`R޲<2[&F;#Σvlncّ´V},j;N>=VFsL!C[-6.H&&@: rcAl&E.k| ͛pI Qy[k;p!.NOY/PbRXR2歟:eߝp!utqYhlڍ:Nun〱eB﯎QNK[Z1ͦ_Tԙ}Izy6C-ư֋7(fp`V}x-ThTAxU[s1S/I@Tؔq{H"=vsv 3Gclh563ze*zm#ls-U3«F'NY9/7#F<ߋbeq%G4>zjH؝ 9pT49 0n:X7:կuvqI]/^e/`E\x (Ki% l;yUl>n{L[&Zn v=#Ӽkcz؍cDq|!uwtFhP#q@,MlGVU,t Dl/\Oت|N>sur=6LW{]ckNU]m1[D4qM.Fq-^㋩plq5D,.-5š1z>Q:[/:8'V{^3};98U9Tܫˇ𯛞@N5W?!Ū, 9Bk*˪(7f:h[.QN6'Ge֚e W)O(0*VDRqzhQnS(w[8y_NqT4 N͛b!_rؠ_Rcwh^CPR'~<I1c0y%v9 w~eZ7&w q;iaJM L{9 +rU>m7}CKyGrczTEF+_TUɳZJv-˩QYu&zy!nj-|WF/7Gi*"Z ~KYT/{J2chpj!{MtQysFsJj [r&Xk[P:Sme,?#Rc1Si˷Nރ81Q[+W$ZӼP7nqH&!8@ h] \Kl8#ȸWQa8Y"Bz!u¾zq$n]I!Z5G-0vG?^1{>J HItdRHXpoT;m?^D|%0jrRTaGgrjd&ϤT]CPen47=Mr%XZ88imeTTfb~+P MIN>@.lKuF~zapOY\\ʠMj\AuE=ot*4;ȱ"H˾Vm:qpR9(w^'mS5Ъh SMhk-wk6W&t!씕O؜^q 7XmaX W7I[y_quޫv|w_RzToU﷐ZuGg,<~IGݺ2tW)HNlP1ٳlTNk:Z9^pCNb5x㜆B֮ykr|5os2G_ƏfrK󪨮^SIRmz:ʁ^\>~ܝts^*Ҫ =-ƭ}ᥴy%)seaOWh(i21NxDJ8A#XH{: ZkaEUW^=~EnE'e~Jz5ǗӍPAqoܵ,Eסi+Sj"fd5ޜ,/4oluιSask7vF|3pl(>ee0ngwIc};(x /bҔF] [ mݩ^P4*~4KFKk=1)Ro\!rrX^-W/?;'S6s-za @̪xforbU*uui,J|ETqjxStf^;9pWmq<jO hї]JxeYZ6z<4%E9:ӌo [h4ƚKarNp-TW݉xY[Cc.Z/L T]=3?ӝݳ{hz~~zρ+·{^k973{]+w훛9}LwnPxsҵ;gzfOwo83ftsU<ν^u{v7TUzbwٙ<5e.JՄ\J=!\=s!%gs֐K\?}LLF-N,ܽ*ctSůPgEݽw䌳skf4SH:N_7$ʼn) RowO_53ov[{fvΆS{ 0jZí-";]p9},w͓^Lٝۻ?ήx S$T|Ʀw<8_RTX$ʹ4L+A–;F0ud4\ ɩ/.5}˜I V Q/wN3ϺEm?Ey7֮NuI¹Ch5f{}b_TV2!r:ϐ89{_GckK{yfd0 {'z:WgP|fMh|j54j1bTσu\:U˘8= θrEV[ F}z_˨FNlƮM!Acۻ-vz'SsW{Wنi\/xk\(XAWDe+kKt1,]~@~_kѺWoŵ 3wJZg6|lZ(;6l&'@}ջNrN썧}q;Ӯ?Z6B R6.Ն4y.ҲW[5uk`P-q~x9Qa>vd5"2u"KO]|YN:>5\=~VqMؽꦹIE$q<{)S`)Nl=qVEWC 4)? -8|a9T|]}ss{G-<ݽbKٮ ;,ʎ%zlq~C<8V|qdxö*p2r 6 WŃ=! y~ BJ:Eɑ޷Hi 2ԍm<~{bzǕդzIvnW25_nutWoOi箟4vx'eM5bݚۅDo.aX-(!Dluf2^y]7 iLT@آt&a(;1O֜|rD+=HhS9 \ 2V qB„N\sガDsԉ3T>sɥkK͹ۿV ⚓{sg]8uõSK'^xkktWõ榯,, sxBs?d7>eݰ(~tM m~3k{;}jvo?s/.{7?/_>E}M~잗</x폽_q9?gM7>|GrcN]~ËzC~9s|𡏜 ?םwk􍋮zo?s{߹ xC7? xo{oɻsu _~s/3^ _v8ȿyw{;z!Ϯz^/{ԟ=:g7LNϞ䧞~ާ|˕?g~ik_}>e7}o}=?/xgƎ{+yݧC3z#_x-7Y~^—nwz˟m?v;.|pN/Io{‹sw?#ïl]wkiǿy˻3uaϹw]#矼/3O_zޱws_7oZ<{ҟǛ:{~#O~Wn^!-}+{swc~1r鷖>p?m__u~o]sw{]~ʉn͟.^w_'>zgx ?{wo_黿ӾzxO0ows^GzI;?t򓯺eyoʹӯ|җϼ3>s?=Ix³B. !<'?sC>'gCs!|n`!>cC8BxA_!^¡pc7p$! n n  E!8CxI/ !>rOC8rp_—Np*! l+^b@PH!|i_—^B/!B?.p9A 6B a(^8IC a'ׅ!>\sŅ›Bxso !|Uo C׆R ]!|]C)mxg=BSÿ}FB!tBx| Y!\gŸ !|vB!>7 Cx~_B7?0 B8p8C)#! X7pKp[pGwpW/ !KBxiK!N½!!%!,! tgB8ۊ.?ƒ!<!<+Bxe_—!*W b!a=K!\akBx46C0 a+׆0a4p5B u!|E+CxC17[Bxk_B&!#;CxW_»C{ʟ*_39qСCǎ>maƓx?Kʹw?>?~ɕ _>/_x]| m}~|i3?z2qM7>㦛Ϗ92[|~ۜoO9wdNGߑ9wdNGߑ9wdNG9wtNG9wtNG߱9wlN߱9wlN߱9wlN<'n7ωs9wyN<'n̉[-s9w˜eN2'n̉[߭s9wuN:'nΉ[߭s9wۜmN6'n͉ms9wۜmN>'nωs9w}N>'nw̉;s9wǜcN1'w̉;8{c{'>trNV`} б7mq}޷|z9r[zlz`<c4a{44描vƃKLؽwwϏ֧W{~p7 ^ Ww_ty:ݚyãt;oZw:ꮆw{õ`2.nO݋aAֻ9Fk)`x;GnocctvsMΎ͋ 9=}1[An{1F}ZhܙTOe4w &ЯƏNW;)ğu7Giwn{j8nt?\:!֦Gׯv;q:fjK)Z!{vdؙ=pti7wﱳg!%)/ҸyPf2iK񳸕['Fvc>QX"jp%x{7#RnՐ_[?i%zjc[G72W;8YojݵQfJ3!~9?W9|oưpkܿr2b qxImM-s{<8O/+=>~1^j< FVi%JXZ`=&;n/Vxot)f ?}#l^nuq+ Hr~Mgx,)batF~dOZ+w^Ю$~n۝V˱Uʰ\ꃚ\%PB5N!tRb 7I!ulbHP2ў=_ 7rwz5i? &{T7Fō)C,"1sipJwK!sH$]l_SXJ}^곺>dyWtWC>9_w+19dX줤z&w9w|1$vN5nv7v{0N?j]]D1~4Soəb)Հx)B+WinȃCe= K|$1`>h3E`u\8tx"/x{{nZJ[&mھt9}e7^Շp;%R.ٱ̌էpͭps(I1l79@!%+*'əs^֝설 [Ͱ.[b=Pˑ9Z^Q=$W13M*d!&pWsJ5TocnM{|f֫\5#FH*cJr"sQjc^G5aYzph.K*%K]"])BdnXG6vePݬi/ַխG9&Js*axwL[W⧱">5xΖ !ۡ0L>rC:ٰrvx%aPs彳\A oB![-&BX<ᖹبD+=ާ/RݗMwb/͚tb@s+%©TgkT.Ƃ/֥: 4L; Iw\.]OC4;lsw!1u䝐i,m%^~z&Jrs\^pPaCa?ױ;CQ^7bs$aLEͱKO\/9 %>A"OT]U;Ieմ&c|Aj0uaί:v+a{(dH63答-7eM1.LYLf1cOa)a9Z y<6=2@=z4QNDž7gԿo͚[X(~{+=;F{3XU׺+U5?oEds\S6}0XZ{gOvTu55|ϐWnc'W6J7Kjl.pm ^?]SndNG\ /;IixTZ!ML]9ޕdze>ûzZoU$vZw}jUj,Zfؗh]7WIZ X(ZLV7~iW]IP RGW:ۻuXfo^ po=Iș ܌\ |)SJžX>[uX gqe-U].;i]įoUda 鑺ɷAlvmih{*TXvm`-3 ؽ*1|)ysl0ݞx jh_3TDq>cZP?6r2/.dک 7[ -IǕrCy(1y!gƸsɖKQ+VcLa nDěnno~VJÝ(LYW}Rȝۓ}.ZjJ]31nt./6)A>x(FN疻F[ޛ,4k͉S I1F^[ Lixog_W7c; gf4k9MML8OpR%~f>]&'GnJXwQǑ'g71=Q~.߰Y աc?pr;\X$r1ӡVTu1zy8]Ih[0fGNpw׷7BiM8K(ߏP ;r*Z9{qLc~Zh֦>ћ'B4l^ ??r{3 olR*TIt駞ęh(c=9LKl^ǂbDē-cᢥEH ٽ8VvOc0G|PCu:H d)#[4e`|u#3IhJYpHٮv3+ s >D43h-zҪ['W76J,O`84}eKӌ!f#@ky9лt)&8n;tIk)ˑw.\5Kl`4ڳ~bޯf\4zsC&= Sm˗F5}BvnmCestd̙Q)'!g5 րf1C9'B~┪fZki<OJr3>;67QRL_`~mSQ.SH4wM)mV6VJZ8HxuX9fC.X߸є=YakR쌏XH*fndT zSgMg.KjHǖY?[ 3qM\f(qcDG^&M XCީpX+6\ﴷL5^d;NU.f`t0܎0壥t([<5' m^fxPL94^$>36ܓUbL^{Vzy67۱ j[mVbWu'#桟TqZKY*]_՟n;u[i vo$o&2ٷ=ۿ]EjJo{;߽=.H'tB`X x<ܪ_O`yP(Vv֮4Y5OfNJwe8wzR[kzήMTwY{sU>tj(%؅Ft6M֩TŒ:{N̓yEu6ASAQ7pZy2T*Rv ܧQ8+Fz/Fٍ0nfVǣɤ2E +\:W!O&}".jsi2>5g;'Εk{r t`| j<Q4Kib{E{,JK+mK͸̅~աzG|loOi̗ӽY=vl~~gܯfpg(OՕ2TMcbpGF 5js~=Yݑ53~<8徾=qP?G[' KpTR"5krwW֬폫 }`3ؐX:8OLq~bwR>y[xӓ(ԷjAyyQj\tbzJT¨9آRmfӔGlj2!)&q|+ydU%3W5'9LGnI[w]Y B7M͖jiu?!zKUȐ^5oqhkHqdsX`xsBJ^3epúqۤrXZ>fъW׊鱽Iҫ_jNe`8bgrF**ㅯ0 {~Z}%6zu͎4q%&]ơ1O/c,͏4YZF>qYj)~&~)-xog5gZdg91w̜n)]̼9¼V}Hlb;N־2^pRr)Cn~8TFi{.7r.k&̎~kSOM՛xNQ=` kiRiT Oϭ^ *9筗UD Sgng5: E[h̜vկUW4r[i[ķ͎inul9p])vu>t;ڧ3Zmt,Tԓ l퇙Ӕ N= wv |CcINqdq5Ξr]_3!uvQULY/# OUkW?MZ: &F9ҏ tN 0G}'Fv]@w:XNBeNI{Eb5\i빑ȯ֩Wwd\%7|٧3hU셔ر m]ɶ䮏[R=KUi ;yܵUM}D!7vJhP.=éC-4V6Gט .q;3M qNEz ӊk) 9C+"dkK=qۭu+T7qw?Wq́)3ݟI] Gꢾ\6vyiBMa=XޯUY4 ;7wl\]w=ɜI wNjN4Qi,Ԩ{uNoV[S.We ۥ?t ^4!.2˚y5vyUI0{~vU_2*S>s)kNNmWE"f!NVzZ&8Λ^HR=ש4v둇N 7tAЙs5z@!9ՑΛq0RnxZ&( [ݕ %[0e7Α:FwA>vtgl[Dͣq×N,…uLI)Ƃؕe֔ΞiU}.=[ٙe.}k{Ǡ(O. MAN@]w5ٜ6+fF0d'djs8afXnN X9okn [A]7JKxqO9=ˇR=;?)Щ+hBlتShsVk|ɮ.윔KO\yzev:HuA.페q )Nz2v%8]^/]kPX2|uIWB4 ]ӓSf^rxѵpQUzrRiǂI `kBe 씱Y&6;~)'lyNf@afU4MտZ Kkvo;%묺x9IO:^cI;OfěIe y -Vw/?̅GǑqS7pHO}kyM@l3y@'W{uyu?z{ToV|iZ5rlۯ~R#-&7ޮJs5U'5AZ4-Ch:43늗)!oqr{dN,luḸ5FzI]̵6ڬo]ke U0-gVcsYlq`_b!j}M3hfɨ,oP=Z6lnoL{{bL=+st TKTOŞtJ_S0,-~rx5Mx͘xt]~wU}\Bjǥ*Pf]iݳW!l]S1S*uu/E3T7@8yvf5GmjFG3Zf|/RlA] %R).g|*fkr4 Uv#WAҙtiމqu=ShUջvj^UFRS}|}{.`H17,Ob3# U3{7Q&/ywj…{,={r=''oNz ݇_>=7 K+[rg>rn/t?sg]XY>cX:@ yB}3<}啴Ϟ[>>@ʩpÕO>p"bg.tO3 _pfV[m=Ls]gJZ{W.Hq'u>pˇ9 FB[9n8/`PݰSK/}ι.S<#gED8'f"%Fr/<vsS%_,<=|<ҹG=r<ùK+b,?s\ʙ9z8O.}Mݓg9ta8yr!=t\9FQ;jը8NٙupYWY9{t5^zph|jǍw#ZsS7M*"9Li7 Qe!8u%\j7D4vobUo[O;&MqMg+Nyq~o= 1͜"ugN>pa#]颖ٝn6nʎ7~ro+VoHصtyg+75c]`γ+^ó=j"viVlKwV{mΆPc riik7h'Ni(kWӤ 9G~ZK %T4qփ7N~s뽒4;z;ܥwi;|s˅l}a7SWLZOKr^"6ő~yNy./7iAEHbWv~c,֜G+HWWjJq"Z{qPxM\T'=s$9C!m\8|y.$m_+O}?Go9/n7S}{泿)S7~sk~gy樂__z?}vno?}_{ѷy}ou|kܷ[޾>7~Gn՗yO?[}Ӟ~_ _z[ۻޏo_/oo߷x_}~>~uн>ӝ~ԋ?~S{I<۟8xY?m_=Cxw?x`kΉ?#<ɧ\7m?}?xo {|>|u>8}g_z}ʥxlïO/S?s7?n7zǷዎؿ}7O|<4o¯>%_Y/}n[O?^sCm?skw=Οow[3k'W죿=y^]=/xuO<󞏽}G_E>pG;e%}7-/;s~.ɝ}K߼]/=מ~7=c޵߹۞?>͏\^{?>__~^yw/n>=?sw<嗿{ᗎ}<_{WݷO~=/7})}W<7|әum?۾w_կ;7<[?rtm髟_?ysG_˯>5zk{_C;OyýC>_|ydgW_rױsۿo}{/g/[sГ~N_'淏~=8 u;NA[nobodd-0.4/tests/gpt_disk.img.gz000066400000000000000000001007371457216553300167300ustar00rootroot00000000000000A,egpt_disk.img?HUQߓ!HE$WT!#zj * hA*u >Ås9s.{"ו+^=wwvZvEb_~˵s3l][T6]h*u'_n4teggFF|ҳ1aȝs4ޚ=HjD)ĺ8&:N.~wjx|]:9|k~HBe] p3jȇ[ϕ7gLRX/^ >]_q\{4z_=$=/r|H̳ы\OMSw;8n?.|\yP_եK' Z szBW纝̟b4_5&d|&}.yq4x]Ӻ?Jɷj7Ⱥnobodd-0.4/tests/mbr_disk.img.gz000066400000000000000000000777341457216553300167300ustar00rootroot000000000000001embr_disk.img1 ` tT?y.Nkt@xBoɐg=/K m+jZd<"~8}/;tp&AА4mleʠG%e.6SwK!q4]?#~\k#v#v0nobodd-0.4/tests/test_config.py000066400000000000000000000172431457216553300166640ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2024 Dave Jones # Copyright (c) 2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import datetime as dt from pathlib import Path from ipaddress import ip_address from configparser import ConfigParser import pytest from nobodd.config import * @pytest.fixture() def parser(tmp_path): default_conf = tmp_path / 'default.conf' default_conf.write_text("""\ [foos] foo = 0 path = /tmp [bars] bar = 3.141 frob = no path = /tmp """) parser = ConfigArgumentParser(template=default_conf) parser.add_argument('--version', action='version', version='0.1') top = parser.add_argument('--top', type=str) foos = parser.add_argument_group('foos', section='foos') foos.add_argument('--foo', key='foo', type=int) foos.add_argument('--foo-path', key='path', type=Path) bars = parser.add_argument_group('bars', section='bars') bars.add_argument('--bar', key='bar', type=float) bars.add_argument('--frob', key='frob', action='store_true') bars.add_argument('--no-frob', dest='frob', key='frob', action='store_false') bars.add_argument('--bar-path', key='path', type=Path) return parser def test_port(): assert port('5000') == 5000 try: with Path('/etc/services').open('r') as services: for line in services: if line.strip() and line.split()[0] == 'tftp': assert port('tftp') == 69 break except FileNotFoundError: pass with pytest.raises(ValueError): port('supercalifragilisticexpialidocious') def test_boolean(): assert boolean('y') is True assert boolean('0') is False with pytest.raises(ValueError): boolean('mu') def test_size(): assert size('0') == 0 assert size('200B') == 200 assert size('1KB') == 1024 assert size('2.2GB') == int(2.2 * 2**30) with pytest.raises(ValueError): size('fooB') def test_duration(): assert duration('0s') == dt.timedelta(seconds=0) assert duration('1h') == dt.timedelta(hours=1) assert duration('5m 30s') == dt.timedelta(minutes=5, seconds=30) with pytest.raises(ValueError): duration('2 hours later...') def test_serial(): assert serial('1234abcd') == 0x1234abcd assert serial(' deadbeef ') == 0xdeadbeef assert serial('100000001234abcd') == 0x1234abcd with pytest.raises(ValueError): assert serial('foo') with pytest.raises(ValueError): assert serial('ffffffffff') def test_board_from_string(): assert Board.from_string('1234abcd,ubuntu.img') == ( 0x1234abcd, Path('ubuntu.img'), 1, None) assert Board.from_string('100000001234abcd,ubuntu.img,2') == ( 0x1234abcd, Path('ubuntu.img'), 2, None) assert Board.from_string('1234abcd,ubuntu.img,2,192.168.0.5') == ( 0x1234abcd, Path('ubuntu.img'), 2, ip_address('192.168.0.5')) with pytest.raises(ValueError): Board.from_string('a,b,c,d,e') with pytest.raises(ValueError): Board.from_string('1234abcd,ubuntu.img,foo') def test_board_from_section(): assert Board.from_section({ 'board:1234abcd': { 'image': '/srv/images/ubuntu-22.04.img', 'partition': '1', 'ip': '192.168.0.5', } }, 'board:1234abcd') == ( 0x1234abcd, Path('/srv/images/ubuntu-22.04.img'), 1, ip_address('192.168.0.5')) assert Board.from_section({ 'board:100000001234abcd': { 'image': '/srv/images/ubuntu-22.04.img', } }, 'board:100000001234abcd') == ( 0x1234abcd, Path('/srv/images/ubuntu-22.04.img'), 1, None) with pytest.raises(ValueError): Board.from_section({}, 'foo') with pytest.raises(ValueError): Board.from_section({ 'board:100000001234abcd': { 'image': '/srv/images/ubuntu-22.04.img', 'partition': 'foo', } }, 'board:100000001234abcd') def test_configargparse_basics(parser): config = ConfigParser(interpolation=None) config.read_dict({ 'foos': {'foo': '10'}, 'bars': {'bar': '10.1', 'frob': 'yes'} }) parser.set_defaults_from(config) ns = parser.parse_args([]) assert ns.top is None assert ns.foo == 10 assert ns.bar == 10.1 assert ns.frob is True ns = parser.parse_args(['--no-frob', '--bar', '3.141']) assert ns.top is None assert ns.foo == 10 assert ns.bar == 3.141 assert ns.frob is False def test_configargparse_bad_init(): parser = ConfigArgumentParser() with pytest.raises(ValueError): parser.add_argument('--top', type=str, section='foo') with pytest.raises(ValueError): frobs = parser.add_argument_group('frobs', section='frobs') frobs.add_argument('--frob', key='frob', action='store_true') frobs.add_argument('--no-frob', dest='frob', key='no-frob', action='store_false') def test_configargparse_update_config(parser): config = ConfigParser(interpolation=None) config.read_dict({ 'foos': {'foo': '10'}, 'bars': {'bar': '10.1', 'frob': 'yes'} }) parser.set_defaults_from(config) ns = parser.parse_args(['--no-frob', '--bar', '3.141']) parser.update_config(config, ns) assert config['foos']['foo'] == '10' assert config['bars']['bar'] == '3.141' assert config['bars']['frob'] == 'False' def test_configargparse_of_type(parser): assert parser.of_type(int) == {('foos', 'foo')} assert parser.of_type(boolean) == {('bars', 'frob')} def test_configargparse_read_configs(parser, tmp_path): user_conf = tmp_path / 'user.conf' user_conf.write_text("""\ [foos] foo = 10 [bars] bar = 6.282 frob = no """) config = parser.read_configs([user_conf]) assert config['foos']['foo'] == '10' assert config['bars']['bar'] == '6.282' assert config['bars']['frob'] == 'no' parser.set_defaults_from(config) assert parser.get_default('foo') == '10' assert parser.get_default('bar') == '6.282' def test_configargparse_bad_configs(parser, tmp_path): # Bad section title bad_conf1 = tmp_path / 'bad1.conf' bad_conf1.write_text("""\ [bazs] foo = 10 """) with pytest.raises(ValueError): parser.read_configs([bad_conf1]) # Good section, good key, but in wrong section bad_conf2 = tmp_path / 'bad1.conf' bad_conf2.write_text("""\ [foos] bar = 10 """) with pytest.raises(ValueError): parser.read_configs([bad_conf2]) def test_configargparse_resolves_paths(parser, tmp_path): user_conf = tmp_path / 'user.conf' user_conf.write_text("""\ [foos] foo = 10 path = somefile """) config = parser.read_configs([user_conf]) assert config['foos']['path'] == str(tmp_path / 'somefile') def test_configargparse_no_template(tmp_path): parser = ConfigArgumentParser() parser.add_argument('--version', action='version', version='0.1') top = parser.add_argument('--top', type=str) foos = parser.add_argument_group('foos', section='foos') foos.add_argument('--foo', key='foo', type=int) bars = parser.add_argument_group('bars', section='bars') bars.add_argument('--bar', key='bar', type=float) bars.add_argument('--frob', key='frob', action='store_true') bars.add_argument('--no-frob', dest='frob', key='frob', action='store_false') user_conf = tmp_path / 'user.conf' user_conf.write_text("""\ [foos] foo = 10 [bars] bar = 6.282 frob = no """) # Doesn't complain about validity of sections despite there being no # template config = parser.read_configs([user_conf]) assert config['foos']['foo'] == '10' assert config['bars']['bar'] == '6.282' assert config['bars']['frob'] == 'no' nobodd-0.4/tests/test_disk.py000066400000000000000000000142421457216553300163450ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2024 Dave Jones # Copyright (c) 2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import mmap from uuid import UUID from pathlib import Path import pytest from nobodd.disk import * def test_disk_init_file(gpt_disk): with gpt_disk.open('rb') as source, DiskImage(gpt_disk) as disk: assert repr(disk) == ( f" " f"style='gpt' signature=UUID('733b49a8-6918-4e44-8d3d-47ed9b481335')>") def test_disk_init_path(gpt_disk): with DiskImage(gpt_disk) as disk: assert repr(disk) == ( f" " f"style='gpt' signature=UUID('733b49a8-6918-4e44-8d3d-47ed9b481335')>") def test_disk_close_idempotency(gpt_disk): disk = DiskImage(gpt_disk) try: assert disk._map is not None disk.close() assert disk._map is None finally: disk.close() assert disk._map is None def test_bad_disks_gpt(gpt_disk_w): with gpt_disk_w.open('r+b') as source: m = mmap.mmap(source.fileno(), 0, access=mmap.ACCESS_WRITE) h = GPTHeader.from_buffer(m, offset=512) # Corrupted signature m[512:512 + h._FORMAT.size] = bytes(h._replace(signature=b'EPICFART')) with DiskImage(source) as disk: with pytest.raises(ValueError): disk.partitions # Unrecognized revision m[512:512 + h._FORMAT.size] = bytes(h._replace(revision=0x20000)) with DiskImage(source) as disk: with pytest.raises(ValueError): disk.partitions # Unrecognized header size m[512:512 + h._FORMAT.size] = bytes(h._replace(header_size=20)) with DiskImage(source) as disk: with pytest.raises(ValueError): disk.partitions # Bad CRC32 m[512:512 + h._FORMAT.size] = bytes(h._replace(header_crc32=1)) with DiskImage(source) as disk: with pytest.raises(ValueError): disk.partitions def test_bad_disks_mbr(mbr_disk_w): with mbr_disk_w.open('r+b') as source: m = mmap.mmap(source.fileno(), 0, access=mmap.ACCESS_WRITE) h = MBRHeader.from_buffer(m) # Corrupted boot signature m[:h._FORMAT.size] = bytes(h._replace(boot_sig=0xDEAD)) with DiskImage(source) as disk: with pytest.raises(ValueError): disk.partitions # Zero field isn't m[:h._FORMAT.size] = bytes(h._replace(zero=1)) with DiskImage(source) as disk: with pytest.raises(ValueError): disk.partitions p = MBRPartition.from_bytes(h.partition_3) offset = p.first_lba * 512 h2 = MBRHeader.from_buffer(m, offset=offset) # Corrupted boot signature in EBR m[:h._FORMAT.size] = bytes(h) m[offset:offset + h2._FORMAT.size] = bytes(h2._replace(boot_sig=0xDEAD)) with DiskImage(source) as disk: with pytest.raises(ValueError): disk.partitions p2 = MBRPartition.from_bytes(h2.partition_2) # Partition type of second partition of EBR isn't terminal or another EBR m[offset:offset + h2._FORMAT.size] = bytes( h2._replace(partition_2=bytes(p2._replace(part_type=1)))) with DiskImage(source) as disk: with pytest.raises(ValueError): disk.partitions # Two EBRs in primary m[offset:offset + h2._FORMAT.size] = bytes(h2) m[:h._FORMAT.size] = bytes(h._replace(partition_4=h.partition_3)) with DiskImage(source) as disk: with pytest.warns(UserWarning): disk.partitions def test_disk_gpt_attr(gpt_disk): with DiskImage(gpt_disk) as disk: assert disk.style == 'gpt' assert disk.signature == UUID('733b49a8-6918-4e44-8d3d-47ed9b481335') assert len(disk.partitions) == 4 def test_disk_mbr_attr(mbr_disk): with DiskImage(mbr_disk) as disk: assert disk.style == 'mbr' assert disk.signature == 0x3b1190bc assert len(disk.partitions) == 4 def test_disk_gpt_partition_attr(gpt_disk): with DiskImage(gpt_disk) as disk: with disk.partitions[1] as part: assert repr(part) == ( f"") assert part.type == UUID('EBD0A0A2-B9E5-4433-87C0-68B6B72699C7') assert part.label == 'big-part' assert len(part.data) == 8 * 1024 * 1024 def test_disk_mbr_partition_attr(mbr_disk): with DiskImage(mbr_disk) as disk: with disk.partitions[2] as part: assert repr(part) == ( f"") assert part.type == 12 assert part.label == 'Partition 2' assert len(part.data) == 205312 def test_disk_partitions_repr(gpt_disk): with DiskImage(gpt_disk) as disk: assert repr(disk.partitions) == ( 'DiskPartitionsGPT({\n' "1: ,\n" "2: ,\n" "5: ,\n" "6: ,\n" '})') def test_disk_partitions_get_gpt(gpt_disk): with DiskImage(gpt_disk) as disk: with pytest.raises(KeyError): disk.partitions[0] with pytest.raises(KeyError): disk.partitions[10] assert disk.partitions.keys() == {1, 2, 5, 6} def test_disk_partitions_get_mbr(mbr_disk): with DiskImage(mbr_disk) as disk: with pytest.raises(KeyError): disk.partitions[0] with pytest.raises(KeyError): disk.partitions[10] assert disk.partitions.keys() == {1, 2, 5, 6} nobodd-0.4/tests/test_fat.py000066400000000000000000000270701457216553300161700ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2024 Dave Jones # Copyright (c) 2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import pytest from nobodd.disk import DiskImage from nobodd.fat import * def root_offset(part): # Only for FAT-12/16 where this is in a fixed location bpb = BIOSParameterBlock.from_buffer(part) return ( bpb.reserved_sectors + (bpb.sectors_per_fat * bpb.fat_count) ) * bpb.bytes_per_sector def root_dir(part): bpb = BIOSParameterBlock.from_buffer(part) offset = root_offset(part) return part[offset:offset + bpb.max_root_entries * DirectoryEntry._FORMAT.size] def first_lfn_offset(part): offset = root_offset(part) with root_dir(part) as mem: for entry in DirectoryEntry.iter_over(mem): if entry.attr == 0xF: return offset offset += DirectoryEntry._FORMAT.size def test_bpb_from_buffer(mbr_disk): with DiskImage(mbr_disk) as img: bpb = BIOSParameterBlock.from_buffer(img.partitions[1].data) assert bpb.oem_name == b'mkfs.fat' assert bpb.bytes_per_sector == 512 assert bpb.sectors_per_cluster == 1 assert bpb.fat_count == 2 assert bpb.max_root_entries == 64 assert bpb.fat16_total_sectors == 8192 assert bpb.sectors_per_fat == 32 assert bpb.hidden_sectors == 0 def test_bpb_from_bytes(mbr_disk): with DiskImage(mbr_disk) as img: bpb1 = BIOSParameterBlock.from_buffer(img.partitions[1].data) bpb2 = BIOSParameterBlock.from_bytes( bytes(img.partitions[1].data[:BIOSParameterBlock._FORMAT.size])) assert bpb1 == bpb2 def test_bpb_to_bytes(mbr_disk): with DiskImage(mbr_disk) as img: bpb = BIOSParameterBlock.from_buffer(img.partitions[1].data) assert img.partitions[1].data[:BIOSParameterBlock._FORMAT.size] == bytes(bpb) def test_bpb_to_buffer(mbr_disk): with DiskImage(mbr_disk) as img: bpb = BIOSParameterBlock.from_buffer(img.partitions[1].data) buf1 = bytes(bpb) buf2 = bytearray(len(buf1)) bpb.to_buffer(buf2) assert buf1 == buf2 def test_ebpb_from_buffer(mbr_disk): with DiskImage(mbr_disk) as img: ebpb = ExtendedBIOSParameterBlock.from_buffer( img.partitions[1].data, offset=BIOSParameterBlock._FORMAT.size) assert ebpb.extended_boot_sig in (0x28, 0x29) assert ebpb.volume_label == b'NOBODD---16' assert ebpb.file_system == b'FAT16 ' def test_ebpb_from_bytes(mbr_disk): with DiskImage(mbr_disk) as img: ebpb1 = ExtendedBIOSParameterBlock.from_buffer( img.partitions[1].data, offset=BIOSParameterBlock._FORMAT.size) ebpb2 = ExtendedBIOSParameterBlock.from_bytes( img.partitions[1].data[ BIOSParameterBlock._FORMAT.size: BIOSParameterBlock._FORMAT.size + ExtendedBIOSParameterBlock._FORMAT.size]) assert ebpb1 == ebpb2 def test_ebpb_to_bytes(mbr_disk): with DiskImage(mbr_disk) as img: ebpb = ExtendedBIOSParameterBlock.from_buffer( img.partitions[1].data, offset=BIOSParameterBlock._FORMAT.size) assert img.partitions[1].data[ BIOSParameterBlock._FORMAT.size: BIOSParameterBlock._FORMAT.size + ExtendedBIOSParameterBlock._FORMAT.size] == bytes(ebpb) def test_ebpb_to_buffer(mbr_disk): with DiskImage(mbr_disk) as img: ebpb = ExtendedBIOSParameterBlock.from_buffer( img.partitions[1].data, offset=BIOSParameterBlock._FORMAT.size) buf1 = bytes(ebpb) buf2 = bytearray(len(buf1)) ebpb.to_buffer(buf2) assert buf1 == buf2 def test_fat32bpb_from_buffer(fat32_disk): with DiskImage(fat32_disk) as img: f32bpb = FAT32BIOSParameterBlock.from_buffer( img.partitions[1].data, offset=BIOSParameterBlock._FORMAT.size) assert f32bpb.sectors_per_fat == 126 assert f32bpb.version == 0 assert f32bpb.root_dir_cluster != 0 assert f32bpb.info_sector == 1 assert f32bpb.backup_sector == 6 def test_fat32bpb_from_bytes(fat32_disk): with DiskImage(fat32_disk) as img: f32bpb1 = FAT32BIOSParameterBlock.from_buffer( img.partitions[1].data, offset=BIOSParameterBlock._FORMAT.size) f32bpb2 = FAT32BIOSParameterBlock.from_bytes( img.partitions[1].data[ BIOSParameterBlock._FORMAT.size: BIOSParameterBlock._FORMAT.size + FAT32BIOSParameterBlock._FORMAT.size]) assert f32bpb1 == f32bpb2 def test_fat32bpb_to_bytes(fat32_disk): with DiskImage(fat32_disk) as img: f32bpb = FAT32BIOSParameterBlock.from_buffer( img.partitions[1].data, offset=BIOSParameterBlock._FORMAT.size) assert img.partitions[1].data[ BIOSParameterBlock._FORMAT.size: BIOSParameterBlock._FORMAT.size + FAT32BIOSParameterBlock._FORMAT.size] == bytes(f32bpb) def test_fat32bpb_to_buffer(fat32_disk): with DiskImage(fat32_disk) as img: f32bpb = FAT32BIOSParameterBlock.from_buffer( img.partitions[1].data, offset=BIOSParameterBlock._FORMAT.size) buf1 = bytes(f32bpb) buf2 = bytearray(len(buf1)) f32bpb.to_buffer(buf2) assert buf1 == buf2 def test_fat32info_from_buffer(fat32_disk): with DiskImage(fat32_disk) as img: f32bpb = FAT32BIOSParameterBlock.from_buffer( img.partitions[1].data, offset=BIOSParameterBlock._FORMAT.size) f32info = FAT32InfoSector.from_buffer( img.partitions[1].data, offset=f32bpb.info_sector * 512) assert f32info.sig1 == b'RRaA' assert f32info.sig2 == b'rrAa' assert f32info.sig3 == b'\0\0\x55\xAA' def test_fat32info_from_bytes(fat32_disk): with DiskImage(fat32_disk) as img: f32bpb = FAT32BIOSParameterBlock.from_buffer( img.partitions[1].data, offset=BIOSParameterBlock._FORMAT.size) f32info1 = FAT32InfoSector.from_buffer( img.partitions[1].data, offset=f32bpb.info_sector * 512) f32info2 = FAT32InfoSector.from_bytes( img.partitions[1].data[ f32bpb.info_sector * 512: f32bpb.info_sector * 512 + FAT32InfoSector._FORMAT.size]) assert f32info1 == f32info2 def test_fat32info_to_bytes(fat32_disk): with DiskImage(fat32_disk) as img: f32bpb = FAT32BIOSParameterBlock.from_buffer( img.partitions[1].data, offset=BIOSParameterBlock._FORMAT.size) f32info = FAT32InfoSector.from_buffer( img.partitions[1].data, offset=f32bpb.info_sector * 512) assert img.partitions[1].data[ f32bpb.info_sector * 512: f32bpb.info_sector * 512 + FAT32InfoSector._FORMAT.size] == bytes(f32info) def test_fat32info_to_buffer(fat32_disk): with DiskImage(fat32_disk) as img: f32bpb = FAT32BIOSParameterBlock.from_buffer( img.partitions[1].data, offset=BIOSParameterBlock._FORMAT.size) f32info = FAT32InfoSector.from_buffer( img.partitions[1].data, offset=f32bpb.info_sector * 512) buf1 = bytes(img.partitions[1].data[ f32bpb.info_sector * 512: f32bpb.info_sector * 512 + FAT32InfoSector._FORMAT.size]) buf2 = bytearray(len(buf1)) f32info.to_buffer(buf2) assert buf1 == buf2 def test_direntry_from_buffer(gpt_disk): with DiskImage(gpt_disk) as img: offset = root_offset(img.partitions[1].data) # First root entry is the volume ID volid = DirectoryEntry.from_buffer(img.partitions[1].data, offset) assert volid.filename + volid.ext == b'NOBODD---16' assert volid.attr == 8 assert volid.attr2 == 0 assert volid.first_cluster_lo == 0 assert volid.first_cluster_hi == 0 assert volid.size == 0 def test_direntry_from_bytes(gpt_disk): with DiskImage(gpt_disk) as img: offset = root_offset(img.partitions[1].data) dirent1 = DirectoryEntry.from_buffer(img.partitions[1].data, offset) dirent2 = DirectoryEntry.from_bytes( img.partitions[1].data[offset:offset + DirectoryEntry._FORMAT.size]) assert dirent1 == dirent2 def test_direntry_to_bytes(gpt_disk): with DiskImage(gpt_disk) as img: offset = root_offset(img.partitions[1].data) dirent = DirectoryEntry.from_buffer(img.partitions[1].data, offset) assert img.partitions[1].data[ offset:offset + DirectoryEntry._FORMAT.size] == bytes(dirent) def test_direntry_to_buffer(gpt_disk): with DiskImage(gpt_disk) as img: offset = root_offset(img.partitions[1].data) dirent = DirectoryEntry.from_buffer(img.partitions[1].data, offset) buf1 = bytes(img.partitions[1].data[ offset:offset + DirectoryEntry._FORMAT.size]) buf2 = bytearray(len(buf1)) dirent.to_buffer(buf2) assert buf1 == buf2 def test_direntry_eof(): # These are the only things that really matter in an EOF dir entry assert DirectoryEntry.eof().filename[0] == 0 assert DirectoryEntry.eof().attr == 0 def test_direntry_iter(gpt_disk): with DiskImage(gpt_disk) as img: bpb = BIOSParameterBlock.from_buffer(img.partitions[1].data) with root_dir(img.partitions[1].data) as dir_mem: entries = list(DirectoryEntry.iter_over(dir_mem)) assert len(entries) == bpb.max_root_entries assert entries[0].filename + entries[0].ext == b'NOBODD---16' assert entries[0].attr == 8 def test_lfnentry_from_buffer(gpt_disk): with DiskImage(gpt_disk) as img: lfn = LongFilenameEntry.from_buffer( img.partitions[1].data, offset=first_lfn_offset(img.partitions[1].data)) assert lfn.sequence == 0x41 # terminal, part 1 assert (lfn.name_1 + lfn.name_2 + lfn.name_3).decode('utf-16le') == 'lots-of-zeros' def test_lfnentry_from_bytes(gpt_disk): with DiskImage(gpt_disk) as img: offset = first_lfn_offset(img.partitions[1].data) lfn = LongFilenameEntry.from_bytes( img.partitions[1].data[offset:offset + LongFilenameEntry._FORMAT.size]) assert lfn.sequence == 0x41 # terminal, part 1 assert (lfn.name_1 + lfn.name_2 + lfn.name_3).decode('utf-16le') == 'lots-of-zeros' def test_lfnentry_to_bytes(gpt_disk): with DiskImage(gpt_disk) as img: offset = first_lfn_offset(img.partitions[1].data) lfn = LongFilenameEntry.from_buffer(img.partitions[1].data, offset=offset) assert img.partitions[1].data[ offset:offset + LongFilenameEntry._FORMAT.size] == bytes(lfn) def test_lfnentry_to_buffer(gpt_disk): with DiskImage(gpt_disk) as img: lfn = LongFilenameEntry.from_buffer( img.partitions[1].data, offset=first_lfn_offset(img.partitions[1].data)) buf1 = bytes(lfn) buf2 = bytearray(len(buf1)) lfn.to_buffer(buf2) assert buf1 == buf2 def test_lfnentry_iter(gpt_disk): with DiskImage(gpt_disk) as img: bpb = BIOSParameterBlock.from_buffer(img.partitions[1].data) with root_dir(img.partitions[1].data) as dir_mem: entries = list(LongFilenameEntry.iter_over(dir_mem)) assert len(entries) == bpb.max_root_entries assert any(entry.attr == 0xF for entry in entries) def test_lfn_checksum(): assert lfn_checksum(b' ', b' ') == 247 assert lfn_checksum(b'FOO ', b'BAR') == 83 def test_lfn_valid(): assert lfn_valid('foo.bar baz') assert lfn_valid('123 föo') assert not lfn_valid('') assert not lfn_valid('foo*') nobodd-0.4/tests/test_fs.py000066400000000000000000001564721457216553300160370ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2024 Dave Jones # Copyright (c) 2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import io import mmap import errno import struct import pytest from nobodd.fat import ( BIOSParameterBlock, ExtendedBIOSParameterBlock, lfn_valid, lfn_checksum, ) from nobodd.disk import DiskImage from nobodd.fs import * @pytest.fixture(params=(False, True)) def with_fsinfo(request, fat32_disk_w): with DiskImage(fat32_disk_w, access=mmap.ACCESS_WRITE) as img: with img.partitions[1].data as part: bpb = BIOSParameterBlock.from_buffer(part) f32bpb = FAT32BIOSParameterBlock.from_buffer( part, offset=BIOSParameterBlock._FORMAT.size) info_offset = ( f32bpb.info_sector * bpb.bytes_per_sector) info = FAT32InfoSector.from_buffer(part, offset=info_offset) if not request.param: info._replace(sig1=b'EPIC', sig2=b'FAIL').to_buffer( part, offset=info_offset) yield request.param def dir_eof(fat_dir): for offset, entries in fat_dir._group_entries(): pass return offset + DirectoryEntry._FORMAT.size def first_dir(fat_dir): for offset, entries in fat_dir._group_entries(): if entries[-1].attr & 0x10: return offset, entries assert False, 'failed to find dir' def find_lfn_file(fat_dir): for offset, entries in fat_dir._group_entries(): if len(entries) > 1 and not entries[-1].attr & 0x10: return offset, entries assert False, 'failed to find file with LFN' def find_non_lfn_file(fat_dir): for offset, entries in fat_dir._group_entries(): if len(entries) == 1 and not entries[-1].attr & 0x10: return offset, entries assert False, 'failed to find file without LFN' def find_empty_file(fat_dir): for offset, entries in fat_dir._group_entries(): if entries[-1].size == 0 and not entries[-1].attr & 0x10: return offset, entries assert False, 'failed to find non-empty file' def find_non_empty_file(fat_dir): for offset, entries in fat_dir._group_entries(): if entries[-1].size > 0: return offset, entries assert False, 'failed to find non-empty file' def test_fs_init(fat12_disk, fat16_disk, fat32_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: assert fs.fat_type == 'fat12' assert fs.label == 'NOBODD---12' assert fs.sfn_encoding == 'iso-8859-1' assert not fs.atime with DiskImage(fat16_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: assert fs.fat_type == 'fat16' assert fs.label == 'NOBODD---16' assert fs.sfn_encoding == 'iso-8859-1' assert not fs.atime with DiskImage(fat32_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: assert fs.fat_type == 'fat32' assert fs.label == 'NOBODD---32' assert fs.sfn_encoding == 'iso-8859-1' assert not fs.atime def test_fs_init_bad(fat16_disk_w): # The bad/dirty flags are present on FAT16/32 only, hence using the larger # disk image here with DiskImage(fat16_disk_w, access=mmap.ACCESS_WRITE) as img: with FatFileSystem(img.partitions[1].data) as fs: fs.fat[1] = 0x7FFF with pytest.warns(DirtyFileSystem): with FatFileSystem(img.partitions[1].data) as fs: fs.fat[1] = 0xBFFF with pytest.warns(DamagedFileSystem): with FatFileSystem(img.partitions[1].data) as fs: pass def test_ambiguous_headers_fat12(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with img.partitions[1].data as part: ebpb = ExtendedBIOSParameterBlock.from_buffer( part, offset=BIOSParameterBlock._FORMAT.size) # It's FAT Jim, but not as we know it ... ebpb._replace(file_system=b'FAT ').to_buffer( part, offset=BIOSParameterBlock._FORMAT.size) with FatFileSystem(img.partitions[1].data) as fs: assert fs.fat_type == 'fat12' def test_ambiguous_headers_fat32(fat32_disk): with DiskImage(fat32_disk, access=mmap.ACCESS_COPY) as img: with img.partitions[1].data as part: bpb = BIOSParameterBlock.from_buffer(part) f32bpb = FAT32BIOSParameterBlock.from_buffer( part, offset=BIOSParameterBlock._FORMAT.size) ebpb = ExtendedBIOSParameterBlock.from_buffer( part, offset=BIOSParameterBlock._FORMAT.size + FAT32BIOSParameterBlock._FORMAT.size) # Pretend we've got a normal number of sectors for FAT32 (the test # image is deliberately undersized for efficiency), and that the # file-system label is ambiguous bpb._replace( fat16_total_sectors=0, fat32_total_sectors=128000).to_buffer(part) ebpb._replace(file_system=b'FAT ').to_buffer( part, offset=BIOSParameterBlock._FORMAT.size + FAT32BIOSParameterBlock._FORMAT.size) with FatFileSystem(img.partitions[1].data) as fs: assert fs.fat_type == 'fat32' def test_ambiguous_headers_huge_fat32(fat32_disk): with DiskImage(fat32_disk, access=mmap.ACCESS_COPY) as img: with img.partitions[1].data as part: bpb = BIOSParameterBlock.from_buffer(part) f32bpb = FAT32BIOSParameterBlock.from_buffer( part, offset=BIOSParameterBlock._FORMAT.size) ebpb = ExtendedBIOSParameterBlock.from_buffer( part, offset=BIOSParameterBlock._FORMAT.size + FAT32BIOSParameterBlock._FORMAT.size) # Pretend we've got a normal number of sectors for FAT32 (the test # image is deliberately undersized for efficiency), and that the # file-system label is ambiguous bpb._replace( fat16_total_sectors=0, fat32_total_sectors=0).to_buffer(part) ebpb._replace(file_system=struct.pack('" def test_fs_close_idempotent(fat12_disk): with DiskImage(fat12_disk) as img: fs = FatFileSystem(img.partitions[1].data) fs.close() fs.close() assert fs._fat is None assert fs._data is None assert fs._root is None def test_fs_readonly(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_READ) as img: with FatFileSystem(img.partitions[1].data) as fs: assert fs.readonly with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: assert not fs.readonly def test_fs_opendir(fat12_disk, fat16_disk, fat32_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: assert isinstance(fs.open_dir(0), FatDirectory) with DiskImage(fat16_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: assert isinstance(fs.open_dir(0), FatDirectory) with DiskImage(fat32_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: assert isinstance(fs.open_dir(0), FatDirectory) with DiskImage(fat32_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: # Cheating by using the root-dir cluster as a sub-directory assert isinstance(fs.open_dir(fs._root), FatSubDirectory) def test_fs_open_file(fat32_disk): with DiskImage(fat32_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: # In FAT32, the root-dir is a sub-directory; opening the root # cluster as a file gets us the "file" underlying the root dir with fs.open_file(fs._root) as f: assert isinstance(f, FatFile) def test_fs_open_entry(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: index = fs.open_dir(0) for entry in index.values(): if entry.attr == 0x20: # archive bit only with fs.open_entry(index, entry) as f: assert isinstance(f, FatFile) break else: assert False, 'No file entries found in root' def test_fs_root(fat12_disk, fat16_disk, fat32_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: assert isinstance(fs.root, FatPath) with DiskImage(fat16_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: assert isinstance(fs.root, FatPath) with DiskImage(fat32_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: assert isinstance(fs.root, FatPath) def test_fattable_close_idempotent(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: with fs._fat as tab: assert len(tab._tables) == 2 assert not fs._fat._tables fs._fat.close() assert not fs._fat._tables def test_fattable_free(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: for cluster in fs._fat.free(): assert cluster > 1 break with pytest.raises(OSError) as err: for cluster in fs._fat.free(): pass assert err.value.errno == errno.ENOSPC def test_fattable_free_fat32(fat32_disk, with_fsinfo): with DiskImage(fat32_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: for cluster in fs._fat.free(): assert cluster > 1 break with pytest.raises(OSError) as err: for cluster in fs._fat.free(): pass assert err.value.errno == errno.ENOSPC def test_fattable_free_fat32_bad_last_alloc(fat32_disk_w): # When FSINFO's last_alloc is invalid, test we just fall back to scanning # sequentially with DiskImage(fat32_disk_w, access=mmap.ACCESS_COPY) as img: with img.partitions[1].data as part: bpb = BIOSParameterBlock.from_buffer(part) f32bpb = FAT32BIOSParameterBlock.from_buffer( part, offset=BIOSParameterBlock._FORMAT.size) info_offset = ( f32bpb.info_sector * bpb.bytes_per_sector) info = FAT32InfoSector.from_buffer(part, offset=info_offset) info._replace(last_alloc=0).to_buffer(part, offset=info_offset) with FatFileSystem(img.partitions[1].data) as fs: for cluster in fs._fat.free(): assert cluster > 1 break with pytest.raises(OSError) as err: for cluster in fs._fat.free(): pass assert err.value.errno == errno.ENOSPC # When FSINFO's last_alloc+1 is allocated, test we skip it (this isn't # really "bad", more inconvenient) with DiskImage(fat32_disk_w, access=mmap.ACCESS_WRITE) as img: with FatFileSystem(img.partitions[1].data) as fs: info = fs._fat._info fs._fat._info = None fs.fat.mark_end(info.last_alloc + 1) with FatFileSystem(img.partitions[1].data) as fs: for cluster in fs._fat.free(): assert cluster > info.last_alloc + 1 break def test_fattable_free_too_large(): # FAT table can exceed max_valid clusters for the purposes of filling a # sector; this ensures we do not yield clusters out of range fat_table = bytearray(6144) fat_table[:6114] = b'\xFF' * 6114 with Fat12Table(memoryview(fat_table), len(fat_table)) as tbl: with pytest.raises(OSError) as err: for cluster in tbl.free(): assert tbl.min_valid <= cluster <= tbl.max_valid assert err.value.errno == errno.ENOSPC def test_fat12table_sequence(fat12_disk): with DiskImage(fat12_disk) as img: with img.partitions[1].data as part: bpb = BIOSParameterBlock.from_buffer(part) with FatFileSystem(img.partitions[1].data) as fs: assert fs._fat.readonly == fs.readonly assert len(fs._fat) == ( bpb.sectors_per_fat * bpb.bytes_per_sector // 1.5) assert fs._fat[0] > fs._fat.max_valid assert fs._fat[1] > fs._fat.max_valid first = fs._fat[0] second = fs._fat[1] assert all(c == first for c in fs._fat.get_all(0)) assert all(c == second for c in fs._fat.get_all(1)) with pytest.raises(TypeError): del fs._fat[0] with pytest.raises(TypeError): fs._fat.insert(0, 0) with pytest.raises(IndexError): fs._fat[4000000000] with pytest.raises(IndexError): fs._fat.get_all(4000000000) def test_fat12table_mutate(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: # Given FAT-12 crosses three-bytes for every two values, it's # important to check adjacent values aren't affected by mutation save2 = fs._fat[2] save3 = fs._fat[3] fs._fat[2] = 3 assert fs._fat[2] == 3 assert fs._fat[3] == save3 fs._fat.mark_end(3) assert fs._fat[2] == 3 assert fs._fat[3] == fs._fat.end_mark fs._fat.mark_free(2) assert fs._fat[2] == 0 assert fs._fat[3] == fs._fat.end_mark assert fs._fat.get_all(2) == (0, 0) assert fs._fat.get_all(3) == (fs._fat.end_mark, fs._fat.end_mark) with pytest.raises(ValueError): fs._fat[0] = 0xFFFF with pytest.raises(IndexError): fs._fat[4000000000] = 2 def test_fat16table_sequence(fat16_disk): with DiskImage(fat16_disk) as img: with img.partitions[1].data as part: bpb = BIOSParameterBlock.from_buffer(part) with FatFileSystem(img.partitions[1].data) as fs: assert fs._fat.readonly == fs.readonly assert len(fs._fat) == ( bpb.sectors_per_fat * bpb.bytes_per_sector // 2) assert fs._fat[0] > fs._fat.max_valid assert fs._fat[1] > fs._fat.max_valid first = fs._fat[0] second = fs._fat[1] assert all(c == first for c in fs._fat.get_all(0)) assert all(c == second for c in fs._fat.get_all(1)) with pytest.raises(TypeError): del fs._fat[0] with pytest.raises(TypeError): fs._fat.insert(0, 0) with pytest.raises(IndexError): fs._fat[4000000000] with pytest.raises(IndexError): fs._fat.get_all(4000000000) def test_fat16table_mutate(fat16_disk): with DiskImage(fat16_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: fs._fat[2] = 3 assert fs._fat[2] == 3 fs._fat.mark_end(3) assert fs._fat[3] == fs._fat.end_mark fs._fat.mark_free(2) assert fs._fat[2] == 0 assert fs._fat.get_all(2) == (0, 0) assert fs._fat.get_all(3) == (fs._fat.end_mark, fs._fat.end_mark) with pytest.raises(ValueError): fs._fat[0] = 0xFFFFFF with pytest.raises(IndexError): fs._fat[4000000000] = 2 def test_fat32table_sequence(fat32_disk, with_fsinfo): with DiskImage(fat32_disk) as img: with img.partitions[1].data as part: bpb = BIOSParameterBlock.from_buffer(part) f32bpb = FAT32BIOSParameterBlock.from_buffer( part, offset=BIOSParameterBlock._FORMAT.size) with FatFileSystem(img.partitions[1].data) as fs: assert fs._fat.readonly == fs.readonly assert len(fs._fat) == ( f32bpb.sectors_per_fat * bpb.bytes_per_sector // 4) assert fs._fat[0] > fs._fat.max_valid assert fs._fat[1] > fs._fat.max_valid first = fs._fat[0] second = fs._fat[1] assert all(c == first for c in fs._fat.get_all(0)) assert all(c == second for c in fs._fat.get_all(1)) with pytest.raises(TypeError): del fs._fat[0] with pytest.raises(TypeError): fs._fat.insert(0, 0) with pytest.raises(IndexError): fs._fat[4000000000] with pytest.raises(IndexError): fs._fat.get_all(4000000000) def test_fat32table_mutate(fat32_disk, with_fsinfo): with DiskImage(fat32_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: fs._fat[2] = 3 assert fs._fat[2] == 3 fs._fat.mark_end(3) assert fs._fat[3] == fs._fat.end_mark if with_fsinfo: save_free = fs._fat._info.free_clusters fs._fat.mark_free(2) assert fs._fat[2] == 0 if with_fsinfo: assert fs._fat._info.free_clusters == save_free + 1 assert fs._fat.get_all(2) == (0, 0) assert fs._fat.get_all(3) == (fs._fat.end_mark, fs._fat.end_mark) # Have to be sure we both de-allocate and re-allocate a cluster to # test FAT32's info sector manipulation fs._fat[2] = 3 assert fs._fat[2] == 3 if with_fsinfo: assert fs._fat._info.free_clusters == save_free assert fs._fat._info.last_alloc == 2 with pytest.raises(ValueError): fs._fat[0] = 0xFFFFFFFF with pytest.raises(IndexError): fs._fat[4000000000] = 2 def test_fat32table_alloc_bad(fat32_disk): # Ignore FSINFO block's free_clusters when allocating and it says there # are 0 free clusters left with DiskImage(fat32_disk, access=mmap.ACCESS_COPY) as img: with img.partitions[1].data as part: bpb = BIOSParameterBlock.from_buffer(part) f32bpb = FAT32BIOSParameterBlock.from_buffer( part, offset=BIOSParameterBlock._FORMAT.size) info_offset = ( f32bpb.info_sector * bpb.bytes_per_sector) info = FAT32InfoSector.from_buffer(part, offset=info_offset) info._replace(free_clusters=0).to_buffer(part, offset=info_offset) with FatFileSystem(img.partitions[1].data) as fs: assert fs._fat._info.free_clusters == 0 fs.fat.mark_end(next(fs.fat.free())) assert fs._fat._info.free_clusters == 0 # ... and likewise when deallocating; ignore bad info that says # everything's free with DiskImage(fat32_disk, access=mmap.ACCESS_COPY) as img: with img.partitions[1].data as part, FatFileSystem(part) as fs: bpb = BIOSParameterBlock.from_buffer(part) f32bpb = FAT32BIOSParameterBlock.from_buffer( part, offset=BIOSParameterBlock._FORMAT.size) info_offset = ( f32bpb.info_sector * bpb.bytes_per_sector) info = FAT32InfoSector.from_buffer(part, offset=info_offset) info._replace(free_clusters=len(fs.fat)).to_buffer( part, offset=info_offset) with FatFileSystem(img.partitions[1].data) as fs: assert fs._fat._info.free_clusters == len(fs.fat) for cluster, value in enumerate(fs.fat): if cluster >= 2 and value: fs.fat.mark_free(cluster) break assert fs._fat._info.free_clusters == len(fs.fat) def test_fatclusters_close_idempotent(fat12_disk): with DiskImage(fat12_disk) as img: with img.partitions[1].data as part: bpb = BIOSParameterBlock.from_buffer(part) with FatFileSystem(img.partitions[1].data) as fs: with fs._data as data: assert data._cs == bpb.sectors_per_cluster * bpb.bytes_per_sector assert not fs._data._mem fs._data.close() assert not fs._data._mem def test_fatclusters_sequence(fat12_disk): with DiskImage(fat12_disk) as img: with img.partitions[1].data as part: bpb = BIOSParameterBlock.from_buffer(part) with FatFileSystem(img.partitions[1].data) as fs: data_clusters = ( bpb.fat16_total_sectors - bpb.reserved_sectors # fat_sectors - (bpb.fat_count * bpb.sectors_per_fat) # root_sectors - (bpb.max_root_entries * DirectoryEntry._FORMAT.size // bpb.bytes_per_sector) ) // bpb.sectors_per_cluster assert len(fs._data) == data_clusters assert len(fs._data[2]) == fs._data._cs assert len(fs._data[data_clusters - 1]) == fs._data._cs with pytest.raises(IndexError): fs._data[0] with pytest.raises(IndexError): fs._data[1] with pytest.raises(IndexError): fs._data[data_clusters + 2] def test_fatclusters_mutate(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: zeros = b'\0' * fs._data._cs ones = b'\xff' * fs._data._cs fs._data[2] = zeros assert fs._data[2] == zeros fs._data[2] = ones assert fs._data[2] == ones with pytest.raises(IndexError): fs._data[0] = zeros with pytest.raises(IndexError): fs._data[1] = zeros with pytest.raises(IndexError): fs._data[len(fs._data) + 2] = zeros with pytest.raises(TypeError): del fs._data[2] with pytest.raises(TypeError): fs._data.insert(2, zeros) def test_fatdirectory_mapping(fat_disks): for fat_disk in fat_disks.values(): with DiskImage(fat_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) # Length, contains, iter, and all mapping views assert len(root) > 0 assert 'empty' in root assert 'EMPTY' in root assert 'lots-of-zeros' in root assert 'LOTS-O~1' in root assert root['empty'] == root['EMPTY'] assert root['lots-of-zeros'] == root['LOTS-O~1'] assert 'lots-of-ones' not in root with pytest.raises(KeyError): root['lots-of-ones'] for name1, entry1, (name2, entry2) in zip( root, root.values(), root.items() ): assert lfn_valid(name1) assert name1 == name2 assert isinstance(entry1, DirectoryEntry) assert entry1 == entry2 assert ( len(list(root)) == len(list(root.values())) == len(list(root.items())) ) def test_fatdirectory_no_update_atime(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data, atime=True) as fs: root = fs.open_dir(0) offsets, entries = first_dir(root) subdir = fs.open_dir(get_cluster(entries[-1], fs.fat_type)) # There is no atime to update because there's no _entry in the # subdir's underlying file, but we hit it for coverage anyway assert len(subdir) > 0 def test_fatdirectory_iter_all(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) offset, entries = first_dir(root) *entries, entry = entries subdir = fs.open_dir(get_cluster(entry, fs.fat_type)) # Check _iter_entries returns *everything* in the underlying medium # whether that's a memory block or a file assert sum(1 for e in root._iter_entries()) == ( len(root._mem) // DirectoryEntry._FORMAT.size) assert sum(1 for e in subdir._iter_entries()) == ( subdir._file.seek(0, io.SEEK_END) // DirectoryEntry._FORMAT.size) def test_fatdirectory_mutate_out_of_range(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) empty = root['empty'] with pytest.raises(OSError): root._update_entry(128000, empty) def test_fatdirectory_mutate(fat_disks): for fat_disk in fat_disks.values(): with DiskImage(fat_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) # Append, overwrite, and delete of simple SFN entries l = len(root) empty = root['empty'] touched = empty._replace(adate=empty.adate + 1) root['empty'] = touched assert root['empty'] == touched root['empty2'] = touched assert 'empty2' in root assert root['empty'] == root['empty2']._replace(filename=b'EMPTY ') assert len(root) == l + 1 del root['empty'] assert 'empty' not in root assert len(root) == l # Cover deletion of LFN entries too assert 'lots-of-zeros' in root del root['lots-of-zeros'] assert 'lots-of-zeros' not in root assert len(root) == l - 1 with pytest.raises(KeyError): del root['i-dont-exist'] def test_fatsubdirectory_mutate(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) offset, entries = first_dir(root) *entries, entry = entries subdir = fs.open_dir(get_cluster(entry, fs.fat_type)) map_len = len(subdir._file._map) assert subdir._file._entry is None # Write at least enough new entries to guarantee the underlying # sub-dir file requires a new cluster for i in range(fs.clusters.size // DirectoryEntry._FORMAT.size): subdir[f'FOO-{i:03d}'] = DirectoryEntry( filename=f'FOO-{i:03d}'.encode('ascii'), ext=b'DAT', attr=0x20, attr2=0, cdate=0, ctime=0, ctime_cs=0, mdate=0, mtime=0, adate=0, first_cluster_hi=0, first_cluster_lo=0, size=0) assert subdir._file._entry is None assert len(subdir._file._map) > map_len def test_fatdirectory_split_entries(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) offset, entries = find_lfn_file(root) # Filename with mod 13 chars (no \0 terminator) lfn, sfn, _ = root._split_entries(entries) assert (lfn, sfn) == ('lots-of-zeros', 'LOTS-O~1') # Filenames with ! mod 13 chars lfn, sfn, _ = root._split_entries([ LongFilenameEntry( sequence=0x41, name_1=b'a\0b\0c\0d\0e\0', attr=0xF, checksum=0xCA, name_2=b'f\0g\0h\0i\0j\0k\0', first_cluster=0, name_3=b'l\0\0\0'), entries[-1]._replace( filename=b'ABCDEF~1', ext=b' ') ]) assert (lfn, sfn) == ('abcdefghijkl', 'ABCDEF~1') def test_fatdirectory_prefix_entries(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) offset, entries = find_non_lfn_file(root) lfn, sfn, entry = root._split_entries(entries) assert (lfn, sfn) == ('cmdline.txt', 'CMDLINE.TXT') # Ensure we don't generate LFNs when unnecessary assert root._prefix_entries('cmdline.txt', entry) == entries # Short filenames with extension and variety of cases cksum = lfn_checksum(b'CMDLINE ', b'TXT') assert root._prefix_entries('CMDLINE.TXT', entry) == [ entry._replace(filename=b'CMDLINE ', ext=b'TXT', attr2=0) ] assert root._prefix_entries('cmdline.txt', entry) == [ entry._replace(filename=b'CMDLINE ', ext=b'TXT', attr2=0b11000) ] assert root._prefix_entries('CMDLINE.txt', entry) == [ entry._replace(filename=b'CMDLINE ', ext=b'TXT', attr2=0b10000) ] assert root._prefix_entries('cmdline.TXT', entry) == [ entry._replace(filename=b'CMDLINE ', ext=b'TXT', attr2=0b1000) ] # Short filename with mixed case, demanding LFN cksum = lfn_checksum(b'CMDLIN~1', b'TXT') assert root._prefix_entries('CmdLine.Txt', entry) == [ LongFilenameEntry( sequence=0x41, name_1=b'C\0m\0d\0L\0i\0', attr=0xF, checksum=cksum, name_2=b'n\0e\0.\0T\0x\0t\0', first_cluster=0, name_3=b'\0\0\xFF\xFF', ), entry._replace(filename=b'CMDLIN~1', ext=b'TXT', attr2=0) ] # "Special" . and .. entries assert root._prefix_entries('.', entry) == [ entry._replace(filename=b'. ', ext=b' ', attr2=0) ] assert root._prefix_entries('..', entry) == [ entry._replace(filename=b'.. ', ext=b' ', attr2=0) ] # Filename with mod 13 chars (no \0 terminator) cksum = lfn_checksum(b'ABCDEF~1', b' ') assert root._prefix_entries('abcdefghijklmnopqrstuvwxyz', entry) == [ LongFilenameEntry( sequence=0x42, name_1=b'n\0o\0p\0q\0r\0', attr=0xF, checksum=cksum, name_2=b's\0t\0u\0v\0w\0x\0', first_cluster=0, name_3=b'y\0z\0', ), LongFilenameEntry( sequence=0x01, name_1=b'a\0b\0c\0d\0e\0', attr=0xF, checksum=cksum, name_2=b'f\0g\0h\0i\0j\0k\0', first_cluster=0, name_3=b'l\0m\0', ), entry._replace( filename=b'ABCDEF~1', ext=b' ', attr2=0, ), ] # Filename with !mod 13 chars (adds \0 terminator and padding) cksum = lfn_checksum(b'ABCDEF~1', b' ') assert root._prefix_entries('abcdefghijklmnopqrstuvw', entry) == [ LongFilenameEntry( sequence=0x42, name_1=b'n\0o\0p\0q\0r\0', attr=0xF, checksum=cksum, name_2=b's\0t\0u\0v\0w\0\0\0', first_cluster=0, name_3=b'\xff' * 4, ), LongFilenameEntry( sequence=0x01, name_1=b'a\0b\0c\0d\0e\0', attr=0xF, checksum=cksum, name_2=b'f\0g\0h\0i\0j\0k\0', first_cluster=0, name_3=b'l\0m\0', ), entry._replace( filename=b'ABCDEF~1', ext=b' ', attr2=0, ), ] # Excessive length with pytest.raises(ValueError): root._prefix_entries('foo' * 255, entry) def test_fatdirectory_bad_lfn(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) offset, entries = find_lfn_file(root) # Blank LFN with pytest.warns(BadLongFilename): lfn, sfn, _ = root._split_entries([ entries[0]._replace( name_1=b'\xFF' * 10, name_2=b'\xFF' * 12, name_3=b'\xFF' * 4), entries[-1]]) assert (lfn, sfn) == ('LOTS-O~1', 'LOTS-O~1') # Bad first_cluster with pytest.warns(BadLongFilename): lfn, sfn, _ = root._split_entries([ entries[0]._replace(first_cluster=1), entries[-1]]) assert (lfn, sfn) == ('LOTS-O~1', 'LOTS-O~1') # Bad checksum with pytest.warns(OrphanedLongFilename): lfn, sfn, _ = root._split_entries([ entries[0]._replace(checksum=0xFF), entries[-1]]) assert (lfn, sfn) == ('LOTS-O~1', 'LOTS-O~1') # Repeated terminal entry with pytest.warns(OrphanedLongFilename): lfn, sfn, _ = root._split_entries([ entries[0]._replace(sequence=0x43), entries[0]._replace(sequence=0x02), entries[0], entries[-1]]) assert (lfn, sfn) == ('lots-of-zeros', 'LOTS-O~1') # Bad sequence number with pytest.warns(BadLongFilename): lfn, sfn, _ = root._split_entries([ entries[0]._replace(sequence=0x40), entries[-1]]) assert (lfn, sfn) == ('LOTS-O~1', 'LOTS-O~1') # More bad sequence numbers with pytest.warns(OrphanedLongFilename): lfn, sfn, _ = root._split_entries([ entries[0]._replace(sequence=0x42), entries[0]._replace(sequence=0x03), entries[-1]]) assert (lfn, sfn) == ('LOTS-O~1', 'LOTS-O~1') # More entries after last with pytest.warns(OrphanedLongFilename): lfn, sfn, _ = root._split_entries([ entries[0], entries[0], entries[0], entries[-1]]) assert (lfn, sfn) == ('lots-of-zeros', 'LOTS-O~1') # Missing entries with pytest.warns(OrphanedLongFilename): lfn, sfn, _ = root._split_entries([ entries[0]._replace(sequence=0x43), entries[0]._replace(sequence=0x02), entries[-1]]) assert (lfn, sfn) == ('LOTS-O~1', 'LOTS-O~1') def test_fatdirectory_ignores_deleted(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) del_offset, entries = find_lfn_file(root) # Mark lots-of-zeros as deleted entries[0] = entries[0]._replace(sequence=0xE5) entries[1] = entries[1]._replace( filename=b'\xE5' + entries[1].filename[1:]) root._update_entry( del_offset - DirectoryEntry._FORMAT.size, entries[0]) root._update_entry( del_offset, entries[1]) # Ensure _group_entries never yields the offsets we deleted for offset, entries in root._group_entries(): assert offset != del_offset def test_fatdirectory_group_no_eof(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) # Lop off the terminal EOF record and check we still enumerate the # same results tbl = bytearray(root._mem[:dir_eof(root)]) fakeroot = Fat12Root(tbl, fs.sfn_encoding) assert list(root._group_entries()) == list(fakeroot._group_entries()) def test_fatdirectory_clean(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) eof_offset = dir_eof(root) del_offset, entries = find_lfn_file(root) # Mark lots-of-zeros as deleted entries[0] = entries[0]._replace(sequence=0xE5) entries[1] = entries[1]._replace( filename=b'\xE5' + entries[1].filename[1:]) root._update_entry( del_offset - DirectoryEntry._FORMAT.size, entries[0]) root._update_entry( del_offset, entries[1]) # Offsets may change after clean, but not entries before_entries = [e for offset, e in root._group_entries()] new_eof = root._clean_entries() after_entries = [e for offset, e in root._group_entries()] assert before_entries == after_entries assert dir_eof(root) == new_eof def test_fatdirectory_clean_no_eof(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) # Create a directory that has no EOF record (technically invalid) # and ensure clean still works tbl = bytearray(root._mem[:dir_eof(root)]) fakeroot = Fat12Root(tbl, fs.sfn_encoding) # Mark empty as deleted del_offset, entries = find_empty_file(fakeroot) entries[0] = entries[0]._replace( filename=b'\xE5' + entries[0].filename[1:]) fakeroot._update_entry(del_offset, entries[0]) # Offsets may change after clean, but not entries before_entries = [e for offset, e in fakeroot._group_entries()] new_eof = fakeroot._clean_entries() after_entries = [e for offset, e in fakeroot._group_entries()] assert before_entries == after_entries assert dir_eof(fakeroot) == new_eof def test_fatdirectory_clean_when_out_of_space(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) # As above, create a directory structure that's entirely full and # has no EOF (technically invalid) with one deleted entry. Attempt # to set a new entry, and make sure clean runs and the subsequent # write succeeds tbl = bytearray(root._mem[:dir_eof(root)]) fakeroot = Fat12Root(tbl, fs.sfn_encoding) # Mark lots-of-zeros as deleted (need two entries to delete so we # can replace them with one and the EOF; if we can't write the EOF # we'll, correctly, raise ENOSPC) del_offset, entries = find_lfn_file(fakeroot) entries[0] = entries[0]._replace(sequence=0xE5) entries[1] = entries[1]._replace( filename=b'\xE5' + entries[1].filename[1:]) fakeroot._update_entry( del_offset - DirectoryEntry._FORMAT.size, entries[0]) fakeroot._update_entry( del_offset, entries[1]) # Offsets may change after clean, but not entries assert 'FOO.BAR' not in fakeroot fakeroot['FOO.BAR'] = entries[1]._replace(filename=b'FOO', ext=b'BAR') assert 'FOO.BAR' in fakeroot def test_fatdirectory_really_out_of_space(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) # As above, create a directory structure that's entirely full and # has no EOF (technically invalid) with one deleted entry. Attempt # to set a new entry, and make sure clean runs and the subsequent # write succeeds tbl = bytearray(root._mem[:dir_eof(root)]) fakeroot = Fat12Root(tbl, fs.sfn_encoding) # Mark empty as deleted del_offset, entries = find_empty_file(fakeroot) entries[0] = entries[0]._replace( filename=b'\xE5' + entries[0].filename[1:]) fakeroot._update_entry(del_offset, entries[0]) # Offsets may change after clean, but not entries assert 'FOO.BAR' not in fakeroot with pytest.raises(OSError) as err: fakeroot['FOO.BAR'] = entries[0]._replace(filename=b'FOO', ext=b'BAR') assert err.value.errno == errno.ENOSPC assert 'FOO.BAR' not in fakeroot def test_fatdirectory_get_unique_sfn(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) offset, entries = find_lfn_file(root) lfn, sfn, entry = root._split_entries(entries) assert (lfn, sfn) == ('lots-of-zeros', 'LOTS-O~1') # Colliding SFN cksum = lfn_checksum(b'LOTS-O~2', b' ') entries_2 = root._prefix_entries('lots-of-ones', entry) assert entries_2 == [ LongFilenameEntry( sequence=0x41, name_1=b'l\0o\0t\0s\0-\0', attr=0xF, checksum=cksum, name_2=b'o\0f\0-\0o\0n\0e\0', first_cluster=0, name_3=b's\0\0\0', ), entry._replace( filename=b'LOTS-O~2', ext=b' ', attr2=0, ), ] offset = dir_eof(root) for e in entries_2: root._update_entry(offset, e) offset += DirectoryEntry._FORMAT.size # Colliding LFN and SFN cksum = lfn_checksum(b'LOTS-O~4', b' ') entries_3 = [ LongFilenameEntry( sequence=0x41, name_1=b'L\0O\0T\0S\0-\0', attr=0xF, checksum=cksum, name_2=b'O\0~\x003\0\0\0' + b'\xFF' * 4, first_cluster=0, name_3=b'\xFF' * 4, ), entry._replace( filename=b'LOTS-O~4', ext=b' ', attr2=0, ), ] offset = dir_eof(root) for e in entries_3: root._update_entry(offset, e) offset += DirectoryEntry._FORMAT.size cksum = lfn_checksum(b'LOTS-O~5', b' ') entries_5 = root._prefix_entries('lots-of-nowt', entry) assert entries_5 == [ LongFilenameEntry( sequence=0x41, name_1=b'l\0o\0t\0s\0-\0', attr=0xF, checksum=cksum, name_2=b'o\0f\0-\0n\0o\0w\0', first_cluster=0, name_3=b't\0\0\0', ), entry._replace( filename=b'LOTS-O~5', ext=b' ', attr2=0, ), ] def test_fatdirectory_cluster(fat_disks): for fat_type, fat_disk in fat_disks.items(): with DiskImage(fat_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) if fat_type == 'fat32': assert root.cluster != 0 else: assert root.cluster == 0 def test_fatfile_readonly(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) offset, entries = find_non_empty_file(root) *entries, entry = entries f = fs.open_entry(root, entry) assert f.tell() == 0 assert f.seek(0, io.SEEK_END) > 0 assert f.seek(0, io.SEEK_END) == entry.size with pytest.raises(ValueError): f.seek(0, whence=100) with pytest.raises(OSError): f.seek(-1) assert f.readable() assert f.seekable() assert not f.writable() with pytest.raises(OSError): f.write(b'foo') with pytest.raises(OSError): f.truncate() f.seek(0) buf = f.read() assert isinstance(buf, bytes) assert len(buf) == entry.size buf = bytearray(10) assert f.readinto(buf) == 0 assert f.tell() == entry.size f.close() with pytest.raises(ValueError): f.seek(0) with pytest.raises(ValueError): fs.open_entry(root, entry, mode='r') def test_fatfile_close_idempotent(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) offset, entries = find_non_empty_file(root) *entries, entry = entries f = fs.open_entry(root, entry) assert not f.closed f.close() assert f.closed f.close() assert f.closed def test_fatfile_fs_gone(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) offset, entries = find_non_empty_file(root) *entries, entry = entries f = fs.open_entry(root, entry) # Necessary to make sure fs is well and truly gone; will probably fail # on non-CPython, due to slower GC del fs with pytest.raises(ValueError): f.read(1024) def test_fatfile_dir_no_key(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) offset, entries = first_dir(root) *entries, entry = entries f = fs.open_dir(get_cluster(entry, fs.fat_type)) with pytest.raises(ValueError): f._file._get_key() def test_fatfile_writable(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) offset, entries = find_non_empty_file(root) *entries, entry = entries with fs.open_entry(root, entry, mode='a+b') as f: assert f.tell() == entry.size assert f.readable() assert f.seekable() assert f.writable() with fs.open_entry(root, entry, mode='wb') as f: assert f.tell() == 0 assert not f.readable() assert f.seekable() assert f.writable() with pytest.raises(OSError): f.read(10) with pytest.raises(OSError): f.readall() # FatFile maintains one cluster even when file is empty, to # avoid re-allocation of clusters assert len(f._map) == 1 # Write something multiple clusters long to test allocation of # new clusters assert f.write(b'\xFF' * fs.clusters.size * 2) assert len(f._map) == 2 def test_fatfile_write_empty(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) offset, entries = find_empty_file(root) *entries, entry = entries with fs.open_entry(root, entry, mode='wb') as f: assert f.tell() == 0 # Ensure map really is empty so we're allocating from scratch assert len(f._map) == 0 # Write something multiple clusters long to test allocation of # new clusters assert f.write(b'\xFF' * fs.clusters.size * 2) assert len(f._map) == 2 # This shouldn't be possible given how _write1 is normally # called, but for the sake of coverage... assert f._write1(b'') == 0 def test_fatfile_atime(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data, atime=True) as fs: root = fs.open_dir(0) offset, entries = find_non_empty_file(root) *entries, entry = entries with fs.open_entry(root, entry) as f: f.read() assert f._entry.adate > entry.adate def test_fatfile_mtime(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data, atime=True) as fs: root = fs.open_dir(0) offset, entries = find_non_empty_file(root) *entries, entry = entries with fs.open_entry(root, entry, mode='r+b') as f: f.write(b'\x00' * 10) assert f._entry.mdate > entry.mdate def test_fatfile_truncate(fat12_disk): # Check general truncate functionality (truncate to 0, and implicit # truncation to current position) with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) offset, entries = find_non_empty_file(root) *entries, entry = entries with fs.open_entry(root, entry, mode='r+b') as f: # Ensure truncate actually removes bytes from the file's record assert f.tell() == 0 assert f._entry.size > 0 assert f.truncate() == 0 assert f._entry.size == 0 # Seek beyond new EOF and ensure next write "truncates" up to # the new position assert f.seek(512) == 512 assert f.write(b'foo') == 3 assert f.tell() == 515 assert f._entry.size == 515 # Check truncate with explicit sizes with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) offset, entries = find_non_empty_file(root) *entries, entry = entries with fs.open_entry(root, entry, mode='r+b') as f: assert f.tell() == 0 assert f._entry.size > 2 assert f.truncate(size=2) == 2 assert f._entry.size == 2 # Check truncate with multiple extra clusters with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) offset, entries = find_non_empty_file(root) *entries, entry = entries with fs.open_entry(root, entry, mode='wb') as f: assert f.seek(fs.clusters.size * 4) == fs.clusters.size * 4 assert f.write(b'foo') == 3 assert f.tell() == fs.clusters.size * 4 + 3 def test_fatfile_empty(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: root = fs.open_dir(0) offset, entries = find_non_empty_file(root) *entries, entry = entries with fs.open_entry(root, entry, mode='r+b') as f: assert f._map f.truncate() # Ensure truncate sets size to 0 but doesn't actually remove # the first cluster so we don't go bouncing stuff in and out of # allocation in the FAT when truncating and re-writing a file assert f._entry.size == 0 assert len(f._map) == 1 # Only once the file is actually *closed* empty do we eliminate the # last cluster assert f._entry.size == 0 assert not f._map nobodd-0.4/tests/test_gpt.py000066400000000000000000000061221457216553300162030ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2024 Dave Jones # Copyright (c) 2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import uuid from binascii import crc32 import pytest from nobodd.gpt import * def test_gpt_header_from_buffer(gpt_disk): with gpt_disk.open('rb') as source: source.seek(1 * 512) head = source.read(512) source.seek(2 * 512) table = source.read(128 * 128) h = GPTHeader.from_buffer(head) assert h.signature == b'EFI PART' assert h.revision == 0x10000 assert h.header_size == GPTHeader._FORMAT.size assert h.header_crc32 == crc32(bytes(h._replace(header_crc32=0))) assert h.current_lba == 1 assert h.backup_lba == 65535 assert h.first_usable_lba == 34 assert h.last_usable_lba == 65502 assert uuid.UUID(bytes_le=h.disk_guid) == uuid.UUID( '733B49A8-6918-4E44-8D3D-47ED9B481335') assert h.part_table_lba == 2 assert h.part_table_size == 128 assert h.part_entry_size == 128 assert h.part_table_crc32 == crc32(table) def test_gpt_header_from_bytes(gpt_disk): with gpt_disk.open('rb') as source: source.seek(1 * 512) head = source.read(512) h1 = GPTHeader.from_bytes(head[:GPTHeader._FORMAT.size]) h2 = GPTHeader.from_buffer(head) assert h1 == h2 def test_gpt_header_to_bytes(gpt_disk): with gpt_disk.open('rb') as source: source.seek(1 * 512) head = source.read(512) h = GPTHeader.from_buffer(head) assert head[:GPTHeader._FORMAT.size] == bytes(h) def test_gpt_partition_from_buffer(gpt_disk): with gpt_disk.open('rb') as source: source.seek(2 * 512) table = source.read(128 * 128) part1 = GPTPartition.from_buffer(table, offset=0) assert part1.part_label.decode('utf-16le').rstrip('\0') == 'big-part' assert part1.flags == 0 assert part1.first_lba == 2048 assert part1.last_lba == 18431 assert uuid.UUID(bytes_le=part1.type_guid) == uuid.UUID( 'EBD0A0A2-B9E5-4433-87C0-68B6B72699C7') part2 = GPTPartition.from_buffer(table, offset=GPTPartition._FORMAT.size) assert part2.part_label.decode('utf-16le').rstrip('\0') == 'little-part1' assert part2.flags == 0 assert part2.first_lba == 18432 assert part2.last_lba == 18831 assert uuid.UUID(bytes_le=part1.type_guid) == uuid.UUID( 'EBD0A0A2-B9E5-4433-87C0-68B6B72699C7') def test_gpt_partition_from_bytes(gpt_disk): with gpt_disk.open('rb') as source: source.seek(2 * 512) table = source.read(128 * 128) p1 = GPTPartition.from_bytes(table[:GPTPartition._FORMAT.size]) p2 = GPTPartition.from_buffer(table) assert p1 == p2 def test_gpt_partition_to_bytes(gpt_disk): with gpt_disk.open('rb') as source: source.seek(2 * 512) table = source.read(128 * 128) p = GPTPartition.from_buffer(table) assert table[:GPTPartition._FORMAT.size] == bytes(p) nobodd-0.4/tests/test_mbr.py000066400000000000000000000047101457216553300161720ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2024 Dave Jones # Copyright (c) 2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import pytest from nobodd.mbr import * def test_mbr_header_from_buffer(mbr_disk): with mbr_disk.open('rb') as source: source.seek(0) head = source.read(512) h = MBRHeader.from_buffer(head) assert h.boot_sig == 0xAA55 assert h.zero == 0 assert h.disk_sig == 0x3b1190bc assert h.copy_protect == 0 def test_mbr_header_from_bytes(mbr_disk): with mbr_disk.open('rb') as source: source.seek(0) head = source.read(512) h1 = MBRHeader.from_bytes(head[:MBRHeader._FORMAT.size]) h2 = MBRHeader.from_buffer(head) assert h1 == h2 def test_mbr_header_to_bytes(mbr_disk): with mbr_disk.open('rb') as source: source.seek(0) head = source.read(512) h = MBRHeader.from_buffer(head) assert head[:MBRHeader._FORMAT.size] == bytes(h) def test_mbr_header_partitions(mbr_disk): with mbr_disk.open('rb') as source: source.seek(0) head = source.read(512) h = MBRHeader.from_buffer(head) assert len(h.partitions) == 4 assert h.partition_1 == h.partitions[0] def test_mbr_partition_from_buffer(mbr_disk): with mbr_disk.open('rb') as source: source.seek(0) head = source.read(512) h = MBRHeader.from_buffer(head) part1 = MBRPartition.from_buffer(h.partition_1) assert part1.status == 0 assert part1.part_type == 0xC assert part1.first_lba == 2048 assert part1.part_size == 16384 part2 = MBRPartition.from_buffer(h.partition_2) assert part2.status == 0 assert part2.part_type == 0xC assert part2.first_lba == 18432 assert part2.part_size == 401 def test_mbr_partition_from_bytes(mbr_disk): with mbr_disk.open('rb') as source: source.seek(0) head = source.read(512) h = MBRHeader.from_buffer(head) p1 = MBRPartition.from_bytes(h.partition_1) p2 = MBRPartition.from_buffer(h.partition_1) assert p1 == p2 def test_mbr_partition_to_bytes(mbr_disk): with mbr_disk.open('rb') as source: source.seek(0) head = source.read(512) h = MBRHeader.from_buffer(head) p = MBRPartition.from_buffer(h.partition_1) assert h.partition_1 == bytes(p) nobodd-0.4/tests/test_netascii.py000066400000000000000000000101371457216553300172110ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2024 Dave Jones # Copyright (c) 2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import io import os import codecs import pytest from nobodd.netascii import * def test_encode(): assert ''.encode('netascii') == b'' assert 'foo'.encode('netascii') == b'foo' assert f'foo{os.linesep}bar'.encode('netascii') == b'foo\r\nbar' assert 'lf\n crlf\r\n cr\r eof'.encode('netascii') == { '\r': b'lf\n crlf\r\n\n cr\r\n eof', '\n': b'lf\r\n crlf\r\0\r\n cr\r\0 eof', '\r\n': b'lf\n crlf\r\n cr\r\0 eof', }[os.linesep] def test_decode(): assert b''.decode('netascii') == '' assert b'foo'.decode('netascii') == 'foo' assert b'foo\r\nbar'.decode('netascii') == f'foo{os.linesep}bar' assert { '\r': b'lf\n crlf\r\n\n cr\r\n eof', '\n': b'lf\r\n crlf\r\0\r\n cr\r\0 eof', '\r\n': b'lf\n crlf\r\n cr\r\0 eof', }[os.linesep].decode('netascii') == 'lf\n crlf\r\n cr\r eof' def test_decode_errors(): with pytest.raises(UnicodeError): b'crcr\r\r'.decode('netascii', errors='strict') assert b'crcr\r\r'.decode('netascii', errors='replace') == 'crcr??' assert b'crcr\r\r'.decode('netascii', errors='ignore') == 'crcr' with pytest.raises(ValueError): b'crcr\r\r'.decode('netascii', errors='foo') def test_incremental_encoder(): assert list(codecs.iterencode([''], 'netascii')) == [] assert list(codecs.iterencode(['fo', 'o'], 'netascii')) == [b'fo', b'o'] assert list(codecs.iterencode(['foo', os.linesep, 'bar'], 'netascii')) == [ b'foo', b'\r\n', b'bar'] assert b''.join(codecs.iterencode([ f'foo{os.linesep[0]}', f'{os.linesep[1:]}bar' ], 'netascii')) == b'foo\r\nbar' assert list(codecs.iterencode(['foo', '\r', 'bar\r'], 'netascii')) == { '\r': [b'foo', b'\r\n', b'bar\r\n'], '\n': [b'foo', b'\r\0', b'bar\r\0'], '\r\n': [b'foo', b'\r\0bar', b'\r\0'], }[os.linesep] assert list(codecs.iterencode(['lf\n ', 'crlf\r\n ', 'cr\r ', 'eof'], 'netascii')) == { '\r': [b'lf\n ', b'crlf\r\n\n ', b'cr\r\n ', b'eof'], '\n': [b'lf\r\n ', b'crlf\r\0\r\n ', b'cr\r\0 ', b'eof'], '\r\n': [b'lf\n ', b'crlf\r\n ', b'cr\r\0 ', b'eof'], }[os.linesep] def test_incremental_decoder(): assert list(codecs.iterdecode([b''], 'netascii')) == [] assert list(codecs.iterdecode([b'fo', b'o'], 'netascii')) == ['fo', 'o'] assert list(codecs.iterdecode([b'foo\r', b'\nbar'], 'netascii')) == ['foo', f'{os.linesep}bar'] assert list(codecs.iterdecode({ '\r': [b'lf\n ', b'crlf\r\n\n ', b'cr\r\n ', b'eof'], '\n': [b'lf\r\n ', b'crlf\r\0\r\n ', b'cr\r\0 ', b'eof'], '\r\n': [b'lf\n ', b'crlf\r\n ', b'cr\r\0 ', b'eof'], }[os.linesep], 'netascii')) == ['lf\n ', 'crlf\r\n ', 'cr\r ', 'eof'] def test_stream_writer(): with io.BytesIO() as buf, codecs.getwriter('netascii')(buf) as writer: writer.write('') assert buf.getvalue() == b'' writer.write('foo') assert buf.getvalue() == b'foo' writer.write('\r') assert buf.getvalue() == { '\r': b'foo\r\n', '\n': b'foo\r\0', '\r\n': b'foo', }[os.linesep] writer.write('\n') assert buf.getvalue() == { '\r': b'foo\r\n\n', '\n': b'foo\r\0\r\n', '\r\n': b'foo\r\n', }[os.linesep] writer.write('\r') writer.flush() assert buf.getvalue() == { '\r': b'foo\r\n\n\r\n', '\n': b'foo\r\0\r\n\r\0', '\r\n': b'foo\r\n\r\0', }[os.linesep] def test_stream_reader(): with io.BytesIO() as buf, codecs.getreader('netascii')(buf, errors='replace') as reader: buf.write(b'foo\r\nbar\r\0\r\r') buf.seek(0) assert reader.read(0) == '' assert reader.read(3) == 'foo' assert reader.read(len(os.linesep)) == os.linesep assert reader.read(3) == 'bar' assert reader.read(1) == '\r' assert reader.read(1) == '?' nobodd-0.4/tests/test_path.py000066400000000000000000000417211457216553300163510ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2024 Dave Jones # Copyright (c) 2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import io import mmap import errno import datetime as dt import pytest from nobodd.disk import DiskImage from nobodd.fs import FatFileSystem from nobodd.path import * def test_path_init(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: assert repr(fs.root) == "FatPath(, '/')" p = fs.root / 'empty' assert repr(p) == "FatPath(, '/empty')" assert p.exists() assert p.is_file() assert not p.is_dir() p = fs.root / 'i-dont-exist' assert repr(p) == "FatPath(, '/i-dont-exist')" assert not p.exists() p = fs.root / 'a.dir/licenses' assert p.exists() assert not p.is_file() assert p.is_dir() with pytest.raises(ValueError): p = FatPath(fs, 'relative/path') p._resolve() with pytest.raises(ValueError): fs.root / 'emp*' def test_path_closed_fs(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: p = fs.root / 'empty' del fs with pytest.raises(OSError): p._get_fs() def test_path_open_readonly(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: p = fs.root / 'lots-of-zeros' with p.open('rb', buffering=0) as f: assert not isinstance(f, io.BufferedIOBase) assert f.tell() == 0 assert f._mode == 'r' with p.open('rb', buffering=512) as f: assert isinstance(f, io.BufferedIOBase) assert f.tell() == 0 assert f.raw._mode == 'r' with p.open('r') as f: assert isinstance(f, io.TextIOWrapper) assert f.tell() == 0 def test_path_open_bad(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: p = fs.root / 'lots-of-zeros' with pytest.warns(RuntimeWarning): # buffering=1 is line buffering; not valid but only a warning # in binary mode with p.open('rb', buffering=1) as f: assert isinstance(f, io.BufferedIOBase) with pytest.raises(ValueError): p.open('foo') with pytest.raises(ValueError): p.open('rb', encoding='utf-8') with pytest.raises(ValueError): p.open('rb', errors='replace') with pytest.raises(ValueError): p.open('rb', newline='\n') with pytest.raises(ValueError): p.open('r', buffering=0) with pytest.raises(PermissionError): p.open('r+b') def test_path_open_readwrite(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: p = fs.root / 'lots-of-zeros' assert p.exists() with p.open('r+b', buffering=0) as f: assert not isinstance(f, io.BufferedIOBase) assert f.tell() == 0 assert f._mode == '+' with pytest.raises(OSError): p.open('xb') with pytest.raises(ValueError): p.open('wab') p = fs.root / 'new-file' assert not p.exists() with p.open('wb', buffering=0) as f: assert f.tell() == 0 assert f._mode == 'w' assert p.exists() def test_path_open_create(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: p = fs.root / 'new-file' with p.open('xb', buffering=0) as f: assert f.tell() == 0 assert f._mode == 'w' assert p.exists() def test_path_unlink(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: # Test unlinking both an empty file and a file with allocated # clusters to hit all loop possibilities in unlink p = fs.root / 'lots-of-zeros' assert p.exists() p.unlink() p = fs.root / 'empty' assert p.exists() p.unlink() assert not p.exists() p.unlink(missing_ok=True) assert not p.exists() with pytest.raises(FileNotFoundError): p.unlink() # Testunlinking files in a sub-directory with multiple pages p = fs.root / 'a.dir' / 'many-many-files' assert (p / '000.txt').exists() assert (p / '014.txt').exists() (p / '000.txt').unlink() (p / '014.txt').unlink() assert not (p / '000.txt').exists() assert not (p / '014.txt').exists() def test_path_rename(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: s = fs.root / 'random' t = fs.root / 'still-random' o = fs.root / 'lots-of-zeros' d = fs.root / 'a.dir' assert s.exists() and not t.exists() and o.exists() buf = s.read_bytes() # Rename to new file; touches new entry, removes old entry s.rename(t) assert not s.exists() and t.exists() and o.exists() assert t.read_bytes() == buf # Rename over existing file; should remove existing file's clusters t.rename(o) assert not s.exists() and not t.exists() and o.exists() assert o.read_bytes() == buf # Cannot rename over a directory with pytest.raises(IsADirectoryError): o.rename(d) # Cannot rename across FS instances (even if targetting the same # underlying media) with FatFileSystem(img.partitions[1].data) as fs2: with pytest.raises(ValueError): (fs2.root / 'empty').rename(fs.root / 'still-empty') # Can rename to implicitly constructed FatPath (fs.root / 'empty').rename('/still-empty') assert (fs.root / 'still-empty').exists() def test_path_mkdir(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: p = fs.root / 'b.dir' assert not p.exists() p.mkdir() assert p.exists() assert (p / '.').exists() assert (p / '..').exists() p.mkdir(exist_ok=True) assert p.exists() with pytest.raises(FileExistsError): p.mkdir() p = fs.root / 'empty' assert p.exists() and p.is_file() with pytest.raises(FileExistsError): p.mkdir(exist_ok=True) p = fs.root / 'foo' / 'bar' with pytest.raises(FileNotFoundError): p.mkdir() p.mkdir(parents=True) assert ( p.parent.exists() and p.parent.is_dir() and p.exists() and p.is_dir() ) def test_path_rmdir(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: p = fs.root / 'a.dir' with pytest.raises(OSError) as err: p.rmdir() assert err.value.errno == errno.ENOTEMPTY p = fs.root / 'empty.dir' assert p.exists() p.rmdir() assert not p.exists() with pytest.raises(OSError): fs.root.rmdir() def test_path_resolve(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: p = fs.root / 'empty.dir' / '.' / '..' / 'a.dir' / 'licenses' q = p.resolve() assert p is not q assert str(q) == '/a.dir/licenses' q = p.resolve(strict=True) assert str(q) == '/a.dir/licenses' with pytest.raises(ValueError): FatPath(fs, 'foo').resolve() with pytest.raises(FileNotFoundError): (fs.root / 'foo').resolve(strict=True) def test_path_match(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: p = fs.root / 'a.dir/licenses/gpl3.txt' assert p.match('*.txt') assert p.match('licenses/*.txt') assert not p.match('*.py') assert not p.match('/*.txt') assert not p.match('/a.dir/licenses/gpls/*.txt') with pytest.raises(ValueError): (fs.root / 'foo').match('') def test_path_glob(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: p = fs.root / 'a.dir' / 'many-many-files' assert sum(1 for f in p.glob('?[0-9][02468].txt')) == 50 assert sum(1 for f in fs.root.glob('*/many-many-files/*.txt')) == 100 assert sum(1 for f in fs.root.glob('**/many-*/*.txt')) == 100 with pytest.raises(ValueError): list(p.glob('')) with pytest.raises(ValueError): list(p.glob('/')) with pytest.raises(ValueError): list(p.glob('**.dir/*.txt')) def test_path_rglob(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: p = fs.root / 'a.dir' # gpl3.txt + {000..999}.txt assert sum(1 for f in p.rglob('*.txt')) == 101 # gpl3.txt + *[13579].txt assert sum(1 for f in p.rglob('*[13579].txt')) == 51 with pytest.raises(ValueError): list(p.rglob('')) with pytest.raises(ValueError): list(p.rglob('/')) with pytest.raises(ValueError): list(p.rglob('**.dir/*.txt')) def test_path_stat(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: epoch = dt.datetime(2023, 1, 1).timestamp() p = fs.root / 'lots-of-zeros' s = p.stat() assert s.st_size > 0 assert s.st_mode == 0o444 assert s.st_ino != 0 assert s.st_dev != 0 assert s.st_nlink == 1 assert s.st_uid == 0 assert s.st_gid == 0 assert s.st_ctime > epoch assert s.st_mtime > epoch assert s.st_atime > epoch p = fs.root / 'a.dir' s = p.stat() assert s.st_size == 0 assert s.st_mode == 0o40555 assert s.st_ino != 0 assert s.st_nlink == 0 assert s.st_uid == 0 assert s.st_gid == 0 assert s.st_ctime == 0 assert s.st_mtime == 0 assert s.st_atime == 0 def test_path_attr(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: epoch = dt.datetime(2023, 1, 1).timestamp() p = fs.root / 'a.dir' / 'licenses' / 'gpl3.txt' assert p.fs is fs assert p.root == '/' assert p.anchor == '/' assert p.name == 'gpl3.txt' assert p.suffix == '.txt' assert p.suffixes == ['.txt'] assert p.stem == 'gpl3' assert p.parent.stem == 'licenses' assert p.parts == ('/', 'a.dir', 'licenses', 'gpl3.txt') assert fs.root.suffix == '' assert (fs.root / 'nobodd.tar.gz').suffixes == ['.tar', '.gz'] assert str(fs.root.parent) == '/' assert str(FatPath(fs).parent) == '.' assert tuple(str(s) for s in p.parents) == ( '/a.dir/licenses', '/a.dir', '/') def test_path_join(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: p = fs.root / 'a.dir' / 'licenses' assert str(p / 'gpl3.txt') == '/a.dir/licenses/gpl3.txt' assert str(p / '/empty.dir') == '/empty.dir' def test_path_queries(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: p = fs.root / 'a.dir' / 'licenses' / 'gpl3.txt' assert p.exists() assert p.is_file() assert not p.is_dir() assert p.parent.exists() assert p.parent.is_dir() assert not p.parent.is_file() assert fs.root.is_mount() assert not p.parent.is_mount() assert p.is_absolute() assert p.is_relative_to(p.parent) assert not p.is_relative_to(fs.root / 'empty.dir') assert str(p.relative_to(p.parent)) == 'gpl3.txt' assert not p.relative_to(p.parent).is_absolute() with pytest.raises(TypeError): p.relative_to() def test_path_with(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: p = fs.root / 'a.dir' / 'licenses' / 'gpl3.txt' assert str(p.with_name('gpl2.txt')) == '/a.dir/licenses/gpl2.txt' with pytest.raises(ValueError): FatPath(fs).with_name('foo') with pytest.raises(ValueError): p.with_name('') assert str(p.with_stem('mit')) == '/a.dir/licenses/mit.txt' assert str(p.with_suffix('.rst')) == '/a.dir/licenses/gpl3.rst' assert str(p.with_suffix('')) == '/a.dir/licenses/gpl3' assert str(p.parent.with_suffix('.dir')) == '/a.dir/licenses.dir' with pytest.raises(ValueError): p.with_suffix('/rst') with pytest.raises(ValueError): p.with_suffix('rst') def test_path_read(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: assert (fs.root / 'empty').read_bytes() == b'' assert (fs.root / 'a.dir' / 'licenses' / 'gpl3.txt').read_text().startswith( 'SPDX-License-Identifier: GPL-3.0-or-later\n') def test_path_write(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: p = fs.root / 'not-empty' assert not p.exists() assert p.write_text('foo bar baz') == 11 assert p.read_text() == 'foo bar baz' data = b'\x01\x02\x03\x04' * 4096 assert p.write_bytes(data) == len(data) assert p.read_bytes() == data def test_path_touch(fat12_disk): with DiskImage(fat12_disk, access=mmap.ACCESS_COPY) as img: with FatFileSystem(img.partitions[1].data) as fs: p = fs.root / 'empty' old = p.stat() p.touch() new = p.stat() assert new.st_ctime == old.st_ctime assert new.st_atime == old.st_atime assert new.st_mtime > old.st_mtime with pytest.raises(FileExistsError): p.touch(exist_ok=False) p = fs.root / 'foo' assert not p.exists() p.touch(exist_ok=False) assert p.exists() def test_path_compares(fat12_disk): with DiskImage(fat12_disk) as img: with FatFileSystem(img.partitions[1].data) as fs: assert [str(p) for p in sorted(fs.root.iterdir())] == [ '/a.dir', '/cmdline.txt', '/empty', '/empty.dir', '/lots-of-zeros', '/random', ] p1 = fs.root / 'a.dir' p2 = fs.root / 'empty.dir' assert p1 == p1 assert p2 == p2 assert p1 != p2 assert p1 < p2 assert p1 <= p2 assert p1 <= p1 assert p2 > p1 assert p2 >= p1 assert p2 >= p2 assert not ((fs.root / 'a.dir') == '') assert (fs.root / 'a.dir') != '' with pytest.raises(TypeError): (fs.root / 'a.dir') > '' with pytest.raises(TypeError): (fs.root / 'a.dir') >= '' with pytest.raises(TypeError): (fs.root / 'a.dir') < '' with pytest.raises(TypeError): (fs.root / 'a.dir') <= '' with FatFileSystem(img.partitions[1].data) as fs2: with pytest.raises(TypeError): (fs.root / 'a.dir') == (fs2.root / 'a.dir') with pytest.raises(TypeError): (fs.root / 'a.dir') <= (fs2.root / 'a.dir') nobodd-0.4/tests/test_prep.py000066400000000000000000000241411457216553300163600ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2024 Dave Jones # Copyright (c) 2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import os from shutil import copyfileobj from unittest import mock import pytest from conftest import make_disk from nobodd.disk import DiskImage from nobodd.fs import FatFileSystem from nobodd.mbr import MBRPartition from nobodd.prep import * def test_help(capsys): with pytest.raises(SystemExit) as err: main(['--version']) assert err.value.code == 0 capture = capsys.readouterr() assert capture.out.strip() == '0.4' with pytest.raises(SystemExit) as err: main(['--help']) assert err.value.code == 0 capture = capsys.readouterr() assert capture.out.startswith('usage:') def test_error_exit_no_debug(capsys, monkeypatch): with \ mock.patch('nobodd.prep.get_parser') as get_parser, \ monkeypatch.context() as m: m.delenv('DEBUG', raising=False) get_parser.side_effect = RuntimeError('trouble is bad') assert main(['foo.img']) == 1 capture = capsys.readouterr() assert 'trouble is bad' in capture.err def test_error_exit_with_debug(monkeypatch): with \ mock.patch('nobodd.prep.get_parser') as get_parser, \ monkeypatch.context() as m: m.setenv('DEBUG', '1') get_parser.side_effect = RuntimeError('trouble is bad') with pytest.raises(RuntimeError): main(['foo.img']) def test_error_exit_with_pdb(monkeypatch): with \ mock.patch('nobodd.prep.get_parser') as get_parser, \ mock.patch('pdb.post_mortem') as post_mortem, \ monkeypatch.context() as m: m.setenv('DEBUG', '2') get_parser.side_effect = RuntimeError('trouble is bad') main(['foo.img']) assert post_mortem.called def test_regular_operation(fat_disks_w, tmp_path): for fat_disk in fat_disks_w.values(): assert fat_disk.stat().st_size < 50 * 1048576 assert main([ '--size', '50MB', '--nbd-host', 'myserver', '--nbd-name', 'myshare', '--serial', 'abcd1234', '--tftpd-conf', str(tmp_path / 'tftpd.conf'), '--nbd-conf', str(tmp_path / 'nbd.conf'), str(fat_disk) ]) == 0 assert fat_disk.stat().st_size == 50 * 1048576 with \ DiskImage(fat_disk) as img, \ FatFileSystem(img.partitions[1].data) as fs: assert (fs.root / 'cmdline.txt').read_text() == ( 'ip=dhcp nbdroot=myserver/myshare root=/dev/nbd0p5 ' 'console=serial0,115200 dwc_otg.lpm_enable=0 console=tty1 ' 'rootfstype=ext4 rootwait fixrtc quiet splash') assert (tmp_path / 'tftpd.conf').read_text() == f"""\ [board:abcd1234] image = {fat_disk} partition = 1 """ assert (tmp_path / 'nbd.conf').read_text() == f"""\ [myshare] exportname = {fat_disk} """ def test_cmdline_no_newline(fat16_disk_w): with \ DiskImage(fat16_disk_w, access=mmap.ACCESS_WRITE) as img, \ FatFileSystem(img.partitions[1].data) as fs: # Ensure the transformation works even when cmdline.txt has no newlines path = fs.root / 'cmdline.txt' path.write_text(path.read_text().rstrip('\n')) assert main([ '--size', '50MB', '--boot-partition', '1', '--root-partition', '5', '--nbd-host', 'myserver', '--nbd-name', 'myshare', str(fat16_disk_w) ]) == 0 with \ DiskImage(fat16_disk_w) as img, \ FatFileSystem(img.partitions[1].data) as fs: assert (fs.root / 'cmdline.txt').read_text() == ( 'ip=dhcp nbdroot=myserver/myshare root=/dev/nbd0p5 ' 'console=serial0,115200 dwc_otg.lpm_enable=0 console=tty1 ' 'rootfstype=ext4 rootwait fixrtc quiet splash') def test_already_big_enough(fat32_disk_w, caplog): with fat32_disk_w.open('r+b') as f: f.seek(70 * 1048576) f.truncate() with caplog.at_level(logging.INFO): assert main([ '--verbose', '--size', '50MB', '--boot-partition', '1', '--root-partition', '5', '--nbd-host', 'myserver', '--nbd-name', 'myshare', str(fat32_disk_w) ]) == 0 assert fat32_disk_w.stat().st_size == 70 * 1048576 msg = ( f'Skipping resize; {fat32_disk_w} is already ' f'{50 * 1048576} bytes or larger') assert ('prep', logging.INFO, msg) in caplog.record_tuples def test_detect_later_boot_partition(tmp_path, caplog): disk = tmp_path / 'weird.img' with disk.open('wb') as output: make_disk(output, part_style='mbr', part_map={5: 'ext2', 6: 'fat12'}) with caplog.at_level(logging.INFO): assert main([ '--verbose', '--size', '50MB', '--nbd-host', 'myserver', '--nbd-name', 'myshare', str(disk) ]) == 0 assert ('prep', logging.INFO, 'Boot partition is 6 (fat12)') in caplog.record_tuples def test_detect_multi_root(tmp_path, caplog): disk = tmp_path / 'weird.img' with disk.open('w+b') as output: make_disk(output, part_style='mbr', part_map={ 1: 'ext2', 5: 'ext2', 6: 'fat12'}) # Re-write partition 1's type to Linux (0x83) output.seek(446) part = MBRPartition.from_bytes(output.read(16)) part = part._replace(part_type=0x83) output.seek(446) output.write(MBRPartition._FORMAT.pack(*part)) with caplog.at_level(logging.INFO): assert main([ '--verbose', '--size', '50MB', '--nbd-host', 'myserver', '--nbd-name', 'myshare', str(disk) ]) == 0 assert ('prep', logging.INFO, 'Root partition is 1') in caplog.record_tuples assert ('prep', logging.INFO, 'Boot partition is 6 (fat12)') in caplog.record_tuples def test_detect_boot_fail(tmp_path, capsys): disk = tmp_path / 'empty.img' with disk.open('wb') as output: make_disk(output, part_style='mbr', part_map={}) assert main([ '--size', '50MB', '--nbd-host', 'myserver', '--nbd-name', 'myshare', str(disk) ]) == 1 capture = capsys.readouterr() assert 'Unable to detect boot partition' in capture.err def test_detect_root_fail(tmp_path, capsys): disk = tmp_path / 'allfat.img' with disk.open('wb') as output: make_disk(output, part_style='gpt', part_map={ 1: 'fat12', 2: 'fat32', 5: 'fat16', 6: 'fat12'}) assert main([ '--size', '50MB', '--nbd-host', 'myserver', '--nbd-name', 'myshare', str(disk) ]) == 1 capture = capsys.readouterr() assert 'Unable to detect root partition' in capture.err def test_default_host_share(fat16_disk_w): with mock.patch('nobodd.prep.socket.getfqdn') as getfqdn: getfqdn.return_value = 'louis.prima.org' assert main([ '--size', '50MB', '--boot-partition', '1', '--root-partition', '5', str(fat16_disk_w) ]) == 0 with \ DiskImage(fat16_disk_w) as img, \ FatFileSystem(img.partitions[1].data) as fs: assert (fs.root / 'cmdline.txt').read_text() == ( 'ip=dhcp nbdroot=louis.prima.org/fat16-mutable root=/dev/nbd0p5 ' 'console=serial0,115200 dwc_otg.lpm_enable=0 console=tty1 ' 'rootfstype=ext4 rootwait fixrtc quiet splash') def test_remove_files(fat16_disk_w, caplog): with caplog.at_level(logging.INFO): assert main([ '--verbose', '--size', '50MB', '--remove', 'a.dir', '--remove', 'random', '--remove', 'i-dont-exist', str(fat16_disk_w) ]) == 0 with \ DiskImage(fat16_disk_w) as img, \ FatFileSystem(img.partitions[1].data) as fs: assert not (fs.root / 'a.dir').exists() assert not (fs.root / 'random').exists() assert ( 'prep', logging.WARNING, 'No such file/dir /i-dont-exist in partition 1' ) in caplog.record_tuples def test_bad_copy_files(fat16_disk_w, caplog, tmp_path): assert main([ '--verbose', '--size', '50MB', '--copy', 'i-dont-exist', '--copy', 'seed', str(fat16_disk_w) ]) == 1 def test_copy_files(fat16_disk_w, caplog, tmp_path): config_txt = """\ [all] arm_64bit=1 kernel=vmlinuz initramfs initrd.img followkernel cmdline=cmdline.txt """ user_data = """\ chpasswd: expire: true list: - elmer:WascallyWabbit # For maximum secuwity! ssh_pwauth: true """ network_config = """\ version: 2 wifis: wlan0: dhcp4: true optional: true access-points: elmerswifi: password: "VewyVewySecwet" """ (tmp_path / 'seed').mkdir() (tmp_path / 'config.txt').write_text(config_txt) (tmp_path / 'seed' / 'meta-data').touch() (tmp_path / 'seed' / 'user-data').write_text(user_data) (tmp_path / 'seed' / 'network-config').write_text(network_config) (tmp_path / 'seed' / 'foo').mkdir() (tmp_path / 'seed' / 'foo' / 'a foo').touch() (tmp_path / 'seed' / 'foo' / 'a wild foo').touch() with caplog.at_level(logging.INFO): assert main([ '--verbose', '--size', '50MB', '--copy', str(tmp_path / 'config.txt'), '--copy', str(tmp_path / 'seed'), str(fat16_disk_w) ]) == 0 with \ DiskImage(fat16_disk_w) as img, \ FatFileSystem(img.partitions[1].data) as fs: assert (fs.root / 'config.txt').read_text() == config_txt assert (fs.root / 'seed').is_dir() assert (fs.root / 'seed' / 'meta-data').stat().st_size == 0 assert (fs.root / 'seed' / 'user-data').read_text() == user_data assert (fs.root / 'seed' / 'network-config').read_text() == network_config assert (fs.root / 'seed' / 'foo').is_dir() assert (fs.root / 'seed' / 'foo' / 'a foo').is_file() assert (fs.root / 'seed' / 'foo' / 'a wild foo').is_file() nobodd-0.4/tests/test_server.py000066400000000000000000000413161457216553300167230ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2024 Dave Jones # Copyright (c) 2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import os import sys import socket from time import time, sleep from threading import Thread from unittest import mock import pytest from nobodd import disk, fs, tftp from nobodd.server import * @pytest.fixture() def main_thread(): class MainThread(Thread): def __init__(self): super().__init__() self.exit_code = None self.exception = None self.argv = [] self.address = None def run(self): class MyBootServer(BootServer): def __init__(slf, server_address, boards): super().__init__(server_address, boards) if self.address is None: self.address = slf.server_address try: with mock.patch('nobodd.server.BootServer', MyBootServer): self.exit_code = main(self.argv) except Exception as e: self.exception = e def wait_for_ready(self, capsys): start = time() while time() - start < 10: capture = capsys.readouterr() if 'Ready' in capture.err: return self.join(0.1) if not self.is_alive(): assert False, 'service died before becoming ready' assert False, 'service did not become ready' def __enter__(self): self.start() def __exit__(self, *exc): self.join(timeout=1) if self.is_alive(): exit_write.send(b'TERM') self.join(timeout=1) thread = MainThread() yield thread thread.join(timeout=10) assert not thread.is_alive() def test_help(capsys): with pytest.raises(SystemExit) as err: main(['--version']) assert err.value.code == 0 capture = capsys.readouterr() assert capture.out.strip() == '0.4' with pytest.raises(SystemExit) as err: main(['--help']) assert err.value.code == 0 capture = capsys.readouterr() assert capture.out.startswith('usage:') def test_ctrl_c(main_thread, capsys): main_thread.argv = [ '--listen', '127.0.0.1', '--port', '0', '--board', f'1234abcd,foo.img', ] with main_thread: os.kill(os.getpid(), signal.SIGINT) capture = capsys.readouterr() assert capture.err.strip().endswith('Interrupted') assert main_thread.exception is None assert main_thread.exit_code == 2 def test_sigterm(main_thread, capsys): main_thread.argv = [ '--listen', '127.0.0.1', '--port', '0', '--board', f'1234abcd,foo.img', ] with main_thread: os.kill(os.getpid(), signal.SIGTERM) capture = capsys.readouterr() assert capture.err.strip().endswith('Terminated') assert main_thread.exception is None assert main_thread.exit_code == 0 def test_sighup(main_thread, capsys): main_thread.argv = [ '--listen', '127.0.0.1', '--port', '0', '--board', f'1234abcd,foo.img', ] with main_thread: os.kill(os.getpid(), signal.SIGHUP) os.kill(os.getpid(), signal.SIGTERM) capture = capsys.readouterr() assert 'Reloading configuration' in capture.err.strip() assert capture.err.strip().endswith('Terminated') assert main_thread.exception is None assert main_thread.exit_code == 0 def test_error_exit_no_debug(main_thread, capsys, monkeypatch): with \ mock.patch('nobodd.server.get_parser') as get_parser, \ monkeypatch.context() as m: m.delenv('DEBUG', raising=False) get_parser.side_effect = RuntimeError('trouble is bad') main_thread.argv = ['--listen', '127.0.0.1', '--port', '0'] with main_thread: pass capture = capsys.readouterr() assert 'trouble is bad' in capture.err assert main_thread.exception is None assert main_thread.exit_code == 1 def test_error_exit_with_debug(main_thread, monkeypatch): with \ mock.patch('nobodd.server.get_parser') as get_parser, \ monkeypatch.context() as m: m.setenv('DEBUG', '1') get_parser.side_effect = RuntimeError('trouble is bad') main_thread.argv = ['--listen', '127.0.0.1', '--port', '0'] with main_thread: pass assert isinstance(main_thread.exception, RuntimeError) def test_error_exit_with_pdb(main_thread, capsys, monkeypatch): with \ mock.patch('nobodd.server.get_parser') as get_parser, \ mock.patch('pdb.post_mortem') as post_mortem, \ monkeypatch.context() as m: m.setenv('DEBUG', '2') get_parser.side_effect = RuntimeError('trouble is bad') main_thread.argv = ['--listen', '127.0.0.1', '--port', '0'] with main_thread: pass assert post_mortem.called def test_regular_operation(fat16_disk, main_thread, capsys, monkeypatch): with \ disk.DiskImage(fat16_disk) as img, \ fs.FatFileSystem(img.partitions[1].data) as boot: expected = (boot.root / 'random').read_bytes() with monkeypatch.context() as m: m.delenv('DEBUG', raising=False) main_thread.argv = [ '--listen', '127.0.0.1', '--port', '0', '--board', f'1234abcd,{fat16_disk}', ] with main_thread: main_thread.wait_for_ready(capsys) with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client: # Start a valid transfer from client... client.settimeout(10) client.sendto( bytes(tftp.RRQPacket('1234abcd/random', 'octet')), main_thread.address) received = [] for block, offset in enumerate(range(0, len(expected), 512), start=1): buf, addr = client.recvfrom(1500) pkt = tftp.Packet.from_bytes(buf) assert isinstance(pkt, tftp.DATAPacket) assert pkt.block == block received.append(pkt.data) client.sendto(bytes(tftp.ACKPacket(pkt.block)), addr) # Because random is a precise multiple of the block size, there # should be one final (empty) DATA packet buf, addr = client.recvfrom(1500) pkt = tftp.Packet.from_bytes(buf) assert isinstance(pkt, tftp.DATAPacket) assert pkt.block == block + 1 assert pkt.data == b'' client.sendto(bytes(tftp.ACKPacket(pkt.block)), addr) assert b''.join(received) == expected def test_bad_fd_type_stdin(main_thread, capsys, tmp_path, monkeypatch): with (tmp_path / 'foo').open('wb') as f: with mock.patch('nobodd.server.sys.stdin', f), monkeypatch.context() as m: m.delenv('DEBUG', raising=False) main_thread.argv = [ '--listen', 'stdin', '--board', '1234abcd,foo.img', ] with main_thread: pass capture = capsys.readouterr() assert f'inherited fd {f.fileno()} is not a socket' in capture.err assert main_thread.exit_code == 1 def test_listen_stdin(fat16_disk, main_thread, capsys, monkeypatch): with \ disk.DiskImage(fat16_disk) as img, \ fs.FatFileSystem(img.partitions[1].data) as boot: expected = (boot.root / 'random').read_bytes() with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.bind(('127.0.0.1', 0)) with monkeypatch.context() as m, \ mock.patch('nobodd.server.sys.stdin', sock.dup()): m.delenv('DEBUG', raising=False) main_thread.argv = [ '--listen', 'stdin', '--board', f'1234abcd,{fat16_disk}', ] main_thread.address = sock.getsockname() with main_thread: main_thread.wait_for_ready(capsys) with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client: # Start a valid transfer from client... client.settimeout(10) client.sendto( bytes(tftp.RRQPacket('1234abcd/random', 'octet')), main_thread.address) received = [] for block, offset in enumerate( range(0, len(expected), 512), start=1 ): buf, addr = client.recvfrom(1500) pkt = tftp.Packet.from_bytes(buf) assert isinstance(pkt, tftp.DATAPacket) assert pkt.block == block received.append(pkt.data) client.sendto(bytes(tftp.ACKPacket(pkt.block)), addr) # Because random is a precise multiple of the block size, # there should be one final (empty) DATA packet buf, addr = client.recvfrom(1500) pkt = tftp.Packet.from_bytes(buf) assert isinstance(pkt, tftp.DATAPacket) assert pkt.block == block + 1 assert pkt.data == b'' client.sendto(bytes(tftp.ACKPacket(pkt.block)), addr) assert b''.join(received) == expected def test_bad_listen_systemd(main_thread, capsys, monkeypatch): with monkeypatch.context() as m: m.delenv('DEBUG', raising=False) m.setenv('LISTEN_PID', str(os.getpid())) m.setenv('LISTEN_FDS', '2') main_thread.argv = [ '--listen', 'systemd', '--board', '1234abcd,foo.img', ] with main_thread: pass capture = capsys.readouterr() assert f'Expected 1 fd from systemd but got 2' in capture.err assert main_thread.exit_code == 1 def test_bad_sock_type_systemd(main_thread, capsys, monkeypatch): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as systemd_sock: systemd_sock.bind(('127.0.0.1', 0)) service_sock = systemd_sock.dup() with monkeypatch.context() as m, \ mock.patch('nobodd.systemd.Systemd.LISTEN_FDS_START', service_sock.fileno()): m.delenv('DEBUG', raising=False) m.setenv('LISTEN_PID', str(os.getpid())) m.setenv('LISTEN_FDS', '1') main_thread.argv = [ '--listen', 'systemd', '--board', '1234abcd,foo.img', ] with main_thread: pass capture = capsys.readouterr() assert f'inherited fd {service_sock.fileno()} is not a datagram socket' in capture.err assert main_thread.exit_code == 1 def test_bad_addr_family_systemd(main_thread, capsys, monkeypatch): with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as systemd_sock: service_sock = systemd_sock.dup() with monkeypatch.context() as m, \ mock.patch('nobodd.systemd.Systemd.LISTEN_FDS_START', service_sock.fileno()): m.delenv('DEBUG', raising=False) m.setenv('LISTEN_PID', str(os.getpid())) m.setenv('LISTEN_FDS', '1') main_thread.argv = [ '--listen', 'systemd', '--board', '1234abcd,foo.img', ] with main_thread: pass capture = capsys.readouterr() assert f'inherited fd {service_sock.fileno()} is not an INET or INET6 socket' in capture.err assert main_thread.exit_code == 1 def test_listen_systemd(fat16_disk, main_thread, capsys, monkeypatch): with \ disk.DiskImage(fat16_disk) as img, \ fs.FatFileSystem(img.partitions[1].data) as boot: expected = (boot.root / 'random').read_bytes() with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.bind(('127.0.0.1', 0)) with monkeypatch.context() as m, \ mock.patch('nobodd.systemd.Systemd.LISTEN_FDS_START', os.dup(sock.fileno())): m.delenv('DEBUG', raising=False) m.setenv('LISTEN_PID', str(os.getpid())) m.setenv('LISTEN_FDS', '1') main_thread.argv = [ '--listen', 'systemd', '--board', f'1234abcd,{fat16_disk}', ] main_thread.address = sock.getsockname() with main_thread: main_thread.wait_for_ready(capsys) with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client: # Start a valid transfer from client... client.settimeout(10) client.sendto( bytes(tftp.RRQPacket('1234abcd/random', 'octet')), main_thread.address) received = [] for block, offset in enumerate( range(0, len(expected), 512), start=1 ): buf, addr = client.recvfrom(1500) pkt = tftp.Packet.from_bytes(buf) assert isinstance(pkt, tftp.DATAPacket) assert pkt.block == block received.append(pkt.data) client.sendto(bytes(tftp.ACKPacket(pkt.block)), addr) # Because random is a precise multiple of the block size, # there should be one final (empty) DATA packet buf, addr = client.recvfrom(1500) pkt = tftp.Packet.from_bytes(buf) assert isinstance(pkt, tftp.DATAPacket) assert pkt.block == block + 1 assert pkt.data == b'' client.sendto(bytes(tftp.ACKPacket(pkt.block)), addr) assert b''.join(received) == expected assert sock.getsockname() def test_bad_requests(fat16_disk, main_thread, capsys): main_thread.argv = [ '--listen', '127.0.0.1', '--port', '0', '--board', f'1234abcd,{fat16_disk}', '--board', f'5678abcd,{fat16_disk},1,127.0.0.2', ] with main_thread: main_thread.wait_for_ready(capsys) with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client: # Request something that doesn't exist in the image client.settimeout(10) client.sendto( bytes(tftp.RRQPacket('1234abcd/invalid', 'octet')), main_thread.address) buf, addr = client.recvfrom(1500) pkt = tftp.Packet.from_bytes(buf) assert isinstance(pkt, tftp.ERRORPacket) assert pkt.error == tftp.Error.NOT_FOUND with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client: # Request something that won't even parse client.settimeout(10) client.sendto( bytes(tftp.RRQPacket('.', 'octet')), main_thread.address) buf, addr = client.recvfrom(1500) pkt = tftp.Packet.from_bytes(buf) assert isinstance(pkt, tftp.ERRORPacket) assert pkt.error == tftp.Error.NOT_FOUND with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client: # Request something else invalid (a directory) client.settimeout(10) client.sendto( bytes(tftp.RRQPacket('1234abcd/a.dir', 'octet')), main_thread.address) buf, addr = client.recvfrom(1500) pkt = tftp.Packet.from_bytes(buf) assert isinstance(pkt, tftp.ERRORPacket) assert pkt.error == tftp.Error.UNDEFINED with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client: # Request something from an unconfigured prefix client.settimeout(10) client.sendto( bytes(tftp.RRQPacket('deadbeef/random', 'octet')), main_thread.address) buf, addr = client.recvfrom(1500) pkt = tftp.Packet.from_bytes(buf) assert isinstance(pkt, tftp.ERRORPacket) assert pkt.error == tftp.Error.NOT_FOUND with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client: # Request something from the wrong address (127.0.0.1, not # 127.0.0.2) client.settimeout(10) client.sendto( bytes(tftp.RRQPacket('5678abcd/random', 'octet')), main_thread.address) buf, addr = client.recvfrom(1500) pkt = tftp.Packet.from_bytes(buf) assert isinstance(pkt, tftp.ERRORPacket) assert pkt.error == tftp.Error.NOT_AUTH nobodd-0.4/tests/test_systemd.py000066400000000000000000000123101457216553300170750ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2024 Dave Jones # Copyright (c) 2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import os import socket from unittest import mock import pytest from nobodd.systemd import Systemd, get_systemd @pytest.fixture() def mock_sock(request, tmp_path): save_addr = os.environ.get('NOTIFY_SOCKET') addr = tmp_path / 'notify' os.environ['NOTIFY_SOCKET'] = str(addr) s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC) s.bind(str(addr)) yield s s.close() if save_addr is None: os.environ.pop('NOTIFY_SOCKET', None) else: os.environ['NOTIFY_SOCKET'] = save_addr @pytest.fixture() def mock_abstract_sock(request, tmp_path): save_addr = os.environ.get('NOTIFY_SOCKET') addr = tmp_path / 'abstract' os.environ['NOTIFY_SOCKET'] = '@' + str(addr) s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC) s.bind('\0' + str(addr)) yield s s.close() if save_addr is None: os.environ.pop('NOTIFY_SOCKET', None) else: os.environ['NOTIFY_SOCKET'] = save_addr def test_available_undefined(): intf = Systemd() with pytest.raises(RuntimeError): intf.available() def test_available_invalid(): with mock.patch.dict('os.environ'): os.environ['NOTIFY_SOCKET'] = 'FOO' intf = Systemd() with pytest.raises(RuntimeError): intf.available() def test_available_ioerror(tmp_path): with mock.patch.dict('os.environ'): os.environ['NOTIFY_SOCKET'] = str(tmp_path / 'FOO') intf = Systemd() with pytest.raises(RuntimeError): intf.available() def test_notify_not(): intf = Systemd() intf.notify('foo') intf.notify(b'foo') def test_available(mock_sock): intf = Systemd() intf.available() def test_abstract_available(mock_abstract_sock): intf = Systemd() intf.available() def test_known_available(tmp_path): addr = tmp_path / 'known' s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC) s.bind(str(addr)) try: intf = Systemd(str(addr)) intf.available() finally: s.close() def test_available(mock_sock): intf = Systemd() intf.notify('foo') assert mock_sock.recv(64) == b'foo' intf.notify(b'bar') assert mock_sock.recv(64) == b'bar' def test_ready(mock_sock): intf = Systemd() intf.ready() assert mock_sock.recv(64) == b'READY=1' def test_abstract_ready(mock_abstract_sock): intf = Systemd() intf.ready() assert mock_abstract_sock.recv(64) == b'READY=1' def test_reloading(mock_sock): intf = Systemd() intf.reloading() assert mock_sock.recv(64) == b'RELOADING=1' def test_stopping(mock_sock): intf = Systemd() intf.stopping() assert mock_sock.recv(64) == b'STOPPING=1' def test_extend_timeout(mock_sock): intf = Systemd() intf.extend_timeout(5) assert mock_sock.recv(64) == b'EXTEND_TIMEOUT_USEC=5000000' def test_watchdog_ping(mock_sock): intf = Systemd() intf.watchdog_ping() assert mock_sock.recv(64) == b'WATCHDOG=1' def test_watchdog_reset(mock_sock): intf = Systemd() intf.watchdog_reset(3) assert mock_sock.recv(64) == b'WATCHDOG_USEC=3000000' def test_watchdog_period(): with mock.patch.dict('os.environ'): intf = Systemd() os.environ.pop('WATCHDOG_USEC', None) assert intf.watchdog_period() is None os.environ['WATCHDOG_USEC'] = '5000000' assert intf.watchdog_period() == 5 os.environ['WATCHDOG_PID'] = '1' assert intf.watchdog_period() is None def test_watchdog_clean(): with mock.patch.dict('os.environ'): intf = Systemd() os.environ['WATCHDOG_USEC'] = '5000000' os.environ['WATCHDOG_PID'] = str(os.getpid()) intf.watchdog_clean() assert 'WATCHDOG_USEC' not in os.environ assert 'WATCHDOG_PID' not in os.environ def test_main_pid(mock_sock): intf = Systemd() intf.main_pid(10) assert mock_sock.recv(64) == b'MAINPID=10' intf.main_pid() assert mock_sock.recv(64) == (f'MAINPID={os.getpid()}').encode('ascii') def test_listen_fds(mock_sock): intf = Systemd() os.environ['LISTEN_PID'] = str(os.getpid()) os.environ['LISTEN_FDS'] = '2' assert intf.listen_fds() == {3: 'unknown', 4: 'unknown'} def test_listen_fds_wrong_pid(mock_sock): intf = Systemd() os.environ['LISTEN_PID'] = '1' os.environ['LISTEN_FDS'] = '2' assert intf.listen_fds() == {} def test_listen_fds_with_names(mock_sock): intf = Systemd() os.environ['LISTEN_PID'] = str(os.getpid()) os.environ['LISTEN_FDS'] = '2' os.environ['LISTEN_FDNAMES'] = 'connection:stored' assert intf.listen_fds() == {3: 'connection', 4: 'stored'} def test_listen_fds_bad_names(mock_sock): intf = Systemd() os.environ['LISTEN_PID'] = str(os.getpid()) os.environ['LISTEN_FDS'] = '2' os.environ['LISTEN_FDNAMES'] = 'connection:stored:foo:bar' assert intf.listen_fds() == {} def test_get_systemd(): with mock.patch('nobodd.systemd._SYSTEMD', None): sd = get_systemd() assert get_systemd() is sd nobodd-0.4/tests/test_tftp.py000066400000000000000000000054021457216553300163660ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2024 Dave Jones # Copyright (c) 2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import pytest from nobodd.tftp import * def test_rrq_init(): pkt = RRQPacket('foo.txt', 'octet', {'blksize': '1428'}) assert pkt.filename == 'foo.txt' assert pkt.mode == 'octet' assert pkt.options == {'blksize': '1428'} assert repr(pkt) == ( "RRQPacket(filename='foo.txt', mode='octet', options=" "FrozenDict({'blksize': '1428'}))") pkt = RRQPacket('bar.txt', 'netascii') assert pkt.filename == 'bar.txt' assert pkt.mode == 'netascii' assert pkt.options == {} def test_rrq_roundtrip(): pkt = Packet.from_bytes(b'\x00\x01foo.txt\x00octet\x00blksize\x001428\x00') pkt2 = Packet.from_bytes(bytes(pkt)) assert pkt.filename == pkt2.filename assert pkt.mode == pkt2.mode assert pkt.options == pkt2.options with pytest.raises(ValueError): Packet.from_bytes(b'\x00\x01foo.txt\x00') with pytest.raises(ValueError): Packet.from_bytes(b'\x00\x01foo.txt\x00ebcdic\x00\x00') def test_data_init(): pkt = DATAPacket('1', b'\0' * 512) assert pkt.block == 1 assert len(pkt.data) == 512 assert repr(pkt) == ( "DATAPacket(block=1, data=b'" + "\\x00" * 512 + "')") with pytest.raises(ValueError): DATAPacket(1000000, b'\0' * 512) def test_data_roundtrip(): pkt = Packet.from_bytes(b'\x00\x03\x00\x01' + b'\x00' * 512) pkt2 = Packet.from_bytes(bytes(pkt)) assert pkt.block == pkt2.block assert pkt.data == pkt2.data def test_ack_init(): pkt = ACKPacket('10') assert pkt.block == 10 with pytest.raises(ValueError): ACKPacket(1000000) def test_ack_roundtrip(): pkt = Packet.from_bytes(b'\x00\x04\x00\x0A') pkt2 = Packet.from_bytes(bytes(pkt)) assert pkt.block == pkt2.block def test_error_init(): pkt = ERRORPacket('1') assert pkt.error == Error.NOT_FOUND assert pkt.message == 'File not found' pkt = ERRORPacket('0', 'Everything is on fire') assert pkt.error == Error.UNDEFINED assert pkt.message == 'Everything is on fire' def test_error_roundtrip(): pkt = Packet.from_bytes(b'\x00\x05\x00\x01') pkt2 = Packet.from_bytes(bytes(pkt)) assert pkt.error == pkt2.error assert pkt.message == pkt2.message def test_oack_init(): pkt = OACKPacket({'blksize': '1428'}) assert pkt.options == {'blksize': '1428'} def test_oack_roundtrip(): pkt = Packet.from_bytes(b'\x00\x06blksize\x001428\x00') pkt2 = Packet.from_bytes(bytes(pkt)) assert pkt.options == pkt2.options def test_bad_init(): with pytest.raises(ValueError): Packet.from_bytes(b'\x00\x08\x00\x00\x00\x00') nobodd-0.4/tests/test_tftpd.py000066400000000000000000000700121457216553300165310ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2024 Dave Jones # Copyright (c) 2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import io import socket import select import logging from fnmatch import fnmatch from threading import Thread from time import sleep, monotonic from unittest import mock import pytest from nobodd.tools import BufferedTranscoder from nobodd.tftpd import * @pytest.fixture() def localhost(): return ('127.0.0.1', 54321) @pytest.fixture(scope='session') def tftp_root(tmp_path_factory): root = tmp_path_factory.mktemp('tftpd') # The .../files path forms the configured root of the tftp_server fixture # below and contains files that should be accessible. The .../private path # should remain deliberately inaccessible, outside the configured root (root / 'files').mkdir() (root / 'files' / 'a.dir').mkdir() (root / 'private').mkdir() return root / 'files' @pytest.fixture(scope='session') def cmdline_txt(tftp_root): p = tftp_root / 'cmdline.txt' p.write_text('nbdroot=server:image root=/dev/nbd0p2 quiet splash') p.chmod(0o444) return p @pytest.fixture(scope='session') def initrd_img(tftp_root): p = tftp_root / 'initrd.img' p.write_bytes(b'\x00' * 4096) p.chmod(0o444) return p @pytest.fixture(scope='session') def unreadable(tftp_root): p = tftp_root / 'unreadable.txt' p.write_text("Nah nah, can't read me!") p.chmod(0o222) return p @pytest.fixture(scope='session') def secret(tftp_root): p = tftp_root / '..' / 'private' / 'elmer.txt' p.write_text("I'm huntin' wabbits!") p.chmod(0o444) return p @pytest.fixture(scope='session') def tftp_server(tftp_root, cmdline_txt, initrd_img, unreadable, secret): # NOTE: Because this is a session scoped fixture, tests must ensure they # leave the server "clean" of connections after they finish; other tests # may rely upon the server having no outstanding connections with SimpleTFTPServer(('127.0.0.1', 0), tftp_root) as server: thread = Thread(target=server.serve_forever, daemon=True) thread.start() yield server server.shutdown() thread.join(10) assert not thread.is_alive() def wait_for_idle(server): start = monotonic() while server.subs._alive and monotonic() - start < 10: sleep(0.1) assert not server.subs._alive def match_records(record_tuples, patterns): return all( rec_facility == pat_facility and rec_level == pat_level and fnmatch(rec_message, pat_message) for (rec_facility, rec_level, rec_message), (pat_facility, pat_level, pat_message) in zip(record_tuples, patterns) ) def test_clientstate_init(localhost, cmdline_txt): state = TFTPClientState(localhost, cmdline_txt) assert state.address == localhost assert state.source.read assert state.mode == 'octet' assert state.blocks == {} assert state.blocks_read == 0 assert state.block_size == 512 assert state.timeout > 0 assert state.started > 0 assert state.last_recv == state.started assert state.last_send is None state = TFTPClientState(localhost, cmdline_txt, mode='netascii') assert state.address == localhost assert isinstance(state.source, BufferedTranscoder) def test_clientstate_negotiate(localhost, cmdline_txt): state = TFTPClientState(localhost, cmdline_txt) assert state.negotiate({}) == {} assert state.block_size == 512 state = TFTPClientState(localhost, cmdline_txt) assert state.negotiate({'blksize': '1428'}) == {'blksize': 1428} assert state.block_size == 1428 state = TFTPClientState(localhost, cmdline_txt) with pytest.raises(BadOptions): assert state.negotiate({'blksize': '1'}) # Negotiate transfer-size with "real" file which we can query with fstat state = TFTPClientState(localhost, cmdline_txt) assert state.negotiate({'tsize': '0'}) == { 'tsize': cmdline_txt.stat().st_size} # Negotiate with "fake", but still seekable file fake_file = io.BytesIO(cmdline_txt.read_bytes()) class fake_path: def open(mode='rb'): return fake_file state = TFTPClientState(localhost, fake_path) assert state.negotiate({'tsize': '0'}) == { 'tsize': cmdline_txt.stat().st_size} # Negotiate with a wrapped file we can't seek; reject option state = TFTPClientState(localhost, cmdline_txt, mode='netascii') assert state.negotiate({'tsize': '0'}) == {} state = TFTPClientState(localhost, cmdline_txt) assert state.negotiate({'timeout': '10'}) == {'timeout': '10'} assert state.timeout == 10_000_000_000 state = TFTPClientState(localhost, cmdline_txt) assert state.negotiate({ 'utimeout': '500000', 'timeout': '0.5'}) == {'utimeout': '500000'} assert state.timeout == 500_000_000 state = TFTPClientState(localhost, cmdline_txt) with pytest.raises(BadOptions): assert state.negotiate({'utimeout': '1000'}) def test_clientstate_transfer(localhost, initrd_img): # Simulate transmission of a large(ish) 10MB initrd.img; first block state = TFTPClientState(localhost, initrd_img) assert len(state.blocks) == 0 assert state.blocks_read == 0 assert state.transferred == 0 assert not state.finished assert state.get_block(1) == b'\x00' * state.block_size assert len(state.blocks) == 1 assert state.blocks_read == 1 assert state.transferred == 0 # not acknowledged yet assert not state.finished # Re-transmit first block assert state.get_block(1) == b'\x00' * state.block_size assert len(state.blocks) == 1 assert state.blocks_read == 1 assert state.transferred == 0 assert not state.finished # First block acknowledged; check we ignore already ACK'd blocks state.ack(1) assert len(state.blocks) == 0 assert state.blocks_read == 1 assert state.transferred == state.block_size with pytest.raises(AlreadyAcknowledged): state.get_block(1) assert state.transferred == state.block_size assert not state.finished # Invalid future request with pytest.raises(ValueError): state.get_block(3) # Transfer the rest of the file; ensure sub-block-size last block is # required even when it's empty (because file-size is an exact multiple of # block size) and that TransferDone is raised correctly last_block = ( initrd_img.stat().st_size + (state.block_size - 1)) // state.block_size for block in range(2, last_block + 1): assert state.get_block(block) == b'\x00' * state.block_size state.ack(block) assert state.transferred == state.block_size * block assert state.get_block(last_block + 1) == b'' state.ack(last_block + 1) with pytest.raises(TransferDone): state.get_block(last_block + 2) assert state.finished def test_tftp_rrq_transfer(tftp_server, caplog): with \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client, \ caplog.at_level(logging.INFO): # All tests start with this to ensure previous tests have left the # session-scoped server in a "clean" state (see note in the tftp_server # fixture above) assert not tftp_server.subs._alive client.settimeout(10) client.sendto( bytes(RRQPacket('cmdline.txt', 'octet')), tftp_server.server_address) buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, DATAPacket) assert pkt.block == 1 assert pkt.data == b'nbdroot=server:image root=/dev/nbd0p2 quiet splash' # Responses come from the same address, but *not* the same port as the # initial request (an ephemeral port is allocated per transfer) assert addr[0] == tftp_server.server_address[0] assert addr[1] != tftp_server.server_address[1] assert tftp_server.subs._alive # Be nice and ACK the DATA packet, then wait for the server to idle client.sendto(bytes(ACKPacket(pkt.block)), addr) wait_for_idle(tftp_server) assert match_records(caplog.record_tuples, [ ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - RRQ (octet) cmdline.txt'), ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - DONE - * secs, * bytes, ~* Kb/s'), ]) def test_tftp_rrq_transfer_repeat_ack(tftp_server, caplog): with \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client, \ caplog.at_level(logging.INFO): assert not tftp_server.subs._alive client.settimeout(10) client.sendto( bytes(RRQPacket('initrd.img', 'octet')), tftp_server.server_address) for block, offset in enumerate(range(0, 4096, 512), start=1): buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, DATAPacket) assert pkt.block == block assert pkt.data == b'\0' * 512 # ACK the received packet client.sendto(bytes(ACKPacket(pkt.block)), addr) # ACK the first DATA packet repeatedly after we've received the # first block; this should be ignored and should not cause repeated # transfers (after later packets are ACKed) if block > 1: client.sendto(bytes(ACKPacket(1)), addr) # Final packet is empty (because length is a multiple of block size) buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, DATAPacket) assert pkt.block == 9 assert pkt.data == b'' # ACK the last packet and wait for the ephemeral server to finish client.sendto(bytes(ACKPacket(pkt.block)), addr) wait_for_idle(tftp_server) assert match_records(caplog.record_tuples, [ ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - RRQ (octet) initrd.img'), ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - DONE - * secs, * bytes, ~* Kb/s'), ]) def test_tftp_rrq_transfer_future_ack(tftp_server, caplog): with \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client, \ caplog.at_level(logging.INFO): assert not tftp_server.subs._alive client.settimeout(10) client.sendto( bytes(RRQPacket('initrd.img', 'octet')), tftp_server.server_address) buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, DATAPacket) assert pkt.block == 1 assert pkt.data == b'\0' * 512 # ACK a packet we haven't seen yet; this should return an ERROR packet # and terminate the transfer client.sendto(bytes(ACKPacket(2)), addr) buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, ERRORPacket) assert pkt.error == Error.UNDEFINED wait_for_idle(tftp_server) assert match_records(caplog.record_tuples, [ ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - RRQ (octet) initrd.img'), ]) def test_tftp_rrq_transfer_resend(tftp_server, caplog): with \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client, \ caplog.at_level(logging.INFO): assert not tftp_server.subs._alive client.settimeout(10) client.sendto( bytes(RRQPacket('cmdline.txt', 'octet')), tftp_server.server_address) buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, DATAPacket) assert pkt.block == 1 assert pkt.data == b'nbdroot=server:image root=/dev/nbd0p2 quiet splash' # Don't ACK the packet and await the resend after timeout buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, DATAPacket) assert pkt.block == 1 assert pkt.data == b'nbdroot=server:image root=/dev/nbd0p2 quiet splash' # Now we can ACK the packet client.sendto(bytes(ACKPacket(pkt.block)), addr) wait_for_idle(tftp_server) assert match_records(caplog.record_tuples, [ ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - RRQ (octet) cmdline.txt'), ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - DONE - * secs, * bytes, ~* Kb/s'), ]) def test_tftp_rrq_transfer_with_options(tftp_server, caplog): with \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client, \ caplog.at_level(logging.INFO): assert not tftp_server.subs._alive client.settimeout(10) client.sendto( bytes(RRQPacket('cmdline.txt', 'octet', {'blksize': '128'})), tftp_server.server_address) buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, OACKPacket) assert pkt.options == {'blksize': '128'} # Responses come from the same address, but *not* the same port as the # initial request (an ephemeral port is allocated per transfer) assert addr[0] == tftp_server.server_address[0] assert addr[1] != tftp_server.server_address[1] client.sendto(bytes(ACKPacket(0)), addr) buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, DATAPacket) assert pkt.block == 1 assert pkt.data == b'nbdroot=server:image root=/dev/nbd0p2 quiet splash' # Be nice and ACK the DATA packet client.sendto(bytes(ACKPacket(pkt.block)), addr) wait_for_idle(tftp_server) assert match_records(caplog.record_tuples, [ ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - RRQ (octet) cmdline.txt'), ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - DONE - * secs, * bytes, ~* Kb/s'), ]) def test_tftp_rrq_transfer_resend_and_die(tftp_server, caplog): with \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client, \ caplog.at_level(logging.INFO): assert not tftp_server.subs._alive # We're using a ludicrously short timeout below (the minimum permitted) # just to keep the test quick client.settimeout(10) client.sendto( bytes(RRQPacket('cmdline.txt', 'octet', {'utimeout': '10000'})), tftp_server.server_address) buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, OACKPacket) assert pkt.options == {'utimeout': '10000'} client.sendto(bytes(ACKPacket(0)), addr) # Now don't ACK any further DATA packets and just wait for the server # to keep retrying until it gives up retries = 0 while select.select([client], [], [], 0.1)[0]: buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, DATAPacket) assert pkt.block == 1 assert pkt.data == b'nbdroot=server:image root=/dev/nbd0p2 quiet splash' retries += 1 # We should receive at least 4 retries before the server finally gives # up, but oddities of timing can mean less so relax the test a bit assert retries >= 3 wait_for_idle(tftp_server) # No need to ACK the packet; the server's given up by this point assert match_records(caplog.record_tuples, [ ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - RRQ (octet) cmdline.txt'), ('tftpd', logging.WARNING, f'127.0.0.1:{client.getsockname()[1]} - timed out to 127.0.0.1:{addr[1]}'), ]) def test_tftp_rrq_transfer_bad_options(tftp_server, caplog): with \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client, \ caplog.at_level(logging.INFO): assert not tftp_server.subs._alive client.settimeout(10) client.sendto( bytes(RRQPacket('cmdline.txt', 'octet', {'blksize': '1'})), tftp_server.server_address) buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, ERRORPacket) assert pkt.error == Error.INVALID_OPT wait_for_idle(tftp_server) assert match_records(caplog.record_tuples, [ ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - RRQ (octet) cmdline.txt'), ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - ERROR - bad options; silly block size'), ]) def test_tftp_rrq_transfer_bad_filename(tftp_server, caplog): with \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client, \ caplog.at_level(logging.INFO): assert not tftp_server.subs._alive client.settimeout(10) client.sendto( bytes(RRQPacket('foo.txt', 'octet')), tftp_server.server_address) buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, ERRORPacket) assert pkt.error == Error.NOT_FOUND wait_for_idle(tftp_server) assert match_records(caplog.record_tuples, [ ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - RRQ (octet) foo.txt'), ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - ERROR - not found'), ]) def test_tftp_rrq_unknown_error(tftp_server, caplog): with \ mock.patch('nobodd.tftpd.TFTPBaseHandler.do_RRQ') as do_rrq, \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client, \ caplog.at_level(logging.INFO): assert not tftp_server.subs._alive do_rrq.side_effect = TypeError('something weird happened') client.settimeout(10) client.sendto( bytes(RRQPacket('cmdline.txt', 'octet')), tftp_server.server_address) buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, ERRORPacket) assert pkt.error == Error.UNDEFINED wait_for_idle(tftp_server) assert match_records(caplog.record_tuples, [ ('tftpd', logging.ERROR, f'127.0.0.1:{client.getsockname()[1]} - ERROR - unexpected error; ' f'something weird happened'), ]) def test_tftp_rrq_os_error(tftp_server, caplog): with \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client, \ caplog.at_level(logging.INFO): assert not tftp_server.subs._alive client.settimeout(10) client.sendto( bytes(RRQPacket('a.dir', 'octet')), tftp_server.server_address) buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, ERRORPacket) assert pkt.error == Error.UNDEFINED wait_for_idle(tftp_server) assert match_records(caplog.record_tuples, [ ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - RRQ (octet) a.dir'), ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - ERROR - * Is a directory: *'), ]) def test_tftp_rrq_transfer_permission_error1(tftp_server, caplog): with \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client, \ caplog.at_level(logging.INFO): assert not tftp_server.subs._alive client.settimeout(10) client.sendto( bytes(RRQPacket('unreadable.txt', 'octet')), tftp_server.server_address) buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, ERRORPacket) assert pkt.error == Error.NOT_AUTH wait_for_idle(tftp_server) assert match_records(caplog.record_tuples, [ ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - RRQ (octet) unreadable.txt'), ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - ERROR - permission denied'), ]) def test_tftp_rrq_transfer_permission_error2(tftp_server, caplog): with \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client, \ caplog.at_level(logging.INFO): assert not tftp_server.subs._alive client.settimeout(10) client.sendto( bytes(RRQPacket('../private/elmer.txt', 'octet')), tftp_server.server_address) buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, ERRORPacket) assert pkt.error == Error.NOT_AUTH wait_for_idle(tftp_server) assert match_records(caplog.record_tuples, [ ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - RRQ (octet) ../private/elmer.txt'), ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - ERROR - permission denied'), ]) def test_tftp_wrq_transfer(tftp_server, caplog): with \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client, \ caplog.at_level(logging.INFO): assert not tftp_server.subs._alive client.settimeout(10) client.sendto( bytes(WRQPacket('cmdline.txt', 'octet')), tftp_server.server_address) buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, ERRORPacket) assert pkt.error == Error.UNDEFINED assert pkt.message == ( "Unsupported operation, 'SimpleTFTPHandler' object has no " "attribute 'do_WRQ'") wait_for_idle(tftp_server) assert match_records(caplog.record_tuples, [ ('tftpd', logging.WARNING, f'127.0.0.1:{client.getsockname()[1]} - ERROR - unsupported operation; ' "'SimpleTFTPHandler' object has no attribute 'do_WRQ'"), ]) def test_tftp_client_error1(tftp_server, caplog): with \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client, \ caplog.at_level(logging.INFO): assert not tftp_server.subs._alive client.settimeout(10) client.sendto( bytes(ERRORPacket(Error.UNDEFINED)), tftp_server.server_address) # If client sends an error on the main connection, it's simply # ignored... assert select.select([client], [], [], 0.1) == ([], [], []) wait_for_idle(tftp_server) # ... to the extent it's not even logging, because this is a valid way # to terminate a connection assert caplog.record_tuples == [] def test_tftp_client_error2(tftp_server, caplog): with \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client, \ caplog.at_level(logging.INFO): assert not tftp_server.subs._alive client.settimeout(10) client.sendto( bytes(RRQPacket('initrd.img', 'octet')), tftp_server.server_address) buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, DATAPacket) assert pkt.block == 1 assert pkt.data == b'\0' * 512 # If client sends an error on the ephemeral connection, it simply # terminates the transfer immediately client.sendto(bytes(ERRORPacket(Error.UNDEFINED)), addr) wait_for_idle(tftp_server) assert match_records(caplog.record_tuples, [ ('tftpd', logging.INFO, f'127.0.0.1:{client.getsockname()[1]} - RRQ (octet) initrd.img'), ]) def test_tftp_bad_request(tftp_server, caplog): with \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client, \ caplog.at_level(logging.INFO): assert not tftp_server.subs._alive client.settimeout(10) client.sendto(b'\x00\x08\x00\x00\x00', tftp_server.server_address) buf, addr = client.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, ERRORPacket) assert pkt.error == Error.UNDEFINED assert pkt.message == 'Invalid request, invalid packet opcode 8' wait_for_idle(tftp_server) assert match_records(caplog.record_tuples, [ ('tftpd', logging.WARNING, f'127.0.0.1:{client.getsockname()[1]} - ERROR - invalid request; ' f'invalid packet opcode 8'), ]) def test_tftp_bad_client(tftp_server, caplog): with \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client1, \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client2, \ caplog.at_level(logging.INFO): assert not tftp_server.subs._alive # Start a valid transfer from client1... client1.settimeout(10) client1.sendto( bytes(RRQPacket('cmdline.txt', 'octet', {'blksize': '128'})), tftp_server.server_address) buf, addr = client1.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, OACKPacket) assert pkt.options == {'blksize': '128'} assert addr[0] == tftp_server.server_address[0] assert addr[1] != tftp_server.server_address[1] # Now have client2 hijack the ephemeral port of client1 and try to # talk to the server with an otherwise valid response. This should be # ignored by the server... client2.settimeout(10) client2.sendto(bytes(ACKPacket(0)), addr) # ...client1 should be able to talk, however client1.sendto(bytes(ACKPacket(0)), addr) assert select.select( [client1, client2], [], [], 0.1) == ([client1], [], []) buf, addr = client1.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, DATAPacket) assert pkt.block == 1 # Be nice and ACK the DATA packet from client1, then wait for server # completion client1.sendto(bytes(ACKPacket(pkt.block)), addr) wait_for_idle(tftp_server) assert match_records(caplog.record_tuples, [ ('tftpd', logging.INFO, f'127.0.0.1:{client1.getsockname()[1]} - RRQ (octet) cmdline.txt'), ('tftpd', logging.WARNING, f'127.0.0.1:{client2.getsockname()[1]} - IGNORE - bad client ' f'for 127.0.0.1:{addr[1]}'), ('tftpd', logging.INFO, f'127.0.0.1:{client1.getsockname()[1]} - DONE - * secs, * bytes, ~* Kb/s'), ]) def test_tftp_shuts_down_transfers(tftp_root, cmdline_txt): # Set up our own one-shot SimpleTFTPServer as we need to shut it down # during this test... with \ SimpleTFTPServer(('127.0.0.1', 0), tftp_root) as server, \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client1, \ socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client2: \ thread = Thread(target=server.serve_forever, daemon=True) thread.start() assert not server.subs._alive # Start a valid transfer from client1... client1.settimeout(10) client1.sendto( bytes(RRQPacket('cmdline.txt', 'octet', {'blksize': '128'})), server.server_address) buf, addr = client1.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, OACKPacket) assert pkt.options == {'blksize': '128'} # Start another valid transfer from client2... client2.settimeout(10) client2.sendto( bytes(RRQPacket('cmdline.txt', 'octet', {'blksize': '128'})), server.server_address) buf, addr = client2.recvfrom(1500) pkt = Packet.from_bytes(buf) assert isinstance(pkt, OACKPacket) assert pkt.options == {'blksize': '128'} # Now, with transfers active, shut down the server and ensure by the # time it terminates, the _alive dict has been emptied out print(server.subs._alive) server.shutdown() # This test has to be outside the "with" context because the exiting of the # context is what calls server.server_close (which is what, in turn, causes # the sub-servers thread to shutdown all the ephemeral server threads) thread.join(10) assert not thread.is_alive() assert not server.subs._alive nobodd-0.4/tests/test_tools.py000066400000000000000000000113401457216553300165470ustar00rootroot00000000000000# nobodd: a boot configuration tool for the Raspberry Pi # # Copyright (c) 2024 Dave Jones # Copyright (c) 2024 Canonical Ltd. # # SPDX-License-Identifier: GPL-3.0 import re import socket import datetime as dt from textwrap import dedent from unittest import mock import pytest from nobodd.tools import * @pytest.fixture() def ebpb(request): return dedent(""" B drive_number 1x reserved B extended_boot_sig 4s volume_id 11s volume_label 8s file_system """) def test_labels(ebpb): assert labels(ebpb) == ( 'drive_number', 'extended_boot_sig', 'volume_id', 'volume_label', 'file_system' ) def test_formats(ebpb): assert formats(ebpb) == '