diff options
| -rw-r--r-- | LICENSE | 695 | ||||
| -rw-r--r-- | README.org | 264 | ||||
| -rwxr-xr-x | rsyncshot | 1047 | ||||
| -rwxr-xr-x | tests/cases/test_backup.sh | 239 | ||||
| -rwxr-xr-x | tests/cases/test_cron.sh | 138 | ||||
| -rwxr-xr-x | tests/cases/test_dryrun.sh | 120 | ||||
| -rwxr-xr-x | tests/cases/test_includes.sh | 161 | ||||
| -rwxr-xr-x | tests/cases/test_validation.sh | 106 | ||||
| -rwxr-xr-x | tests/lib/test_helpers.sh | 247 | ||||
| -rwxr-xr-x | tests/test_rsyncshot.sh | 120 | ||||
| -rw-r--r-- | todo.org | 73 |
11 files changed, 2925 insertions, 285 deletions
@@ -1,21 +1,674 @@ -MIT License - -Copyright (c) 2020 Craig Jennings - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + 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. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + 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 <https://www.gnu.org/licenses/>. + +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: + + <program> Copyright (C) <year> <name of author> + 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 +<https://www.gnu.org/licenses/>. + + 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 +<https://www.gnu.org/licenses/why-not-lgpl.html>. @@ -1,73 +1,215 @@ -* About rsyncshot -Linux backups backup with bash, cron, rsync, and hard links. -Inspired by http://www.mikerubel.org/computers/rsync_snapshots/ -* Usage -** Simple Setup -rsyncshot installs itself. To setup, just run the following commands: +#+TITLE: rsyncshot +#+AUTHOR: Craig Jennings + +* About + +rsyncshot creates space-efficient backups using rsync and hard links. Each snapshot looks like a full backup, but unchanged files share disk space across all snapshots. + +Supports backing up to local drives (USB, NFS) or remote servers via SSH. + +Inspired by [[http://www.mikerubel.org/computers/rsync_snapshots/][Mike Rubel's rsync snapshots]]. + +* Quick Start + #+begin_src sh - wget https://raw.githubusercontent.com/cjennings/rsyncshot/main/rsyncshot - sudo bash ./rsyncshot setup +# Download and install +wget https://raw.githubusercontent.com/cjennings/rsyncshot/main/rsyncshot +sudo bash ./rsyncshot setup + +# Or clone and install +git clone https://github.com/cjennings/rsyncshot +cd rsyncshot && sudo make install #+end_src -or simply clone this repo and run make install as root. -** Automatic Hourly/Daily/Weekly Schedule via Cron -rsyncshot will install a default schedule: -- every hour at the top of the hour -- every day at noon -- every week on Sunday at noon - -Edit the schedule with: + +After setup, edit =/etc/rsyncshot/config= to set your backup destination. + +* Commands + +| Command | Description | +|----------------------------+------------------------------------------| +| =rsyncshot backup= | Run an immediate one-off backup | +| =rsyncshot <name> <count>= | Create snapshot with name and retention | +| =rsyncshot setup= | Install script and configure cron jobs | +| =rsyncshot status= | Show installation and environment status | +| =rsyncshot list= | Show existing snapshots with sizes | +| =rsyncshot dryrun <n> <c>= | Preview backup without making changes | +| =rsyncshot help= | Show help message | + +** Examples + #+begin_src sh -sudo crontab -e +# Immediate backup (creates manual.0) +sudo rsyncshot backup + +# Keep 24 hourly snapshots +sudo rsyncshot hourly 24 + +# Keep 7 daily snapshots +sudo rsyncshot daily 7 + +# Preview what would be backed up +sudo rsyncshot dryrun manual 1 + +# Check if everything is configured correctly +sudo rsyncshot status + +# See existing snapshots +sudo rsyncshot list #+end_src -** Manual Backups -Manual backups in the terminal with two arguments: -- TYPE is a way to group backups together. Automatic backups will have the TYPE as HOURLY, DAILY, or WEEKLY. But you can give your manual backups any TYPE you wish. -- MAX is the maximum number of backups before the oldest gets removed. -For instance: +* Configuration + +All settings live in =/etc/rsyncshot/config=. Created automatically by =rsyncshot setup=. + +** Backup Modes + +*** Remote Mode (SSH) + +Back up to a remote server over SSH: + +#+begin_src sh +REMOTE_HOST="myserver" +REMOTE_PATH="/mnt/backups" +#+end_src + +Backups go to =myserver:/mnt/backups/hostname/=. + +If your SSH key isn't in root's =~/.ssh/=, specify the path: + #+begin_src sh -rsyncshot manual 100 +SSH_IDENTITY_FILE="/home/youruser/.ssh/id_ed25519" #+end_src -** Filtering -Specify which directories to backup by editing /etc/rsyncshot/includes.txt. The defaults are: +*** Local Mode + +Back up to a mounted drive (USB, NFS, etc.): + +#+begin_src sh +REMOTE_HOST="" +MOUNTDIR="/media/backup" +#+end_src + +Backups go to =/media/backup/hostname/=. If the drive isn't mounted, rsyncshot will try to mount it. + +** What Gets Backed Up + +Edit =/etc/rsyncshot/include.txt= - one directory per line (supports paths with spaces): + #+begin_src - - /home - - /etc - - /usr/local/bin +/home +/etc +/usr/local/bin +# Comments start with # #+end_src -Specify what filetype patterns to exclude by editing /etc/rsyncshot/excludes.txt. The defaults are: + +** What Gets Excluded + +Edit =/etc/rsyncshot/exclude.txt= - one pattern per line: + #+begin_src - - *.pyc - - *.pyo - - *.class - - *.elc - - *.o - - *.tmp - - .cache* +# Caches and temp files +.cache +*.tmp +*.log + +# Build artifacts +node_modules +__pycache__ +*.pyc #+end_src -** A Few Safeguards -- rsyncshot separates backups by the machine's name. Use one external drive to backup multiple machines without conflicts. -- rsyncshot leverages lockfiles to prevent overapping runs. A rsyncshot backup will not begin if a previous run is still in progress. -- rsyncshot will validate the specified source directories exist before beginning the backup. -- rsyncshot validates it's backing up to a mounted drive. If the drive isn't mounted, rsyncshot attempts to mount it. -- Backup directory permissions are changed to read-only to avoid mistaken deletion. -- rsyncshot logs the details of all runs to /var/log/rsyncshot.log. -** Requirements -- Bash -- Cron -- Rsync -- Grep -- Flock -** Uninstalling -- run 'sudo make uninstall' from the cloned directory. -- delete relevant cron entries as root - -or become root and remove -- the /usr/local/bin/rsyncshot script -- the /etc/rsyncshot directory -- (optional) /var/log/rsyncshot.log -... and the relevant cron entries with crontab -e - -* Notes -rsyncshot isn't production software, even though the underlying utilities are. + +* Automatic Backups + +Setup installs a default cron schedule: + +| Type | Schedule | Retention | +|--------+-----------------------------+-----------| +| Hourly | Every hour, 1am-11pm | 22 | +| Daily | Noon, Monday-Saturday | 6 | +| Weekly | Noon, Sunday | 51 | + +Edit with =sudo crontab -e=. + +* How It Works + +1. rsync copies your directories to =destination/latest/= +2. Oldest snapshot beyond retention count is deleted +3. Existing snapshots rotate (=hourly.0= → =hourly.1= → =hourly.2=...) +4. =latest/= is hard-linked to =hourly.0= (or whatever type you specified) + +Hard links mean unchanged files share disk space. A 100GB backup with 24 hourly snapshots might only use 110GB total if most files don't change. + +* Safeguards + +- *Separate by hostname* - one drive can back up multiple machines +- *Lockfile* - prevents overlapping runs +- *Validates sources* - checks directories exist before starting +- *Validates destination* - checks mount or SSH connectivity +- *Checks rsync exit code* - won't rotate if backup failed +- *Read-only snapshots* - prevents accidental deletion +- *Timestamped logging* - all runs logged to =/var/log/rsyncshot.log= + +* Requirements + +- bash +- rsync +- cron +- grep +- flock +- ssh (for remote mode) + +The script checks for rsync and ssh at startup and shows install instructions if missing. + +* Uninstalling + +#+begin_src sh +# If you cloned the repo +sudo make uninstall + +# Or manually +sudo rm /usr/local/bin/rsyncshot +sudo rm -rf /etc/rsyncshot +sudo rm /var/log/rsyncshot.log # optional +sudo crontab -e # remove rsyncshot entries +#+end_src + +* Testing + +An automated test suite is included in the =tests/= directory. + +** Running Tests + +#+begin_src sh +# Run all tests (requires sudo) +sudo ./tests/test_rsyncshot.sh + +# Skip slow backup/cron tests +sudo ./tests/test_rsyncshot.sh --quick + +# Verbose output +sudo ./tests/test_rsyncshot.sh -v +#+end_src + +** Test Coverage + +| Category | Tests | Description | +|-------------+-------+------------------------------------------------------| +| Validation | 6 | Input validation, help command, argument checking | +| Include | 5 | Path parsing, comments, empty lines, spaces in paths | +| Dry-run | 4 | Preview mode doesn't modify anything | +| Backup | 7 | Directory creation, file copying, rotation, retention, exclusions | +| Cron | 3 | Cron job management, no duplicates, preserves existing jobs | + +** Test Structure + +- =tests/test_rsyncshot.sh= - Main test runner +- =tests/lib/test_helpers.sh= - Assertion functions and test environment setup +- =tests/cases/test_validation.sh= - Input validation tests +- =tests/cases/test_includes.sh= - Include file parsing tests +- =tests/cases/test_dryrun.sh= - Dry-run mode tests +- =tests/cases/test_backup.sh= - Backup and rotation tests +- =tests/cases/test_cron.sh= - Cron job management tests + +* License + +GPL v3. See [[file:LICENSE][LICENSE]] file. @@ -1,188 +1,975 @@ #!/usr/bin/env bash -# rsyncshot -# convenient backups using rsync and hard links - -# Craig Jennings <c@cjennings.net> -# Inspired by Mike Rubel: http://www.mikerubel.org/computers/rsync_snapshots/ -# requirements: bash, rsync, flock, cron, grep -# - unix filesystem capable of hard links at destination -# - core unix utilities: rm, mv, cp, touch - -# debugging: uncomment next 4 lines for debugging output +# ============================================================================== +# rsyncshot - Compact Snapshots Using rsync and Hard Links +# ============================================================================== +# +# Creates space-efficient incremental backups using rsync and hard links. +# Supports both local mount destinations (USB drives, NFS) and remote SSH. +# +# Author: Craig Jennings <c@cjennings.net> +# Inspired by: Mike Rubel (http://www.mikerubel.org/computers/rsync_snapshots/) +# +# REQUIREMENTS: +# - bash, rsync, flock, cron, grep +# - Unix filesystem capable of hard links at destination +# - Core unix utilities: rm, mv, cp, touch +# - For remote mode: ssh with key-based authentication +# (set SSH_IDENTITY_FILE in config if key is not in root's ~/.ssh/) +# +# USAGE: +# rsyncshot <name> <count> Run backup with snapshot name and retention count +# rsyncshot backup Run immediate one-off backup (alias for 'manual 1') +# rsyncshot setup Install script, create config files, add cron jobs +# rsyncshot status Show installation and environment status +# rsyncshot list Show existing snapshots and sizes +# rsyncshot dryrun <n> <c> Preview backup without making changes +# rsyncshot help Display usage information +# +# EXAMPLES: +# rsyncshot hourly 24 Keep 24 hourly snapshots (hourly.0 through hourly.23) +# rsyncshot daily 7 Keep 7 daily snapshots +# rsyncshot weekly 4 Keep 4 weekly snapshots +# rsyncshot manual 1 One-off backup (manual.0 only) +# rsyncshot dryrun hourly 24 Preview what hourly backup would do +# rsyncshot list Show all snapshots with timestamps and sizes +# +# CONFIGURATION: +# Settings are stored in /etc/rsyncshot/config (created by setup). +# Edit this file to change backup destination, not the script itself. +# +# HOW IT WORKS: +# 1. rsync copies source directories to <destination>/latest/ +# 2. Oldest snapshot beyond retention count is deleted +# 3. Existing snapshots are rotated (name.0 -> name.1, name.1 -> name.2, etc.) +# 4. <destination>/latest/ is hard-linked to <destination>/name.0 +# +# Hard links mean unchanged files share disk space across all snapshots. +# Only modified files consume additional space. +# +# ============================================================================== + +# ------------------------------------------------------------------------------ +# DEBUG MODE +# ------------------------------------------------------------------------------ +# Uncomment the following 4 lines to enable verbose debug output to syslog. +# Useful for troubleshooting cron job failures. +# # exec 5> >(logger -t $0) # BASH_XTRACEFD="5" # PS4='$LINENO: ' # set -x -# default locations for setup -# modify MOUNTDIR to point to the mount point of your backup - -# ---------------------------- Constants ---------------------------- - -MOUNTDIR=/media/backup; -SCRIPTLOC=/usr/local/bin/rsyncshot; -DESTINATION=$MOUNTDIR/$HOSTNAME - -INSTALLHOME=/etc/rsyncshot -LOGFILE=/var/log/rsyncshot.log; - -INCLUDES="$INSTALLHOME/include.txt"; -EXCLUDES="$INSTALLHOME/exclude.txt"; +# ============================================================================== +# DEFAULT CONFIGURATION +# ============================================================================== +# These defaults are used if /etc/rsyncshot/config doesn't exist or doesn't +# define a value. After running 'rsyncshot setup', edit /etc/rsyncshot/config +# to customize settings. + +# ------------------------------------------------------------------------------ +# BACKUP MODE SELECTION +# ------------------------------------------------------------------------------ +# rsyncshot supports two backup modes: +# +# REMOTE MODE (SSH): +# Backups are sent over the network via SSH to a remote server. +# Set REMOTE_HOST to the SSH hostname or IP address. +# Set REMOTE_PATH to the base backup directory on the remote server. +# The script will create <REMOTE_PATH>/<hostname>/ for this machine's backups. +# +# Example: +# REMOTE_HOST="truenas" +# REMOTE_PATH="/mnt/vault/Backups" +# Result: Backups go to truenas:/mnt/vault/Backups/myhostname/ +# +# LOCAL MODE (Mount): +# Backups are written to a locally mounted filesystem (USB drive, NFS, etc.). +# Set REMOTE_HOST="" (empty) to enable local mode. +# Set MOUNTDIR to the mount point of your backup drive. +# The script will attempt to mount the filesystem if not already mounted. +# +# Example: +# REMOTE_HOST="" +# MOUNTDIR="/media/backup" +# Result: Backups go to /media/backup/myhostname/ +# +# ------------------------------------------------------------------------------ + +# Remote mode settings (defaults) +REMOTE_HOST="" +REMOTE_PATH="" + +# SSH identity file (optional, for remote mode) +# If root's SSH key is in a non-standard location (e.g., a user's home directory), +# specify the path here. Leave empty to use SSH's default key discovery. +# Example: SSH_IDENTITY_FILE="/home/cjennings/.ssh/id_ed25519" +SSH_IDENTITY_FILE="" + +# Local mode settings (defaults) +MOUNTDIR="/media/backup" + +# ------------------------------------------------------------------------------ +# INSTALLATION PATHS +# ------------------------------------------------------------------------------ + +# Where to install the script for system-wide access +# These can be overridden via environment variables for testing +SCRIPTLOC="${SCRIPTLOC:-/usr/local/bin/rsyncshot}" + +# Directory for configuration files +INSTALLHOME="${INSTALLHOME:-/etc/rsyncshot}" + +# Configuration file path +CONFIGFILE="$INSTALLHOME/config" + +# Log file location for cron job output +LOGFILE="${LOGFILE:-/var/log/rsyncshot.log}" + +# Paths to include and exclude configuration files +INCLUDES="$INSTALLHOME/include.txt" # Directories to back up (one per line) +EXCLUDES="$INSTALLHOME/exclude.txt" # Patterns to exclude (one per line) + +# ------------------------------------------------------------------------------ +# COMMAND ALIASES +# ------------------------------------------------------------------------------ +# Use absolute paths to avoid issues with shell aliases that might add +# interactive prompts (e.g., alias rm='rm -i'). Only used in local mode. -# copy, move, and rm commands have been aliased by the user or distro -# creator to require confirmation on certain actions. -# using variables allows us to sidestep any alias CP="/usr/bin/cp" MV="/usr/bin/mv" RM="/usr/bin/rm" -# prevent overlapping runs with flock +# ------------------------------------------------------------------------------ +# CONCURRENCY CONTROL +# ------------------------------------------------------------------------------ +# Use flock to prevent multiple instances from running simultaneously. +# This is important when cron might trigger a new run before the previous +# one completes (e.g., slow network, large backup). + FLOCKCHECK="flock -x /tmp/rsyncshot.lock -c" -# default cron job entries -CRON_H="0 1-23 * * * "; # hourly on minute 0 from 1am to 11pm -CRON_D="0 12 * * 1-6 "; # daily at noon, monday - saturday -CRON_W="0 12 * * 7 "; # weekly at noon on sundays +# ------------------------------------------------------------------------------ +# DEFAULT CRON SCHEDULE +# ------------------------------------------------------------------------------ +# These schedules are installed by 'rsyncshot setup'. Modify as needed. +# +# Format: minute hour day-of-month month day-of-week +# +# Default schedule: +# - Hourly: Every hour from 1am to 11pm (not midnight to avoid daily/weekly) +# - Daily: Noon, Monday through Saturday +# - Weekly: Noon on Sundays + +CRON_H="0 1-23 * * * " # Hourly: minute 0, hours 1-23, every day +CRON_D="0 12 * * 1-6 " # Daily: noon, Monday-Saturday +CRON_W="0 12 * * 7 " # Weekly: noon, Sunday + +# ============================================================================== +# LOAD CONFIGURATION FILE +# ============================================================================== +# Source the config file if it exists, overriding defaults above. +# This allows customization without editing the script. + +if [ -f "$CONFIGFILE" ]; then + # shellcheck source=/etc/rsyncshot/config + source "$CONFIGFILE" +fi + +# ------------------------------------------------------------------------------ +# DERIVED PATHS (auto-configured based on mode) +# ------------------------------------------------------------------------------ +# These are calculated from the settings above. Do not modify directly. + +if [ -n "$REMOTE_HOST" ]; then + # Remote mode: backup destination is on a remote server via SSH + REMOTE_DEST="$REMOTE_PATH/$HOSTNAME" # Full path on remote server + DESTINATION="$REMOTE_HOST:$REMOTE_DEST" # rsync-compatible remote path + MODE="remote" +else + # Local mode: backup destination is a locally mounted filesystem + DESTINATION="$MOUNTDIR/$HOSTNAME" # Local backup path + MODE="local" +fi + +# ============================================================================== +# UTILITY FUNCTIONS +# ============================================================================== -# ------------------------ Utility Functions ------------------------ +# ------------------------------------------------------------------------------ +# help() - Display usage information +# ------------------------------------------------------------------------------ +# Shows command syntax, current mode, and configuration details. help() { printf "\nrsyncshot - compact snapshots on Linux using rsync and hard links.\n\n" - printf "Usage:\nrsyncshot <name> <number of backups to retain>\n" - printf " setup (installs rsyncshot and cron jobs)\n" - printf " help (prints this info)\n" - printf "Notes:\n" - printf '%s\n' "- rsyncshot must be run as root" - printf '%s\n\n' "- install and log locations defined in script." + printf "Usage:\n" + printf " rsyncshot <name> <count> Create snapshot with given name and retention count\n" + printf " rsyncshot backup Run immediate one-off backup\n" + printf " rsyncshot setup Install script and configure cron jobs\n" + printf " rsyncshot status Show installation and environment status\n" + printf " rsyncshot list Show existing snapshots with sizes\n" + printf " rsyncshot dryrun <n> <c> Preview backup without making changes\n" + printf " rsyncshot help Show this help message\n" + printf "\nExamples:\n" + printf " rsyncshot hourly 24 Keep 24 hourly snapshots\n" + printf " rsyncshot daily 7 Keep 7 daily snapshots\n" + printf " rsyncshot backup Immediate one-off backup\n" + printf " rsyncshot dryrun manual 1 Preview a manual backup\n" + printf "\nConfiguration:\n" + printf " Config file: %s\n" "$CONFIGFILE" + printf " Includes: %s\n" "$INCLUDES" + printf " Excludes: %s\n" "$EXCLUDES" + printf "\nCurrent settings:\n" + printf '%s\n' "- rsyncshot must be run as root" + + # Display mode-specific configuration + if [ "$MODE" = "remote" ]; then + printf '%s\n' "- Mode: remote (SSH)" + printf '%s\n' "- Remote host: $REMOTE_HOST" + printf '%s\n\n' "- Remote path: $REMOTE_PATH/$HOSTNAME" + else + printf '%s\n' "- Mode: local (mount)" + printf '%s\n\n' "- Mount dir: $MOUNTDIR/$HOSTNAME" + fi } +# ------------------------------------------------------------------------------ +# error() - Display error message and exit +# ------------------------------------------------------------------------------ +# Arguments: +# $@ - Error message to display +# +# Outputs error to stderr and exits with code 1. + error() { - echo "ERROR: $0:" "$@" 1>&2; + echo "ERROR: $0:" "$@" 1>&2 echo "See \"rsyncshot help\" for usage." - exit 1; + exit 1 +} + +# ------------------------------------------------------------------------------ +# log() - Print timestamped log message +# ------------------------------------------------------------------------------ +# Arguments: +# $@ - Message to log +# +# Outputs message with ISO timestamp prefix. + +log() +{ + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +# ------------------------------------------------------------------------------ +# run_cmd() - Execute a command locally or remotely based on mode +# ------------------------------------------------------------------------------ +# Arguments: +# $@ - Command to execute (as a single string for remote, or arguments for local) +# +# In remote mode: Executes command on REMOTE_HOST via SSH +# In local mode: Executes command locally using eval +# +# If SSH_IDENTITY_FILE is set, uses that key for SSH connections. +# This abstraction allows the same rotation logic to work in both modes. + +run_cmd() +{ + if [ "$MODE" = "remote" ]; then + if [ -n "$SSH_IDENTITY_FILE" ]; then + ssh -i "$SSH_IDENTITY_FILE" "$REMOTE_HOST" "$@" + else + ssh "$REMOTE_HOST" "$@" + fi + else + eval "$@" + fi +} + +# ------------------------------------------------------------------------------ +# get_base_path() - Return the base backup path for file operations +# ------------------------------------------------------------------------------ +# Returns the path where snapshots are stored, appropriate for the current mode. +# +# Remote mode: Returns the path on the remote server (for use with run_cmd) +# Local mode: Returns the local filesystem path +# +# This is needed because rsync uses "host:path" format, but ssh commands +# and local operations need just the path portion. + +get_base_path() +{ + if [ "$MODE" = "remote" ]; then + echo "$REMOTE_DEST" + else + echo "$MOUNTDIR/$HOSTNAME" + fi +} + +# ------------------------------------------------------------------------------ +# list_snapshots() - Display existing snapshots with timestamps and sizes +# ------------------------------------------------------------------------------ +# Shows all snapshot directories at the backup destination, sorted by name, +# with modification time and disk usage. + +list_snapshots() +{ + # Verify we can access the destination + if [ "$MODE" = "remote" ]; then + SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=5" + if [ -n "$SSH_IDENTITY_FILE" ]; then + SSH_OPTS="$SSH_OPTS -i $SSH_IDENTITY_FILE" + fi + if ! ssh $SSH_OPTS "$REMOTE_HOST" "true" 2>/dev/null; then + error "Cannot connect to $REMOTE_HOST via SSH." + fi + else + if [ ! -d "$MOUNTDIR" ]; then + error "$MOUNTDIR doesn't exist." + fi + if ! grep -qs "$MOUNTDIR" /proc/mounts 2>/dev/null; then + error "$MOUNTDIR is not mounted." + fi + fi + + BASE_PATH=$(get_base_path) + + echo "" + echo "Snapshots at: $DESTINATION" + echo "============================================================" + + # Build command to list snapshots with details + # Format: name, modification time, size + LIST_CMD=" + if [ -d '$BASE_PATH' ]; then + cd '$BASE_PATH' + found=0 + for dir in */ ; do + if [ -d \"\$dir\" ] && [ \"\$dir\" != '*/' ]; then + found=1 + name=\$(basename \"\$dir\") + mtime=\$(stat -c '%y' \"\$dir\" 2>/dev/null | cut -d'.' -f1) + size=\$(du -sh \"\$dir\" 2>/dev/null | cut -f1) + printf '%-20s %s %s\n' \"\$name\" \"\$mtime\" \"\$size\" + fi + done | sort + if [ \"\$found\" -eq 0 ]; then + echo 'No snapshots found.' + fi + else + echo 'No snapshots found.' + fi + " + + run_cmd "$LIST_CMD" + echo "" } +# ------------------------------------------------------------------------------ +# show_status() - Display installation and environment status +# ------------------------------------------------------------------------------ +# Shows comprehensive status of rsyncshot installation, configuration, +# destination accessibility, and cron job status. + +show_status() +{ + echo "" + echo "rsyncshot status" + echo "============================================================" + + # --- Installation status --- + echo "" + echo "Installation:" + if [ -x "$SCRIPTLOC" ]; then + echo " ✓ Script installed at $SCRIPTLOC" + else + echo " ✗ Script NOT installed (run 'rsyncshot setup')" + fi + + # --- Configuration files --- + echo "" + echo "Configuration:" + if [ -f "$CONFIGFILE" ]; then + echo " ✓ Config file: $CONFIGFILE" + else + echo " ✗ Config file NOT found: $CONFIGFILE" + fi + + if [ -f "$INCLUDES" ]; then + # Count non-empty, non-comment lines + INCLUDE_COUNT=$(grep -cv '^[[:space:]]*#\|^[[:space:]]*$' "$INCLUDES" 2>/dev/null || echo 0) + echo " ✓ Include file: $INCLUDES ($INCLUDE_COUNT directories)" + else + echo " ✗ Include file NOT found: $INCLUDES" + fi + + if [ -f "$EXCLUDES" ]; then + EXCLUDE_COUNT=$(grep -cv '^#\|^$' "$EXCLUDES" 2>/dev/null || echo 0) + echo " ✓ Exclude file: $EXCLUDES ($EXCLUDE_COUNT patterns)" + else + echo " ✗ Exclude file NOT found: $EXCLUDES" + fi + + # --- Backup mode and destination --- + echo "" + echo "Backup Destination:" + echo " Mode: $MODE" + + if [ "$MODE" = "remote" ]; then + echo " Remote host: $REMOTE_HOST" + echo " Remote path: $REMOTE_PATH/$HOSTNAME" + + if [ -n "$SSH_IDENTITY_FILE" ]; then + if [ -f "$SSH_IDENTITY_FILE" ]; then + echo " ✓ SSH key: $SSH_IDENTITY_FILE" + else + echo " ✗ SSH key NOT found: $SSH_IDENTITY_FILE" + fi + else + echo " SSH key: (using default)" + fi + + # Test SSH connectivity + SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=5" + if [ -n "$SSH_IDENTITY_FILE" ]; then + SSH_OPTS="$SSH_OPTS -i $SSH_IDENTITY_FILE" + fi + if ssh $SSH_OPTS "$REMOTE_HOST" "true" 2>/dev/null; then + echo " ✓ SSH connection: OK" + + # Check if backup directory exists + if run_cmd "[ -d '$REMOTE_DEST' ]" 2>/dev/null; then + echo " ✓ Backup directory exists" + else + echo " ✗ Backup directory NOT found (will be created on first backup)" + fi + else + echo " ✗ SSH connection: FAILED" + fi + else + echo " Mount point: $MOUNTDIR" + + if [ -d "$MOUNTDIR" ]; then + echo " ✓ Mount point exists" + + if grep -qs "$MOUNTDIR" /proc/mounts 2>/dev/null; then + echo " ✓ Filesystem mounted" + + if [ -d "$MOUNTDIR/$HOSTNAME" ]; then + echo " ✓ Backup directory exists" + else + echo " ✗ Backup directory NOT found (will be created on first backup)" + fi + else + echo " ✗ Filesystem NOT mounted" + fi + else + echo " ✗ Mount point does NOT exist" + fi + fi + + # --- Cron jobs --- + echo "" + echo "Scheduled Backups:" + CRON_JOBS=$(crontab -l 2>/dev/null | grep -c "rsyncshot" || echo 0) + if [ "$CRON_JOBS" -gt 0 ]; then + echo " ✓ $CRON_JOBS cron job(s) installed:" + crontab -l 2>/dev/null | grep "rsyncshot" | while read -r line; do + # Extract the backup type from the cron line + if echo "$line" | grep -q "hourly"; then + SCHED=$(echo "$line" | awk '{print $1, $2, $3, $4, $5}') + echo " - hourly: $SCHED" + elif echo "$line" | grep -q "daily"; then + SCHED=$(echo "$line" | awk '{print $1, $2, $3, $4, $5}') + echo " - daily: $SCHED" + elif echo "$line" | grep -q "weekly"; then + SCHED=$(echo "$line" | awk '{print $1, $2, $3, $4, $5}') + echo " - weekly: $SCHED" + fi + done + else + echo " ✗ No cron jobs installed (run 'rsyncshot setup')" + fi + + # --- Log file --- + echo "" + echo "Logging:" + if [ -f "$LOGFILE" ]; then + LOG_SIZE=$(du -h "$LOGFILE" 2>/dev/null | cut -f1) + LOG_LINES=$(wc -l < "$LOGFILE" 2>/dev/null) + echo " ✓ Log file: $LOGFILE ($LOG_SIZE, $LOG_LINES lines)" + + # Show last backup time from log + LAST_BACKUP=$(grep "completed successfully" "$LOGFILE" 2>/dev/null | tail -1) + if [ -n "$LAST_BACKUP" ]; then + # Extract timestamp from [YYYY-MM-DD HH:MM:SS] format (portable, no Perl regex) + LAST_TIME=$(echo "$LAST_BACKUP" | sed 's/.*\[\([^]]*\)\].*/\1/') + echo " Last successful backup: $LAST_TIME" + fi + else + echo " ✗ Log file NOT found: $LOGFILE" + fi + + # --- Snapshots summary --- + echo "" + echo "Snapshots:" + if [ "$MODE" = "remote" ]; then + if ssh $SSH_OPTS "$REMOTE_HOST" "true" 2>/dev/null; then + SNAP_COUNT=$(run_cmd "ls -d '$REMOTE_DEST'/*/ 2>/dev/null | wc -l" 2>/dev/null || echo 0) + if [ "$SNAP_COUNT" -gt 0 ]; then + echo " $SNAP_COUNT snapshot(s) found (run 'rsyncshot list' for details)" + else + echo " No snapshots found" + fi + else + echo " (cannot check - SSH connection failed)" + fi + else + if [ -d "$MOUNTDIR/$HOSTNAME" ]; then + SNAP_COUNT=$(ls -d "$MOUNTDIR/$HOSTNAME"/*/ 2>/dev/null | wc -l || echo 0) + if [ "$SNAP_COUNT" -gt 0 ]; then + echo " $SNAP_COUNT snapshot(s) found (run 'rsyncshot list' for details)" + else + echo " No snapshots found" + fi + else + echo " (backup directory does not exist)" + fi + fi + + echo "" +} + +# ------------------------------------------------------------------------------ +# setup() - Install rsyncshot and configure the system +# ------------------------------------------------------------------------------ +# Performs initial setup: +# 1. Verifies connectivity (SSH for remote, mount point for local) +# 2. Copies script to /usr/local/bin/rsyncshot +# 3. Creates /etc/rsyncshot/ directory +# 4. Creates config file with current settings +# 5. Creates default include.txt (directories to back up) +# 6. Creates default exclude.txt (patterns to skip) +# 7. Creates backup destination directory +# 8. Installs cron jobs for automated backups +# +# After setup, edit /etc/rsyncshot/config, include.txt, and exclude.txt as needed. + setup() { - # copy this file to directory on path and make executable - $CP -f "$0" "$SCRIPTLOC" - sudo chmod +x "$SCRIPTLOC" - echo "$0 copied to $SCRIPTLOC and made executable" - - # make install home if it doesn't exist; - if [ ! -d $INSTALLHOME ]; then - mkdir -p $INSTALLHOME; - echo "Created install home at $INSTALLHOME"; - fi - - # create includes file and add default entries - if [ -f $INCLUDES ]; then $RM $INCLUDES; fi - printf "/home /etc /usr/local/bin" >> $INCLUDES; - echo "modify include file at $INCLUDES"; - - # create excludes file and add default entries - if [ -f $EXCLUDES ]; then $RM $EXCLUDES; fi - printf "*.pyc\n*.pyo\n*.class\n*.elc\n*.o\n*.tmp\n.cache*" >> $EXCLUDES; - echo "modify exclude file at $EXCLUDES"; - - # write out current crontab, append default entries, and install - touch "$LOGFILE" - crontab -l > crontemp; - { - echo "$CRON_H $FLOCKCHECK '$SCRIPTLOC hourly 22 >> $LOGFILE 2>&1'" - echo "$CRON_D $FLOCKCHECK '$SCRIPTLOC daily 6 >> $LOGFILE 2>&1'" - echo "$CRON_W $FLOCKCHECK '$SCRIPTLOC weekly 51 >> $LOGFILE 2>&1'" - } >> crontemp - crontab crontemp; - $RM crontemp; - echo "hourly, daily, and weekly cron jobs installed."; + # --- Verify destination is accessible --- + if [ "$MODE" = "remote" ]; then + # Remote mode: test SSH connectivity with key-based auth + echo "Testing SSH connection to $REMOTE_HOST..." + SSH_TEST_OPTS="-o BatchMode=yes -o ConnectTimeout=5" + if [ -n "$SSH_IDENTITY_FILE" ]; then + SSH_TEST_OPTS="$SSH_TEST_OPTS -i $SSH_IDENTITY_FILE" + fi + if ! ssh $SSH_TEST_OPTS "$REMOTE_HOST" "echo 'SSH connection successful'" 2>/dev/null; then + error "Cannot connect to $REMOTE_HOST via SSH. Ensure SSH key auth is configured." + fi + else + # Local mode: verify mount directory exists + if [ ! -d "$MOUNTDIR" ]; then + error "$MOUNTDIR doesn't exist. Create it or update MOUNTDIR in script." + fi + fi + + # --- Install script to system path --- + $CP -f "$0" "$SCRIPTLOC" + chmod +x "$SCRIPTLOC" + echo "$0 copied to $SCRIPTLOC and made executable" + + # --- Create configuration directory --- + if [ ! -d "$INSTALLHOME" ]; then + mkdir -p "$INSTALLHOME" + echo "Created install home at $INSTALLHOME" + fi + + # --- Create config file --- + # Contains the key settings that users will want to customize + cat > "$CONFIGFILE" << EOF +# ============================================================================== +# rsyncshot configuration +# ============================================================================== +# Edit this file to customize backup settings. +# Changes take effect on next rsyncshot run. + +# ------------------------------------------------------------------------------ +# BACKUP MODE +# ------------------------------------------------------------------------------ +# Set REMOTE_HOST for SSH mode, or leave empty for local mount mode. + +# Remote mode (SSH to a server): +REMOTE_HOST="$REMOTE_HOST" +REMOTE_PATH="$REMOTE_PATH" + +# SSH identity file (optional, for remote mode) +# If running as root but your SSH key is in your user's home directory, +# specify the full path here. Leave empty to use SSH's default key discovery. +# Example: SSH_IDENTITY_FILE="/home/username/.ssh/id_ed25519" +SSH_IDENTITY_FILE="$SSH_IDENTITY_FILE" + +# Local mode (USB drive, NFS mount, etc.): +# REMOTE_HOST="" +MOUNTDIR="$MOUNTDIR" +EOF + echo "Created config file at $CONFIGFILE" + + # --- Create include file (directories to back up) --- + # Default: /home (user data), /etc (system config), /usr/local/bin (custom scripts) + # Format: one directory per line (supports paths with spaces) + if [ -f "$INCLUDES" ]; then $RM "$INCLUDES"; fi + cat >> "$INCLUDES" << 'EOF' +/home +/etc +/usr/local/bin +EOF + echo "Modify include file at $INCLUDES" + + # --- Create exclude file (patterns to skip) --- + # Default excludes: caches, temp files, compiled code, editor backups + # Format: one pattern per line (rsync --exclude-from format) + if [ -f "$EXCLUDES" ]; then $RM "$EXCLUDES"; fi + cat >> "$EXCLUDES" << 'EOF' +# Compiled/bytecode files +*.pyc +*.pyo +*.class +*.elc +*.o + +# Temporary files +*.tmp +*.swp +*~ + +# Cache directories +.cache +.cache* +*/.cache/* + +# Trash and browser caches +.local/share/Trash +.mozilla/firefox/*/cache2 +.thunderbird/*/ImapMail + +# Package manager caches and build artifacts +node_modules +__pycache__ + +# Log files (usually regenerated) +*.log +EOF + echo "Modify exclude file at $EXCLUDES" + + # --- Create backup destination directory --- + BASE_PATH=$(get_base_path) + echo "Creating backup directory..." + run_cmd "mkdir -p '$BASE_PATH'" + + # --- Install cron jobs --- + # Remove any existing rsyncshot entries, then add fresh ones + # This prevents duplicate entries if setup is run multiple times + touch "$LOGFILE" + CRONTEMP=$(mktemp) + crontab -l 2>/dev/null | grep -v "rsyncshot" > "$CRONTEMP" || true + { + echo "# rsyncshot automated backups" + echo "$CRON_H $FLOCKCHECK '$SCRIPTLOC hourly 22 >> $LOGFILE 2>&1'" + echo "$CRON_D $FLOCKCHECK '$SCRIPTLOC daily 6 >> $LOGFILE 2>&1'" + echo "$CRON_W $FLOCKCHECK '$SCRIPTLOC weekly 51 >> $LOGFILE 2>&1'" + } >> "$CRONTEMP" + crontab "$CRONTEMP" + $RM "$CRONTEMP" + echo "Hourly, daily, and weekly cron jobs installed." + + # --- Display summary --- + echo "" + echo "Setup complete. Configuration:" + echo " Mode: $MODE" + if [ "$MODE" = "remote" ]; then + echo " Remote host: $REMOTE_HOST" + echo " Remote path: $REMOTE_DEST" + else + echo " Mount dir: $MOUNTDIR" + echo " Destination: $DESTINATION" + fi + echo " Config file: $CONFIGFILE" + echo " Includes: $INCLUDES" + echo " Excludes: $EXCLUDES" + echo " Log file: $LOGFILE" + echo "" + echo "Next steps:" + echo " 1. Edit $CONFIGFILE to set backup destination" + echo " 2. Edit $INCLUDES to specify directories to back up" + echo " 3. Edit $EXCLUDES to add any additional exclusion patterns" + echo " 4. Run 'sudo rsyncshot dryrun manual 1' to preview" + echo " 5. Run 'sudo rsyncshot manual 1' to test" } -# ----------------------------- Script ---------------------------- +# ============================================================================== +# MAIN SCRIPT +# ============================================================================== -# uppercase for case-insensitivity -TYPE=$(tr '[a-z]' '[A-Z]' <<< $1); +# --- Handle help command (before root check, so anyone can view help) --- +TYPE=$(tr '[:lower:]' '[:upper:]' <<< "$1") if [ "$TYPE" = "HELP" ]; then help; exit; fi -# ensure we're running as root +# --- Verify running as root --- +# Root is required for: +# - Reading all files in /home and /etc +# - Writing to /var/log and /usr/local/bin +# - Installing cron jobs if [ "$EUID" -ne 0 ]; then error "This script must be run as root."; fi -# display start information -echo "rsyncshot invoked on $(date -u) with: $0 $1 $2"; +# --- Verify required commands exist --- +if ! command -v rsync &> /dev/null; then + echo "ERROR: rsync is not installed." 1>&2 + echo "" + echo "Install rsync using your package manager:" + echo " Arch Linux: sudo pacman -S rsync" + echo " Debian/Ubuntu: sudo apt install rsync" + echo " Fedora/RHEL: sudo dnf install rsync" + echo " macOS: brew install rsync" + exit 1 +fi + +if ! command -v flock &> /dev/null; then + echo "ERROR: flock is not installed (required for cron job locking)." 1>&2 + echo "" + echo "Install flock using your package manager:" + echo " Arch Linux: sudo pacman -S util-linux" + echo " Debian/Ubuntu: sudo apt install util-linux" + echo " Fedora/RHEL: sudo dnf install util-linux" + echo " macOS: brew install flock" + exit 1 +fi + +if [ "$MODE" = "remote" ] && ! command -v ssh &> /dev/null; then + echo "ERROR: ssh is not installed (required for remote mode)." 1>&2 + echo "" + echo "Install openssh using your package manager:" + echo " Arch Linux: sudo pacman -S openssh" + echo " Debian/Ubuntu: sudo apt install openssh-client" + echo " Fedora/RHEL: sudo dnf install openssh-clients" + exit 1 +fi + +# --- Handle list command --- +if [ "$TYPE" = "LIST" ]; then list_snapshots; exit; fi -# if logfile was removed, recreate it. -[ ! -f "$LOGFILE" ] || touch "$LOGFILE" +# --- Handle status command --- +if [ "$TYPE" = "STATUS" ]; then show_status; exit; fi -# validate backup type -# first argument must be alpha characters -if ! [[ $1 =~ [a-zA-Z] ]]; then error "snapshot type not recognized."; fi +# --- Handle backup command (immediate one-off backup) --- +# 'rsyncshot backup' is a convenient alias for 'rsyncshot manual 1' +if [ "$TYPE" = "BACKUP" ]; then + set -- "manual" "1" # Replace arguments + TYPE="MANUAL" +fi + +# --- Handle dryrun command --- +# Dryrun mode: show what would be backed up without making changes +DRYRUN=false +if [ "$TYPE" = "DRYRUN" ]; then + DRYRUN=true + shift # Remove 'dryrun' from arguments, process remaining as normal + TYPE=$(tr '[:lower:]' '[:upper:]' <<< "$1") +fi + +# --- Log invocation --- +if [ "$DRYRUN" = true ]; then + log "rsyncshot DRYRUN invoked with: $0 $1 $2 (mode: $MODE)" +else + log "rsyncshot invoked with: $0 $1 $2 (mode: $MODE)" +fi + +# --- Ensure log file exists --- +[ -f "$LOGFILE" ] || touch "$LOGFILE" + +# --- Validate first argument (snapshot type) --- +# Must be alphabetic only (e.g., "hourly", "daily", "weekly", "manual") +if ! [[ $1 =~ ^[a-zA-Z]+$ ]]; then error "snapshot type must be alphabetic (e.g., hourly, daily, manual)."; fi + +# --- Handle setup command --- if [ "$TYPE" = "SETUP" ]; then setup; exit; fi -# validate max snapshots -# second argument must be numeric -if ! [[ $2 =~ [0-9] ]]; then error "max snapshots not a number."; fi -MAX=$(($2-1)); +# --- Validate second argument (retention count) --- +# Must be numeric only (e.g., 24 for 24 hourly snapshots) +if ! [[ $2 =~ ^[0-9]+$ ]]; then error "max snapshots must be a number (e.g., 24)."; fi +MAX=$(($2-1)) # Convert count to max index (e.g., 24 -> 23 for indices 0-23) -# validate include file (source directories) exist -# validates the include file exists, and checks the file contents are valid directories +# --- Validate include file exists and contains valid directories --- if [ ! -f "$INCLUDES" ]; then error "include file $INCLUDES not found."; fi -SOURCES=$(<$INCLUDES); -for SOURCE in $SOURCES -do + +# Read include file line by line (supports paths with spaces) +while IFS= read -r SOURCE || [ -n "$SOURCE" ]; do + # Skip empty lines and comments + [[ -z "$SOURCE" || "$SOURCE" =~ ^[[:space:]]*# ]] && continue if [ ! -d "$SOURCE" ]; then error "source $SOURCE not found"; fi -done +done < "$INCLUDES" -# validate exclude file (exclusion patterns) exist +# --- Validate exclude file exists --- if [ ! -f "$EXCLUDES" ]; then error "Exclude file $EXCLUDES not found."; fi -[ -d $MOUNTDIR ] || error "$MOUNTDIR doesn't exist." +# --- Mode-specific destination validation --- +if [ "$MODE" = "remote" ]; then + # Verify SSH identity file exists if specified + if [ -n "$SSH_IDENTITY_FILE" ] && [ ! -f "$SSH_IDENTITY_FILE" ]; then + error "SSH identity file not found: $SSH_IDENTITY_FILE" + fi -# if destination filesystem not mounted attempt mounting; error if attempt fails -if ! grep -qs "$MOUNTDIR" /proc/mounts >> /dev/null 2>&1; then - mount "$MOUNTDIR" >> /dev/null 2>&1 - if ! grep -qs "$MOUNTDIR" /proc/mounts >> /dev/null 2>&1; then - error "$MOUNTDIR not mounted and mount attempt failed." - fi + # Remote mode: verify SSH connectivity + # Uses BatchMode to fail immediately if key auth doesn't work + # Uses ConnectTimeout to fail quickly if host is unreachable + SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=10" + if [ -n "$SSH_IDENTITY_FILE" ]; then + SSH_OPTS="$SSH_OPTS -i $SSH_IDENTITY_FILE" + fi + if ! ssh $SSH_OPTS "$REMOTE_HOST" "true" 2>/dev/null; then + error "Cannot connect to $REMOTE_HOST via SSH." + fi +else + # Local mode: verify mount point exists + [ -d "$MOUNTDIR" ] || error "$MOUNTDIR doesn't exist." + + # Attempt to mount if not already mounted + # This supports fstab entries with 'noauto' option + # RSYNCSHOT_SKIP_MOUNT_CHECK can be set for testing purposes + if [ "${RSYNCSHOT_SKIP_MOUNT_CHECK:-}" != "1" ]; then + if ! grep -qs "$MOUNTDIR" /proc/mounts >> /dev/null 2>&1; then + mount "$MOUNTDIR" >> /dev/null 2>&1 + if ! grep -qs "$MOUNTDIR" /proc/mounts >> /dev/null 2>&1; then + error "$MOUNTDIR not mounted and mount attempt failed." + fi + fi + fi fi -[ -d "$DESTINATION" ] || mkdir "$DESTINATION" || \ - error "$DESTINATION doesn't exist, and attempt to create directory failed." +# --- Get base path for file operations --- +BASE_PATH=$(get_base_path) -# sync each backup directory in turn -for SOURCE in $SOURCES -do - rsync -avh -i --times \ - --delete --delete-excluded \ - --exclude-from="$EXCLUDES" \ - --update "$SOURCE" "$DESTINATION"/latest ; -done +# --- Ensure destination directory structure exists --- +if [ "$DRYRUN" = false ]; then + run_cmd "mkdir -p '$BASE_PATH/latest'" +fi -# delete max+1 snapshot if it exists -if [ -d "$DESTINATION"/"$TYPE"."$MAX" ]; then - $RM -rf "$DESTINATION"/"$TYPE"."$MAX"; +# ============================================================================== +# BACKUP PHASE: Sync source directories to destination +# ============================================================================== +# rsync options: +# -a Archive mode (preserves permissions, timestamps, symlinks, etc.) +# -v Verbose output +# -h Human-readable sizes +# --times Preserve modification times +# --delete Delete files in destination that don't exist in source +# --delete-excluded Also delete excluded files from destination +# --exclude-from Read exclusion patterns from file +# --dry-run Show what would be transferred without making changes + +# Track if any rsync operation fails +RSYNC_FAILED=false + +# Set RSYNC_RSH environment variable if using custom SSH identity +# rsync automatically uses this variable for the remote shell command +if [ "$MODE" = "remote" ] && [ -n "$SSH_IDENTITY_FILE" ]; then + export RSYNC_RSH="ssh -i $SSH_IDENTITY_FILE" +fi + +# Read include file and sync each directory +while IFS= read -r SOURCE || [ -n "$SOURCE" ]; do + # Skip empty lines and comments + [[ -z "$SOURCE" || "$SOURCE" =~ ^[[:space:]]*# ]] && continue + + log "Syncing $SOURCE to $DESTINATION/latest" + + if [ "$DRYRUN" = true ]; then + # Dry run: show what would be transferred + rsync -avh --times \ + --delete --delete-excluded \ + --exclude-from="$EXCLUDES" \ + --dry-run \ + "$SOURCE" "$DESTINATION"/latest + RSYNC_EXIT=$? + else + # Actual backup + rsync -avh --times \ + --delete --delete-excluded \ + --exclude-from="$EXCLUDES" \ + "$SOURCE" "$DESTINATION"/latest + RSYNC_EXIT=$? + fi + # Check rsync exit code + if [ $RSYNC_EXIT -ne 0 ]; then + echo "ERROR: rsync failed for $SOURCE (exit code: $RSYNC_EXIT)" 1>&2 + RSYNC_FAILED=true + fi +done < "$INCLUDES" + +# --- Abort if rsync failed --- +# Don't rotate snapshots if the backup didn't complete successfully +if [ "$RSYNC_FAILED" = true ]; then + error "Backup failed. Snapshot rotation skipped to preserve existing backups." fi -# rotate remaining snapshots descending -for (( start=$((MAX)); start>=0; start--)); do - end=$((start+1)); - if [ -d "$DESTINATION"/"$TYPE".$start ]; then - $MV "$DESTINATION"/"$TYPE".$start "$DESTINATION"/"$TYPE".$end; - fi +# --- Exit here if dry run --- +if [ "$DRYRUN" = true ]; then + log "Dry run complete. No changes were made." + echo "" + echo "To perform actual backup, run without 'dryrun':" + echo " sudo rsyncshot $1 $2" + exit 0 +fi + +# ============================================================================== +# ROTATION PHASE: Manage snapshot history +# ============================================================================== +# Snapshot naming: TYPE.N where N is the age (0 = newest, MAX = oldest) +# +# Example with "hourly 4" (keep 4 snapshots, indices 0-3, MAX=3): +# Before: hourly.0, hourly.1, hourly.2, hourly.3 +# Step 1: Delete hourly.3 (oldest, beyond retention) +# Step 2: Rotate: hourly.2 -> hourly.3, hourly.1 -> hourly.2, hourly.0 -> hourly.1 +# Step 3: Hard-link latest/ to hourly.0 (newest snapshot) +# After: hourly.0 (new), hourly.1, hourly.2, hourly.3 + +log "Rotating snapshots..." + +# --- Delete oldest snapshot if it exceeds retention count --- +if run_cmd "[ -d '$BASE_PATH/$TYPE.$MAX' ]"; then + log "Deleting oldest snapshot: $TYPE.$MAX" + run_cmd "rm -rf '$BASE_PATH/$TYPE.$MAX'" +fi + +# --- Rotate existing snapshots (newest to oldest to avoid overwriting) --- +for (( start=$((MAX)); start>=0; start-- )); do + end=$((start+1)) + if run_cmd "[ -d '$BASE_PATH/$TYPE.$start' ]"; then + log "Rotating: $TYPE.$start -> $TYPE.$end" + run_cmd "mv '$BASE_PATH/$TYPE.$start' '$BASE_PATH/$TYPE.$end'" + fi done -# reset directory timestamp -touch "$DESTINATION"/latest +# --- Update timestamp on latest/ directory --- +run_cmd "touch '$BASE_PATH/latest'" + +# --- Create new snapshot using hard links --- +# cp -al creates a copy where all files are hard links to the originals. +# This is instant and uses no additional disk space for unchanged files. +# Only files that differ between snapshots consume extra space. +log "Creating new snapshot: $TYPE.0" +run_cmd "cp -al '$BASE_PATH/latest' '$BASE_PATH/$TYPE.0'" -# hard link / copy to destination -$CP -al "$DESTINATION"/latest "$DESTINATION"/"$TYPE".0; +# --- Make snapshot read-only to prevent accidental modification --- +run_cmd "chmod -w '$BASE_PATH/$TYPE.0'" -# make directory type read-only -chmod -w "$DESTINATION"/"$TYPE".0 +# ============================================================================== +# COMPLETION +# ============================================================================== -# print time and exit -echo "rsyncshot completed $(date -u) "; -exit 0; +log "rsyncshot completed successfully" +exit 0 diff --git a/tests/cases/test_backup.sh b/tests/cases/test_backup.sh new file mode 100755 index 0000000..1ab4fbd --- /dev/null +++ b/tests/cases/test_backup.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# ============================================================================== +# Backup and Rotation Tests +# ============================================================================== + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh" + +# ------------------------------------------------------------------------------ +# Test: Creates backup directory structure +# ------------------------------------------------------------------------------ +test_creates_backup_structure() { + setup_test_env + + create_test_config + create_test_includes + create_test_excludes + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1) + local exit_code=$? + + # Check directory structure + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME" "should create hostname dir" || { + teardown_test_env + return 1 + } + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest" "should create latest dir" || { + teardown_test_env + return 1 + } + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "should create MANUAL.0 snapshot" || { + teardown_test_env + return 1 + } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Test: Copies files to backup +# ------------------------------------------------------------------------------ +test_copies_files() { + setup_test_env + + create_test_config + create_test_includes + create_test_excludes + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1) + + # Check files were copied + assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/home/testuser/file1.txt" "should copy file1.txt" || { + teardown_test_env + return 1 + } + assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/etc/test.conf" "should copy test.conf" || { + teardown_test_env + return 1 + } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Test: Snapshot is read-only +# ------------------------------------------------------------------------------ +test_snapshot_readonly() { + setup_test_env + + create_test_config + create_test_includes + create_test_excludes + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1) + + # Check snapshot directory has no write permission + # Note: We use stat to check actual permissions because -w always returns true for root + local perms + perms=$(stat -c '%A' "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" 2>/dev/null) + if [[ "$perms" == *w* ]]; then + echo "FAIL: Snapshot should be read-only (perms: $perms)" + teardown_test_env + return 1 + fi + + teardown_test_env + return 0 +} + +# ------------------------------------------------------------------------------ +# Test: Rotation works correctly +# ------------------------------------------------------------------------------ +test_rotation() { + setup_test_env + + create_test_config + create_test_includes + create_test_excludes + + # Run backup twice + sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 2>&1 >/dev/null + sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 2>&1 >/dev/null + + # Should have MANUAL.0 and MANUAL.1 + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "should have MANUAL.0" || { + teardown_test_env + return 1 + } + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.1" "should have MANUAL.1 after rotation" || { + teardown_test_env + return 1 + } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Test: Deletes oldest snapshot beyond retention +# ------------------------------------------------------------------------------ +test_retention_limit() { + setup_test_env + + create_test_config + create_test_includes + create_test_excludes + + # Run backup 4 times with retention of 3 + for i in 1 2 3 4; do + sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 3 2>&1 >/dev/null + done + + # Should have MANUAL.0, MANUAL.1, MANUAL.2 but NOT MANUAL.3 + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "should have MANUAL.0" || { + teardown_test_env + return 1 + } + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.1" "should have MANUAL.1" || { + teardown_test_env + return 1 + } + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.2" "should have MANUAL.2" || { + teardown_test_env + return 1 + } + assert_dir_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.3" "should NOT have MANUAL.3 (beyond retention)" || { + teardown_test_env + return 1 + } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Test: Backup command works as alias +# ------------------------------------------------------------------------------ +test_backup_command() { + setup_test_env + + create_test_config + create_test_includes + create_test_excludes + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" backup 2>&1) + local exit_code=$? + + # Should create MANUAL.0 (backup is alias for manual 1) + assert_dir_exists "$TEST_BACKUP_DIR/$HOSTNAME/MANUAL.0" "backup should create MANUAL.0" || { + teardown_test_env + return 1 + } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Test: Excludes files matching patterns +# ------------------------------------------------------------------------------ +test_excludes_patterns() { + setup_test_env + + create_test_config + create_test_includes + + # Create exclude file + cat > "$TEST_CONFIG_DIR/exclude.txt" << 'EOF' +*.tmp +*.log +EOF + + # Create files that should be excluded + echo "temp" > "$TEST_SOURCE_DIR/home/testuser/temp.tmp" + echo "log" > "$TEST_SOURCE_DIR/home/testuser/app.log" + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" manual 1 2>&1) + + # Excluded files should not exist in backup + assert_file_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/home/testuser/temp.tmp" "should exclude .tmp files" || { + teardown_test_env + return 1 + } + assert_file_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/home/testuser/app.log" "should exclude .log files" || { + teardown_test_env + return 1 + } + # Regular files should still exist + assert_file_exists "$TEST_BACKUP_DIR/$HOSTNAME/latest/home/testuser/file1.txt" "should include regular files" || { + teardown_test_env + return 1 + } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Run tests +# ------------------------------------------------------------------------------ +run_backup_tests() { + echo "" + echo "Running backup tests..." + echo "------------------------------------------------------------" + + run_test "creates backup directory structure" test_creates_backup_structure + run_test "copies files to backup" test_copies_files + run_test "snapshot is read-only" test_snapshot_readonly + run_test "rotation works correctly" test_rotation + run_test "respects retention limit" test_retention_limit + run_test "backup command works as alias" test_backup_command + run_test "excludes files matching patterns" test_excludes_patterns +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + run_backup_tests + print_summary +fi diff --git a/tests/cases/test_cron.sh b/tests/cases/test_cron.sh new file mode 100755 index 0000000..2fdb6f5 --- /dev/null +++ b/tests/cases/test_cron.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# ============================================================================== +# Cron Job Management Tests +# ============================================================================== + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh" + +# Save original crontab to restore later +ORIGINAL_CRONTAB="" + +save_crontab() { + ORIGINAL_CRONTAB=$(crontab -l 2>/dev/null || true) +} + +restore_crontab() { + if [ -n "$ORIGINAL_CRONTAB" ]; then + echo "$ORIGINAL_CRONTAB" | crontab - + else + crontab -r 2>/dev/null || true + fi +} + +# ------------------------------------------------------------------------------ +# Test: Setup adds cron jobs +# ------------------------------------------------------------------------------ +test_setup_adds_cron_jobs() { + setup_test_env + save_crontab + + # Clear existing crontab + crontab -r 2>/dev/null || true + + # Create minimal config + cat > "$TEST_CONFIG_DIR/config" << EOF +REMOTE_HOST="" +MOUNTDIR="$TEST_BACKUP_DIR" +EOF + create_test_includes + create_test_excludes + + # Run setup (will fail on some checks but should still add cron) + sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup 2>&1 >/dev/null || true + + # Check crontab contains rsyncshot entries + local crontab_content + crontab_content=$(crontab -l 2>/dev/null) + + restore_crontab + teardown_test_env + + assert_contains "$crontab_content" "rsyncshot" "crontab should contain rsyncshot" || return 1 + assert_contains "$crontab_content" "hourly" "crontab should contain hourly job" || return 1 + assert_contains "$crontab_content" "daily" "crontab should contain daily job" || return 1 + assert_contains "$crontab_content" "weekly" "crontab should contain weekly job" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Repeated setup doesn't duplicate entries +# ------------------------------------------------------------------------------ +test_no_duplicate_cron_entries() { + setup_test_env + save_crontab + + # Clear existing crontab + crontab -r 2>/dev/null || true + + cat > "$TEST_CONFIG_DIR/config" << EOF +REMOTE_HOST="" +MOUNTDIR="$TEST_BACKUP_DIR" +EOF + create_test_includes + create_test_excludes + + # Run setup twice + sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup 2>&1 >/dev/null || true + sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup 2>&1 >/dev/null || true + + # Count rsyncshot entries + local crontab_content hourly_count + crontab_content=$(crontab -l 2>/dev/null) + hourly_count=$(echo "$crontab_content" | grep -c "hourly" || echo 0) + + restore_crontab + teardown_test_env + + # Should only have 1 hourly entry, not 2 + assert_equals "1" "$hourly_count" "should have exactly 1 hourly entry after repeated setup" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Setup preserves existing cron jobs +# ------------------------------------------------------------------------------ +test_preserves_existing_cron() { + setup_test_env + save_crontab + + # Add a custom cron job + (crontab -l 2>/dev/null || true; echo "0 5 * * * /custom/job.sh") | crontab - + + cat > "$TEST_CONFIG_DIR/config" << EOF +REMOTE_HOST="" +MOUNTDIR="$TEST_BACKUP_DIR" +EOF + create_test_includes + create_test_excludes + + # Run setup + sudo INSTALLHOME="$TEST_CONFIG_DIR" SCRIPTLOC="/tmp/rsyncshot-test" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" setup 2>&1 >/dev/null || true + + # Check custom job still exists + local crontab_content + crontab_content=$(crontab -l 2>/dev/null) + + restore_crontab + teardown_test_env + + assert_contains "$crontab_content" "/custom/job.sh" "should preserve existing cron jobs" || return 1 + assert_contains "$crontab_content" "rsyncshot" "should also have rsyncshot jobs" || return 1 +} + +# ------------------------------------------------------------------------------ +# Run tests +# ------------------------------------------------------------------------------ +run_cron_tests() { + echo "" + echo "Running cron tests..." + echo "------------------------------------------------------------" + + run_test "setup adds cron jobs" test_setup_adds_cron_jobs + run_test "repeated setup doesn't duplicate entries" test_no_duplicate_cron_entries + run_test "setup preserves existing cron jobs" test_preserves_existing_cron +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + run_cron_tests + print_summary +fi diff --git a/tests/cases/test_dryrun.sh b/tests/cases/test_dryrun.sh new file mode 100755 index 0000000..bff45e1 --- /dev/null +++ b/tests/cases/test_dryrun.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# ============================================================================== +# Dry-Run Mode Tests +# ============================================================================== + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh" + +# ------------------------------------------------------------------------------ +# Test: Dry-run doesn't create backup directory +# ------------------------------------------------------------------------------ +test_dryrun_no_directory_creation() { + setup_test_env + + create_test_config + create_test_includes + create_test_excludes + + # Remove backup dir to verify it's not created + rmdir "$TEST_BACKUP_DIR" + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + + # Backup directory should NOT be created in dry-run mode + assert_dir_not_exists "$TEST_BACKUP_DIR/$HOSTNAME" "backup dir should not be created in dryrun" || { + teardown_test_env + return 1 + } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Test: Dry-run shows what would be transferred +# ------------------------------------------------------------------------------ +test_dryrun_shows_transfer_info() { + setup_test_env + + create_test_config + create_test_includes + create_test_excludes + + # Create the backup directory structure for dryrun to work + mkdir -p "$TEST_BACKUP_DIR/$HOSTNAME/latest" + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + + teardown_test_env + + # Should show syncing messages + assert_contains "$output" "Syncing" "should show syncing info" || return 1 + # Should show dry run message + assert_contains "$output" "Dry run complete" "should show dryrun complete message" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Dry-run shows command to run actual backup +# ------------------------------------------------------------------------------ +test_dryrun_shows_actual_command() { + setup_test_env + + create_test_config + create_test_includes + create_test_excludes + + mkdir -p "$TEST_BACKUP_DIR/$HOSTNAME/latest" + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + + teardown_test_env + + # Should show how to run actual backup + assert_contains "$output" "sudo rsyncshot manual 1" "should show actual command" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Dry-run doesn't create snapshots +# ------------------------------------------------------------------------------ +test_dryrun_no_snapshot_creation() { + setup_test_env + + create_test_config + create_test_includes + create_test_excludes + + mkdir -p "$TEST_BACKUP_DIR/$HOSTNAME/latest" + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + + # Should not create manual.0 snapshot + assert_dir_not_exists "$TEST_BACKUP_DIR/$HOSTNAME/manual.0" "snapshot should not be created in dryrun" || { + teardown_test_env + return 1 + } + + teardown_test_env +} + +# ------------------------------------------------------------------------------ +# Run tests +# ------------------------------------------------------------------------------ +run_dryrun_tests() { + echo "" + echo "Running dry-run tests..." + echo "------------------------------------------------------------" + + run_test "dry-run doesn't create backup directory" test_dryrun_no_directory_creation + run_test "dry-run shows transfer info" test_dryrun_shows_transfer_info + run_test "dry-run shows actual command" test_dryrun_shows_actual_command + run_test "dry-run doesn't create snapshots" test_dryrun_no_snapshot_creation +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + run_dryrun_tests + print_summary +fi diff --git a/tests/cases/test_includes.sh b/tests/cases/test_includes.sh new file mode 100755 index 0000000..8f556e3 --- /dev/null +++ b/tests/cases/test_includes.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# ============================================================================== +# Include File Parsing Tests +# ============================================================================== + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh" + +# ------------------------------------------------------------------------------ +# Test: Reads newline-separated paths +# ------------------------------------------------------------------------------ +test_reads_newline_paths() { + setup_test_env + + # Create config and include files + create_test_config + create_test_excludes + + # Create include file with newline-separated paths + cat > "$TEST_CONFIG_DIR/include.txt" << EOF +$TEST_SOURCE_DIR/home +$TEST_SOURCE_DIR/etc +EOF + + # Run dryrun to test parsing + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + local exit_code=$? + + teardown_test_env + + # Should process both directories + assert_contains "$output" "Syncing $TEST_SOURCE_DIR/home" "should sync home" || return 1 + assert_contains "$output" "Syncing $TEST_SOURCE_DIR/etc" "should sync etc" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Skips comment lines +# ------------------------------------------------------------------------------ +test_skips_comments() { + setup_test_env + + create_test_config + create_test_excludes + + # Create include file with comments + cat > "$TEST_CONFIG_DIR/include.txt" << EOF +# This is a comment +$TEST_SOURCE_DIR/home +# Another comment +$TEST_SOURCE_DIR/etc +EOF + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + + teardown_test_env + + # Should not try to sync comment lines + assert_not_contains "$output" "Syncing # This" "should skip comments" || return 1 + assert_contains "$output" "Syncing $TEST_SOURCE_DIR/home" "should sync home" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Skips empty lines +# ------------------------------------------------------------------------------ +test_skips_empty_lines() { + setup_test_env + + create_test_config + create_test_excludes + + # Create include file with empty lines + cat > "$TEST_CONFIG_DIR/include.txt" << EOF +$TEST_SOURCE_DIR/home + +$TEST_SOURCE_DIR/etc + +EOF + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + + teardown_test_env + + # Should process both directories without errors + assert_contains "$output" "Syncing $TEST_SOURCE_DIR/home" "should sync home" || return 1 + assert_contains "$output" "Syncing $TEST_SOURCE_DIR/etc" "should sync etc" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Handles paths with spaces +# ------------------------------------------------------------------------------ +test_handles_paths_with_spaces() { + setup_test_env + + create_test_config + create_test_excludes + + # Create a directory with spaces + mkdir -p "$TEST_SOURCE_DIR/path with spaces" + echo "test" > "$TEST_SOURCE_DIR/path with spaces/file.txt" + + # Create include file with path containing spaces + cat > "$TEST_CONFIG_DIR/include.txt" << EOF +$TEST_SOURCE_DIR/path with spaces +EOF + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + + teardown_test_env + + # Should handle the path with spaces + assert_contains "$output" "path with spaces" "should handle spaces in path" || return 1 + assert_not_contains "$output" "not found" "should not report path not found" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Reports missing directory +# ------------------------------------------------------------------------------ +test_reports_missing_directory() { + setup_test_env + + create_test_config + create_test_excludes + + # Create include file with non-existent path + cat > "$TEST_CONFIG_DIR/include.txt" << EOF +/nonexistent/path/that/does/not/exist +EOF + + local output + output=$(sudo INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" dryrun manual 1 2>&1) + local exit_code=$? + + teardown_test_env + + assert_exit_code 1 "$exit_code" "should fail for missing directory" || return 1 + assert_contains "$output" "not found" "should report directory not found" || return 1 +} + +# ------------------------------------------------------------------------------ +# Run tests +# ------------------------------------------------------------------------------ +run_includes_tests() { + echo "" + echo "Running include file tests..." + echo "------------------------------------------------------------" + + run_test "reads newline-separated paths" test_reads_newline_paths + run_test "skips comment lines" test_skips_comments + run_test "skips empty lines" test_skips_empty_lines + run_test "handles paths with spaces" test_handles_paths_with_spaces + run_test "reports missing directory" test_reports_missing_directory +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + run_includes_tests + print_summary +fi diff --git a/tests/cases/test_validation.sh b/tests/cases/test_validation.sh new file mode 100755 index 0000000..03da676 --- /dev/null +++ b/tests/cases/test_validation.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# ============================================================================== +# Input Validation Tests +# ============================================================================== + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/test_helpers.sh" + +# ------------------------------------------------------------------------------ +# Test: Help works without root +# ------------------------------------------------------------------------------ +test_help_without_root() { + local output + output=$("$SCRIPT_PATH" help 2>&1) + local exit_code=$? + + assert_exit_code 0 "$exit_code" "help should exit with 0" || return 1 + assert_contains "$output" "rsyncshot" "help should mention rsyncshot" || return 1 + assert_contains "$output" "Usage" "help should show usage" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Rejects non-alphabetic snapshot type +# ------------------------------------------------------------------------------ +test_rejects_numeric_snapshot_type() { + local output + output=$(sudo "$SCRIPT_PATH" "123" "5" 2>&1) + local exit_code=$? + + assert_exit_code 1 "$exit_code" "should reject numeric snapshot type" || return 1 + assert_contains "$output" "must be alphabetic" "should show alphabetic error" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Rejects mixed alphanumeric snapshot type +# ------------------------------------------------------------------------------ +test_rejects_mixed_snapshot_type() { + local output + output=$(sudo "$SCRIPT_PATH" "hourly123" "5" 2>&1) + local exit_code=$? + + assert_exit_code 1 "$exit_code" "should reject mixed snapshot type" || return 1 + assert_contains "$output" "must be alphabetic" "should show alphabetic error" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Rejects non-numeric retention count +# ------------------------------------------------------------------------------ +test_rejects_alpha_retention_count() { + local output + output=$(sudo "$SCRIPT_PATH" "manual" "abc" 2>&1) + local exit_code=$? + + assert_exit_code 1 "$exit_code" "should reject alphabetic count" || return 1 + assert_contains "$output" "must be a number" "should show number error" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Rejects mixed alphanumeric retention count +# ------------------------------------------------------------------------------ +test_rejects_mixed_retention_count() { + local output + output=$(sudo "$SCRIPT_PATH" "manual" "5abc" 2>&1) + local exit_code=$? + + assert_exit_code 1 "$exit_code" "should reject mixed count" || return 1 + assert_contains "$output" "must be a number" "should show number error" || return 1 +} + +# ------------------------------------------------------------------------------ +# Test: Accepts valid alphabetic snapshot types +# ------------------------------------------------------------------------------ +test_accepts_valid_snapshot_types() { + # We use dryrun to avoid actual backup, and expect it to fail on missing config + # but it should get past the validation stage + local output + output=$(sudo "$SCRIPT_PATH" dryrun "hourly" "24" 2>&1) + + # Should not contain the validation error (might fail for other reasons like missing config) + assert_not_contains "$output" "must be alphabetic" "should accept valid type" || return 1 +} + +# ------------------------------------------------------------------------------ +# Run tests +# ------------------------------------------------------------------------------ +run_validation_tests() { + echo "" + echo "Running validation tests..." + echo "------------------------------------------------------------" + + setup_test_env + + run_test "help works without root" test_help_without_root + run_test "rejects numeric snapshot type" test_rejects_numeric_snapshot_type + run_test "rejects mixed alphanumeric snapshot type" test_rejects_mixed_snapshot_type + run_test "rejects alphabetic retention count" test_rejects_alpha_retention_count + run_test "rejects mixed retention count" test_rejects_mixed_retention_count + run_test "accepts valid snapshot types" test_accepts_valid_snapshot_types + + teardown_test_env +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + run_validation_tests + print_summary +fi diff --git a/tests/lib/test_helpers.sh b/tests/lib/test_helpers.sh new file mode 100755 index 0000000..726d788 --- /dev/null +++ b/tests/lib/test_helpers.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# ============================================================================== +# Test Helper Functions for rsyncshot +# ============================================================================== + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +# Counters +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Test environment paths +TEST_DIR="" +TEST_CONFIG_DIR="" +TEST_BACKUP_DIR="" +SCRIPT_PATH="" + +# ------------------------------------------------------------------------------ +# Setup/Teardown +# ------------------------------------------------------------------------------ + +setup_test_env() { + # Create temporary directories for testing + TEST_DIR=$(mktemp -d) + TEST_CONFIG_DIR="$TEST_DIR/etc/rsyncshot" + TEST_BACKUP_DIR="$TEST_DIR/backup" + TEST_SOURCE_DIR="$TEST_DIR/source" + + mkdir -p "$TEST_CONFIG_DIR" + mkdir -p "$TEST_BACKUP_DIR" + mkdir -p "$TEST_SOURCE_DIR/home/testuser" + mkdir -p "$TEST_SOURCE_DIR/etc" + + # Create some test files + echo "test file 1" > "$TEST_SOURCE_DIR/home/testuser/file1.txt" + echo "test file 2" > "$TEST_SOURCE_DIR/home/testuser/file2.txt" + echo "config data" > "$TEST_SOURCE_DIR/etc/test.conf" + + # Find the script (relative to tests directory) + SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/rsyncshot" + + if [ ! -f "$SCRIPT_PATH" ]; then + echo "ERROR: Cannot find rsyncshot script at $SCRIPT_PATH" + exit 1 + fi +} + +teardown_test_env() { + # Clean up temporary directories + if [ -n "$TEST_DIR" ] && [ -d "$TEST_DIR" ]; then + rm -rf "$TEST_DIR" + fi +} + +# Run rsyncshot with test environment variables +# Usage: run_rsyncshot [args...] +run_rsyncshot() { + INSTALLHOME="$TEST_CONFIG_DIR" RSYNCSHOT_SKIP_MOUNT_CHECK=1 "$SCRIPT_PATH" "$@" +} + +# Create a test config file +create_test_config() { + local config_file="$TEST_CONFIG_DIR/config" + cat > "$config_file" << EOF +REMOTE_HOST="" +MOUNTDIR="$TEST_BACKUP_DIR" +EOF + echo "$config_file" +} + +# Create a test include file +create_test_includes() { + local include_file="$TEST_CONFIG_DIR/include.txt" + cat > "$include_file" << EOF +$TEST_SOURCE_DIR/home +$TEST_SOURCE_DIR/etc +EOF + echo "$include_file" +} + +# Create a test exclude file +create_test_excludes() { + local exclude_file="$TEST_CONFIG_DIR/exclude.txt" + cat > "$exclude_file" << EOF +*.tmp +*.log +.cache +EOF + echo "$exclude_file" +} + +# ------------------------------------------------------------------------------ +# Assertions +# ------------------------------------------------------------------------------ + +assert_equals() { + local expected="$1" + local actual="$2" + local message="${3:-Values should be equal}" + + if [ "$expected" = "$actual" ]; then + return 0 + else + echo -e "${RED}FAIL${NC}: $message" + echo " Expected: $expected" + echo " Actual: $actual" + return 1 + fi +} + +assert_exit_code() { + local expected="$1" + local actual="$2" + local message="${3:-Exit code should be $expected}" + + if [ "$expected" -eq "$actual" ]; then + return 0 + else + echo -e "${RED}FAIL${NC}: $message" + echo " Expected exit code: $expected" + echo " Actual exit code: $actual" + return 1 + fi +} + +assert_contains() { + local haystack="$1" + local needle="$2" + local message="${3:-Output should contain '$needle'}" + + if echo "$haystack" | grep -q "$needle"; then + return 0 + else + echo -e "${RED}FAIL${NC}: $message" + echo " Looking for: $needle" + echo " In output: $haystack" + return 1 + fi +} + +assert_not_contains() { + local haystack="$1" + local needle="$2" + local message="${3:-Output should not contain '$needle'}" + + if ! echo "$haystack" | grep -q "$needle"; then + return 0 + else + echo -e "${RED}FAIL${NC}: $message" + echo " Should not contain: $needle" + echo " But found in: $haystack" + return 1 + fi +} + +assert_file_exists() { + local file="$1" + local message="${2:-File should exist: $file}" + + if [ -f "$file" ]; then + return 0 + else + echo -e "${RED}FAIL${NC}: $message" + return 1 + fi +} + +assert_dir_exists() { + local dir="$1" + local message="${2:-Directory should exist: $dir}" + + if [ -d "$dir" ]; then + return 0 + else + echo -e "${RED}FAIL${NC}: $message" + return 1 + fi +} + +assert_file_not_exists() { + local file="$1" + local message="${2:-File should not exist: $file}" + + if [ ! -f "$file" ]; then + return 0 + else + echo -e "${RED}FAIL${NC}: $message" + return 1 + fi +} + +assert_dir_not_exists() { + local dir="$1" + local message="${2:-Directory should not exist: $dir}" + + if [ ! -d "$dir" ]; then + return 0 + else + echo -e "${RED}FAIL${NC}: $message" + return 1 + fi +} + +# ------------------------------------------------------------------------------ +# Test Runner +# ------------------------------------------------------------------------------ + +run_test() { + local test_name="$1" + local test_func="$2" + + TESTS_RUN=$((TESTS_RUN + 1)) + + # Run the test function and capture result + if $test_func; then + echo -e "${GREEN}PASS${NC}: $test_name" + TESTS_PASSED=$((TESTS_PASSED + 1)) + return 0 + else + TESTS_FAILED=$((TESTS_FAILED + 1)) + return 1 + fi +} + +print_summary() { + echo "" + echo "============================================================" + echo "Test Summary" + echo "============================================================" + echo -e "Total: $TESTS_RUN" + echo -e "Passed: ${GREEN}$TESTS_PASSED${NC}" + echo -e "Failed: ${RED}$TESTS_FAILED${NC}" + echo "" + + if [ "$TESTS_FAILED" -eq 0 ]; then + echo -e "${GREEN}All tests passed!${NC}" + return 0 + else + echo -e "${RED}Some tests failed.${NC}" + return 1 + fi +} diff --git a/tests/test_rsyncshot.sh b/tests/test_rsyncshot.sh new file mode 100755 index 0000000..15f2a99 --- /dev/null +++ b/tests/test_rsyncshot.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# ============================================================================== +# rsyncshot Test Suite +# ============================================================================== +# +# Runs all automated tests for rsyncshot. +# +# Usage: +# sudo ./tests/test_rsyncshot.sh # Run all tests +# sudo ./tests/test_rsyncshot.sh -v # Verbose output +# sudo ./tests/test_rsyncshot.sh --quick # Skip slow tests +# +# ============================================================================== + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERBOSE=false +QUICK=false + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + -v|--verbose) + VERBOSE=true + shift + ;; + -q|--quick) + QUICK=true + shift + ;; + -h|--help) + echo "Usage: $0 [-v|--verbose] [-q|--quick]" + echo "" + echo "Options:" + echo " -v, --verbose Show detailed test output" + echo " -q, --quick Skip slow tests (backup/rotation)" + exit 0 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +# Check for root +if [ "$EUID" -ne 0 ]; then + echo "Tests must be run as root (sudo ./tests/test_rsyncshot.sh)" + exit 1 +fi + +# Source test helpers +source "$SCRIPT_DIR/lib/test_helpers.sh" + +echo "============================================================" +echo "rsyncshot Test Suite" +echo "============================================================" +echo "" +echo "Script: $(cd "$SCRIPT_DIR/.." && pwd)/rsyncshot" +echo "Date: $(date)" +echo "" + +# Track overall results +TOTAL_RUN=0 +TOTAL_PASSED=0 +TOTAL_FAILED=0 + +# Run a test file and accumulate results +run_test_file() { + local test_file="$1" + local test_name="$2" + + # Reset counters before sourcing + TESTS_RUN=0 + TESTS_PASSED=0 + TESTS_FAILED=0 + + # Source and run the test file + source "$test_file" + + # Call the run function + "run_${test_name}_tests" + + # Accumulate totals + TOTAL_RUN=$((TOTAL_RUN + TESTS_RUN)) + TOTAL_PASSED=$((TOTAL_PASSED + TESTS_PASSED)) + TOTAL_FAILED=$((TOTAL_FAILED + TESTS_FAILED)) +} + +# Run test suites +run_test_file "$SCRIPT_DIR/cases/test_validation.sh" "validation" +run_test_file "$SCRIPT_DIR/cases/test_includes.sh" "includes" +run_test_file "$SCRIPT_DIR/cases/test_dryrun.sh" "dryrun" + +if [ "$QUICK" = false ]; then + run_test_file "$SCRIPT_DIR/cases/test_backup.sh" "backup" + run_test_file "$SCRIPT_DIR/cases/test_cron.sh" "cron" +else + echo "" + echo "Skipping slow tests (backup, cron) - use without --quick to run all" +fi + +# Print final summary +echo "" +echo "============================================================" +echo "Final Summary" +echo "============================================================" +echo -e "Total: $TOTAL_RUN" +echo -e "Passed: ${GREEN}$TOTAL_PASSED${NC}" +echo -e "Failed: ${RED}$TOTAL_FAILED${NC}" +echo "" + +if [ "$TOTAL_FAILED" -eq 0 ]; then + echo -e "${GREEN}All tests passed!${NC}" + exit 0 +else + echo -e "${RED}Some tests failed.${NC}" + exit 1 +fi diff --git a/todo.org b/todo.org deleted file mode 100644 index e2a1374..0000000 --- a/todo.org +++ /dev/null @@ -1,73 +0,0 @@ -* Rsyncshot Open Work -** [#B] crontab should offer more schedules -*** 2024-05-06 Mon @ 15:51:58 -0500 problem to solve -currently, rsyncshot has the following -hourly each day -daily each week -every week - -...and that's it. Of course, the user can fiddle with this as they please to achieve any backup schedule they desire. However, most users won't want to mess with cron. - -We should do it for them. Here's what I suspect these two will be the most common: - -DATE BOUND -- hourly, expiring past a specific date period (e.g., month or year) -- daily, expiring past a specific date period (e.g., month or year) -- every X minutes, expiring past a specific date period (e.g., month or year) - -STAGGERED -- every 10 mins for the hour (max 5, as the last will be the hourly) -- every hour for the day (max 23, as the last will be daily) -- every day for the week (max 6 as, the last will be weekly) -- every week for the year (max 51 as, the last will be yearly) -- every year (ongoing) - -This means that a full year will have 86 backups. -The user can choose to not do the 10 min or hourly backups and just allow for day, week, year. -** [#B] backup pruning should be more robust -*** 2024-05-06 Mon @ 16:21:29 -0500 problem to solve -if the user uses a -use awk to identify files outside of range and delete via loop -** [#C] strip - and -- to ease users finding help/usage options -*** 2024-05-06 Mon @ 15:31:50 -0500 problem to solve -The user may not know how to even get help on the command line -typical command line arguments are --help -h and others. -let's make it easy for the user by stripping off the -** [#C] help should be able to tell more about environment and act on it -*** 2024-05-06 Mon @ 15:34:10 -0500 problem to solve -is there a drive mounted on /media/backup (or the default location)? -has rsyncshot been installed? -have the cron jobs been set up? - -instructions should then change based on install state -- if not installed, setup instructions are clear -- if installed, install and log location is clear -- if installed but no cron jobs, tell user how to set them up, offer to set up dailies, etc. -- if no drive mounted, give user the command line option or offer to mount the drive - -You could tackle this in pieces: installation check, cron job check, mounted drive check, etc. -** [#C] backup drive should have an optional command line argument -*** 2024-05-06 Mon @ 15:47:56 -0500 problem to solve -reason: users may want to backup to a different directory via crontab or manually -default exists now (i.e., "/media/backup") -changing it means changing the source - -we should allow the user to pass -d (for destination) with a path -- validate the path exists -- assigning the destination -** [#C] ability to trigger immediate backup -*** 2024-05-06 Mon @ 16:21:53 -0500 problem to solve -the user has to wait for the cron job to kick off for a backup -however, they may want to manually trigger a backup immediately -they can do this by: "rsyncshot <some-random-name> <some-random-number> - -however, we should make it easier for them. -options: -- no command line arguments issues the equivalent of rsyncshot BACKUP 100 - the downside here is that the user may expect this to be a way to get usage information. -- a command line option like: "rsyncshot --backup-now" - it's not obvious what to choose for the switch. - what have other authors chosen for an option in similar situations? - -* Rsyncshot Resolved -** DONE [#B] backups should be contained in a subdirectory based on hostname |
