diff --git a/Cargo.lock b/Cargo.lock index 82c3c8c8..42a939cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3998,6 +3998,7 @@ dependencies = [ "inquire", "log", "memmap2", + "nom", "num_cpus", "once_cell", "parking_lot", @@ -4284,6 +4285,7 @@ dependencies = [ "libc", "mio 1.0.4", "pin-project-lite", + "signal-hook-registry", "socket2 0.6.0", "tokio-macros", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index f62f6785..a79f3f03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.16.0" edition = "2024" authors = ["Syncable Team"] description = "A Rust-based CLI that analyzes code repositories and generates Infrastructure as Code configurations" -license = "MIT OR Apache-2.0" +license = "GPL-3.0" repository = "https://github.com/syncable-dev/syncable-cli" keywords = ["iac", "infrastructure", "docker", "terraform", "cli"] categories = ["command-line-utilities", "development-tools"] @@ -48,7 +48,7 @@ term_size = "0.3" # Vulnerability checking dependencies rustsec = "0.30" reqwest = { version = "0.12", features = ["json", "blocking"] } -tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread", "sync"] } +tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread", "sync", "process", "io-util"] } textwrap = "0.16" tempfile = "3" dirs = "6" @@ -77,6 +77,9 @@ rig-core = { version = "0.26", features = ["derive"] } # Diff rendering for file confirmation UI similar = "2.6" +# Dockerfile linting (hadolint-rs) +nom = "7" # Parser combinators for Dockerfile parsing + [dev-dependencies] assert_cmd = "2" predicates = "3" diff --git a/LICENSE b/LICENSE index ab13a8b0..30ace6a8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,674 @@ -MIT License - -Copyright (c) 2024 Syncable Team - -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. \ No newline at end of file + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {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 . + +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: + + {project} Copyright (C) {year} {fullname} + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. \ No newline at end of file diff --git a/README.md b/README.md index 9779dd5f..ecec30c2 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

Crates.io Downloads - License + License Rust

@@ -241,7 +241,16 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. ## 📄 License -MIT License — see [LICENSE](LICENSE) for details. +This project is licensed under the **GNU General Public License v3.0** (GPL-3.0). + +See [LICENSE](LICENSE) for the full license text. + +### Third-Party Attributions + +The Dockerfile linting functionality (`src/analyzer/hadolint/`) is a Rust translation +of [Hadolint](https://github.com/hadolint/hadolint), originally written in Haskell by +Lukas Martinelli and contributors. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) +for full attribution details. --- diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..bdb1f680 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,75 @@ +# Third Party Notices + +This file contains attributions and license information for third-party software +incorporated into Syncable-CLI. + +--- + +## Hadolint + +The Dockerfile linting functionality in `src/analyzer/hadolint/` is a Rust +translation of the original Hadolint project. + +**Original Project:** [Hadolint](https://github.com/hadolint/hadolint) + +**Original Authors:** +- Lukas Martinelli (lukasmartinelli) +- Lorenzo Bolla (lbolla) +- And all contributors to the Hadolint project + +**Original License:** GNU General Public License v3.0 (GPL-3.0) + +**Original Copyright:** +``` +Copyright (c) 2016-2024 Lukas Martinelli and contributors +``` + +**What was translated:** +- Dockerfile parsing logic (originally in Haskell) +- Lint rule definitions (DL3xxx, DL4xxx series) +- Pragma/ignore directive handling +- Configuration file format +- Rule severity and messaging + +**Modifications made:** +- Complete rewrite from Haskell to Rust +- Integration with Syncable-CLI's agent and tool system +- Native async support for streaming output +- Adaptation to Rust error handling patterns +- Additional rules and improvements specific to Syncable's use cases + +**License Notice:** +This derivative work is licensed under GPL-3.0, as required by the original +Hadolint license. See the LICENSE file in the root of this repository. + +The full text of the GPL-3.0 license can be found at: +https://www.gnu.org/licenses/gpl-3.0.en.html + +--- + +## ShellCheck (Rule Concepts) + +Some shell-related lint rules are inspired by ShellCheck. + +**Original Project:** [ShellCheck](https://github.com/koalaman/shellcheck) + +**Original Author:** Vidar Holen (koalaman) + +**Original License:** GNU General Public License v3.0 (GPL-3.0) + +**Note:** Syncable-CLI does not include ShellCheck code directly. The shell +analysis rules are original implementations inspired by ShellCheck's rule +concepts and documentation. + +--- + +## Acknowledgments + +We are grateful to the open source community and the authors of Hadolint for +creating and maintaining excellent Dockerfile linting tools. This translation +to Rust allows native integration with Syncable-CLI while preserving the +valuable rule definitions and linting logic developed by the original authors. + +If you are the author of any software mentioned here and believe the attribution +is incorrect or incomplete, please open an issue at: +https://github.com/syncable-dev/syncable-cli/issues diff --git a/src/agent/mod.rs b/src/agent/mod.rs index c9c8992f..724624bf 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -99,8 +99,12 @@ pub type AgentResult = Result; /// Get the system prompt for the agent based on query type fn get_system_prompt(project_path: &Path, query: Option<&str>) -> String { - // If query suggests generation (Docker, Terraform, Helm), use DevOps prompt if let Some(q) = query { + // First check if it's a code development task (highest priority) + if prompts::is_code_development_query(q) { + return prompts::get_code_development_prompt(project_path); + } + // Then check if it's DevOps generation (Docker, Terraform, Helm) if prompts::is_generation_query(q) { return prompts::get_devops_prompt(project_path); } @@ -264,6 +268,7 @@ pub async fn run_interactive( .tool(AnalyzeTool::new(project_path_buf.clone())) .tool(SecurityScanTool::new(project_path_buf.clone())) .tool(VulnerabilitiesTool::new(project_path_buf.clone())) + .tool(HadolintTool::new(project_path_buf.clone())) .tool(ReadFileTool::new(project_path_buf.clone())) .tool(ListDirectoryTool::new(project_path_buf.clone())); @@ -312,6 +317,7 @@ pub async fn run_interactive( .tool(AnalyzeTool::new(project_path_buf.clone())) .tool(SecurityScanTool::new(project_path_buf.clone())) .tool(VulnerabilitiesTool::new(project_path_buf.clone())) + .tool(HadolintTool::new(project_path_buf.clone())) .tool(ReadFileTool::new(project_path_buf.clone())) .tool(ListDirectoryTool::new(project_path_buf.clone())); @@ -777,6 +783,7 @@ pub async fn run_query( .tool(AnalyzeTool::new(project_path_buf.clone())) .tool(SecurityScanTool::new(project_path_buf.clone())) .tool(VulnerabilitiesTool::new(project_path_buf.clone())) + .tool(HadolintTool::new(project_path_buf.clone())) .tool(ReadFileTool::new(project_path_buf.clone())) .tool(ListDirectoryTool::new(project_path_buf.clone())); @@ -811,6 +818,7 @@ pub async fn run_query( .tool(AnalyzeTool::new(project_path_buf.clone())) .tool(SecurityScanTool::new(project_path_buf.clone())) .tool(VulnerabilitiesTool::new(project_path_buf.clone())) + .tool(HadolintTool::new(project_path_buf.clone())) .tool(ReadFileTool::new(project_path_buf.clone())) .tool(ListDirectoryTool::new(project_path_buf.clone())); diff --git a/src/agent/prompts/mod.rs b/src/agent/prompts/mod.rs index 3ad07bbd..90d8eddc 100644 --- a/src/agent/prompts/mod.rs +++ b/src/agent/prompts/mod.rs @@ -23,8 +23,9 @@ You have access to tools to help analyze and understand the project: 1. **analyze_project** - Analyze the project to detect languages, frameworks, dependencies, and architecture 2. **security_scan** - Perform security analysis to find potential vulnerabilities and secrets 3. **check_vulnerabilities** - Check dependencies for known security vulnerabilities -4. **read_file** - Read the contents of a file in the project -5. **list_directory** - List files and directories in a path +4. **hadolint** - Lint Dockerfiles for best practices (use this instead of shell hadolint) +5. **read_file** - Read the contents of a file in the project +6. **list_directory** - List files and directories in a path ## Guidelines - Use the available tools to gather information before answering questions about the project @@ -35,6 +36,77 @@ You have access to tools to help analyze and understand the project: ) } +/// Get the code development prompt for implementing features, translating code, etc. +pub fn get_code_development_prompt(project_path: &std::path::Path) -> String { + format!( + r#"You are an expert software engineer helping to develop, implement, and improve code in this project. + +## Project Context +You are working with a project located at: {} + +## Your Capabilities +You have access to the following tools: + +### Analysis Tools +1. **analyze_project** - Analyze the project structure, languages, and dependencies +2. **read_file** - Read file contents +3. **list_directory** - List files and directories + +### Development Tools +4. **write_file** - Write or update a single file +5. **write_files** - Write multiple files at once +6. **shell** - Run shell commands (build, test, lint) + +## CRITICAL RULES - READ CAREFULLY + +### Rule 1: DO NOT RE-READ FILES +- Once you read a file, DO NOT read it again in the same conversation +- Keep track of what you've read - the content is in your context +- If you need to reference a file you already read, use your memory + +### Rule 2: BIAS TOWARDS ACTION +- After reading 3-5 key files, START WRITING CODE +- Don't endlessly analyze - make progress by writing +- It's better to write code and iterate than to analyze forever +- If unsure, write a minimal first version and improve it + +### Rule 3: WRITE IN CHUNKS +- For large implementations, write one file at a time +- Don't try to write everything in one response +- Complete one module, test it, then move to the next + +### Rule 4: PLAN BRIEFLY, EXECUTE QUICKLY +- State your plan in 2-3 sentences +- Then immediately start executing +- Don't write long planning documents before coding + +## Work Protocol + +1. **Quick Analysis** (1-3 tool calls max): + - Read the most relevant existing files + - Understand the project structure + +2. **Plan** (2-3 sentences): + - Briefly state what you'll create + - Identify the files you'll write + +3. **Implement** (start writing immediately): + - Create the files using write_file or write_files + - Write real, working code - not pseudocode + +4. **Validate**: + - Run build/test commands with shell + - Fix any errors + +## Code Quality Standards +- Follow the existing code style in the project +- Add appropriate error handling +- Include basic documentation/comments for complex logic +- Write idiomatic code for the language being used"#, + project_path.display() + ) +} + /// Get the DevOps generation prompt (Docker, Terraform, Helm, K8s) pub fn get_devops_prompt(project_path: &std::path::Path) -> String { format!( @@ -50,15 +122,16 @@ You have access to the following tools: 1. **analyze_project** - Analyze the project to detect languages, frameworks, dependencies, build commands, and architecture 2. **security_scan** - Perform security analysis to find potential vulnerabilities 3. **check_vulnerabilities** - Check dependencies for known security vulnerabilities -4. **read_file** - Read the contents of a file in the project -5. **list_directory** - List files and directories in a path +4. **hadolint** - Native Dockerfile linter (use this instead of shell hadolint command) +5. **read_file** - Read the contents of a file in the project +6. **list_directory** - List files and directories in a path ### Generation Tools -6. **write_file** - Write a single file (Dockerfile, terraform config, helm values, etc.) -7. **write_files** - Write multiple files at once (Terraform modules, Helm charts) +7. **write_file** - Write a single file (Dockerfile, terraform config, helm values, etc.) +8. **write_files** - Write multiple files at once (Terraform modules, Helm charts) ### Validation Tools -8. **shell** - Execute validation commands (docker build, terraform validate, helm lint, hadolint, etc.) +9. **shell** - Execute validation commands (docker build, terraform validate, helm lint, etc.) ## Production-Ready Standards @@ -97,12 +170,20 @@ You have access to the following tools: 1. **Analyze First**: Always use `analyze_project` to understand the project before generating anything 2. **Plan**: Think through what files need to be created 3. **Generate**: Use `write_file` or `write_files` to create the artifacts -4. **Validate**: Use `shell` to validate with appropriate tools: - - Docker: `hadolint Dockerfile && docker build -t test .` - - Terraform: `terraform init && terraform validate` - - Helm: `helm lint ./chart` +4. **Validate**: Use appropriate validation tools: + - Docker: Use `hadolint` tool (native, no shell needed), then `shell` for `docker build -t test .` + - Terraform: `shell` for `terraform init && terraform validate` + - Helm: `shell` for `helm lint ./chart` 5. **Self-Correct**: If validation fails, read the error, fix the files, and re-validate +**IMPORTANT**: For Dockerfile linting, ALWAYS use the native `hadolint` tool, NOT `shell hadolint`. The native tool is faster and doesn't require the hadolint binary to be installed. + +**CRITICAL**: If `hadolint` finds ANY errors or warnings: +1. STOP and report ALL the issues to the user FIRST +2. DO NOT proceed to `docker build` until the user acknowledges the issues +3. Show each violation with its line number, rule code, and message +4. Ask if the user wants you to fix the issues before building + ## Error Handling - If any validation command fails, analyze the error output - Use `write_file` to fix the artifacts @@ -179,7 +260,39 @@ pub fn is_generation_query(query: &str) -> bool { "terraform", "helm", "kubernetes", "k8s", "manifest", "chart", "module", "infrastructure", "containerize", "containerise", "deploy", "ci/cd", "pipeline", + // Code development keywords + "implement", "translate", "port", "convert", "refactor", + "add feature", "new feature", "develop", "code", ]; generation_keywords.iter().any(|kw| query_lower.contains(kw)) } + +/// Detect if a query is specifically about code development (not DevOps) +pub fn is_code_development_query(query: &str) -> bool { + let query_lower = query.to_lowercase(); + + // DevOps-specific terms - if these appear, it's DevOps not code dev + let devops_keywords = [ + "dockerfile", "docker-compose", "docker compose", + "terraform", "helm", "kubernetes", "k8s", + "manifest", "chart", "infrastructure", + "containerize", "containerise", "deploy", "ci/cd", "pipeline", + ]; + + // If it's clearly DevOps, return false + if devops_keywords.iter().any(|kw| query_lower.contains(kw)) { + return false; + } + + // Code development keywords + let code_keywords = [ + "implement", "translate", "port", "convert", "refactor", + "add feature", "new feature", "develop", "module", "library", + "crate", "function", "class", "struct", "trait", + "rust", "python", "javascript", "typescript", "haskell", + "code", "rewrite", "build a", "create a", + ]; + + code_keywords.iter().any(|kw| query_lower.contains(kw)) +} diff --git a/src/agent/tools/hadolint.rs b/src/agent/tools/hadolint.rs new file mode 100644 index 00000000..a187419d --- /dev/null +++ b/src/agent/tools/hadolint.rs @@ -0,0 +1,579 @@ +//! Hadolint tool - Native Dockerfile linting using Rig's Tool trait +//! +//! Provides native Dockerfile linting without requiring the external hadolint binary. +//! Implements hadolint rules with full pragma support. +//! +//! Output is optimized for AI agent decision-making with: +//! - Categorized issues (security, best-practice, maintainability, performance) +//! - Priority rankings (critical, high, medium, low) +//! - Actionable fix recommendations +//! - Rule documentation links + +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::path::PathBuf; + +use crate::analyzer::hadolint::{lint, lint_file, HadolintConfig, LintResult, Severity}; + +/// Arguments for the hadolint tool +#[derive(Debug, Deserialize)] +pub struct HadolintArgs { + /// Path to Dockerfile (relative to project root) or inline content + #[serde(default)] + pub dockerfile: Option, + + /// Inline Dockerfile content to lint (alternative to path) + #[serde(default)] + pub content: Option, + + /// Rules to ignore (e.g., ["DL3008", "DL3013"]) + #[serde(default)] + pub ignore: Vec, + + /// Minimum severity threshold: "error", "warning", "info", "style" + #[serde(default)] + pub threshold: Option, +} + +/// Error type for hadolint tool +#[derive(Debug, thiserror::Error)] +#[error("Hadolint error: {0}")] +pub struct HadolintError(String); + +/// Tool to lint Dockerfiles natively +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HadolintTool { + project_path: PathBuf, +} + +impl HadolintTool { + pub fn new(project_path: PathBuf) -> Self { + Self { project_path } + } + + fn parse_threshold(threshold: &str) -> Severity { + match threshold.to_lowercase().as_str() { + "error" => Severity::Error, + "warning" => Severity::Warning, + "info" => Severity::Info, + "style" => Severity::Style, + _ => Severity::Warning, // Default + } + } + + /// Get the category for a rule code + fn get_rule_category(code: &str) -> &'static str { + match code { + // Security rules + "DL3000" | "DL3002" | "DL3004" | "DL3047" => "security", + // Best practice rules + "DL3003" | "DL3006" | "DL3007" | "DL3008" | "DL3009" | "DL3013" | + "DL3014" | "DL3015" | "DL3016" | "DL3018" | "DL3019" | "DL3020" | + "DL3025" | "DL3027" | "DL3028" | "DL3033" | "DL3042" | "DL3059" => "best-practice", + // Maintainability rules + "DL3005" | "DL3010" | "DL3021" | "DL3022" | "DL3023" | "DL3024" | + "DL3026" | "DL3029" | "DL3030" | "DL3032" | "DL3034" | "DL3035" | + "DL3036" | "DL3044" | "DL3045" | "DL3048" | "DL3049" | "DL3050" | + "DL3051" | "DL3052" | "DL3053" | "DL3054" | "DL3055" | "DL3056" | + "DL3057" | "DL3058" | "DL3060" | "DL3061" => "maintainability", + // Performance rules + "DL3001" | "DL3011" | "DL3017" | "DL3031" | "DL3037" | "DL3038" | + "DL3039" | "DL3040" | "DL3041" | "DL3046" | "DL3062" => "performance", + // Deprecated instructions + "DL4000" | "DL4001" | "DL4003" | "DL4005" | "DL4006" => "deprecated", + // ShellCheck rules + _ if code.starts_with("SC") => "shell", + _ => "other", + } + } + + /// Get priority based on severity and category + fn get_priority(severity: Severity, category: &str) -> &'static str { + match (severity, category) { + (Severity::Error, "security") => "critical", + (Severity::Error, _) => "high", + (Severity::Warning, "security") => "high", + (Severity::Warning, "best-practice") => "medium", + (Severity::Warning, _) => "medium", + (Severity::Info, _) => "low", + (Severity::Style, _) => "low", + (Severity::Ignore, _) => "info", + } + } + + /// Get actionable fix recommendation for a rule + fn get_fix_recommendation(code: &str) -> &'static str { + match code { + "DL3000" => "Use absolute WORKDIR paths like '/app' instead of relative paths.", + "DL3001" => "Remove commands that have no effect in Docker (like 'ssh', 'mount').", + "DL3002" => "Remove the last USER instruction setting root, or add 'USER ' at the end.", + "DL3003" => "Use WORKDIR to change directories instead of 'cd' in RUN commands.", + "DL3004" => "Remove 'sudo' from RUN commands. Docker runs as root by default, or use proper USER switching.", + "DL3005" => "Remove 'apt-get upgrade' or 'dist-upgrade'. Pin packages instead for reproducibility.", + "DL3006" => "Add explicit version tag to base image, e.g., 'FROM node:18-alpine' instead of 'FROM node'.", + "DL3007" => "Use specific version tag instead of ':latest', e.g., 'nginx:1.25-alpine'.", + "DL3008" => "Pin apt package versions: 'apt-get install package=version' or use '--no-install-recommends'.", + "DL3009" => "Add 'rm -rf /var/lib/apt/lists/*' after apt-get install to reduce image size.", + "DL3010" => "Use ADD only for extracting archives. For other files, use COPY.", + "DL3011" => "Use valid port numbers (0-65535) in EXPOSE.", + "DL3013" => "Pin pip package versions: 'pip install package==version'.", + "DL3014" => "Add '-y' flag to apt-get install for non-interactive mode.", + "DL3015" => "Add '--no-install-recommends' to apt-get install to minimize image size.", + "DL3016" => "Pin npm package versions: 'npm install package@version'.", + "DL3017" => "Remove 'apt-get upgrade'. Pin specific package versions instead.", + "DL3018" => "Pin apk package versions: 'apk add package=version'.", + "DL3019" => "Add '--no-cache' to apk add instead of separate cache cleanup.", + "DL3020" => "Use COPY instead of ADD for files from build context. ADD is for URLs and archives.", + "DL3021" => "Use COPY with --from for multi-stage builds instead of COPY from external images.", + "DL3022" => "Use COPY --from=stage instead of --from=image for multi-stage builds.", + "DL3023" => "Reference build stage by name instead of number in COPY --from.", + "DL3024" => "Use lowercase for 'as' in multi-stage builds: 'FROM image AS builder'.", + "DL3025" => "Use JSON array format for CMD/ENTRYPOINT: CMD [\"executable\", \"arg1\"].", + "DL3026" => "Use official Docker images when possible, or document why unofficial is needed.", + "DL3027" => "Remove 'apt' and use 'apt-get' for scripting in Dockerfiles.", + "DL3028" => "Pin gem versions: 'gem install package:version'.", + "DL3029" => "Specify --platform explicitly for multi-arch builds.", + "DL3030" => "Pin yum/dnf package versions: 'yum install package-version'.", + "DL3032" => "Replace 'yum clean all' with 'dnf clean all' for newer distros.", + "DL3033" => "Add 'yum clean all' after yum install to reduce image size.", + "DL3034" => "Add '--setopt=install_weak_deps=False' to dnf install.", + "DL3035" => "Add 'dnf clean all' after dnf install to reduce image size.", + "DL3036" => "Pin zypper package versions: 'zypper install package=version'.", + "DL3037" => "Add 'zypper clean' after zypper install.", + "DL3038" => "Add '--no-recommends' to zypper install.", + "DL3039" => "Add 'zypper clean' after zypper install.", + "DL3040" => "Add 'dnf clean all && rm -rf /var/cache/dnf' after dnf install.", + "DL3041" => "Add 'microdnf clean all' after microdnf install.", + "DL3042" => "Avoid pip cache in builds. Use '--no-cache-dir' or set PIP_NO_CACHE_DIR=1.", + "DL3044" => "Only use 'HEALTHCHECK' once per Dockerfile, or it won't work correctly.", + "DL3045" => "Use COPY instead of ADD for local files.", + "DL3046" => "Use 'useradd' instead of 'adduser' for better compatibility.", + "DL3047" => "Add 'wget --progress=dot:giga' or 'curl --progress-bar' to show progress during download.", + "DL3048" => "Prefer setting flag with 'SHELL' instruction instead of inline in RUN.", + "DL3049" => "Add a 'LABEL maintainer=\"name\"' for documentation.", + "DL3050" => "Add 'LABEL version=\"x.y\"' for versioning.", + "DL3051" => "Add 'LABEL description=\"...\"' for documentation.", + "DL3052" => "Prefer relative paths with LABEL for better portability.", + "DL3053" => "Remove unused LABEL instructions.", + "DL3054" => "Use recommended labels from OCI spec (org.opencontainers.image.*).", + "DL3055" => "Add 'LABEL org.opencontainers.image.created' with ISO 8601 date.", + "DL3056" => "Add 'LABEL org.opencontainers.image.description'.", + "DL3057" => "Add a HEALTHCHECK instruction for container health monitoring.", + "DL3058" => "Add 'LABEL org.opencontainers.image.title'.", + "DL3059" => "Combine consecutive RUN instructions with '&&' to reduce layers.", + "DL3060" => "Pin package versions in yarn add: 'yarn add package@version'.", + "DL3061" => "Use specific image digest or tag instead of implicit latest.", + "DL3062" => "Prefer single RUN with '&&' over multiple RUN for related commands.", + "DL4000" => "Replace MAINTAINER with 'LABEL maintainer=\"name \"'.", + "DL4001" => "Use wget or curl instead of ADD for downloading from URLs.", + "DL4003" => "Use 'ENTRYPOINT' and 'CMD' together properly for container startup.", + "DL4005" => "Prefer JSON notation for SHELL: SHELL [\"/bin/bash\", \"-c\"].", + "DL4006" => "Add 'SHELL [\"/bin/bash\", \"-o\", \"pipefail\", \"-c\"]' before RUN with pipes.", + _ if code.starts_with("SC") => "See ShellCheck wiki for shell scripting fix.", + _ => "Review the rule documentation for specific guidance.", + } + } + + /// Get documentation URL for a rule + fn get_rule_url(code: &str) -> String { + if code.starts_with("DL") || code.starts_with("SC") { + if code.starts_with("SC") { + format!("https://www.shellcheck.net/wiki/{}", code) + } else { + format!("https://github.com/hadolint/hadolint/wiki/{}", code) + } + } else { + String::new() + } + } + + /// Format result optimized for agent decision-making + fn format_result(result: &LintResult, filename: &str) -> String { + // Categorize and enrich failures + let enriched_failures: Vec = result.failures.iter().map(|f| { + let code = f.code.as_str(); + let category = Self::get_rule_category(code); + let priority = Self::get_priority(f.severity, category); + + json!({ + "code": code, + "severity": format!("{:?}", f.severity).to_lowercase(), + "priority": priority, + "category": category, + "message": f.message, + "line": f.line, + "column": f.column, + "fix": Self::get_fix_recommendation(code), + "docs": Self::get_rule_url(code), + }) + }).collect(); + + // Group by priority for agent decision ordering + let critical: Vec<_> = enriched_failures.iter() + .filter(|f| f["priority"] == "critical") + .cloned().collect(); + let high: Vec<_> = enriched_failures.iter() + .filter(|f| f["priority"] == "high") + .cloned().collect(); + let medium: Vec<_> = enriched_failures.iter() + .filter(|f| f["priority"] == "medium") + .cloned().collect(); + let low: Vec<_> = enriched_failures.iter() + .filter(|f| f["priority"] == "low") + .cloned().collect(); + + // Group by category for thematic fixes + let mut by_category: std::collections::HashMap<&str, Vec<_>> = std::collections::HashMap::new(); + for f in &enriched_failures { + let cat = f["category"].as_str().unwrap_or("other"); + by_category.entry(cat).or_default().push(f.clone()); + } + + // Build decision context + let decision_context = if critical.is_empty() && high.is_empty() { + if medium.is_empty() && low.is_empty() { + "Dockerfile follows best practices. No issues found." + } else if medium.is_empty() { + "Minor improvements possible. Low priority issues only." + } else { + "Good baseline. Medium priority improvements recommended." + } + } else if !critical.is_empty() { + "Critical issues found. Address security/error issues first before deployment." + } else { + "High priority issues found. Review and fix before production use." + }; + + // Build agent-optimized output + let mut output = json!({ + "file": filename, + "success": !result.has_errors(), + "decision_context": decision_context, + "summary": { + "total": result.failures.len(), + "by_priority": { + "critical": critical.len(), + "high": high.len(), + "medium": medium.len(), + "low": low.len(), + }, + "by_severity": { + "errors": result.failures.iter().filter(|f| f.severity == Severity::Error).count(), + "warnings": result.failures.iter().filter(|f| f.severity == Severity::Warning).count(), + "info": result.failures.iter().filter(|f| f.severity == Severity::Info).count(), + }, + "by_category": by_category.iter().map(|(k, v)| (k.to_string(), v.len())).collect::>(), + }, + "action_plan": { + "critical": critical, + "high": high, + "medium": medium, + "low": low, + }, + }); + + // Add quick fixes summary for agent + if !enriched_failures.is_empty() { + let quick_fixes: Vec = enriched_failures.iter() + .filter(|f| f["priority"] == "critical" || f["priority"] == "high") + .take(5) + .map(|f| format!("Line {}: {} - {}", + f["line"], + f["code"].as_str().unwrap_or(""), + f["fix"].as_str().unwrap_or("") + )) + .collect(); + + if !quick_fixes.is_empty() { + output["quick_fixes"] = json!(quick_fixes); + } + } + + if !result.parse_errors.is_empty() { + output["parse_errors"] = json!(result.parse_errors); + } + + serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string()) + } +} + +impl Tool for HadolintTool { + const NAME: &'static str = "hadolint"; + + type Error = HadolintError; + type Args = HadolintArgs; + type Output = String; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Lint Dockerfiles for best practices, security issues, and common mistakes. \ + Returns AI-optimized JSON with issues categorized by priority (critical/high/medium/low) \ + and type (security/best-practice/maintainability/performance/deprecated). \ + Each issue includes an actionable fix recommendation. Use this to analyze Dockerfiles \ + before deployment or to improve existing ones. The 'decision_context' field provides \ + a summary for quick assessment, and 'quick_fixes' lists the most important changes." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "dockerfile": { + "type": "string", + "description": "Path to Dockerfile relative to project root (e.g., 'Dockerfile', 'docker/Dockerfile.prod')" + }, + "content": { + "type": "string", + "description": "Inline Dockerfile content to lint. Use this when you want to validate generated Dockerfile content before writing." + }, + "ignore": { + "type": "array", + "items": { "type": "string" }, + "description": "List of rule codes to ignore (e.g., ['DL3008', 'DL3013'])" + }, + "threshold": { + "type": "string", + "enum": ["error", "warning", "info", "style"], + "description": "Minimum severity to report. Default is 'warning'." + } + } + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + // Build configuration + let mut config = HadolintConfig::default(); + + // Apply ignored rules + for rule in &args.ignore { + config = config.ignore(rule.as_str()); + } + + // Apply threshold + if let Some(threshold) = &args.threshold { + config = config.with_threshold(Self::parse_threshold(threshold)); + } + + // Determine source, filename, and lint + let (result, filename) = if let Some(content) = &args.content { + // Lint inline content + (lint(content, &config), "".to_string()) + } else if let Some(dockerfile) = &args.dockerfile { + // Lint file + let path = self.project_path.join(dockerfile); + (lint_file(&path, &config), dockerfile.clone()) + } else { + // Default: look for Dockerfile in project root + let path = self.project_path.join("Dockerfile"); + if path.exists() { + (lint_file(&path, &config), "Dockerfile".to_string()) + } else { + return Err(HadolintError( + "No Dockerfile specified and no Dockerfile found in project root".to_string(), + )); + } + }; + + // Check for parse errors + if !result.parse_errors.is_empty() { + log::warn!("Dockerfile parse errors: {:?}", result.parse_errors); + } + + Ok(Self::format_result(&result, &filename)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env::temp_dir; + use std::fs; + + /// Helper to collect all issues from action_plan + fn collect_all_issues(parsed: &serde_json::Value) -> Vec { + let mut all = Vec::new(); + for priority in ["critical", "high", "medium", "low"] { + if let Some(arr) = parsed["action_plan"][priority].as_array() { + all.extend(arr.clone()); + } + } + all + } + + #[tokio::test] + async fn test_hadolint_inline_content() { + let tool = HadolintTool::new(temp_dir()); + let args = HadolintArgs { + dockerfile: None, + content: Some("FROM ubuntu:latest\nRUN sudo apt-get update".to_string()), + ignore: vec![], + threshold: None, + }; + + let result = tool.call(args).await.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // Should detect DL3007 (latest tag) and DL3004 (sudo) + assert!(!parsed["success"].as_bool().unwrap_or(true)); + assert!(parsed["summary"]["total"].as_u64().unwrap_or(0) >= 2); + + // Check new fields exist + assert!(parsed["decision_context"].is_string()); + assert!(parsed["action_plan"].is_object()); + + // Check issues have fix recommendations + let issues = collect_all_issues(&parsed); + assert!(issues.iter().all(|i| i["fix"].is_string() && !i["fix"].as_str().unwrap().is_empty())); + } + + #[tokio::test] + async fn test_hadolint_ignore_rules() { + let tool = HadolintTool::new(temp_dir()); + let args = HadolintArgs { + dockerfile: None, + content: Some("FROM ubuntu:latest".to_string()), + ignore: vec!["DL3007".to_string()], + threshold: None, + }; + + let result = tool.call(args).await.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // DL3007 should be ignored + let all_issues = collect_all_issues(&parsed); + assert!(!all_issues.iter().any(|f| f["code"] == "DL3007")); + } + + #[tokio::test] + async fn test_hadolint_threshold() { + let tool = HadolintTool::new(temp_dir()); + let args = HadolintArgs { + dockerfile: None, + content: Some("FROM ubuntu\nMAINTAINER test".to_string()), + ignore: vec![], + threshold: Some("error".to_string()), + }; + + let result = tool.call(args).await.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // DL4000 (MAINTAINER deprecated) is Error, DL3006 (untagged) is Warning + // With error threshold, only errors should show + let all_issues = collect_all_issues(&parsed); + assert!(all_issues.iter().all(|f| f["severity"] == "error")); + } + + #[tokio::test] + async fn test_hadolint_file() { + let temp = temp_dir().join("hadolint_test"); + fs::create_dir_all(&temp).unwrap(); + let dockerfile = temp.join("Dockerfile"); + fs::write(&dockerfile, "FROM node:18-alpine\nWORKDIR /app\nCOPY . .\nCMD [\"node\", \"app.js\"]").unwrap(); + + let tool = HadolintTool::new(temp.clone()); + let args = HadolintArgs { + dockerfile: Some("Dockerfile".to_string()), + content: None, + ignore: vec![], + threshold: None, + }; + + let result = tool.call(args).await.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // This is a well-formed Dockerfile, should have few/no errors + assert!(parsed["success"].as_bool().unwrap_or(false)); + assert_eq!(parsed["file"], "Dockerfile"); + + // Cleanup + fs::remove_dir_all(&temp).ok(); + } + + #[tokio::test] + async fn test_hadolint_valid_dockerfile() { + let tool = HadolintTool::new(temp_dir()); + let dockerfile = r#" +FROM node:18-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production +COPY . . +RUN npm run build + +FROM node:18-alpine +WORKDIR /app +COPY --from=builder /app/dist ./dist +USER node +EXPOSE 3000 +CMD ["node", "dist/index.js"] +"#; + + let args = HadolintArgs { + dockerfile: None, + content: Some(dockerfile.to_string()), + ignore: vec![], + threshold: None, + }; + + let result = tool.call(args).await.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // Well-structured Dockerfile should pass (no errors) + assert!(parsed["success"].as_bool().unwrap_or(false)); + // Should have decision context + assert!(parsed["decision_context"].is_string()); + // Should not have critical or high priority issues + assert_eq!(parsed["summary"]["by_priority"]["critical"].as_u64().unwrap_or(99), 0); + assert_eq!(parsed["summary"]["by_priority"]["high"].as_u64().unwrap_or(99), 0); + } + + #[tokio::test] + async fn test_hadolint_priority_categorization() { + let tool = HadolintTool::new(temp_dir()); + let args = HadolintArgs { + dockerfile: None, + content: Some("FROM ubuntu\nRUN sudo apt-get update\nMAINTAINER test".to_string()), + ignore: vec![], + threshold: None, + }; + + let result = tool.call(args).await.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // Check priority counts are present + assert!(parsed["summary"]["by_priority"]["critical"].is_number()); + assert!(parsed["summary"]["by_priority"]["high"].is_number()); + assert!(parsed["summary"]["by_priority"]["medium"].is_number()); + + // Check category counts + assert!(parsed["summary"]["by_category"].is_object()); + + // DL3004 (sudo) should be high priority security + let all_issues = collect_all_issues(&parsed); + let sudo_issue = all_issues.iter().find(|i| i["code"] == "DL3004"); + assert!(sudo_issue.is_some()); + assert_eq!(sudo_issue.unwrap()["category"], "security"); + } + + #[tokio::test] + async fn test_hadolint_quick_fixes() { + let tool = HadolintTool::new(temp_dir()); + let args = HadolintArgs { + dockerfile: None, + content: Some("FROM ubuntu\nRUN sudo rm -rf /".to_string()), + ignore: vec![], + threshold: None, + }; + + let result = tool.call(args).await.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // Should have quick_fixes for high priority issues + if parsed["summary"]["by_priority"]["high"].as_u64().unwrap_or(0) > 0 + || parsed["summary"]["by_priority"]["critical"].as_u64().unwrap_or(0) > 0 { + assert!(parsed["quick_fixes"].is_array()); + } + } +} diff --git a/src/agent/tools/mod.rs b/src/agent/tools/mod.rs index 1945bae1..96a9b7a3 100644 --- a/src/agent/tools/mod.rs +++ b/src/agent/tools/mod.rs @@ -17,15 +17,20 @@ //! - `SecurityScanTool` - Security vulnerability scanning //! - `VulnerabilitiesTool` - Dependency vulnerability checking //! +//! ### Linting +//! - `HadolintTool` - Native Dockerfile linting (best practices, security) +//! //! ### Shell //! - `ShellTool` - Execute validation commands (docker build, terraform validate, helm lint) mod analyze; mod file_ops; +mod hadolint; mod security; mod shell; pub use analyze::AnalyzeTool; pub use file_ops::{ListDirectoryTool, ReadFileTool, WriteFileTool, WriteFilesTool}; +pub use hadolint::HadolintTool; pub use security::{SecurityScanTool, VulnerabilitiesTool}; pub use shell::ShellTool; diff --git a/src/agent/tools/shell.rs b/src/agent/tools/shell.rs index 6f7cb1df..9ce46e9c 100644 --- a/src/agent/tools/shell.rs +++ b/src/agent/tools/shell.rs @@ -14,10 +14,11 @@ use rig::completion::ToolDefinition; use rig::tool::Tool; use serde::Deserialize; use serde_json::json; -use std::io::{BufRead, BufReader}; use std::path::PathBuf; -use std::process::{Command, Stdio}; use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tokio::sync::mpsc; /// Allowed command prefixes for security const ALLOWED_COMMANDS: &[&str] = &[ @@ -237,50 +238,92 @@ Use this to validate generated configurations: let mut stream_display = StreamingShellOutput::new(&args.command, timeout_secs); stream_display.render(); - // Execute command with streaming output + // Execute command with async streaming output let mut child = Command::new("sh") .arg("-c") .arg(&args.command) .current_dir(&working_dir) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) .spawn() .map_err(|e| ShellError(format!("Failed to spawn command: {}", e)))?; - // Read stdout and stderr in parallel, streaming output + // Take ownership of stdout/stderr for async reading let stdout = child.stdout.take(); let stderr = child.stderr.take(); + // Channel for streaming output lines from both stdout and stderr + let (tx, mut rx) = mpsc::channel::<(String, bool)>(100); // (line, is_stderr) + + // Spawn task to read stdout + let tx_stdout = tx.clone(); + let stdout_handle = if let Some(stdout) = stdout { + Some(tokio::spawn(async move { + let mut reader = BufReader::new(stdout).lines(); + let mut content = String::new(); + while let Ok(Some(line)) = reader.next_line().await { + content.push_str(&line); + content.push('\n'); + let _ = tx_stdout.send((line, false)).await; + } + content + })) + } else { + None + }; + + // Spawn task to read stderr + let tx_stderr = tx; + let stderr_handle = if let Some(stderr) = stderr { + Some(tokio::spawn(async move { + let mut reader = BufReader::new(stderr).lines(); + let mut content = String::new(); + while let Ok(Some(line)) = reader.next_line().await { + content.push_str(&line); + content.push('\n'); + let _ = tx_stderr.send((line, true)).await; + } + content + })) + } else { + None + }; + + // Process incoming lines and update display in real-time on the main task + // Use tokio::select! to handle both the receiver and the reader completion let mut stdout_content = String::new(); let mut stderr_content = String::new(); - // Read stdout - if let Some(stdout) = stdout { - let reader = BufReader::new(stdout); - for line in reader.lines() { - if let Ok(line) = line { - stdout_content.push_str(&line); - stdout_content.push('\n'); - stream_display.push_line(&line); + // Wait for readers while processing display updates + loop { + tokio::select! { + // Receive lines from either stdout or stderr + line_result = rx.recv() => { + match line_result { + Some((line, _is_stderr)) => { + stream_display.push_line(&line); + } + None => { + // Channel closed, all readers done + break; + } + } } } } - // Read stderr - if let Some(stderr) = stderr { - let reader = BufReader::new(stderr); - for line in reader.lines() { - if let Ok(line) = line { - stderr_content.push_str(&line); - stderr_content.push('\n'); - stream_display.push_line(&line); - } - } + // Collect final content from reader handles + if let Some(handle) = stdout_handle { + stdout_content = handle.await.unwrap_or_default(); + } + if let Some(handle) = stderr_handle { + stderr_content = handle.await.unwrap_or_default(); } // Wait for command to complete let status = child .wait() + .await .map_err(|e| ShellError(format!("Command execution failed: {}", e)))?; // Finalize display diff --git a/src/agent/ui/colors.rs b/src/agent/ui/colors.rs index 55f73d9e..216d4bd6 100644 --- a/src/agent/ui/colors.rs +++ b/src/agent/ui/colors.rs @@ -23,6 +23,13 @@ pub mod icons { pub const FOLDER: &str = "📁"; pub const SECURITY: &str = "🔒"; pub const SEARCH: &str = "🔍"; + pub const DOCKER: &str = "🐳"; + pub const LINT: &str = "📋"; + pub const FIX: &str = "🔧"; + pub const CRITICAL: &str = "🔴"; + pub const HIGH: &str = "🟠"; + pub const MEDIUM: &str = "🟡"; + pub const LOW: &str = "🟢"; } /// ANSI escape codes for direct terminal control @@ -51,6 +58,15 @@ pub mod ansi { pub const GRAY: &str = "\x1b[38;5;245m"; pub const WHITE: &str = "\x1b[38;5;255m"; pub const SUCCESS: &str = "\x1b[38;5;114m"; // Green for success + + // Hadolint/Docker specific colors (teal/docker-blue theme) + pub const DOCKER_BLUE: &str = "\x1b[38;5;39m"; // Docker brand blue + pub const TEAL: &str = "\x1b[38;5;30m"; // Teal for hadolint + pub const CRITICAL: &str = "\x1b[38;5;196m"; // Bright red + pub const HIGH: &str = "\x1b[38;5;208m"; // Orange + pub const MEDIUM: &str = "\x1b[38;5;220m"; // Yellow + pub const LOW: &str = "\x1b[38;5;114m"; // Green + pub const INFO_BLUE: &str = "\x1b[38;5;75m"; // Light blue for info } /// Format a tool name for display diff --git a/src/agent/ui/hadolint_display.rs b/src/agent/ui/hadolint_display.rs new file mode 100644 index 00000000..2dbd2168 --- /dev/null +++ b/src/agent/ui/hadolint_display.rs @@ -0,0 +1,324 @@ +//! Hadolint result display for terminal output +//! +//! Provides colored, formatted output for Dockerfile lint results +//! that's visually distinct and easy to recognize. + +use crate::agent::ui::colors::{ansi, icons}; +use std::io::{self, Write}; + +/// Display hadolint results in a formatted, colored terminal output +pub struct HadolintDisplay; + +impl HadolintDisplay { + /// Format and print hadolint results from the JSON output + pub fn print_result(json_result: &str) { + if let Ok(parsed) = serde_json::from_str::(json_result) { + Self::print_formatted(&parsed); + } else { + // Fallback: just print the raw result + println!("{}", json_result); + } + } + + /// Print formatted hadolint output + fn print_formatted(result: &serde_json::Value) { + let stdout = io::stdout(); + let mut handle = stdout.lock(); + + // Header with Docker icon and file name + let file = result["file"].as_str().unwrap_or("Dockerfile"); + let _ = writeln!( + handle, + "\n{}{}━━━ {} Hadolint: {} ━━━{}", + ansi::DOCKER_BLUE, + ansi::BOLD, + icons::DOCKER, + file, + ansi::RESET + ); + + // Decision context + if let Some(context) = result["decision_context"].as_str() { + let context_color = if context.contains("Critical") { + ansi::CRITICAL + } else if context.contains("High") { + ansi::HIGH + } else if context.contains("Medium") || context.contains("improvements") { + ansi::MEDIUM + } else { + ansi::LOW + }; + let _ = writeln!( + handle, + "{} {} {}{}", + context_color, + icons::ARROW, + context, + ansi::RESET + ); + } + + // Summary counts + if let Some(summary) = result.get("summary") { + let total = summary["total"].as_u64().unwrap_or(0); + if total == 0 { + let _ = writeln!( + handle, + "\n{} {} No issues found!{}", + ansi::SUCCESS, + icons::SUCCESS, + ansi::RESET + ); + } else { + let _ = writeln!(handle); + + // Priority breakdown + if let Some(by_priority) = summary.get("by_priority") { + let critical = by_priority["critical"].as_u64().unwrap_or(0); + let high = by_priority["high"].as_u64().unwrap_or(0); + let medium = by_priority["medium"].as_u64().unwrap_or(0); + let low = by_priority["low"].as_u64().unwrap_or(0); + + let _ = write!(handle, " "); + if critical > 0 { + let _ = write!( + handle, + "{}{} {} critical{} ", + ansi::CRITICAL, + icons::CRITICAL, + critical, + ansi::RESET + ); + } + if high > 0 { + let _ = write!( + handle, + "{}{} {} high{} ", + ansi::HIGH, + icons::HIGH, + high, + ansi::RESET + ); + } + if medium > 0 { + let _ = write!( + handle, + "{}{} {} medium{} ", + ansi::MEDIUM, + icons::MEDIUM, + medium, + ansi::RESET + ); + } + if low > 0 { + let _ = write!( + handle, + "{}{} {} low{}", + ansi::LOW, + icons::LOW, + low, + ansi::RESET + ); + } + let _ = writeln!(handle); + } + } + } + + // Quick fixes (most important) + if let Some(quick_fixes) = result.get("quick_fixes").and_then(|f| f.as_array()) { + if !quick_fixes.is_empty() { + let _ = writeln!( + handle, + "\n{}{} Quick Fixes:{}", + ansi::DOCKER_BLUE, + icons::FIX, + ansi::RESET + ); + for fix in quick_fixes.iter().take(5) { + if let Some(fix_str) = fix.as_str() { + let _ = writeln!( + handle, + "{} {} {}{}", + ansi::INFO_BLUE, + icons::ARROW, + fix_str, + ansi::RESET + ); + } + } + } + } + + // Critical and High priority issues with details + Self::print_priority_section(&mut handle, result, "critical", "Critical Issues", ansi::CRITICAL); + Self::print_priority_section(&mut handle, result, "high", "High Priority", ansi::HIGH); + + // Optionally show medium (collapsed) + if let Some(medium_issues) = result["action_plan"]["medium"].as_array() { + if !medium_issues.is_empty() { + let _ = writeln!( + handle, + "\n{} {} {} medium priority issue{} (run with --verbose to see all){}", + ansi::MEDIUM, + icons::MEDIUM, + medium_issues.len(), + if medium_issues.len() == 1 { "" } else { "s" }, + ansi::RESET + ); + } + } + + // Footer separator + let _ = writeln!( + handle, + "{}{}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", + ansi::DOCKER_BLUE, + ansi::DIM, + ansi::RESET + ); + + let _ = handle.flush(); + } + + /// Print a section for a priority level + fn print_priority_section( + handle: &mut io::StdoutLock, + result: &serde_json::Value, + priority: &str, + title: &str, + color: &str, + ) { + if let Some(issues) = result["action_plan"][priority].as_array() { + if issues.is_empty() { + return; + } + + let _ = writeln!(handle, "\n{} {}:{}", color, title, ansi::RESET); + + for issue in issues.iter().take(10) { + let code = issue["code"].as_str().unwrap_or("???"); + let line = issue["line"].as_u64().unwrap_or(0); + let message = issue["message"].as_str().unwrap_or(""); + let category = issue["category"].as_str().unwrap_or(""); + + // Category badge + let category_badge = match category { + "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET), + "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET), + "deprecated" => format!("{}[DEP]{}", ansi::MEDIUM, ansi::RESET), + "performance" => format!("{}[PERF]{}", ansi::CYAN, ansi::RESET), + "maintainability" => format!("{}[MAINT]{}", ansi::GRAY, ansi::RESET), + _ => String::new(), + }; + + let _ = writeln!( + handle, + " {}{}:{}{} {}{}{} {} {}", + ansi::DIM, + line, + ansi::RESET, + ansi::DOCKER_BLUE, + code, + ansi::RESET, + category_badge, + ansi::GRAY, + message, + ); + + // Show fix recommendation + if let Some(fix) = issue["fix"].as_str() { + let _ = writeln!( + handle, + " {}→ {}{}", + ansi::INFO_BLUE, + fix, + ansi::RESET + ); + } + } + + if issues.len() > 10 { + let _ = writeln!( + handle, + " {}... and {} more{}", + ansi::DIM, + issues.len() - 10, + ansi::RESET + ); + } + } + } + + /// Format a compact single-line summary for tool call display + pub fn format_summary(json_result: &str) -> String { + if let Ok(parsed) = serde_json::from_str::(json_result) { + let success = parsed["success"].as_bool().unwrap_or(false); + let total = parsed["summary"]["total"].as_u64().unwrap_or(0); + + if success && total == 0 { + format!( + "{}{} {} Dockerfile OK - no issues{}", + ansi::SUCCESS, + icons::SUCCESS, + icons::DOCKER, + ansi::RESET + ) + } else { + let critical = parsed["summary"]["by_priority"]["critical"].as_u64().unwrap_or(0); + let high = parsed["summary"]["by_priority"]["high"].as_u64().unwrap_or(0); + + if critical > 0 { + format!( + "{}{} {} {} critical, {} high priority issues{}", + ansi::CRITICAL, + icons::ERROR, + icons::DOCKER, + critical, + high, + ansi::RESET + ) + } else if high > 0 { + format!( + "{}{} {} {} high priority issues{}", + ansi::HIGH, + icons::WARNING, + icons::DOCKER, + high, + ansi::RESET + ) + } else { + format!( + "{}{} {} {} issues (medium/low){}", + ansi::MEDIUM, + icons::WARNING, + icons::DOCKER, + total, + ansi::RESET + ) + } + } + } else { + format!("{} Hadolint analysis complete", icons::DOCKER) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_summary_success() { + let json = r#"{"success": true, "summary": {"total": 0, "by_priority": {"critical": 0, "high": 0, "medium": 0, "low": 0}}}"#; + let summary = HadolintDisplay::format_summary(json); + assert!(summary.contains("OK")); + } + + #[test] + fn test_format_summary_critical() { + let json = r#"{"success": false, "summary": {"total": 3, "by_priority": {"critical": 1, "high": 2, "medium": 0, "low": 0}}}"#; + let summary = HadolintDisplay::format_summary(json); + assert!(summary.contains("critical")); + } +} diff --git a/src/agent/ui/hooks.rs b/src/agent/ui/hooks.rs index bb11857e..3e24b220 100644 --- a/src/agent/ui/hooks.rs +++ b/src/agent/ui/hooks.rs @@ -421,6 +421,7 @@ fn print_tool_result(name: &str, args: &str, result: &str) -> (bool, Vec "list_directory" => format_list_result(&parsed), "analyze_project" => format_analyze_result(&parsed), "security_scan" | "check_vulnerabilities" => format_security_result(&parsed), + "hadolint" => format_hadolint_result(&parsed), _ => (true, vec!["done".to_string()]), }; @@ -711,6 +712,177 @@ fn format_security_result(parsed: &Result) } } +/// Format hadolint result - uses new priority-based format with Docker styling +fn format_hadolint_result(parsed: &Result) -> (bool, Vec) { + if let Ok(v) = parsed { + let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true); + let summary = v.get("summary"); + let action_plan = v.get("action_plan"); + + let mut lines = Vec::new(); + + // Get total count + let total = summary + .and_then(|s| s.get("total")) + .and_then(|t| t.as_u64()) + .unwrap_or(0); + + // Show docker-themed header + if total == 0 { + lines.push(format!( + "{}🐳 Dockerfile OK - no issues found{}", + ansi::SUCCESS, ansi::RESET + )); + return (true, lines); + } + + // Get priority counts + let critical = summary + .and_then(|s| s.get("by_priority")) + .and_then(|p| p.get("critical")) + .and_then(|c| c.as_u64()) + .unwrap_or(0); + let high = summary + .and_then(|s| s.get("by_priority")) + .and_then(|p| p.get("high")) + .and_then(|h| h.as_u64()) + .unwrap_or(0); + let medium = summary + .and_then(|s| s.get("by_priority")) + .and_then(|p| p.get("medium")) + .and_then(|m| m.as_u64()) + .unwrap_or(0); + let low = summary + .and_then(|s| s.get("by_priority")) + .and_then(|p| p.get("low")) + .and_then(|l| l.as_u64()) + .unwrap_or(0); + + // Summary with priority breakdown + let mut priority_parts = Vec::new(); + if critical > 0 { + priority_parts.push(format!("{}🔴 {} critical{}", ansi::CRITICAL, critical, ansi::RESET)); + } + if high > 0 { + priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET)); + } + if medium > 0 { + priority_parts.push(format!("{}🟡 {} medium{}", ansi::MEDIUM, medium, ansi::RESET)); + } + if low > 0 { + priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET)); + } + + let header_color = if critical > 0 { + ansi::CRITICAL + } else if high > 0 { + ansi::HIGH + } else { + ansi::DOCKER_BLUE + }; + + lines.push(format!( + "{}🐳 {} issue{} found: {}{}", + header_color, + total, + if total == 1 { "" } else { "s" }, + priority_parts.join(" "), + ansi::RESET + )); + + // Show critical and high priority issues (these are most important) + let mut shown = 0; + const MAX_PREVIEW: usize = 6; + + // Critical issues first + if let Some(critical_issues) = action_plan + .and_then(|a| a.get("critical")) + .and_then(|c| c.as_array()) + { + for issue in critical_issues.iter().take(MAX_PREVIEW - shown) { + lines.push(format_hadolint_issue(issue, "🔴", ansi::CRITICAL)); + shown += 1; + } + } + + // Then high priority + if shown < MAX_PREVIEW { + if let Some(high_issues) = action_plan + .and_then(|a| a.get("high")) + .and_then(|h| h.as_array()) + { + for issue in high_issues.iter().take(MAX_PREVIEW - shown) { + lines.push(format_hadolint_issue(issue, "🟠", ansi::HIGH)); + shown += 1; + } + } + } + + // Show quick fix hint for most important issue + if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array()) { + if let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str()) { + let truncated = if first_fix.len() > 70 { + format!("{}...", &first_fix[..67]) + } else { + first_fix.to_string() + }; + lines.push(format!( + "{} → Fix: {}{}", + ansi::INFO_BLUE, truncated, ansi::RESET + )); + } + } + + // Note about remaining issues + let remaining = total as usize - shown; + if remaining > 0 { + lines.push(format!( + "{} +{} more issue{}{}", + ansi::GRAY, + remaining, + if remaining == 1 { "" } else { "s" }, + ansi::RESET + )); + } + + (success, lines) + } else { + (false, vec!["parse error".to_string()]) + } +} + +/// Format a single hadolint issue for display +fn format_hadolint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String { + let code = issue.get("code").and_then(|c| c.as_str()).unwrap_or("?"); + let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?"); + let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0); + let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or(""); + + // Category badge + let badge = match category { + "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET), + "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET), + "deprecated" => format!("{}[DEP]{}", ansi::MEDIUM, ansi::RESET), + "performance" => format!("{}[PERF]{}", ansi::CYAN, ansi::RESET), + _ => String::new(), + }; + + // Truncate message + let msg_display = if message.len() > 50 { + format!("{}...", &message[..47]) + } else { + message.to_string() + }; + + format!( + "{}{} L{}:{} {}{}[{}]{} {} {}", + color, icon, line_num, ansi::RESET, + ansi::DOCKER_BLUE, ansi::BOLD, code, ansi::RESET, + badge, + msg_display + ) +} + // Legacy exports for compatibility pub use crate::agent::ui::Spinner; use tokio::sync::mpsc; diff --git a/src/agent/ui/mod.rs b/src/agent/ui/mod.rs index 3fec2470..1b7c335e 100644 --- a/src/agent/ui/mod.rs +++ b/src/agent/ui/mod.rs @@ -14,6 +14,7 @@ pub mod autocomplete; pub mod colors; pub mod confirmation; pub mod diff; +pub mod hadolint_display; pub mod hooks; pub mod input; pub mod response; @@ -26,6 +27,7 @@ pub use autocomplete::*; pub use colors::*; pub use confirmation::*; pub use diff::*; +pub use hadolint_display::*; pub use hooks::*; pub use input::*; pub use response::*; diff --git a/src/analyzer/Screenshot 2025-12-16 at 08.21.18.png b/src/analyzer/Screenshot 2025-12-16 at 08.21.18.png deleted file mode 100644 index aceeb82d..00000000 Binary files a/src/analyzer/Screenshot 2025-12-16 at 08.21.18.png and /dev/null differ diff --git a/src/analyzer/hadolint/config.rs b/src/analyzer/hadolint/config.rs new file mode 100644 index 00000000..3d791503 --- /dev/null +++ b/src/analyzer/hadolint/config.rs @@ -0,0 +1,382 @@ +//! Configuration for the hadolint-rs linter. +//! +//! Supports configuration from: +//! - Programmatic defaults +//! - YAML config files (.hadolint.yaml) +//! +//! Configuration priority (highest to lowest): +//! 1. Programmatic overrides +//! 2. Config file settings +//! 3. Defaults + +use crate::analyzer::hadolint::types::{RuleCode, Severity}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +/// Label validation types for DL3049-DL3056 rules. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LabelType { + /// Email address format + Email, + /// Git commit hash + GitHash, + /// Raw text (no validation) + RawText, + /// RFC3339 timestamp + Rfc3339, + /// Semantic versioning + SemVer, + /// SPDX license identifier + Spdx, + /// URL format + Url, +} + +impl LabelType { + /// Parse a label type from a string. + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "email" => Some(Self::Email), + "hash" => Some(Self::GitHash), + "text" | "" => Some(Self::RawText), + "rfc3339" => Some(Self::Rfc3339), + "semver" => Some(Self::SemVer), + "spdx" => Some(Self::Spdx), + "url" => Some(Self::Url), + _ => None, + } + } + + /// Get the string representation. + pub fn as_str(&self) -> &'static str { + match self { + Self::Email => "email", + Self::GitHash => "hash", + Self::RawText => "text", + Self::Rfc3339 => "rfc3339", + Self::SemVer => "semver", + Self::Spdx => "spdx", + Self::Url => "url", + } + } +} + +/// Configuration for the hadolint linter. +#[derive(Debug, Clone)] +pub struct HadolintConfig { + /// Rules to ignore entirely. + pub ignore_rules: HashSet, + /// Rules to treat as errors (override default severity). + pub error_rules: HashSet, + /// Rules to treat as warnings (override default severity). + pub warning_rules: HashSet, + /// Rules to treat as info (override default severity). + pub info_rules: HashSet, + /// Rules to treat as style (override default severity). + pub style_rules: HashSet, + /// Allowed Docker registries (for DL3026). + pub allowed_registries: HashSet, + /// Label schema requirements (for DL3049-DL3056). + pub label_schema: HashMap, + /// Fail on labels not in schema. + pub strict_labels: bool, + /// Disable inline ignore pragmas. + pub disable_ignore_pragma: bool, + /// Minimum severity to report. + pub failure_threshold: Severity, + /// Don't fail even if rules are violated. + pub no_fail: bool, +} + +impl Default for HadolintConfig { + fn default() -> Self { + Self { + ignore_rules: HashSet::new(), + error_rules: HashSet::new(), + warning_rules: HashSet::new(), + info_rules: HashSet::new(), + style_rules: HashSet::new(), + allowed_registries: HashSet::new(), + label_schema: HashMap::new(), + strict_labels: false, + disable_ignore_pragma: false, + failure_threshold: Severity::Info, + no_fail: false, + } + } +} + +impl HadolintConfig { + /// Create a new config with defaults. + pub fn new() -> Self { + Self::default() + } + + /// Load config from a YAML file. + pub fn from_yaml_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| ConfigError::IoError(e.to_string()))?; + Self::from_yaml_str(&content) + } + + /// Load config from a YAML string. + pub fn from_yaml_str(yaml: &str) -> Result { + let value: serde_yaml::Value = serde_yaml::from_str(yaml) + .map_err(|e| ConfigError::ParseError(e.to_string()))?; + + let mut config = Self::default(); + + // Parse ignored rules + if let Some(ignored) = value.get("ignored").and_then(|v| v.as_sequence()) { + for item in ignored { + if let Some(code) = item.as_str() { + config.ignore_rules.insert(RuleCode::new(code)); + } + } + } + + // Parse override.error + if let Some(overrides) = value.get("override").and_then(|v| v.as_mapping()) { + if let Some(errors) = overrides.get("error").and_then(|v| v.as_sequence()) { + for item in errors { + if let Some(code) = item.as_str() { + config.error_rules.insert(RuleCode::new(code)); + } + } + } + if let Some(warnings) = overrides.get("warning").and_then(|v| v.as_sequence()) { + for item in warnings { + if let Some(code) = item.as_str() { + config.warning_rules.insert(RuleCode::new(code)); + } + } + } + if let Some(infos) = overrides.get("info").and_then(|v| v.as_sequence()) { + for item in infos { + if let Some(code) = item.as_str() { + config.info_rules.insert(RuleCode::new(code)); + } + } + } + if let Some(styles) = overrides.get("style").and_then(|v| v.as_sequence()) { + for item in styles { + if let Some(code) = item.as_str() { + config.style_rules.insert(RuleCode::new(code)); + } + } + } + } + + // Parse trusted registries + if let Some(registries) = value.get("trustedRegistries").and_then(|v| v.as_sequence()) { + for item in registries { + if let Some(registry) = item.as_str() { + config.allowed_registries.insert(registry.to_string()); + } + } + } + + // Parse label schema + if let Some(schema) = value.get("label-schema").and_then(|v| v.as_mapping()) { + for (key, val) in schema { + if let (Some(label), Some(type_str)) = (key.as_str(), val.as_str()) { + if let Some(label_type) = LabelType::from_str(type_str) { + config.label_schema.insert(label.to_string(), label_type); + } + } + } + } + + // Parse boolean flags + if let Some(strict) = value.get("strict-labels").and_then(|v| v.as_bool()) { + config.strict_labels = strict; + } + if let Some(disable) = value.get("disable-ignore-pragma").and_then(|v| v.as_bool()) { + config.disable_ignore_pragma = disable; + } + if let Some(no_fail) = value.get("no-fail").and_then(|v| v.as_bool()) { + config.no_fail = no_fail; + } + + // Parse failure threshold + if let Some(threshold) = value.get("failure-threshold").and_then(|v| v.as_str()) { + if let Some(severity) = Severity::from_str(threshold) { + config.failure_threshold = severity; + } + } + + Ok(config) + } + + /// Find and load config from standard locations. + /// + /// Search order: + /// 1. .hadolint.yaml in current directory + /// 2. .hadolint.yml in current directory + /// 3. XDG config directory + /// 4. Home directory + pub fn find_and_load() -> Option { + let search_paths = [ + ".hadolint.yaml", + ".hadolint.yml", + ]; + + for path in &search_paths { + let path = Path::new(path); + if path.exists() { + if let Ok(config) = Self::from_yaml_file(path) { + return Some(config); + } + } + } + + // Try XDG config directory + if let Some(config_dir) = dirs::config_dir() { + let xdg_path = config_dir.join("hadolint.yaml"); + if xdg_path.exists() { + if let Ok(config) = Self::from_yaml_file(&xdg_path) { + return Some(config); + } + } + } + + // Try home directory + if let Some(home_dir) = dirs::home_dir() { + let home_path = home_dir.join(".hadolint.yaml"); + if home_path.exists() { + if let Ok(config) = Self::from_yaml_file(&home_path) { + return Some(config); + } + } + } + + None + } + + /// Check if a rule should be ignored. + pub fn is_rule_ignored(&self, code: &RuleCode) -> bool { + self.ignore_rules.contains(code) + } + + /// Get the effective severity for a rule. + pub fn effective_severity(&self, code: &RuleCode, default: Severity) -> Severity { + if self.error_rules.contains(code) { + return Severity::Error; + } + if self.warning_rules.contains(code) { + return Severity::Warning; + } + if self.info_rules.contains(code) { + return Severity::Info; + } + if self.style_rules.contains(code) { + return Severity::Style; + } + default + } + + /// Builder method to add an ignored rule. + pub fn ignore(mut self, code: impl Into) -> Self { + self.ignore_rules.insert(code.into()); + self + } + + /// Builder method to add an allowed registry. + pub fn allow_registry(mut self, registry: impl Into) -> Self { + self.allowed_registries.insert(registry.into()); + self + } + + /// Builder method to set failure threshold. + pub fn with_threshold(mut self, threshold: Severity) -> Self { + self.failure_threshold = threshold; + self + } +} + +/// Errors that can occur when loading configuration. +#[derive(Debug, Clone)] +pub enum ConfigError { + /// I/O error reading the file. + IoError(String), + /// YAML parsing error. + ParseError(String), +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::IoError(msg) => write!(f, "I/O error: {}", msg), + Self::ParseError(msg) => write!(f, "Parse error: {}", msg), + } + } +} + +impl std::error::Error for ConfigError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = HadolintConfig::default(); + assert!(config.ignore_rules.is_empty()); + assert!(!config.strict_labels); + assert!(!config.disable_ignore_pragma); + assert_eq!(config.failure_threshold, Severity::Info); + } + + #[test] + fn test_yaml_parsing() { + let yaml = r#" +ignored: + - DL3008 + - DL3009 + +override: + error: + - DL3001 + warning: + - DL3002 + +trustedRegistries: + - docker.io + - gcr.io + +failure-threshold: warning +strict-labels: true +"#; + + let config = HadolintConfig::from_yaml_str(yaml).unwrap(); + assert!(config.ignore_rules.contains(&RuleCode::new("DL3008"))); + assert!(config.ignore_rules.contains(&RuleCode::new("DL3009"))); + assert!(config.error_rules.contains(&RuleCode::new("DL3001"))); + assert!(config.warning_rules.contains(&RuleCode::new("DL3002"))); + assert!(config.allowed_registries.contains("docker.io")); + assert!(config.allowed_registries.contains("gcr.io")); + assert_eq!(config.failure_threshold, Severity::Warning); + assert!(config.strict_labels); + } + + #[test] + fn test_effective_severity() { + let config = HadolintConfig::default() + .ignore("DL3008".to_string()); + + assert!(config.is_rule_ignored(&RuleCode::new("DL3008"))); + assert!(!config.is_rule_ignored(&RuleCode::new("DL3009"))); + } + + #[test] + fn test_builder_pattern() { + let config = HadolintConfig::new() + .ignore("DL3008") + .allow_registry("docker.io") + .with_threshold(Severity::Warning); + + assert!(config.ignore_rules.contains(&RuleCode::new("DL3008"))); + assert!(config.allowed_registries.contains("docker.io")); + assert_eq!(config.failure_threshold, Severity::Warning); + } +} diff --git a/src/analyzer/hadolint/formatter/checkstyle.rs b/src/analyzer/hadolint/formatter/checkstyle.rs new file mode 100644 index 00000000..c13838ed --- /dev/null +++ b/src/analyzer/hadolint/formatter/checkstyle.rs @@ -0,0 +1,103 @@ +//! Checkstyle XML formatter for hadolint-rs. +//! +//! Outputs lint results in Checkstyle XML format for Jenkins and other CI tools. + +use crate::analyzer::hadolint::formatter::Formatter; +use crate::analyzer::hadolint::lint::LintResult; +use crate::analyzer::hadolint::types::Severity; +use std::io::Write; + +/// Checkstyle XML output formatter for Jenkins. +#[derive(Debug, Clone, Default)] +pub struct CheckstyleFormatter; + +impl CheckstyleFormatter { + /// Create a new Checkstyle formatter. + pub fn new() -> Self { + Self + } +} + +fn escape_xml(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn severity_to_checkstyle(severity: Severity) -> &'static str { + match severity { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "info", + Severity::Style => "info", + Severity::Ignore => "info", + } +} + +impl Formatter for CheckstyleFormatter { + fn format(&self, result: &LintResult, filename: &str, writer: &mut W) -> std::io::Result<()> { + writeln!(writer, r#""#)?; + writeln!(writer, r#""#)?; + + if !result.failures.is_empty() { + writeln!(writer, r#" "#, escape_xml(filename))?; + + for failure in &result.failures { + let col_attr = failure + .column + .map(|c| format!(r#" column="{}""#, c)) + .unwrap_or_default(); + + writeln!( + writer, + r#" "#, + failure.line, + col_attr, + severity_to_checkstyle(failure.severity), + escape_xml(&failure.message), + escape_xml(&failure.code.to_string()) + )?; + } + + writeln!(writer, " ")?; + } + + writeln!(writer, "") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::types::CheckFailure; + + #[test] + fn test_checkstyle_output() { + let mut result = LintResult::new(); + result.failures.push(CheckFailure::new( + "DL3008", + Severity::Warning, + "Pin versions in apt get install", + 5, + )); + + let formatter = CheckstyleFormatter::new(); + let output = formatter.format_to_string(&result, "Dockerfile"); + + assert!(output.contains(""#)); + assert!(output.contains(r#"line="5""#)); + assert!(output.contains(r#"severity="warning""#)); + assert!(output.contains("DL3008")); + } + + #[test] + fn test_xml_escaping() { + assert_eq!(escape_xml("a < b"), "a < b"); + assert_eq!(escape_xml("a & b"), "a & b"); + assert_eq!(escape_xml(r#"a "b""#), "a "b""); + } +} diff --git a/src/analyzer/hadolint/formatter/codeclimate.rs b/src/analyzer/hadolint/formatter/codeclimate.rs new file mode 100644 index 00000000..2935c2bf --- /dev/null +++ b/src/analyzer/hadolint/formatter/codeclimate.rs @@ -0,0 +1,196 @@ +//! CodeClimate formatter for hadolint-rs. +//! +//! Outputs lint results in CodeClimate JSON format for GitLab CI integration. +//! +//! CodeClimate Specification: https://github.com/codeclimate/platform/blob/master/spec/analyzers/SPEC.md + +use crate::analyzer::hadolint::formatter::Formatter; +use crate::analyzer::hadolint::lint::LintResult; +use crate::analyzer::hadolint::types::Severity; +use serde::Serialize; +use std::io::Write; + +/// CodeClimate JSON output formatter for GitLab CI. +#[derive(Debug, Clone, Default)] +pub struct CodeClimateFormatter; + +impl CodeClimateFormatter { + /// Create a new CodeClimate formatter. + pub fn new() -> Self { + Self + } +} + +/// CodeClimate issue structure. +#[derive(Debug, Serialize)] +struct CodeClimateIssue { + #[serde(rename = "type")] + issue_type: &'static str, + check_name: String, + description: String, + content: CodeClimateContent, + categories: Vec<&'static str>, + location: CodeClimateLocation, + severity: &'static str, + fingerprint: String, +} + +#[derive(Debug, Serialize)] +struct CodeClimateContent { + body: String, +} + +#[derive(Debug, Serialize)] +struct CodeClimateLocation { + path: String, + lines: CodeClimateLines, +} + +#[derive(Debug, Serialize)] +struct CodeClimateLines { + begin: u32, + end: u32, +} + +fn severity_to_codeclimate(severity: Severity) -> &'static str { + match severity { + Severity::Error => "critical", + Severity::Warning => "major", + Severity::Info => "minor", + Severity::Style => "info", + Severity::Ignore => "info", + } +} + +fn get_categories(code: &str) -> Vec<&'static str> { + // Categorize based on rule code prefix + if code.starts_with("DL") { + // Dockerfile linting rules + let rule_num: u32 = code[2..].parse().unwrap_or(0); + match rule_num { + // Security-related rules + 3000..=3010 => vec!["Security", "Bug Risk"], + // Best practices + 3011..=3030 => vec!["Style", "Clarity"], + // Performance + 3031..=3050 => vec!["Performance"], + // Deprecated instructions + 4000..=4999 => vec!["Compatibility", "Bug Risk"], + _ => vec!["Style"], + } + } else if code.starts_with("SC") { + // ShellCheck rules + vec!["Bug Risk", "Security"] + } else { + vec!["Style"] + } +} + +fn generate_fingerprint(filename: &str, code: &str, line: u32) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + filename.hash(&mut hasher); + code.hash(&mut hasher); + line.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +fn get_help_body(code: &str) -> String { + if code.starts_with("DL") { + format!( + "See the hadolint wiki for more information: https://github.com/hadolint/hadolint/wiki/{}", + code + ) + } else if code.starts_with("SC") { + format!( + "See the ShellCheck wiki for more information: https://www.shellcheck.net/wiki/{}", + code + ) + } else { + "See hadolint documentation for more information.".to_string() + } +} + +impl Formatter for CodeClimateFormatter { + fn format(&self, result: &LintResult, filename: &str, writer: &mut W) -> std::io::Result<()> { + let issues: Vec = result + .failures + .iter() + .map(|f| { + let code = f.code.to_string(); + CodeClimateIssue { + issue_type: "issue", + check_name: code.clone(), + description: f.message.clone(), + content: CodeClimateContent { + body: get_help_body(&code), + }, + categories: get_categories(&code), + location: CodeClimateLocation { + path: filename.to_string(), + lines: CodeClimateLines { + begin: f.line, + end: f.line, + }, + }, + severity: severity_to_codeclimate(f.severity), + fingerprint: generate_fingerprint(filename, &code, f.line), + } + }) + .collect(); + + // CodeClimate expects newline-delimited JSON (NDJSON) + for issue in &issues { + let json = serde_json::to_string(issue) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + writeln!(writer, "{}", json)?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::types::CheckFailure; + + #[test] + fn test_codeclimate_output() { + let mut result = LintResult::new(); + result.failures.push(CheckFailure::new( + "DL3008", + Severity::Warning, + "Pin versions in apt get install", + 5, + )); + + let formatter = CodeClimateFormatter::new(); + let output = formatter.format_to_string(&result, "Dockerfile"); + + assert!(output.contains("\"type\":\"issue\"")); + assert!(output.contains("\"check_name\":\"DL3008\"")); + assert!(output.contains("\"severity\":\"major\"")); + assert!(output.contains("\"path\":\"Dockerfile\"")); + assert!(output.contains("\"fingerprint\"")); + } + + #[test] + fn test_fingerprint_consistency() { + let fp1 = generate_fingerprint("Dockerfile", "DL3008", 5); + let fp2 = generate_fingerprint("Dockerfile", "DL3008", 5); + let fp3 = generate_fingerprint("Dockerfile", "DL3008", 6); + + assert_eq!(fp1, fp2); + assert_ne!(fp1, fp3); + } + + #[test] + fn test_categories() { + assert!(get_categories("DL3000").contains(&"Security")); + assert!(get_categories("SC2086").contains(&"Bug Risk")); + assert!(get_categories("DL4000").contains(&"Compatibility")); + } +} diff --git a/src/analyzer/hadolint/formatter/gnu.rs b/src/analyzer/hadolint/formatter/gnu.rs new file mode 100644 index 00000000..0f9d0c9e --- /dev/null +++ b/src/analyzer/hadolint/formatter/gnu.rs @@ -0,0 +1,101 @@ +//! GNU formatter for hadolint-rs. +//! +//! Outputs lint results in GNU compiler-style format for editor integration. +//! Format: filename:line:column: severity: message [code] + +use crate::analyzer::hadolint::formatter::Formatter; +use crate::analyzer::hadolint::lint::LintResult; +use crate::analyzer::hadolint::types::Severity; +use std::io::Write; + +/// GNU compiler-style output formatter. +#[derive(Debug, Clone, Default)] +pub struct GnuFormatter; + +impl GnuFormatter { + /// Create a new GNU formatter. + pub fn new() -> Self { + Self + } +} + +impl Formatter for GnuFormatter { + fn format(&self, result: &LintResult, filename: &str, writer: &mut W) -> std::io::Result<()> { + for failure in &result.failures { + let severity_str = match failure.severity { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "info", + Severity::Style => "style", + Severity::Ignore => "note", + }; + + // GNU format: file:line:column: severity: message [code] + if let Some(col) = failure.column { + writeln!( + writer, + "{}:{}:{}: {}: {} [{}]", + filename, + failure.line, + col, + severity_str, + failure.message, + failure.code + )?; + } else { + writeln!( + writer, + "{}:{}: {}: {} [{}]", + filename, + failure.line, + severity_str, + failure.message, + failure.code + )?; + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::types::CheckFailure; + + #[test] + fn test_gnu_output() { + let mut result = LintResult::new(); + result.failures.push(CheckFailure::new( + "DL3008", + Severity::Warning, + "Pin versions in apt get install", + 5, + )); + + let formatter = GnuFormatter::new(); + let output = formatter.format_to_string(&result, "Dockerfile"); + + assert_eq!( + output.trim(), + "Dockerfile:5: warning: Pin versions in apt get install [DL3008]" + ); + } + + #[test] + fn test_gnu_output_with_column() { + let mut result = LintResult::new(); + result.failures.push(CheckFailure::with_column( + "DL3008", + Severity::Warning, + "Pin versions", + 5, + 10, + )); + + let formatter = GnuFormatter::new(); + let output = formatter.format_to_string(&result, "Dockerfile"); + + assert!(output.contains("Dockerfile:5:10:")); + } +} diff --git a/src/analyzer/hadolint/formatter/json.rs b/src/analyzer/hadolint/formatter/json.rs new file mode 100644 index 00000000..2f8b11ca --- /dev/null +++ b/src/analyzer/hadolint/formatter/json.rs @@ -0,0 +1,98 @@ +//! JSON formatter for hadolint-rs. +//! +//! Outputs lint results in JSON format for CI/CD pipeline integration. +//! Compatible with the original hadolint JSON output. + +use crate::analyzer::hadolint::formatter::Formatter; +use crate::analyzer::hadolint::lint::LintResult; +use crate::analyzer::hadolint::types::Severity; +use serde::Serialize; +use std::io::Write; + +/// JSON output formatter. +#[derive(Debug, Clone, Default)] +pub struct JsonFormatter { + /// Pretty-print the JSON output. + pub pretty: bool, +} + +impl JsonFormatter { + /// Create a new JSON formatter. + pub fn new() -> Self { + Self::default() + } + + /// Create a JSON formatter with pretty-printing enabled. + pub fn pretty() -> Self { + Self { pretty: true } + } +} + +/// JSON representation of a lint failure. +#[derive(Debug, Serialize)] +struct JsonFailure { + line: u32, + #[serde(skip_serializing_if = "Option::is_none")] + column: Option, + code: String, + message: String, + level: String, + file: String, +} + +impl Formatter for JsonFormatter { + fn format(&self, result: &LintResult, filename: &str, writer: &mut W) -> std::io::Result<()> { + let failures: Vec = result + .failures + .iter() + .map(|f| JsonFailure { + line: f.line, + column: f.column, + code: f.code.to_string(), + message: f.message.clone(), + level: match f.severity { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "info", + Severity::Style => "style", + Severity::Ignore => "ignore", + } + .to_string(), + file: filename.to_string(), + }) + .collect(); + + let json = if self.pretty { + serde_json::to_string_pretty(&failures) + } else { + serde_json::to_string(&failures) + } + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + + writeln!(writer, "{}", json) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::types::CheckFailure; + + #[test] + fn test_json_output() { + let mut result = LintResult::new(); + result.failures.push(CheckFailure::new( + "DL3008", + Severity::Warning, + "Pin versions in apt get install", + 5, + )); + + let formatter = JsonFormatter::new(); + let output = formatter.format_to_string(&result, "Dockerfile"); + + assert!(output.contains("DL3008")); + assert!(output.contains("warning")); + assert!(output.contains("Pin versions")); + } +} diff --git a/src/analyzer/hadolint/formatter/mod.rs b/src/analyzer/hadolint/formatter/mod.rs new file mode 100644 index 00000000..6adbc283 --- /dev/null +++ b/src/analyzer/hadolint/formatter/mod.rs @@ -0,0 +1,101 @@ +//! Output formatters for hadolint-rs lint results. +//! +//! Provides multiple output formats for compatibility with various CI/CD systems: +//! - **TTY**: Colored terminal output for human readability +//! - **JSON**: Machine-readable format for CI/CD pipelines +//! - **SARIF**: Static Analysis Results Interchange Format for GitHub Actions +//! - **Checkstyle**: XML format for Jenkins and other tools +//! - **CodeClimate**: JSON format for GitLab CI +//! - **GNU**: Standard compiler-style output for editors + +mod checkstyle; +mod codeclimate; +mod gnu; +mod json; +mod sarif; +mod tty; + +pub use checkstyle::CheckstyleFormatter; +pub use codeclimate::CodeClimateFormatter; +pub use gnu::GnuFormatter; +pub use json::JsonFormatter; +pub use sarif::SarifFormatter; +pub use tty::TtyFormatter; + +use crate::analyzer::hadolint::lint::LintResult; +use std::io::Write; + +/// Output format for lint results. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum OutputFormat { + /// Colored terminal output (default) + #[default] + Tty, + /// JSON format for CI/CD + Json, + /// SARIF format for GitHub Actions + Sarif, + /// Checkstyle XML for Jenkins + Checkstyle, + /// CodeClimate JSON for GitLab + CodeClimate, + /// GNU compiler-style output + Gnu, +} + +impl OutputFormat { + /// Parse format from string (case-insensitive). + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "tty" | "terminal" | "color" => Some(Self::Tty), + "json" => Some(Self::Json), + "sarif" => Some(Self::Sarif), + "checkstyle" => Some(Self::Checkstyle), + "codeclimate" | "gitlab" => Some(Self::CodeClimate), + "gnu" => Some(Self::Gnu), + _ => None, + } + } + + /// Get all available format names. + pub fn all_names() -> &'static [&'static str] { + &["tty", "json", "sarif", "checkstyle", "codeclimate", "gnu"] + } +} + +/// Trait for formatting lint results. +pub trait Formatter { + /// Format the lint result and write to the given writer. + fn format(&self, result: &LintResult, filename: &str, writer: &mut W) -> std::io::Result<()>; + + /// Format the lint result to a string. + fn format_to_string(&self, result: &LintResult, filename: &str) -> String { + let mut buf = Vec::new(); + self.format(result, filename, &mut buf).unwrap_or_default(); + String::from_utf8(buf).unwrap_or_default() + } +} + +/// Format a lint result using the specified output format. +pub fn format_result( + result: &LintResult, + filename: &str, + format: OutputFormat, + writer: &mut W, +) -> std::io::Result<()> { + match format { + OutputFormat::Tty => TtyFormatter::new().format(result, filename, writer), + OutputFormat::Json => JsonFormatter::new().format(result, filename, writer), + OutputFormat::Sarif => SarifFormatter::new().format(result, filename, writer), + OutputFormat::Checkstyle => CheckstyleFormatter::new().format(result, filename, writer), + OutputFormat::CodeClimate => CodeClimateFormatter::new().format(result, filename, writer), + OutputFormat::Gnu => GnuFormatter::new().format(result, filename, writer), + } +} + +/// Format a lint result to a string using the specified output format. +pub fn format_result_to_string(result: &LintResult, filename: &str, format: OutputFormat) -> String { + let mut buf = Vec::new(); + format_result(result, filename, format, &mut buf).unwrap_or_default(); + String::from_utf8(buf).unwrap_or_default() +} diff --git a/src/analyzer/hadolint/formatter/sarif.rs b/src/analyzer/hadolint/formatter/sarif.rs new file mode 100644 index 00000000..7acda7f4 --- /dev/null +++ b/src/analyzer/hadolint/formatter/sarif.rs @@ -0,0 +1,234 @@ +//! SARIF formatter for hadolint-rs. +//! +//! Outputs lint results in SARIF (Static Analysis Results Interchange Format) +//! for GitHub Actions Code Scanning integration. +//! +//! SARIF Specification: https://sarifweb.azurewebsites.net/ + +use crate::analyzer::hadolint::formatter::Formatter; +use crate::analyzer::hadolint::lint::LintResult; +use crate::analyzer::hadolint::types::Severity; +use serde::Serialize; +use std::io::Write; + +/// SARIF output formatter for GitHub Actions. +#[derive(Debug, Clone, Default)] +pub struct SarifFormatter; + +impl SarifFormatter { + /// Create a new SARIF formatter. + pub fn new() -> Self { + Self + } +} + +/// SARIF 2.1.0 schema structures. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SarifReport { + #[serde(rename = "$schema")] + schema: &'static str, + version: &'static str, + runs: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SarifRun { + tool: SarifTool, + results: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SarifTool { + driver: SarifDriver, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SarifDriver { + name: &'static str, + information_uri: &'static str, + version: &'static str, + rules: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SarifRule { + id: String, + name: String, + short_description: SarifMessage, + #[serde(skip_serializing_if = "Option::is_none")] + help_uri: Option, + default_configuration: SarifRuleConfiguration, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SarifRuleConfiguration { + level: &'static str, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SarifMessage { + text: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SarifResult { + rule_id: String, + level: &'static str, + message: SarifMessage, + locations: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SarifLocation { + physical_location: SarifPhysicalLocation, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SarifPhysicalLocation { + artifact_location: SarifArtifactLocation, + region: SarifRegion, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SarifArtifactLocation { + uri: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SarifRegion { + start_line: u32, + #[serde(skip_serializing_if = "Option::is_none")] + start_column: Option, +} + +fn severity_to_sarif_level(severity: Severity) -> &'static str { + match severity { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "note", + Severity::Style => "note", + Severity::Ignore => "none", + } +} + +fn get_rule_help_uri(code: &str) -> Option { + if code.starts_with("DL") { + Some(format!( + "https://github.com/hadolint/hadolint/wiki/{}", + code + )) + } else if code.starts_with("SC") { + Some(format!("https://www.shellcheck.net/wiki/{}", code)) + } else { + None + } +} + +impl Formatter for SarifFormatter { + fn format(&self, result: &LintResult, filename: &str, writer: &mut W) -> std::io::Result<()> { + // Collect unique rules for the rules array + let mut rules: Vec = Vec::new(); + let mut seen_rules = std::collections::HashSet::new(); + + for failure in &result.failures { + let code = failure.code.to_string(); + if !seen_rules.contains(&code) { + seen_rules.insert(code.clone()); + rules.push(SarifRule { + id: code.clone(), + name: code.clone(), + short_description: SarifMessage { + text: failure.message.clone(), + }, + help_uri: get_rule_help_uri(&code), + default_configuration: SarifRuleConfiguration { + level: severity_to_sarif_level(failure.severity), + }, + }); + } + } + + // Build results + let results: Vec = result + .failures + .iter() + .map(|f| SarifResult { + rule_id: f.code.to_string(), + level: severity_to_sarif_level(f.severity), + message: SarifMessage { + text: f.message.clone(), + }, + locations: vec![SarifLocation { + physical_location: SarifPhysicalLocation { + artifact_location: SarifArtifactLocation { + uri: filename.to_string(), + }, + region: SarifRegion { + start_line: f.line, + start_column: f.column, + }, + }, + }], + }) + .collect(); + + let report = SarifReport { + schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + version: "2.1.0", + runs: vec![SarifRun { + tool: SarifTool { + driver: SarifDriver { + name: "hadolint-rs", + information_uri: "https://github.com/syncable-dev/syncable-cli", + version: env!("CARGO_PKG_VERSION"), + rules, + }, + }, + results, + }], + }; + + let json = serde_json::to_string_pretty(&report) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + + writeln!(writer, "{}", json) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::types::CheckFailure; + + #[test] + fn test_sarif_output() { + let mut result = LintResult::new(); + result.failures.push(CheckFailure::new( + "DL3008", + Severity::Warning, + "Pin versions in apt get install", + 5, + )); + + let formatter = SarifFormatter::new(); + let output = formatter.format_to_string(&result, "Dockerfile"); + + assert!(output.contains("\"$schema\"")); + assert!(output.contains("\"version\": \"2.1.0\"")); + assert!(output.contains("hadolint-rs")); + assert!(output.contains("DL3008")); + assert!(output.contains("warning")); + } +} diff --git a/src/analyzer/hadolint/formatter/tty.rs b/src/analyzer/hadolint/formatter/tty.rs new file mode 100644 index 00000000..95b35dde --- /dev/null +++ b/src/analyzer/hadolint/formatter/tty.rs @@ -0,0 +1,212 @@ +//! TTY formatter for hadolint-rs. +//! +//! Outputs lint results with colored terminal output for human readability. +//! Uses ANSI escape codes for colors. + +use crate::analyzer::hadolint::formatter::Formatter; +use crate::analyzer::hadolint::lint::LintResult; +use crate::analyzer::hadolint::types::Severity; +use std::io::Write; + +/// TTY (terminal) output formatter with colors. +#[derive(Debug, Clone)] +pub struct TtyFormatter { + /// Use colors in output. + pub colors: bool, + /// Show the filename in each line. + pub show_filename: bool, +} + +impl Default for TtyFormatter { + fn default() -> Self { + Self { + colors: true, + show_filename: true, + } + } +} + +impl TtyFormatter { + /// Create a new TTY formatter with colors enabled. + pub fn new() -> Self { + Self::default() + } + + /// Create a TTY formatter without colors. + pub fn no_color() -> Self { + Self { + colors: false, + show_filename: true, + } + } + + fn severity_color(&self, severity: Severity) -> &'static str { + if !self.colors { + return ""; + } + match severity { + Severity::Error => "\x1b[1;31m", // Bold red + Severity::Warning => "\x1b[1;33m", // Bold yellow + Severity::Info => "\x1b[1;36m", // Bold cyan + Severity::Style => "\x1b[1;35m", // Bold magenta + Severity::Ignore => "\x1b[2m", // Dim + } + } + + fn reset(&self) -> &'static str { + if self.colors { + "\x1b[0m" + } else { + "" + } + } + + fn dim(&self) -> &'static str { + if self.colors { + "\x1b[2m" + } else { + "" + } + } + + fn bold(&self) -> &'static str { + if self.colors { + "\x1b[1m" + } else { + "" + } + } +} + +impl Formatter for TtyFormatter { + fn format(&self, result: &LintResult, filename: &str, writer: &mut W) -> std::io::Result<()> { + if result.failures.is_empty() { + return Ok(()); + } + + for failure in &result.failures { + let color = self.severity_color(failure.severity); + let reset = self.reset(); + let dim = self.dim(); + let bold = self.bold(); + + // Format: filename:line severity: [code] message + if self.show_filename { + write!( + writer, + "{}{}{}{}:{}", + bold, filename, reset, dim, reset + )?; + } + + write!( + writer, + "{}{}{} ", + dim, + failure.line, + reset + )?; + + // Severity badge + let severity_str = match failure.severity { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "info", + Severity::Style => "style", + Severity::Ignore => "ignore", + }; + + write!( + writer, + "{}{}{}", + color, severity_str, reset + )?; + + // Rule code + write!( + writer, + " {}{}{}: ", + dim, failure.code, reset + )?; + + // Message + writeln!(writer, "{}", failure.message)?; + } + + // Summary line + let error_count = result.failures.iter().filter(|f| f.severity == Severity::Error).count(); + let warning_count = result.failures.iter().filter(|f| f.severity == Severity::Warning).count(); + let info_count = result.failures.iter().filter(|f| f.severity == Severity::Info).count(); + let style_count = result.failures.iter().filter(|f| f.severity == Severity::Style).count(); + + writeln!(writer)?; + + let mut parts = Vec::new(); + if error_count > 0 { + parts.push(format!( + "{}{} error{}{}", + self.severity_color(Severity::Error), + error_count, + if error_count == 1 { "" } else { "s" }, + self.reset() + )); + } + if warning_count > 0 { + parts.push(format!( + "{}{} warning{}{}", + self.severity_color(Severity::Warning), + warning_count, + if warning_count == 1 { "" } else { "s" }, + self.reset() + )); + } + if info_count > 0 { + parts.push(format!( + "{}{} info{}", + self.severity_color(Severity::Info), + info_count, + self.reset() + )); + } + if style_count > 0 { + parts.push(format!( + "{}{} style{}", + self.severity_color(Severity::Style), + style_count, + self.reset() + )); + } + + if !parts.is_empty() { + writeln!(writer, "{}", parts.join(", "))?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::types::CheckFailure; + + #[test] + fn test_tty_output_no_color() { + let mut result = LintResult::new(); + result.failures.push(CheckFailure::new( + "DL3008", + Severity::Warning, + "Pin versions in apt get install", + 5, + )); + + let formatter = TtyFormatter::no_color(); + let output = formatter.format_to_string(&result, "Dockerfile"); + + assert!(output.contains("Dockerfile")); + assert!(output.contains("5")); + assert!(output.contains("warning")); + assert!(output.contains("DL3008")); + assert!(output.contains("Pin versions")); + } +} diff --git a/src/analyzer/hadolint/lint.rs b/src/analyzer/hadolint/lint.rs new file mode 100644 index 00000000..7f786059 --- /dev/null +++ b/src/analyzer/hadolint/lint.rs @@ -0,0 +1,448 @@ +//! Main linting orchestration for hadolint-rs. +//! +//! This module ties together parsing, rules, and pragmas to provide +//! the main linting API. + +use crate::analyzer::hadolint::config::HadolintConfig; +use crate::analyzer::hadolint::parser::{parse_dockerfile, InstructionPos}; +use crate::analyzer::hadolint::pragma::{extract_pragmas, PragmaState}; +use crate::analyzer::hadolint::rules::{all_rules, RuleState}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::{CheckFailure, Severity}; +use crate::analyzer::hadolint::parser::instruction::Instruction; + +use std::path::Path; + +/// Result of linting a Dockerfile. +#[derive(Debug, Clone)] +pub struct LintResult { + /// Rule violations found. + pub failures: Vec, + /// Parse errors (if any). + pub parse_errors: Vec, +} + +impl LintResult { + /// Create a new empty result. + pub fn new() -> Self { + Self { + failures: Vec::new(), + parse_errors: Vec::new(), + } + } + + /// Check if there are any failures. + pub fn has_failures(&self) -> bool { + !self.failures.is_empty() + } + + /// Check if there are any errors (failure with Error severity). + pub fn has_errors(&self) -> bool { + self.failures.iter().any(|f| f.severity == Severity::Error) + } + + /// Check if there are any warnings (failure with Warning severity). + pub fn has_warnings(&self) -> bool { + self.failures.iter().any(|f| f.severity == Severity::Warning) + } + + /// Get the maximum severity in the results. + pub fn max_severity(&self) -> Option { + self.failures.iter().map(|f| f.severity).max() + } + + /// Check if the results should cause a non-zero exit. + pub fn should_fail(&self, config: &HadolintConfig) -> bool { + if config.no_fail { + return false; + } + + if let Some(max) = self.max_severity() { + max >= config.failure_threshold + } else { + false + } + } + + /// Filter failures by severity threshold. + pub fn filter_by_threshold(&mut self, threshold: Severity) { + self.failures.retain(|f| f.severity >= threshold); + } + + /// Sort failures by line number. + pub fn sort(&mut self) { + self.failures.sort(); + } +} + +impl Default for LintResult { + fn default() -> Self { + Self::new() + } +} + +/// Lint a Dockerfile string. +pub fn lint(content: &str, config: &HadolintConfig) -> LintResult { + let mut result = LintResult::new(); + + // Parse Dockerfile + let instructions = match parse_dockerfile(content) { + Ok(instrs) => instrs, + Err(err) => { + result.parse_errors.push(err.to_string()); + return result; + } + }; + + // Extract pragmas + let pragmas = if config.disable_ignore_pragma { + PragmaState::new() + } else { + extract_pragmas(&instructions) + }; + + // Run rules + let failures = run_rules(&instructions, config, &pragmas); + + // Filter by config + result.failures = failures + .into_iter() + .filter(|f| { + // Apply config severity overrides + let effective_severity = config.effective_severity(&f.code, f.severity); + + // Filter by threshold + effective_severity >= config.failure_threshold + }) + .filter(|f| !config.is_rule_ignored(&f.code)) + .filter(|f| !pragmas.is_ignored(&f.code, f.line)) + .map(|mut f| { + // Apply severity overrides + f.severity = config.effective_severity(&f.code, f.severity); + f + }) + .collect(); + + // Sort by line number + result.sort(); + + result +} + +/// Lint a Dockerfile from a file path. +pub fn lint_file(path: &Path, config: &HadolintConfig) -> LintResult { + match std::fs::read_to_string(path) { + Ok(content) => lint(&content, config), + Err(err) => { + let mut result = LintResult::new(); + result.parse_errors.push(format!("Failed to read file: {}", err)); + result + } + } +} + +/// Run all enabled rules on the instructions. +fn run_rules( + instructions: &[InstructionPos], + config: &HadolintConfig, + pragmas: &PragmaState, +) -> Vec { + let rules = all_rules(); + let mut all_failures = Vec::new(); + + for rule in rules { + // Skip ignored rules + if config.is_rule_ignored(rule.code()) { + continue; + } + + let mut state = RuleState::new(); + + // Process each instruction + for instr in instructions { + // Parse shell if this is a RUN instruction + let shell = match &instr.instruction { + Instruction::Run(args) => Some(ParsedShell::from_run_args(args)), + _ => None, + }; + + // Check the instruction + rule.check(&mut state, instr.line_number, &instr.instruction, shell.as_ref()); + + // Also check ONBUILD contents + if let Instruction::OnBuild(inner) = &instr.instruction { + let inner_shell = match inner.as_ref() { + Instruction::Run(args) => Some(ParsedShell::from_run_args(args)), + _ => None, + }; + rule.check(&mut state, instr.line_number, inner.as_ref(), inner_shell.as_ref()); + } + } + + // Finalize the rule + let failures = rule.finalize(state); + all_failures.extend(failures); + } + + all_failures +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lint_empty() { + let result = lint("", &HadolintConfig::default()); + assert!(result.failures.is_empty()); + } + + #[test] + fn test_lint_valid_dockerfile() { + let dockerfile = r#" +FROM ubuntu:20.04 +WORKDIR /app +COPY . . +CMD ["./app"] +"#; + let result = lint(dockerfile, &HadolintConfig::default()); + // Should have no DL3000 (WORKDIR is absolute) + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3000")); + } + + #[test] + fn test_lint_relative_workdir() { + let dockerfile = r#" +FROM ubuntu:20.04 +WORKDIR app +"#; + let result = lint(dockerfile, &HadolintConfig::default()); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3000")); + } + + #[test] + fn test_lint_maintainer() { + let dockerfile = r#" +FROM ubuntu:20.04 +MAINTAINER John Doe +"#; + let result = lint(dockerfile, &HadolintConfig::default()); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL4000")); + } + + #[test] + fn test_lint_untagged_image() { + let dockerfile = "FROM ubuntu\n"; + let result = lint(dockerfile, &HadolintConfig::default()); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3006")); + } + + #[test] + fn test_lint_latest_tag() { + let dockerfile = "FROM ubuntu:latest\n"; + let result = lint(dockerfile, &HadolintConfig::default()); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3007")); + } + + #[test] + fn test_lint_ignore_pragma() { + let dockerfile = r#" +# hadolint ignore=DL3006 +FROM ubuntu +"#; + let result = lint(dockerfile, &HadolintConfig::default()); + // DL3006 should be ignored + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3006")); + } + + #[test] + fn test_lint_config_ignore() { + let dockerfile = "FROM ubuntu\n"; + let config = HadolintConfig::default().ignore("DL3006"); + let result = lint(dockerfile, &config); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3006")); + } + + #[test] + fn test_lint_threshold() { + let dockerfile = r#" +FROM ubuntu +MAINTAINER John +"#; + let mut config = HadolintConfig::default(); + config.failure_threshold = Severity::Error; + let result = lint(dockerfile, &config); + // DL3006 (warning) should be filtered out + // DL4000 (error) should remain + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3006")); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL4000")); + } + + #[test] + fn test_should_fail() { + let dockerfile = "FROM ubuntu:latest\n"; + let config = HadolintConfig::default().with_threshold(Severity::Warning); + let result = lint(dockerfile, &config); + + // DL3007 is a warning, should trigger failure with Warning threshold + assert!(result.should_fail(&config)); + + // With no_fail, should not fail + let mut no_fail_config = config.clone(); + no_fail_config.no_fail = true; + assert!(!result.should_fail(&no_fail_config)); + } + + #[test] + fn test_lint_sudo() { + let dockerfile = r#" +FROM ubuntu:20.04 +RUN sudo apt-get update +"#; + let result = lint(dockerfile, &HadolintConfig::default()); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3004")); + } + + #[test] + fn test_lint_cd() { + let dockerfile = r#" +FROM ubuntu:20.04 +RUN cd /app && npm install +"#; + let result = lint(dockerfile, &HadolintConfig::default()); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3003")); + } + + #[test] + fn test_lint_shell_form_cmd() { + let dockerfile = r#" +FROM ubuntu:20.04 +CMD node app.js +"#; + let result = lint(dockerfile, &HadolintConfig::default()); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3025")); + } + + #[test] + fn test_lint_exec_form_cmd() { + let dockerfile = r#" +FROM ubuntu:20.04 +CMD ["node", "app.js"] +"#; + let result = lint(dockerfile, &HadolintConfig::default()); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3025")); + } + + #[test] + fn test_lint_error_dockerfile() { + // Comprehensive test Dockerfile with many intentional errors + let dockerfile = r#" +# Test Dockerfile with maximum hadolint errors +MAINTAINER bad@example.com + +FROM ubuntu:latest + +LABEL maintainer="test@test.com" \ + description="" \ + org.opencontainers.image.created="not-a-date" \ + org.opencontainers.image.licenses="INVALID" \ + org.opencontainers.image.title="" \ + org.opencontainers.image.description="" \ + org.opencontainers.image.documentation="not-url" \ + org.opencontainers.image.source="not-url" \ + org.opencontainers.image.url="not-url" + +ENV FOO=bar BAR=$FOO + +COPY package.json app/ + +WORKDIR relative/path + +RUN apt update +RUN apt-get upgrade +RUN apt-get install curl wget nginx + +RUN sudo useradd -m testuser + +RUN cd /app && echo "hello" + +RUN pip install flask requests + +RUN npm install -g express + +RUN gem install rails + +FROM alpine:latest AS alpine-stage +RUN apk upgrade +RUN apk add nginx + +FROM centos:latest AS centos-stage +RUN yum update -y +RUN yum install -y httpd + +FROM fedora:latest AS fedora-stage +RUN dnf update +RUN dnf install nginx + +FROM ubuntu:latest AS builder +FROM debian:latest AS builder + +ADD https://example.com/file.txt /app/ +ADD localfile.txt /app/ + +COPY --from=nonexistent /app /app + +EXPOSE 99999 + +RUN ln -s /bin/bash /bin/sh + +RUN curl http://example.com | grep pattern + +RUN wget http://example.com/file1 +RUN curl http://example.com/file2 + +ENTRYPOINT /bin/bash start.sh + +CMD echo "first" +CMD echo "second" + +ENTRYPOINT ["python"] +ENTRYPOINT ["node"] + +HEALTHCHECK CMD curl localhost +HEALTHCHECK CMD wget localhost + +USER root +"#; + let result = lint(dockerfile, &HadolintConfig::default()); + + // Collect unique rule codes triggered + let mut triggered_rules: Vec<&str> = result.failures.iter() + .map(|f| f.code.as_str()) + .collect(); + triggered_rules.sort(); + triggered_rules.dedup(); + + // Print summary for debugging + println!("\n=== HADOLINT ERROR DOCKERFILE TEST ==="); + println!("Total violations: {}", result.failures.len()); + println!("Unique rules triggered: {}", triggered_rules.len()); + println!("\nRules triggered:"); + for rule in &triggered_rules { + let count = result.failures.iter().filter(|f| f.code.as_str() == *rule).count(); + println!(" {} ({}x)", rule, count); + } + + // Verify we catch many rules + assert!(triggered_rules.len() >= 30, "Expected at least 30 different rules, got {}", triggered_rules.len()); + + // Verify some key rules are triggered + assert!(triggered_rules.contains(&"DL3000"), "DL3000 not triggered"); + assert!(triggered_rules.contains(&"DL3004"), "DL3004 not triggered"); + assert!(triggered_rules.contains(&"DL3007"), "DL3007 not triggered"); + assert!(triggered_rules.contains(&"DL3027"), "DL3027 not triggered"); + assert!(triggered_rules.contains(&"DL4000"), "DL4000 not triggered"); + assert!(triggered_rules.contains(&"DL4003"), "DL4003 not triggered"); + assert!(triggered_rules.contains(&"DL4004"), "DL4004 not triggered"); + } +} diff --git a/src/analyzer/hadolint/mod.rs b/src/analyzer/hadolint/mod.rs new file mode 100644 index 00000000..3b0f3a9b --- /dev/null +++ b/src/analyzer/hadolint/mod.rs @@ -0,0 +1,55 @@ +//! Hadolint-RS: Native Rust Dockerfile Linter +//! +//! A Rust translation of the Hadolint Dockerfile linter. +//! +//! # Attribution +//! +//! This module is a derivative work based on [Hadolint](https://github.com/hadolint/hadolint), +//! originally written in Haskell by Lukas Martinelli and contributors. +//! +//! **Original Project:** +//! **Original License:** GPL-3.0 +//! **Original Copyright:** Copyright (c) 2016-2024 Lukas Martinelli and contributors +//! +//! This Rust translation is licensed under GPL-3.0 as required by the original license. +//! See THIRD_PARTY_NOTICES.md and LICENSE files for full details. +//! +//! # Features +//! +//! - Dockerfile parsing into an AST +//! - Configurable linting rules (DL3xxx, DL4xxx) +//! - ShellCheck-inspired RUN instruction analysis +//! - Inline pragma support for ignoring rules +//! +//! # Example +//! +//! ```rust,ignore +//! use syncable_cli::analyzer::hadolint::{lint, HadolintConfig, LintResult}; +//! +//! let dockerfile = r#" +//! FROM ubuntu:latest +//! RUN apt-get update && apt-get install -y nginx +//! "#; +//! +//! let config = HadolintConfig::default(); +//! let result = lint(dockerfile, &config); +//! +//! for failure in result.failures { +//! println!("{}: {} - {}", failure.line, failure.code, failure.message); +//! } +//! ``` + +pub mod config; +pub mod formatter; +pub mod lint; +pub mod parser; +pub mod pragma; +pub mod rules; +pub mod shell; +pub mod types; + +// Re-export main types and functions +pub use config::HadolintConfig; +pub use formatter::{format_result, format_result_to_string, Formatter, OutputFormat}; +pub use lint::{lint, lint_file, LintResult}; +pub use types::{CheckFailure, RuleCode, Severity}; diff --git a/src/analyzer/hadolint/parser/dockerfile.rs b/src/analyzer/hadolint/parser/dockerfile.rs new file mode 100644 index 00000000..f085647a --- /dev/null +++ b/src/analyzer/hadolint/parser/dockerfile.rs @@ -0,0 +1,1070 @@ +//! Dockerfile parser using nom. +//! +//! Parses Dockerfile content into an AST of `InstructionPos` elements. + +use nom::{ + branch::alt, + bytes::complete::{tag, tag_no_case, take_till, take_while}, + character::complete::{char, space0, space1}, + combinator::opt, + multi::separated_list0, + sequence::{pair, preceded, tuple}, + IResult, +}; + +use super::instruction::*; + +/// Parse error information. +#[derive(Debug, Clone)] +pub struct ParseError { + /// Error message. + pub message: String, + /// Line number where the error occurred (1-indexed). + pub line: u32, + /// Column number (1-indexed, if available). + pub column: Option, +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.column { + Some(col) => write!(f, "line {}:{}: {}", self.line, col, self.message), + None => write!(f, "line {}: {}", self.line, self.message), + } + } +} + +impl std::error::Error for ParseError {} + +/// Parse a Dockerfile string into a list of positioned instructions. +pub fn parse_dockerfile(input: &str) -> Result, ParseError> { + let mut instructions = Vec::new(); + let mut line_number = 1u32; + + // Process line by line, handling line continuations + let lines: Vec<&str> = input.lines().collect(); + let mut i = 0; + + while i < lines.len() { + let start_line = line_number; + let mut combined_line = String::new(); + let mut source_text = String::new(); + + // Collect lines with continuations + loop { + let line = lines.get(i).unwrap_or(&""); + source_text.push_str(line); + source_text.push('\n'); + + let trimmed = line.trim_end(); + if trimmed.ends_with('\\') { + // Line continuation - remove backslash and continue + combined_line.push_str(&trimmed[..trimmed.len() - 1]); + combined_line.push(' '); + i += 1; + line_number += 1; + if i >= lines.len() { + break; + } + } else { + combined_line.push_str(trimmed); + i += 1; + line_number += 1; + break; + } + } + + let trimmed = combined_line.trim(); + + // Skip empty lines + if trimmed.is_empty() { + continue; + } + + // Parse the instruction + match parse_instruction(trimmed) { + Ok((_, instruction)) => { + instructions.push(InstructionPos::new( + instruction, + start_line, + source_text.trim_end().to_string(), + )); + } + Err(_) => { + // Try to parse as comment + if trimmed.starts_with('#') { + let comment = trimmed[1..].trim().to_string(); + instructions.push(InstructionPos::new( + Instruction::Comment(comment), + start_line, + source_text.trim_end().to_string(), + )); + } + // Skip unparseable lines (parser directives, empty lines after continuation, etc.) + } + } + } + + Ok(instructions) +} + +/// Parse a single instruction. +fn parse_instruction(input: &str) -> IResult<&str, Instruction> { + alt(( + parse_from, + parse_run, + parse_copy, + parse_add, + parse_env, + parse_label, + parse_expose, + parse_arg, + parse_entrypoint, + parse_cmd, + parse_shell, + parse_user, + parse_workdir, + parse_volume, + parse_maintainer, + parse_healthcheck, + parse_onbuild, + parse_stopsignal, + parse_comment, + ))(input) +} + +/// Parse FROM instruction. +fn parse_from(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("FROM")(input)?; + let (input, _) = space1(input)?; + + // Parse optional --platform flag + let (input, platform) = opt(preceded( + pair(tag("--platform="), space0), + take_till(|c: char| c.is_whitespace()), + ))(input)?; + let (input, _) = space0(input)?; + + // Parse platform with space separator + let (input, platform) = if platform.is_none() { + opt(preceded( + pair(tag("--platform"), space0), + preceded(char('='), take_till(|c: char| c.is_whitespace())), + ))(input)? + } else { + (input, platform) + }; + let (input, _) = space0(input)?; + + // Parse image reference + let (input, image_ref) = take_till(|c: char| c.is_whitespace())(input)?; + let (input, _) = space0(input)?; + + // Parse optional AS alias + let (input, alias) = opt(preceded( + pair(tag_no_case("AS"), space1), + take_while(|c: char| c.is_alphanumeric() || c == '_' || c == '-'), + ))(input)?; + + // Parse image reference into components + let base_image = parse_image_reference(image_ref, platform.map(|s| s.to_string()), alias.map(|s| ImageAlias::new(s))); + + Ok((input, Instruction::From(base_image))) +} + +/// Parse image reference into BaseImage. +fn parse_image_reference( + image_ref: &str, + platform: Option, + alias: Option, +) -> BaseImage { + // Handle digest + if let Some(at_pos) = image_ref.find('@') { + let (image_part, digest) = image_ref.split_at(at_pos); + let digest = &digest[1..]; // Remove @ + + let (image, tag) = parse_image_tag(image_part); + return BaseImage { + image, + tag, + digest: Some(digest.to_string()), + alias, + platform, + }; + } + + // Handle tag + let (image, tag) = parse_image_tag(image_ref); + + BaseImage { + image, + tag, + digest: None, + alias, + platform, + } +} + +/// Parse image:tag into Image and optional tag. +fn parse_image_tag(image_ref: &str) -> (Image, Option) { + // Find the last colon that's not part of a port or registry + // Registry format: host:port/name or host/name + // Tag format: name:tag + + let parts: Vec<&str> = image_ref.split('/').collect(); + + if parts.len() == 1 { + // Simple name or name:tag + if let Some(colon_pos) = image_ref.rfind(':') { + let name = &image_ref[..colon_pos]; + let tag = &image_ref[colon_pos + 1..]; + (Image::new(name), Some(tag.to_string())) + } else { + (Image::new(image_ref), None) + } + } else { + // Has path separators - might have registry + let last_part = parts.last().unwrap(); + + // Check if last part has a tag + if let Some(colon_pos) = last_part.rfind(':') { + // Check if it looks like a tag (not a port) + let potential_tag = &last_part[colon_pos + 1..]; + if !potential_tag.chars().all(|c| c.is_ascii_digit()) || potential_tag.len() > 5 { + // It's a tag, not a port + let full_name = image_ref[..image_ref.len() - potential_tag.len() - 1].to_string(); + let (registry, name) = split_registry(&full_name); + return ( + match registry { + Some(r) => Image::with_registry(r, name), + None => Image::new(name), + }, + Some(potential_tag.to_string()), + ); + } + } + + // No tag + let (registry, name) = split_registry(image_ref); + ( + match registry { + Some(r) => Image::with_registry(r, name), + None => Image::new(name), + }, + None, + ) + } +} + +/// Split registry from image name. +fn split_registry(name: &str) -> (Option, String) { + // Registry indicators: contains '.', ':', or is 'localhost' + if let Some(slash_pos) = name.find('/') { + let potential_registry = &name[..slash_pos]; + if potential_registry.contains('.') + || potential_registry.contains(':') + || potential_registry == "localhost" + { + return ( + Some(potential_registry.to_string()), + name[slash_pos + 1..].to_string(), + ); + } + } + (None, name.to_string()) +} + +/// Parse RUN instruction. +fn parse_run(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("RUN")(input)?; + let (input, _) = space0(input)?; + + // Parse flags (--mount, --network, --security) + let (input, flags) = parse_run_flags(input)?; + let (input, _) = space0(input)?; + + // Parse arguments (exec form or shell form) + let (input, arguments) = parse_arguments(input)?; + + Ok((input, Instruction::Run(RunArgs { arguments, flags }))) +} + +/// Parse RUN flags. +fn parse_run_flags(input: &str) -> IResult<&str, RunFlags> { + let mut flags = RunFlags::default(); + let mut remaining = input; + + loop { + let (input, _) = space0(remaining)?; + + // Check for --mount + if let Ok((input, mount)) = parse_mount_flag(input) { + flags.mount.insert(mount); + remaining = input; + continue; + } + + // Check for --network + if let Ok((input, network)) = parse_flag_value(input, "--network") { + flags.network = Some(network.to_string()); + remaining = input; + continue; + } + + // Check for --security + if let Ok((input, security)) = parse_flag_value(input, "--security") { + flags.security = Some(security.to_string()); + remaining = input; + continue; + } + + break; + } + + Ok((remaining, flags)) +} + +/// Parse --flag=value. +fn parse_flag_value<'a>(input: &'a str, flag: &str) -> IResult<&'a str, &'a str> { + let (input, _) = tag(flag)(input)?; + let (input, _) = char('=')(input)?; + take_till(|c: char| c.is_whitespace())(input) +} + +/// Parse --mount flag. +fn parse_mount_flag(input: &str) -> IResult<&str, RunMount> { + let (input, _) = tag("--mount=")(input)?; + let (input, mount_str) = take_till(|c: char| c.is_whitespace())(input)?; + + // Parse mount options + let mount = parse_mount_options(mount_str); + Ok((input, mount)) +} + +/// Parse mount options string. +fn parse_mount_options(s: &str) -> RunMount { + let opts: std::collections::HashMap<&str, &str> = s + .split(',') + .filter_map(|part| { + let mut parts = part.splitn(2, '='); + let key = parts.next()?; + let value = parts.next().unwrap_or(""); + Some((key, value)) + }) + .collect(); + + let mount_type = opts.get("type").copied().unwrap_or("bind"); + + match mount_type { + "cache" => RunMount::Cache(CacheOpts { + target: opts.get("target").map(|s| s.to_string()), + id: opts.get("id").map(|s| s.to_string()), + sharing: opts.get("sharing").map(|s| s.to_string()), + from: opts.get("from").map(|s| s.to_string()), + source: opts.get("source").map(|s| s.to_string()), + mode: opts.get("mode").map(|s| s.to_string()), + uid: opts.get("uid").and_then(|s| s.parse().ok()), + gid: opts.get("gid").and_then(|s| s.parse().ok()), + read_only: opts.get("ro").is_some() || opts.get("readonly").is_some(), + }), + "tmpfs" => RunMount::Tmpfs(TmpOpts { + target: opts.get("target").map(|s| s.to_string()), + size: opts.get("size").map(|s| s.to_string()), + }), + "secret" => RunMount::Secret(SecretOpts { + id: opts.get("id").map(|s| s.to_string()), + target: opts.get("target").map(|s| s.to_string()), + required: opts.get("required").map(|s| *s == "true").unwrap_or(false), + mode: opts.get("mode").map(|s| s.to_string()), + uid: opts.get("uid").and_then(|s| s.parse().ok()), + gid: opts.get("gid").and_then(|s| s.parse().ok()), + }), + "ssh" => RunMount::Ssh(SshOpts { + id: opts.get("id").map(|s| s.to_string()), + target: opts.get("target").map(|s| s.to_string()), + required: opts.get("required").map(|s| *s == "true").unwrap_or(false), + mode: opts.get("mode").map(|s| s.to_string()), + uid: opts.get("uid").and_then(|s| s.parse().ok()), + gid: opts.get("gid").and_then(|s| s.parse().ok()), + }), + _ => RunMount::Bind(BindOpts { + target: opts.get("target").map(|s| s.to_string()), + source: opts.get("source").map(|s| s.to_string()), + from: opts.get("from").map(|s| s.to_string()), + read_only: opts.get("ro").is_some() || opts.get("readonly").is_some(), + }), + } +} + +/// Parse arguments (exec form or shell form). +fn parse_arguments(input: &str) -> IResult<&str, Arguments> { + // Try exec form first + if let Ok((remaining, list)) = parse_json_array(input) { + return Ok((remaining, Arguments::List(list))); + } + + // Fall back to shell form + Ok(("", Arguments::Text(input.trim().to_string()))) +} + +/// Parse JSON array for exec form. +fn parse_json_array(input: &str) -> IResult<&str, Vec> { + let (input, _) = char('[')(input)?; + let (input, _) = space0(input)?; + let (input, items) = separated_list0( + tuple((space0, char(','), space0)), + parse_json_string, + )(input)?; + let (input, _) = space0(input)?; + let (input, _) = char(']')(input)?; + Ok((input, items)) +} + +/// Parse a JSON string. +fn parse_json_string(input: &str) -> IResult<&str, String> { + let (input, _) = char('"')(input)?; + let mut result = String::new(); + let mut chars = input.chars().peekable(); + let mut consumed = 0; + + while let Some(c) = chars.next() { + consumed += c.len_utf8(); + if c == '"' { + return Ok((&input[consumed..], result)); + } else if c == '\\' { + if let Some(next) = chars.next() { + consumed += next.len_utf8(); + match next { + 'n' => result.push('\n'), + 't' => result.push('\t'), + 'r' => result.push('\r'), + '\\' => result.push('\\'), + '"' => result.push('"'), + _ => { + result.push('\\'); + result.push(next); + } + } + } + } else { + result.push(c); + } + } + + Err(nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Char))) +} + +/// Parse COPY instruction. +fn parse_copy(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("COPY")(input)?; + let (input, _) = space0(input)?; + + // Parse flags + let (input, flags) = parse_copy_flags(input)?; + let (input, _) = space0(input)?; + + // Parse sources and destination + let (input, args) = parse_copy_args(input)?; + + Ok((input, Instruction::Copy(args, flags))) +} + +/// Parse COPY flags. +fn parse_copy_flags(input: &str) -> IResult<&str, CopyFlags> { + let mut flags = CopyFlags::default(); + let mut remaining = input; + + loop { + let (input, _) = space0(remaining)?; + + if let Ok((input, from)) = parse_flag_value(input, "--from") { + flags.from = Some(from.to_string()); + remaining = input; + continue; + } + if let Ok((input, chown)) = parse_flag_value(input, "--chown") { + flags.chown = Some(chown.to_string()); + remaining = input; + continue; + } + if let Ok((input, chmod)) = parse_flag_value(input, "--chmod") { + flags.chmod = Some(chmod.to_string()); + remaining = input; + continue; + } + if let Ok((input, _)) = tag::<&str, &str, nom::error::Error<&str>>("--link")(input) { + flags.link = true; + remaining = input; + continue; + } + + break; + } + + Ok((remaining, flags)) +} + +/// Parse COPY arguments. +fn parse_copy_args(input: &str) -> IResult<&str, CopyArgs> { + // Try exec form first + if let Ok((remaining, items)) = parse_json_array(input) { + if items.len() >= 2 { + let dest = items.last().unwrap().clone(); + let sources = items[..items.len() - 1].to_vec(); + return Ok((remaining, CopyArgs::new(sources, dest))); + } + } + + // Shell form: space-separated paths + let parts: Vec<&str> = input.split_whitespace().collect(); + if parts.len() >= 2 { + let dest = parts.last().unwrap().to_string(); + let sources: Vec = parts[..parts.len() - 1].iter().map(|s| s.to_string()).collect(); + Ok(("", CopyArgs::new(sources, dest))) + } else if parts.len() == 1 { + // Single argument - treat as both source and dest + Ok(("", CopyArgs::new(vec![parts[0].to_string()], parts[0]))) + } else { + Err(nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Space))) + } +} + +/// Parse ADD instruction. +fn parse_add(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("ADD")(input)?; + let (input, _) = space0(input)?; + + // Parse flags + let (input, flags) = parse_add_flags(input)?; + let (input, _) = space0(input)?; + + // Parse sources and destination (same as COPY) + let (input, copy_args) = parse_copy_args(input)?; + let args = AddArgs::new(copy_args.sources, copy_args.dest); + + Ok((input, Instruction::Add(args, flags))) +} + +/// Parse ADD flags. +fn parse_add_flags(input: &str) -> IResult<&str, AddFlags> { + let mut flags = AddFlags::default(); + let mut remaining = input; + + loop { + let (input, _) = space0(remaining)?; + + if let Ok((input, chown)) = parse_flag_value(input, "--chown") { + flags.chown = Some(chown.to_string()); + remaining = input; + continue; + } + if let Ok((input, chmod)) = parse_flag_value(input, "--chmod") { + flags.chmod = Some(chmod.to_string()); + remaining = input; + continue; + } + if let Ok((input, checksum)) = parse_flag_value(input, "--checksum") { + flags.checksum = Some(checksum.to_string()); + remaining = input; + continue; + } + if let Ok((input, _)) = tag::<&str, &str, nom::error::Error<&str>>("--link")(input) { + flags.link = true; + remaining = input; + continue; + } + + break; + } + + Ok((remaining, flags)) +} + +/// Parse ENV instruction. +fn parse_env(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("ENV")(input)?; + let (input, _) = space1(input)?; + + // ENV can be KEY=VALUE or KEY VALUE + let pairs = parse_key_value_pairs(input); + Ok(("", Instruction::Env(pairs))) +} + +/// Parse LABEL instruction. +fn parse_label(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("LABEL")(input)?; + let (input, _) = space1(input)?; + + let pairs = parse_key_value_pairs(input); + Ok(("", Instruction::Label(pairs))) +} + +/// Parse key=value pairs. +fn parse_key_value_pairs(input: &str) -> Vec<(String, String)> { + let mut pairs = Vec::new(); + let mut remaining = input.trim(); + + while !remaining.is_empty() { + // Find key + let key_end = remaining.find(|c: char| c == '=' || c.is_whitespace()).unwrap_or(remaining.len()); + if key_end == 0 { + remaining = remaining.trim_start(); + continue; + } + + let key = &remaining[..key_end]; + remaining = &remaining[key_end..]; + + // Check for = sign + if remaining.starts_with('=') { + remaining = &remaining[1..]; + // Parse value + let value = if remaining.starts_with('"') { + // Quoted value + let end = find_closing_quote(remaining); + let val = &remaining[1..end]; + remaining = &remaining[end + 1..]; + val.to_string() + } else { + // Unquoted value + let end = remaining.find(|c: char| c.is_whitespace()).unwrap_or(remaining.len()); + let val = &remaining[..end]; + remaining = &remaining[end..]; + val.to_string() + }; + pairs.push((key.to_string(), value)); + } else { + // Legacy format: KEY VALUE (no =) + remaining = remaining.trim_start(); + if !remaining.is_empty() { + let value = if remaining.starts_with('"') { + let end = find_closing_quote(remaining); + let val = &remaining[1..end]; + remaining = &remaining[end + 1..]; + val.to_string() + } else { + remaining.to_string() + }; + pairs.push((key.to_string(), value.trim().to_string())); + break; + } + } + + remaining = remaining.trim_start(); + } + + pairs +} + +/// Find closing quote position. +fn find_closing_quote(s: &str) -> usize { + let mut escaped = false; + for (i, c) in s.char_indices().skip(1) { + if escaped { + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '"' { + return i; + } + } + s.len() - 1 +} + +/// Parse EXPOSE instruction. +fn parse_expose(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("EXPOSE")(input)?; + let (input, _) = space1(input)?; + + let mut ports = Vec::new(); + for part in input.split_whitespace() { + if let Some(port) = parse_port_spec(part) { + ports.push(port); + } + } + + Ok(("", Instruction::Expose(ports))) +} + +/// Parse a port specification like "80", "80/tcp", "53/udp". +fn parse_port_spec(s: &str) -> Option { + let parts: Vec<&str> = s.split('/').collect(); + let port_num: u16 = parts[0].parse().ok()?; + let protocol = parts.get(1).map(|p| { + if p.eq_ignore_ascii_case("udp") { + PortProtocol::Udp + } else { + PortProtocol::Tcp + } + }).unwrap_or(PortProtocol::Tcp); + + Some(Port { number: port_num, protocol }) +} + +/// Parse ARG instruction. +fn parse_arg(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("ARG")(input)?; + let (input, _) = space1(input)?; + + let content = input.trim(); + if let Some(eq_pos) = content.find('=') { + let name = content[..eq_pos].to_string(); + let default = content[eq_pos + 1..].to_string(); + Ok(("", Instruction::Arg(name, Some(default)))) + } else { + Ok(("", Instruction::Arg(content.to_string(), None))) + } +} + +/// Parse ENTRYPOINT instruction. +fn parse_entrypoint(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("ENTRYPOINT")(input)?; + let (input, _) = space0(input)?; + + let (input, arguments) = parse_arguments(input)?; + Ok((input, Instruction::Entrypoint(arguments))) +} + +/// Parse CMD instruction. +fn parse_cmd(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("CMD")(input)?; + let (input, _) = space0(input)?; + + let (input, arguments) = parse_arguments(input)?; + Ok((input, Instruction::Cmd(arguments))) +} + +/// Parse SHELL instruction. +fn parse_shell(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("SHELL")(input)?; + let (input, _) = space0(input)?; + + let (input, arguments) = parse_arguments(input)?; + Ok((input, Instruction::Shell(arguments))) +} + +/// Parse USER instruction. +fn parse_user(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("USER")(input)?; + let (input, _) = space1(input)?; + + Ok(("", Instruction::User(input.trim().to_string()))) +} + +/// Parse WORKDIR instruction. +fn parse_workdir(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("WORKDIR")(input)?; + let (input, _) = space1(input)?; + + Ok(("", Instruction::Workdir(input.trim().to_string()))) +} + +/// Parse VOLUME instruction. +fn parse_volume(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("VOLUME")(input)?; + let (input, _) = space1(input)?; + + // VOLUME can be JSON array or space-separated + // For simplicity, store as single string + Ok(("", Instruction::Volume(input.trim().to_string()))) +} + +/// Parse MAINTAINER instruction (deprecated). +fn parse_maintainer(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("MAINTAINER")(input)?; + let (input, _) = space1(input)?; + + Ok(("", Instruction::Maintainer(input.trim().to_string()))) +} + +/// Parse HEALTHCHECK instruction. +fn parse_healthcheck(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("HEALTHCHECK")(input)?; + let (input, _) = space1(input)?; + + let content = input.trim(); + + // Check for NONE + if content.eq_ignore_ascii_case("NONE") { + return Ok(("", Instruction::Healthcheck(HealthCheck::None))); + } + + // Parse options + let mut interval = None; + let mut timeout = None; + let mut start_period = None; + let mut retries = None; + let mut remaining = content; + + loop { + remaining = remaining.trim_start(); + if remaining.starts_with("--interval=") { + let value_start = 11; + let value_end = remaining[value_start..].find(' ').map(|i| value_start + i).unwrap_or(remaining.len()); + interval = Some(remaining[value_start..value_end].to_string()); + remaining = &remaining[value_end..]; + } else if remaining.starts_with("--timeout=") { + let value_start = 10; + let value_end = remaining[value_start..].find(' ').map(|i| value_start + i).unwrap_or(remaining.len()); + timeout = Some(remaining[value_start..value_end].to_string()); + remaining = &remaining[value_end..]; + } else if remaining.starts_with("--start-period=") { + let value_start = 15; + let value_end = remaining[value_start..].find(' ').map(|i| value_start + i).unwrap_or(remaining.len()); + start_period = Some(remaining[value_start..value_end].to_string()); + remaining = &remaining[value_end..]; + } else if remaining.starts_with("--retries=") { + let value_start = 10; + let value_end = remaining[value_start..].find(' ').map(|i| value_start + i).unwrap_or(remaining.len()); + retries = remaining[value_start..value_end].parse().ok(); + remaining = &remaining[value_end..]; + } else { + break; + } + } + + // Parse CMD + remaining = remaining.trim_start(); + if remaining.to_uppercase().starts_with("CMD") { + remaining = &remaining[3..].trim_start(); + } + + let (_, arguments) = parse_arguments(remaining)?; + + Ok(("", Instruction::Healthcheck(HealthCheck::Cmd { + cmd: arguments, + interval, + timeout, + start_period, + retries, + }))) +} + +/// Parse ONBUILD instruction. +fn parse_onbuild(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("ONBUILD")(input)?; + let (input, _) = space1(input)?; + + let (remaining, inner) = parse_instruction(input)?; + Ok((remaining, Instruction::OnBuild(Box::new(inner)))) +} + +/// Parse STOPSIGNAL instruction. +fn parse_stopsignal(input: &str) -> IResult<&str, Instruction> { + let (input, _) = tag_no_case("STOPSIGNAL")(input)?; + let (input, _) = space1(input)?; + + Ok(("", Instruction::Stopsignal(input.trim().to_string()))) +} + +/// Parse comment. +fn parse_comment(input: &str) -> IResult<&str, Instruction> { + let (input, _) = char('#')(input)?; + Ok(("", Instruction::Comment(input.trim().to_string()))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_from_simple() { + let result = parse_dockerfile("FROM ubuntu").unwrap(); + assert_eq!(result.len(), 1); + match &result[0].instruction { + Instruction::From(base) => { + assert_eq!(base.image.name, "ubuntu"); + assert!(base.tag.is_none()); + } + _ => panic!("Expected FROM instruction"), + } + } + + #[test] + fn test_parse_from_with_tag() { + let result = parse_dockerfile("FROM ubuntu:20.04").unwrap(); + match &result[0].instruction { + Instruction::From(base) => { + assert_eq!(base.image.name, "ubuntu"); + assert_eq!(base.tag, Some("20.04".to_string())); + } + _ => panic!("Expected FROM instruction"), + } + } + + #[test] + fn test_parse_from_with_alias() { + let result = parse_dockerfile("FROM ubuntu:20.04 AS builder").unwrap(); + match &result[0].instruction { + Instruction::From(base) => { + assert_eq!(base.image.name, "ubuntu"); + assert_eq!(base.alias.as_ref().map(|a| a.as_str()), Some("builder")); + } + _ => panic!("Expected FROM instruction"), + } + } + + #[test] + fn test_parse_run_shell() { + let result = parse_dockerfile("RUN apt-get update && apt-get install -y nginx").unwrap(); + match &result[0].instruction { + Instruction::Run(args) => { + assert!(args.arguments.is_shell_form()); + assert!(args.arguments.as_text().unwrap().contains("apt-get")); + } + _ => panic!("Expected RUN instruction"), + } + } + + #[test] + fn test_parse_run_exec() { + let result = parse_dockerfile(r#"RUN ["apt-get", "update"]"#).unwrap(); + match &result[0].instruction { + Instruction::Run(args) => { + assert!(args.arguments.is_exec_form()); + let list = args.arguments.as_list().unwrap(); + assert_eq!(list[0], "apt-get"); + assert_eq!(list[1], "update"); + } + _ => panic!("Expected RUN instruction"), + } + } + + #[test] + fn test_parse_copy() { + let result = parse_dockerfile("COPY src/ /app/").unwrap(); + match &result[0].instruction { + Instruction::Copy(args, _) => { + assert_eq!(args.sources, vec!["src/"]); + assert_eq!(args.dest, "/app/"); + } + _ => panic!("Expected COPY instruction"), + } + } + + #[test] + fn test_parse_copy_with_from() { + let result = parse_dockerfile("COPY --from=builder /app/dist /app/").unwrap(); + match &result[0].instruction { + Instruction::Copy(args, flags) => { + assert_eq!(flags.from, Some("builder".to_string())); + assert_eq!(args.sources, vec!["/app/dist"]); + assert_eq!(args.dest, "/app/"); + } + _ => panic!("Expected COPY instruction"), + } + } + + #[test] + fn test_parse_env() { + let result = parse_dockerfile("ENV NODE_ENV=production").unwrap(); + match &result[0].instruction { + Instruction::Env(pairs) => { + assert_eq!(pairs.len(), 1); + assert_eq!(pairs[0].0, "NODE_ENV"); + assert_eq!(pairs[0].1, "production"); + } + _ => panic!("Expected ENV instruction"), + } + } + + #[test] + fn test_parse_expose() { + let result = parse_dockerfile("EXPOSE 80 443/tcp 53/udp").unwrap(); + match &result[0].instruction { + Instruction::Expose(ports) => { + assert_eq!(ports.len(), 3); + assert_eq!(ports[0].number, 80); + assert_eq!(ports[1].number, 443); + assert_eq!(ports[2].number, 53); + assert_eq!(ports[2].protocol, PortProtocol::Udp); + } + _ => panic!("Expected EXPOSE instruction"), + } + } + + #[test] + fn test_parse_workdir() { + let result = parse_dockerfile("WORKDIR /app").unwrap(); + match &result[0].instruction { + Instruction::Workdir(path) => { + assert_eq!(path, "/app"); + } + _ => panic!("Expected WORKDIR instruction"), + } + } + + #[test] + fn test_parse_user() { + let result = parse_dockerfile("USER node").unwrap(); + match &result[0].instruction { + Instruction::User(user) => { + assert_eq!(user, "node"); + } + _ => panic!("Expected USER instruction"), + } + } + + #[test] + fn test_parse_comment() { + let result = parse_dockerfile("# This is a comment").unwrap(); + match &result[0].instruction { + Instruction::Comment(text) => { + assert_eq!(text, "This is a comment"); + } + _ => panic!("Expected Comment"), + } + } + + #[test] + fn test_parse_full_dockerfile() { + let dockerfile = r#" +FROM node:18-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:18-alpine +WORKDIR /app +COPY --from=builder /app/dist ./dist +EXPOSE 3000 +CMD ["node", "dist/index.js"] +"#; + + let result = parse_dockerfile(dockerfile).unwrap(); + // Should have multiple instructions + assert!(result.len() >= 10); + } + + #[test] + fn test_line_continuation() { + let dockerfile = r#"RUN apt-get update && \ + apt-get install -y nginx"#; + + let result = parse_dockerfile(dockerfile).unwrap(); + assert_eq!(result.len(), 1); + match &result[0].instruction { + Instruction::Run(args) => { + let text = args.arguments.as_text().unwrap(); + assert!(text.contains("apt-get update")); + assert!(text.contains("apt-get install")); + } + _ => panic!("Expected RUN instruction"), + } + } + + #[test] + fn test_image_with_registry() { + let result = parse_dockerfile("FROM gcr.io/my-project/my-image:latest").unwrap(); + match &result[0].instruction { + Instruction::From(base) => { + assert_eq!(base.image.registry, Some("gcr.io".to_string())); + assert_eq!(base.image.name, "my-project/my-image"); + assert_eq!(base.tag, Some("latest".to_string())); + } + _ => panic!("Expected FROM instruction"), + } + } +} diff --git a/src/analyzer/hadolint/parser/instruction.rs b/src/analyzer/hadolint/parser/instruction.rs new file mode 100644 index 00000000..5716564e --- /dev/null +++ b/src/analyzer/hadolint/parser/instruction.rs @@ -0,0 +1,549 @@ +//! Dockerfile instruction AST types. +//! +//! These types represent the parsed structure of a Dockerfile, +//! matching the Haskell `language-docker` library for compatibility. + +use std::collections::HashSet; + +/// A positioned instruction with source location information. +#[derive(Debug, Clone, PartialEq)] +pub struct InstructionPos { + /// The parsed instruction. + pub instruction: Instruction, + /// Line number (1-indexed). + pub line_number: u32, + /// Original source text of the instruction. + pub source_text: String, +} + +impl InstructionPos { + /// Create a new positioned instruction. + pub fn new(instruction: Instruction, line_number: u32, source_text: String) -> Self { + Self { + instruction, + line_number, + source_text, + } + } +} + +/// Dockerfile instructions. +#[derive(Debug, Clone, PartialEq)] +pub enum Instruction { + /// FROM instruction + From(BaseImage), + /// RUN instruction + Run(RunArgs), + /// COPY instruction + Copy(CopyArgs, CopyFlags), + /// ADD instruction + Add(AddArgs, AddFlags), + /// ENV instruction + Env(Vec<(String, String)>), + /// LABEL instruction + Label(Vec<(String, String)>), + /// EXPOSE instruction + Expose(Vec), + /// ARG instruction + Arg(String, Option), + /// ENTRYPOINT instruction + Entrypoint(Arguments), + /// CMD instruction + Cmd(Arguments), + /// SHELL instruction + Shell(Arguments), + /// USER instruction + User(String), + /// WORKDIR instruction + Workdir(String), + /// VOLUME instruction + Volume(String), + /// MAINTAINER instruction (deprecated) + Maintainer(String), + /// HEALTHCHECK instruction + Healthcheck(HealthCheck), + /// ONBUILD instruction (wraps another instruction) + OnBuild(Box), + /// STOPSIGNAL instruction + Stopsignal(String), + /// Comment line + Comment(String), +} + +impl Instruction { + /// Check if this is a FROM instruction. + pub fn is_from(&self) -> bool { + matches!(self, Self::From(_)) + } + + /// Check if this is a RUN instruction. + pub fn is_run(&self) -> bool { + matches!(self, Self::Run(_)) + } + + /// Check if this is a COPY instruction. + pub fn is_copy(&self) -> bool { + matches!(self, Self::Copy(_, _)) + } + + /// Check if this is an ONBUILD instruction. + pub fn is_onbuild(&self) -> bool { + matches!(self, Self::OnBuild(_)) + } + + /// Get the wrapped instruction if this is ONBUILD. + pub fn unwrap_onbuild(&self) -> Option<&Instruction> { + match self { + Self::OnBuild(inner) => Some(inner.as_ref()), + _ => None, + } + } +} + +/// Base image in FROM instruction. +#[derive(Debug, Clone, PartialEq)] +pub struct BaseImage { + /// The image reference. + pub image: Image, + /// Image tag (e.g., "latest", "3.9"). + pub tag: Option, + /// Image digest (e.g., "sha256:..."). + pub digest: Option, + /// Stage alias (AS name). + pub alias: Option, + /// Target platform (--platform=...). + pub platform: Option, +} + +impl BaseImage { + /// Create a new base image with just a name. + pub fn new(name: impl Into) -> Self { + Self { + image: Image::new(name), + tag: None, + digest: None, + alias: None, + platform: None, + } + } + + /// Check if the image uses a variable reference. + pub fn is_variable(&self) -> bool { + self.image.name.starts_with('$') + } + + /// Check if this is the scratch image. + pub fn is_scratch(&self) -> bool { + self.image.name.eq_ignore_ascii_case("scratch") + } + + /// Check if the image has an explicit tag or digest. + pub fn has_version(&self) -> bool { + self.tag.is_some() || self.digest.is_some() + } +} + +/// Docker image reference. +#[derive(Debug, Clone, PartialEq)] +pub struct Image { + /// Optional registry (e.g., "docker.io", "gcr.io"). + pub registry: Option, + /// Image name (e.g., "ubuntu", "library/ubuntu"). + pub name: String, +} + +impl Image { + /// Create a new image with just a name. + pub fn new(name: impl Into) -> Self { + Self { + registry: None, + name: name.into(), + } + } + + /// Create a new image with registry. + pub fn with_registry(registry: impl Into, name: impl Into) -> Self { + Self { + registry: Some(registry.into()), + name: name.into(), + } + } + + /// Get the full image reference. + pub fn full_name(&self) -> String { + match &self.registry { + Some(reg) => format!("{}/{}", reg, self.name), + None => self.name.clone(), + } + } +} + +/// Image alias (AS name in FROM). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ImageAlias(pub String); + +impl ImageAlias { + /// Create a new image alias. + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + + /// Get the alias name. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// RUN instruction arguments. +#[derive(Debug, Clone, PartialEq)] +pub struct RunArgs { + /// The command arguments. + pub arguments: Arguments, + /// RUN flags (--mount, --network, etc.). + pub flags: RunFlags, +} + +impl RunArgs { + /// Create a new RUN with shell form. + pub fn shell(cmd: impl Into) -> Self { + Self { + arguments: Arguments::Text(cmd.into()), + flags: RunFlags::default(), + } + } + + /// Create a new RUN with exec form. + pub fn exec(args: Vec) -> Self { + Self { + arguments: Arguments::List(args), + flags: RunFlags::default(), + } + } +} + +/// RUN instruction flags. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct RunFlags { + /// Mount options (--mount=...). + pub mount: HashSet, + /// Network mode (--network=...). + pub network: Option, + /// Security mode (--security=...). + pub security: Option, +} + +/// RUN mount types. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum RunMount { + /// Bind mount + Bind(BindOpts), + /// Cache mount + Cache(CacheOpts), + /// Tmpfs mount + Tmpfs(TmpOpts), + /// Secret mount + Secret(SecretOpts), + /// SSH mount + Ssh(SshOpts), +} + +/// Bind mount options. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +pub struct BindOpts { + pub target: Option, + pub source: Option, + pub from: Option, + pub read_only: bool, +} + +/// Cache mount options. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +pub struct CacheOpts { + pub target: Option, + pub id: Option, + pub sharing: Option, + pub from: Option, + pub source: Option, + pub mode: Option, + pub uid: Option, + pub gid: Option, + pub read_only: bool, +} + +/// Tmpfs mount options. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +pub struct TmpOpts { + pub target: Option, + pub size: Option, +} + +/// Secret mount options. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +pub struct SecretOpts { + pub id: Option, + pub target: Option, + pub required: bool, + pub mode: Option, + pub uid: Option, + pub gid: Option, +} + +/// SSH mount options. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +pub struct SshOpts { + pub id: Option, + pub target: Option, + pub required: bool, + pub mode: Option, + pub uid: Option, + pub gid: Option, +} + +/// COPY instruction arguments. +#[derive(Debug, Clone, PartialEq)] +pub struct CopyArgs { + /// Source paths. + pub sources: Vec, + /// Destination path. + pub dest: String, +} + +impl CopyArgs { + /// Create new copy args. + pub fn new(sources: Vec, dest: impl Into) -> Self { + Self { + sources, + dest: dest.into(), + } + } +} + +/// COPY instruction flags. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct CopyFlags { + /// --from= + pub from: Option, + /// --chown= + pub chown: Option, + /// --chmod= + pub chmod: Option, + /// --link + pub link: bool, +} + +/// ADD instruction arguments. +#[derive(Debug, Clone, PartialEq)] +pub struct AddArgs { + /// Source paths/URLs. + pub sources: Vec, + /// Destination path. + pub dest: String, +} + +impl AddArgs { + /// Create new add args. + pub fn new(sources: Vec, dest: impl Into) -> Self { + Self { + sources, + dest: dest.into(), + } + } + + /// Check if any source is a URL. + pub fn has_url(&self) -> bool { + self.sources.iter().any(|s| s.starts_with("http://") || s.starts_with("https://")) + } + + /// Check if any source appears to be an archive. + pub fn has_archive(&self) -> bool { + const ARCHIVE_EXTENSIONS: &[&str] = &[ + ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz", + ".zip", ".gz", ".bz2", ".xz", ".Z", ".lz", ".lzma", + ]; + self.sources.iter().any(|s| { + ARCHIVE_EXTENSIONS.iter().any(|ext| s.ends_with(ext)) + }) + } +} + +/// ADD instruction flags. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct AddFlags { + /// --chown= + pub chown: Option, + /// --chmod= + pub chmod: Option, + /// --link + pub link: bool, + /// --checksum= + pub checksum: Option, +} + +/// Port specification. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Port { + /// Port number. + pub number: u16, + /// Protocol (tcp/udp). + pub protocol: PortProtocol, +} + +impl Port { + /// Create a TCP port. + pub fn tcp(number: u16) -> Self { + Self { + number, + protocol: PortProtocol::Tcp, + } + } + + /// Create a UDP port. + pub fn udp(number: u16) -> Self { + Self { + number, + protocol: PortProtocol::Udp, + } + } +} + +/// Port protocol. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum PortProtocol { + #[default] + Tcp, + Udp, +} + +/// Arguments in shell form or exec form. +#[derive(Debug, Clone, PartialEq)] +pub enum Arguments { + /// Shell form: RUN apt-get update + Text(String), + /// Exec form: RUN ["apt-get", "update"] + List(Vec), +} + +impl Arguments { + /// Check if this is shell form. + pub fn is_shell_form(&self) -> bool { + matches!(self, Self::Text(_)) + } + + /// Check if this is exec form. + pub fn is_exec_form(&self) -> bool { + matches!(self, Self::List(_)) + } + + /// Get the text if shell form. + pub fn as_text(&self) -> Option<&str> { + match self { + Self::Text(s) => Some(s), + _ => None, + } + } + + /// Get the list if exec form. + pub fn as_list(&self) -> Option<&[String]> { + match self { + Self::List(v) => Some(v), + _ => None, + } + } + + /// Convert to a single string (for shell form, returns as-is; for exec form, joins with spaces). + pub fn to_string_lossy(&self) -> String { + match self { + Self::Text(s) => s.clone(), + Self::List(v) => v.join(" "), + } + } +} + +/// HEALTHCHECK instruction. +#[derive(Debug, Clone, PartialEq)] +pub enum HealthCheck { + /// HEALTHCHECK NONE + None, + /// HEALTHCHECK CMD ... + Cmd { + /// The command to run. + cmd: Arguments, + /// Interval between checks. + interval: Option, + /// Timeout for each check. + timeout: Option, + /// Start period before checks begin. + start_period: Option, + /// Number of retries before unhealthy. + retries: Option, + }, +} + +impl HealthCheck { + /// Create a HEALTHCHECK CMD with defaults. + pub fn cmd(cmd: Arguments) -> Self { + Self::Cmd { + cmd, + interval: None, + timeout: None, + start_period: None, + retries: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_base_image() { + let img = BaseImage::new("ubuntu"); + assert!(!img.is_scratch()); + assert!(!img.is_variable()); + assert!(!img.has_version()); + + let scratch = BaseImage::new("scratch"); + assert!(scratch.is_scratch()); + + let var = BaseImage::new("${BASE_IMAGE}"); + assert!(var.is_variable()); + + let tagged = BaseImage { + tag: Some("20.04".to_string()), + ..BaseImage::new("ubuntu") + }; + assert!(tagged.has_version()); + } + + #[test] + fn test_image() { + let img = Image::new("ubuntu"); + assert_eq!(img.full_name(), "ubuntu"); + + let img_with_reg = Image::with_registry("gcr.io", "my-project/my-image"); + assert_eq!(img_with_reg.full_name(), "gcr.io/my-project/my-image"); + } + + #[test] + fn test_arguments() { + let shell = Arguments::Text("apt-get update".to_string()); + assert!(shell.is_shell_form()); + assert_eq!(shell.as_text(), Some("apt-get update")); + + let exec = Arguments::List(vec!["apt-get".to_string(), "update".to_string()]); + assert!(exec.is_exec_form()); + assert_eq!(exec.as_list(), Some(&["apt-get".to_string(), "update".to_string()][..])); + } + + #[test] + fn test_add_args() { + let add = AddArgs::new(vec!["app.tar.gz".to_string()], "/app"); + assert!(add.has_archive()); + assert!(!add.has_url()); + + let add_url = AddArgs::new(vec!["https://example.com/file.txt".to_string()], "/app"); + assert!(add_url.has_url()); + assert!(!add_url.has_archive()); + } +} diff --git a/src/analyzer/hadolint/parser/mod.rs b/src/analyzer/hadolint/parser/mod.rs new file mode 100644 index 00000000..13bb1789 --- /dev/null +++ b/src/analyzer/hadolint/parser/mod.rs @@ -0,0 +1,11 @@ +//! Dockerfile parser module. +//! +//! Provides: +//! - `instruction` - Dockerfile AST types +//! - `dockerfile` - nom-based parser implementation + +pub mod dockerfile; +pub mod instruction; + +pub use dockerfile::{parse_dockerfile, ParseError}; +pub use instruction::*; diff --git a/src/analyzer/hadolint/pragma.rs b/src/analyzer/hadolint/pragma.rs new file mode 100644 index 00000000..7dfae7e2 --- /dev/null +++ b/src/analyzer/hadolint/pragma.rs @@ -0,0 +1,224 @@ +//! Pragma parsing for inline rule ignores. +//! +//! Hadolint supports inline pragmas to ignore rules: +//! - `# hadolint ignore=DL3008,DL3009` - Ignore for next instruction +//! - `# hadolint global ignore=DL3008` - Ignore for entire file +//! - `# hadolint shell=/bin/bash` - Set shell for ShellCheck + +use crate::analyzer::hadolint::types::RuleCode; +use std::collections::{HashMap, HashSet}; + +/// Parsed pragma state for a Dockerfile. +#[derive(Debug, Clone, Default)] +pub struct PragmaState { + /// Per-line ignored rules: line -> set of ignored codes. + pub ignored: HashMap>, + /// Globally ignored rules. + pub global_ignored: HashSet, + /// Shell override (if specified). + pub shell: Option, +} + +impl PragmaState { + /// Create a new empty pragma state. + pub fn new() -> Self { + Self::default() + } + + /// Check if a rule should be ignored on a specific line. + pub fn is_ignored(&self, code: &RuleCode, line: u32) -> bool { + // Check global ignores + if self.global_ignored.contains(code) { + return true; + } + + // Check line-specific ignores (check previous line, as pragma applies to next line) + if let Some(ignored) = self.ignored.get(&line) { + if ignored.contains(code) { + return true; + } + } + + // Also check if the pragma was on the line before + if line > 0 { + if let Some(ignored) = self.ignored.get(&(line - 1)) { + if ignored.contains(code) { + return true; + } + } + } + + false + } +} + +/// Parse pragma from a comment string. +/// Returns the pragma type and any associated data. +pub fn parse_pragma(comment: &str) -> Option { + let comment = comment.trim(); + + // Look for hadolint pragma + let pragma_start = comment.find("hadolint")?; + let pragma_content = &comment[pragma_start + "hadolint".len()..].trim(); + + // Parse global ignore + if pragma_content.starts_with("global") { + let rest = &pragma_content["global".len()..].trim(); + if let Some(codes) = parse_ignore_list(rest) { + return Some(Pragma::GlobalIgnore(codes)); + } + } + + // Parse ignore + if let Some(codes) = parse_ignore_list(pragma_content) { + return Some(Pragma::Ignore(codes)); + } + + // Parse shell + if pragma_content.starts_with("shell=") { + let shell = &pragma_content["shell=".len()..].trim(); + return Some(Pragma::Shell(shell.to_string())); + } + + None +} + +/// Parse an ignore list from a pragma string. +fn parse_ignore_list(s: &str) -> Option> { + let s = s.trim(); + + // Look for ignore= pattern + if !s.starts_with("ignore=") && !s.starts_with("ignore =") { + return None; + } + + // Find the = sign and get the codes + let eq_pos = s.find('=')?; + let codes_str = &s[eq_pos + 1..].trim(); + + // Split by comma and parse codes + let codes: Vec = codes_str + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| RuleCode::new(s)) + .collect(); + + if codes.is_empty() { + None + } else { + Some(codes) + } +} + +/// Parsed pragma types. +#[derive(Debug, Clone)] +pub enum Pragma { + /// Ignore rules for the next instruction. + Ignore(Vec), + /// Ignore rules globally for the entire file. + GlobalIgnore(Vec), + /// Set shell for ShellCheck analysis. + Shell(String), +} + +/// Extract pragma state from Dockerfile instructions. +pub fn extract_pragmas(instructions: &[crate::analyzer::hadolint::parser::InstructionPos]) -> PragmaState { + let mut state = PragmaState::new(); + + for instr in instructions { + if let crate::analyzer::hadolint::parser::instruction::Instruction::Comment(comment) = &instr.instruction { + if let Some(pragma) = parse_pragma(comment) { + match pragma { + Pragma::Ignore(codes) => { + // Ignore applies to the next line + let entry = state.ignored.entry(instr.line_number).or_default(); + for code in codes { + entry.insert(code); + } + } + Pragma::GlobalIgnore(codes) => { + for code in codes { + state.global_ignored.insert(code); + } + } + Pragma::Shell(shell) => { + state.shell = Some(shell); + } + } + } + } + } + + state +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_ignore() { + let pragma = parse_pragma("# hadolint ignore=DL3008,DL3009").unwrap(); + match pragma { + Pragma::Ignore(codes) => { + assert_eq!(codes.len(), 2); + assert_eq!(codes[0].as_str(), "DL3008"); + assert_eq!(codes[1].as_str(), "DL3009"); + } + _ => panic!("Expected Ignore pragma"), + } + } + + #[test] + fn test_parse_global_ignore() { + let pragma = parse_pragma("# hadolint global ignore=DL3008").unwrap(); + match pragma { + Pragma::GlobalIgnore(codes) => { + assert_eq!(codes.len(), 1); + assert_eq!(codes[0].as_str(), "DL3008"); + } + _ => panic!("Expected GlobalIgnore pragma"), + } + } + + #[test] + fn test_parse_shell() { + let pragma = parse_pragma("# hadolint shell=/bin/bash").unwrap(); + match pragma { + Pragma::Shell(shell) => { + assert_eq!(shell, "/bin/bash"); + } + _ => panic!("Expected Shell pragma"), + } + } + + #[test] + fn test_no_pragma() { + assert!(parse_pragma("# This is a regular comment").is_none()); + } + + #[test] + fn test_pragma_state_is_ignored() { + let mut state = PragmaState::new(); + + // Add line-specific ignore + let mut codes = HashSet::new(); + codes.insert(RuleCode::new("DL3008")); + state.ignored.insert(5, codes); + + // Add global ignore + state.global_ignored.insert(RuleCode::new("DL3009")); + + // Test line-specific (pragma on line 5 affects line 6) + assert!(state.is_ignored(&RuleCode::new("DL3008"), 6)); + assert!(!state.is_ignored(&RuleCode::new("DL3008"), 10)); + + // Test global + assert!(state.is_ignored(&RuleCode::new("DL3009"), 1)); + assert!(state.is_ignored(&RuleCode::new("DL3009"), 100)); + + // Test non-ignored + assert!(!state.is_ignored(&RuleCode::new("DL3010"), 1)); + } +} diff --git a/src/analyzer/hadolint/rules/dl1001.rs b/src/analyzer/hadolint/rules/dl1001.rs new file mode 100644 index 00000000..ed4652cf --- /dev/null +++ b/src/analyzer/hadolint/rules/dl1001.rs @@ -0,0 +1,51 @@ +//! DL1001: Please refrain from using inline ignore pragmas +//! +//! This is a meta-rule that warns when inline ignore pragmas are used. +//! It's disabled by default but can be enabled for strict linting. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL1001", + Severity::Info, + "Please refrain from using inline ignore pragmas `# hadolint ignore=...`.", + |instr, _shell| { + match instr { + Instruction::Comment(comment) => { + // Check if it's a hadolint ignore pragma + let lower = comment.to_lowercase(); + !lower.contains("hadolint") || !lower.contains("ignore") + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::rules::{Rule, RuleState}; + + #[test] + fn test_ignore_pragma() { + let rule = rule(); + let mut state = RuleState::new(); + let instr = Instruction::Comment("hadolint ignore=DL3008".to_string()); + rule.check(&mut state, 1, &instr, None); + assert_eq!(state.failures.len(), 1); + } + + #[test] + fn test_regular_comment() { + let rule = rule(); + let mut state = RuleState::new(); + let instr = Instruction::Comment("This is a regular comment".to_string()); + rule.check(&mut state, 1, &instr, None); + assert!(state.failures.is_empty()); + } +} diff --git a/src/analyzer/hadolint/rules/dl3000.rs b/src/analyzer/hadolint/rules/dl3000.rs new file mode 100644 index 00000000..895c48a7 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3000.rs @@ -0,0 +1,72 @@ +//! DL3000: Use absolute WORKDIR +//! +//! WORKDIR should use an absolute path to avoid confusion about the +//! starting directory. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3000", + Severity::Error, + "Use absolute WORKDIR", + |instr, _shell| { + match instr { + Instruction::Workdir(path) => { + // Allow absolute paths and variables + path.starts_with('/') || path.starts_with('$') || is_windows_absolute(path) + } + _ => true, + } + }, + ) +} + +/// Check if path is a Windows absolute path. +fn is_windows_absolute(path: &str) -> bool { + let chars: Vec = path.chars().collect(); + chars.len() >= 2 && chars[0].is_ascii_alphabetic() && chars[1] == ':' +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::rules::{Rule, RuleState}; + + #[test] + fn test_absolute_path() { + let rule = rule(); + let mut state = RuleState::new(); + + // Good: absolute path + let instr = Instruction::Workdir("/app".to_string()); + rule.check(&mut state, 1, &instr, None); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_relative_path() { + let rule = rule(); + let mut state = RuleState::new(); + + // Bad: relative path + let instr = Instruction::Workdir("app".to_string()); + rule.check(&mut state, 1, &instr, None); + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL3000"); + } + + #[test] + fn test_variable_path() { + let rule = rule(); + let mut state = RuleState::new(); + + // Good: variable + let instr = Instruction::Workdir("$APP_DIR".to_string()); + rule.check(&mut state, 1, &instr, None); + assert!(state.failures.is_empty()); + } +} diff --git a/src/analyzer/hadolint/rules/dl3001.rs b/src/analyzer/hadolint/rules/dl3001.rs new file mode 100644 index 00000000..cb6145a2 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3001.rs @@ -0,0 +1,85 @@ +//! DL3001: Don't use invalid commands in RUN +//! +//! Commands like ssh, vim, shutdown, service, ps, free, top, kill, and mount +//! are not appropriate for Dockerfile RUN instructions. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +/// Invalid commands that shouldn't be used in Dockerfiles. +const INVALID_COMMANDS: &[&str] = &[ + "ssh", + "vim", + "shutdown", + "service", + "ps", + "free", + "top", + "kill", + "mount", + "ifconfig", + "nano", +]; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3001", + Severity::Info, + "For some bash commands it makes no sense running them in a Docker container like ssh, vim, shutdown, service, ps, free, top, kill, mount, ifconfig", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| INVALID_COMMANDS.contains(&cmd.name.as_str())) + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::parser::instruction::RunArgs; + use crate::analyzer::hadolint::rules::{Rule, RuleState}; + + #[test] + fn test_valid_command() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("apt-get update")); + let shell = ParsedShell::parse("apt-get update"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_invalid_ssh() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("ssh user@host")); + let shell = ParsedShell::parse("ssh user@host"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL3001"); + } + + #[test] + fn test_invalid_vim() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("vim /etc/config")); + let shell = ParsedShell::parse("vim /etc/config"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert_eq!(state.failures.len(), 1); + } +} diff --git a/src/analyzer/hadolint/rules/dl3002.rs b/src/analyzer/hadolint/rules/dl3002.rs new file mode 100644 index 00000000..0d471e9c --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3002.rs @@ -0,0 +1,108 @@ +//! DL3002: Last USER should not be root +//! +//! Running as root in containers is a security risk. The last USER +//! instruction should switch to a non-root user. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> CustomRule) + Send + Sync> { + custom_rule( + "DL3002", + Severity::Warning, + "Last USER should not be root", + |state, line, instr, _shell| { + match instr { + Instruction::From(_) => { + // Reset state for each stage + state.data.set_bool("is_root", true); + state.data.set_int("last_user_line", 0); + } + Instruction::User(user) => { + let is_root = user == "root" || user == "0" || user.starts_with("root:"); + state.data.set_bool("is_root", is_root); + state.data.set_int("last_user_line", line as i64); + } + _ => {} + } + }, + ) +} + +/// Custom finalize implementation for DL3002. +/// This is called manually in the lint process. +pub fn finalize(state: RuleState) -> Vec { + let mut failures = state.failures; + + // Check if the last USER was root + if state.data.get_bool("is_root") { + let last_line = state.data.get_int("last_user_line"); + if last_line > 0 { + failures.push(crate::analyzer::hadolint::types::CheckFailure::new( + "DL3002", + Severity::Warning, + "Last USER should not be root", + last_line as u32, + )); + } + } + + failures +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::parser::instruction::BaseImage; + use crate::analyzer::hadolint::rules::Rule; + + #[test] + fn test_non_root_user() { + let rule = rule(); + let mut state = RuleState::new(); + + let from = Instruction::From(BaseImage::new("ubuntu")); + let user = Instruction::User("appuser".to_string()); + + rule.check(&mut state, 1, &from, None); + rule.check(&mut state, 2, &user, None); + + let failures = finalize(state); + assert!(failures.is_empty()); + } + + #[test] + fn test_root_user() { + let rule = rule(); + let mut state = RuleState::new(); + + let from = Instruction::From(BaseImage::new("ubuntu")); + let user = Instruction::User("root".to_string()); + + rule.check(&mut state, 1, &from, None); + rule.check(&mut state, 2, &user, None); + + let failures = finalize(state); + assert_eq!(failures.len(), 1); + assert_eq!(failures[0].code.as_str(), "DL3002"); + } + + #[test] + fn test_switch_from_root() { + let rule = rule(); + let mut state = RuleState::new(); + + let from = Instruction::From(BaseImage::new("ubuntu")); + let user1 = Instruction::User("root".to_string()); + let user2 = Instruction::User("appuser".to_string()); + + rule.check(&mut state, 1, &from, None); + rule.check(&mut state, 2, &user1, None); + rule.check(&mut state, 3, &user2, None); + + let failures = finalize(state); + assert!(failures.is_empty()); + } +} diff --git a/src/analyzer/hadolint/rules/dl3003.rs b/src/analyzer/hadolint/rules/dl3003.rs new file mode 100644 index 00000000..787a90b6 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3003.rs @@ -0,0 +1,60 @@ +//! DL3003: Use WORKDIR to switch to a directory +//! +//! Don't use `cd` in RUN instructions. Use WORKDIR instead to change +//! the working directory. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3003", + Severity::Warning, + "Use WORKDIR to switch to a directory", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + // Check if cd is used as a command + !shell.any_command(|cmd| cmd.name == "cd") + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::parser::instruction::RunArgs; + use crate::analyzer::hadolint::rules::{Rule, RuleState}; + + #[test] + fn test_no_cd() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("apt-get update")); + let shell = ParsedShell::parse("apt-get update"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_with_cd() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("cd /app && npm install")); + let shell = ParsedShell::parse("cd /app && npm install"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL3003"); + } +} diff --git a/src/analyzer/hadolint/rules/dl3004.rs b/src/analyzer/hadolint/rules/dl3004.rs new file mode 100644 index 00000000..b7c6b0b0 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3004.rs @@ -0,0 +1,59 @@ +//! DL3004: Do not use sudo +//! +//! Using sudo in Dockerfiles is unnecessary since containers run as root +//! by default, and using it indicates a misunderstanding of Docker. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3004", + Severity::Error, + "Do not use sudo as it leads to unpredictable behavior. Use a tool like gosu to enforce root", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| cmd.name == "sudo") + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::parser::instruction::RunArgs; + use crate::analyzer::hadolint::rules::{Rule, RuleState}; + + #[test] + fn test_no_sudo() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("apt-get update")); + let shell = ParsedShell::parse("apt-get update"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_with_sudo() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("sudo apt-get update")); + let shell = ParsedShell::parse("sudo apt-get update"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL3004"); + } +} diff --git a/src/analyzer/hadolint/rules/dl3005.rs b/src/analyzer/hadolint/rules/dl3005.rs new file mode 100644 index 00000000..1f1a0287 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3005.rs @@ -0,0 +1,66 @@ +//! DL3005: Do not use apt-get upgrade or dist-upgrade +//! +//! Using apt-get upgrade or dist-upgrade in a Dockerfile is not recommended +//! as it can lead to unpredictable builds. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3005", + Severity::Warning, + "Do not use `apt-get upgrade` or `dist-upgrade`.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| { + cmd.name == "apt-get" && cmd.has_any_arg(&["upgrade", "dist-upgrade"]) + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_apt_get_upgrade() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get update && apt-get upgrade"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3005")); + } + + #[test] + fn test_apt_get_dist_upgrade() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get dist-upgrade"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3005")); + } + + #[test] + fn test_apt_get_update() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get update"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3005")); + } + + #[test] + fn test_apt_get_install() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get install -y nginx"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3005")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3006.rs b/src/analyzer/hadolint/rules/dl3006.rs new file mode 100644 index 00000000..e9fe7d5b --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3006.rs @@ -0,0 +1,133 @@ +//! DL3006: Always tag the version of an image explicitly +//! +//! Images should be tagged to ensure reproducible builds. +//! Using untagged images may result in different versions being pulled. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> CustomRule) + Send + Sync> { + custom_rule( + "DL3006", + Severity::Warning, + "Always tag the version of an image explicitly", + |state, line, instr, _shell| { + match instr { + Instruction::From(base) => { + // Remember stage aliases + if let Some(alias) = &base.alias { + state.data.insert_to_set("aliases", alias.as_str()); + } + + // Check if image needs a tag + let image_name = &base.image.name; + + // Skip check for: + // 1. scratch image + // 2. images with tags + // 3. images with digests + // 4. variable references + // 5. references to previous build stages + + if base.is_scratch() { + return; + } + + if base.has_version() { + return; + } + + if base.is_variable() { + return; + } + + // Check if it's a reference to a previous stage + if state.data.set_contains("aliases", image_name) { + return; + } + + // Image doesn't have a tag + state.add_failure( + "DL3006", + Severity::Warning, + "Always tag the version of an image explicitly", + line, + ); + } + _ => {} + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::parser::instruction::{BaseImage, ImageAlias}; + use crate::analyzer::hadolint::rules::Rule; + + #[test] + fn test_tagged_image() { + let rule = rule(); + let mut state = RuleState::new(); + + let mut base = BaseImage::new("ubuntu"); + base.tag = Some("20.04".to_string()); + let instr = Instruction::From(base); + + rule.check(&mut state, 1, &instr, None); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_untagged_image() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::From(BaseImage::new("ubuntu")); + rule.check(&mut state, 1, &instr, None); + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL3006"); + } + + #[test] + fn test_scratch_image() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::From(BaseImage::new("scratch")); + rule.check(&mut state, 1, &instr, None); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_stage_reference() { + let rule = rule(); + let mut state = RuleState::new(); + + // First stage with alias + let mut base1 = BaseImage::new("node"); + base1.tag = Some("18".to_string()); + base1.alias = Some(ImageAlias::new("builder")); + let instr1 = Instruction::From(base1); + rule.check(&mut state, 1, &instr1, None); + + // Second stage referencing first + let instr2 = Instruction::From(BaseImage::new("builder")); + rule.check(&mut state, 10, &instr2, None); + + assert!(state.failures.is_empty()); + } + + #[test] + fn test_variable_image() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::From(BaseImage::new("${BASE_IMAGE}")); + rule.check(&mut state, 1, &instr, None); + assert!(state.failures.is_empty()); + } +} diff --git a/src/analyzer/hadolint/rules/dl3007.rs b/src/analyzer/hadolint/rules/dl3007.rs new file mode 100644 index 00000000..becd8057 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3007.rs @@ -0,0 +1,74 @@ +//! DL3007: Using latest is prone to errors +//! +//! Using the :latest tag can lead to inconsistent builds and should be avoided. +//! Use specific version tags instead. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3007", + Severity::Warning, + "Using latest is prone to errors if the image will ever update. Pin the version explicitly to a release tag", + |instr, _shell| { + match instr { + Instruction::From(base) => { + // Check if tag is "latest" + match &base.tag { + Some(tag) => tag != "latest", + None => true, // No tag is handled by DL3006 + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::parser::instruction::BaseImage; + use crate::analyzer::hadolint::rules::{Rule, RuleState}; + + #[test] + fn test_specific_tag() { + let rule = rule(); + let mut state = RuleState::new(); + + let mut base = BaseImage::new("ubuntu"); + base.tag = Some("20.04".to_string()); + let instr = Instruction::From(base); + + rule.check(&mut state, 1, &instr, None); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_latest_tag() { + let rule = rule(); + let mut state = RuleState::new(); + + let mut base = BaseImage::new("ubuntu"); + base.tag = Some("latest".to_string()); + let instr = Instruction::From(base); + + rule.check(&mut state, 1, &instr, None); + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL3007"); + } + + #[test] + fn test_no_tag() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::From(BaseImage::new("ubuntu")); + rule.check(&mut state, 1, &instr, None); + // No tag is OK here (handled by DL3006) + assert!(state.failures.is_empty()); + } +} diff --git a/src/analyzer/hadolint/rules/dl3008.rs b/src/analyzer/hadolint/rules/dl3008.rs new file mode 100644 index 00000000..0416610d --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3008.rs @@ -0,0 +1,115 @@ +//! DL3008: Pin versions in apt-get install +//! +//! Package versions should be pinned in apt-get install to ensure +//! reproducible builds. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3008", + Severity::Warning, + "Pin versions in apt get install. Instead of `apt-get install ` use `apt-get install =`", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + // Get apt-get install packages + let packages = apt_get_packages(shell); + // All packages should have versions pinned + packages.iter().all(|pkg| is_version_pinned(pkg)) + } else { + true + } + } + _ => true, + } + }, + ) +} + +/// Extract packages from apt-get install commands. +fn apt_get_packages(shell: &ParsedShell) -> Vec { + let mut packages = Vec::new(); + + for cmd in &shell.commands { + if cmd.name == "apt-get" && cmd.arguments.iter().any(|a| a == "install") { + // Get arguments that aren't flags and aren't "install" + let args: Vec<&str> = cmd + .args_no_flags() + .into_iter() + .filter(|a| *a != "install") + // Filter out -t/--target-release arguments + .collect(); + + packages.extend(args.into_iter().map(|s| s.to_string())); + } + } + + packages +} + +/// Check if a package has a version pinned. +fn is_version_pinned(package: &str) -> bool { + // Version pinned: package=version + package.contains('=') + // APT pinning: package/release + || package.contains('/') + // Local .deb file + || package.ends_with(".deb") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::parser::instruction::RunArgs; + use crate::analyzer::hadolint::rules::{Rule, RuleState}; + + #[test] + fn test_pinned_version() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("apt-get install -y nginx=1.18.0-0ubuntu1")); + let shell = ParsedShell::parse("apt-get install -y nginx=1.18.0-0ubuntu1"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_unpinned_version() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("apt-get install -y nginx")); + let shell = ParsedShell::parse("apt-get install -y nginx"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL3008"); + } + + #[test] + fn test_apt_pinning() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("apt-get install -y nginx/focal")); + let shell = ParsedShell::parse("apt-get install -y nginx/focal"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_update_only() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("apt-get update")); + let shell = ParsedShell::parse("apt-get update"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert!(state.failures.is_empty()); + } +} diff --git a/src/analyzer/hadolint/rules/dl3009.rs b/src/analyzer/hadolint/rules/dl3009.rs new file mode 100644 index 00000000..b02d4f33 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3009.rs @@ -0,0 +1,87 @@ +//! DL3009: Delete the apt-get lists after installing something +//! +//! After installing packages with apt-get, the package lists should be +//! removed to reduce image size. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3009", + Severity::Info, + "Delete the apt-get lists after installing something.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + // Check if apt-get install is used + let has_apt_install = shell.any_command(|cmd| { + cmd.name == "apt-get" && cmd.has_any_arg(&["install"]) + }); + + if !has_apt_install { + return true; + } + + // Check if lists are cleaned + let has_cleanup = shell.any_command(|cmd| { + // rm -rf /var/lib/apt/lists/* + (cmd.name == "rm" && cmd.arguments.iter().any(|arg| { + arg.contains("/var/lib/apt/lists") + })) + // Or apt-get clean + || (cmd.name == "apt-get" && cmd.has_any_arg(&["clean", "autoclean"])) + }); + + has_cleanup + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_apt_get_without_cleanup() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y nginx"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3009")); + } + + #[test] + fn test_apt_get_with_rm_cleanup() { + let result = lint_dockerfile( + "FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y nginx && rm -rf /var/lib/apt/lists/*" + ); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3009")); + } + + #[test] + fn test_apt_get_with_clean() { + let result = lint_dockerfile( + "FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y nginx && apt-get clean" + ); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3009")); + } + + #[test] + fn test_no_apt_get() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN echo hello"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3009")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3010.rs b/src/analyzer/hadolint/rules/dl3010.rs new file mode 100644 index 00000000..c839e6aa --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3010.rs @@ -0,0 +1,83 @@ +//! DL3010: Use ADD for extracting archives into an image +//! +//! ADD can automatically extract tar archives. Use ADD instead of +//! COPY + RUN tar for better efficiency. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3010", + Severity::Info, + "Use ADD for extracting archives into an image.", + |instr, _shell| { + match instr { + Instruction::Copy(args, _) => { + // Check if any source looks like a local tar archive + !args.sources.iter().any(|src| is_local_archive(src)) + } + _ => true, + } + }, + ) +} + +/// Check if source is a local archive file (not URL) +fn is_local_archive(src: &str) -> bool { + // Skip URLs + if src.starts_with("http://") || src.starts_with("https://") || src.starts_with("ftp://") { + return false; + } + + // Skip variables + if src.starts_with('$') { + return false; + } + + // Check for archive extensions + let archive_extensions = [ + ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz", + ".tar.zst", ".tar.lz", ".tar.lzma" + ]; + + let lower = src.to_lowercase(); + archive_extensions.iter().any(|ext| lower.ends_with(ext)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_copy_tar_file() { + let result = lint_dockerfile("FROM ubuntu:20.04\nCOPY app.tar.gz /app/"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3010")); + } + + #[test] + fn test_copy_tgz_file() { + let result = lint_dockerfile("FROM ubuntu:20.04\nCOPY archive.tgz /tmp/"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3010")); + } + + #[test] + fn test_copy_regular_file() { + let result = lint_dockerfile("FROM ubuntu:20.04\nCOPY app.js /app/"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3010")); + } + + #[test] + fn test_copy_directory() { + let result = lint_dockerfile("FROM ubuntu:20.04\nCOPY src/ /app/"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3010")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3011.rs b/src/analyzer/hadolint/rules/dl3011.rs new file mode 100644 index 00000000..1f2f273d --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3011.rs @@ -0,0 +1,62 @@ +//! DL3011: Valid UNIX ports range from 0 to 65535 +//! +//! EXPOSE instruction must use valid port numbers. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3011", + Severity::Error, + "Valid UNIX ports range from 0 to 65535.", + |instr, _shell| { + match instr { + Instruction::Expose(ports) => { + // All ports must be valid (0-65535) + // The parser already validates this as u16, so this should always pass + // But we check anyway for safety + ports.iter().all(|p| p.number <= 65535) + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_valid_port() { + let result = lint_dockerfile("FROM ubuntu:20.04\nEXPOSE 8080"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3011")); + } + + #[test] + fn test_valid_multiple_ports() { + let result = lint_dockerfile("FROM ubuntu:20.04\nEXPOSE 80 443 8080"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3011")); + } + + #[test] + fn test_max_valid_port() { + let result = lint_dockerfile("FROM ubuntu:20.04\nEXPOSE 65535"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3011")); + } + + #[test] + fn test_min_valid_port() { + let result = lint_dockerfile("FROM ubuntu:20.04\nEXPOSE 0"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3011")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3012.rs b/src/analyzer/hadolint/rules/dl3012.rs new file mode 100644 index 00000000..656b32b2 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3012.rs @@ -0,0 +1,78 @@ +//! DL3012: Multiple HEALTHCHECK instructions +//! +//! Only one HEALTHCHECK instruction is allowed per stage. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> CustomRule) + Send + Sync> { + custom_rule( + "DL3012", + Severity::Error, + "Multiple `HEALTHCHECK` instructions.", + |state, line, instr, _shell| { + match instr { + Instruction::From(_) => { + // Reset healthcheck count for new stage + state.data.set_int("healthcheck_count", 0); + } + Instruction::Healthcheck(_) => { + let count = state.data.get_int("healthcheck_count"); + if count > 0 { + state.add_failure( + "DL3012", + Severity::Error, + "Multiple `HEALTHCHECK` instructions.", + line, + ); + } + state.data.set_int("healthcheck_count", count + 1); + } + _ => {} + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_single_healthcheck() { + let result = lint_dockerfile( + "FROM ubuntu:20.04\nHEALTHCHECK CMD curl -f http://localhost/ || exit 1" + ); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3012")); + } + + #[test] + fn test_multiple_healthchecks() { + let result = lint_dockerfile( + "FROM ubuntu:20.04\nHEALTHCHECK CMD curl http://localhost/\nHEALTHCHECK CMD wget http://localhost/" + ); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3012")); + } + + #[test] + fn test_healthcheck_different_stages() { + let result = lint_dockerfile( + "FROM ubuntu:20.04 AS builder\nHEALTHCHECK CMD curl http://localhost/\nFROM ubuntu:20.04\nHEALTHCHECK CMD wget http://localhost/" + ); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3012")); + } + + #[test] + fn test_no_healthcheck() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN echo hello"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3012")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3013.rs b/src/analyzer/hadolint/rules/dl3013.rs new file mode 100644 index 00000000..dcf55006 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3013.rs @@ -0,0 +1,134 @@ +//! DL3013: Pin versions in pip install +//! +//! Package versions should be pinned in pip install to ensure +//! reproducible builds. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3013", + Severity::Warning, + "Pin versions in pip. Instead of `pip install ` use `pip install ==` or `pip install --requirement `", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + // Get pip install packages + let packages = pip_packages(shell); + // Check if using requirements file + let uses_requirements = uses_requirements_file(shell); + // All packages should have versions pinned or use requirements + uses_requirements || packages.iter().all(|pkg| is_pip_version_pinned(pkg)) + } else { + true + } + } + _ => true, + } + }, + ) +} + +/// Extract packages from pip install commands. +fn pip_packages(shell: &ParsedShell) -> Vec { + let mut packages = Vec::new(); + + for cmd in &shell.commands { + if cmd.is_pip_install() { + // Get arguments that aren't flags and aren't pip-related commands + let skip_args = ["install", "pip", "-m"]; + let args: Vec<&str> = cmd + .args_no_flags() + .into_iter() + .filter(|a| !skip_args.contains(a)) + .collect(); + + packages.extend(args.into_iter().map(|s| s.to_string())); + } + } + + packages +} + +/// Check if pip uses a requirements file. +fn uses_requirements_file(shell: &ParsedShell) -> bool { + shell.any_command(|cmd| { + cmd.is_pip_install() && (cmd.has_any_flag(&["r", "requirement"]) || cmd.has_flag("constraint")) + }) +} + +/// Check if a pip package has a version pinned. +fn is_pip_version_pinned(package: &str) -> bool { + // Skip if it starts with - (it's a flag) + if package.starts_with('-') { + return true; + } + + // Skip if it looks like a URL or path + if package.contains("://") || package.starts_with('/') || package.starts_with('.') { + return true; + } + + // Version pinned: package==version or package>=version, etc. + package.contains("==") + || package.contains(">=") + || package.contains("<=") + || package.contains("!=") + || package.contains("~=") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::parser::instruction::RunArgs; + use crate::analyzer::hadolint::rules::{Rule, RuleState}; + + #[test] + fn test_pinned_version() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("pip install requests==2.28.0")); + let shell = ParsedShell::parse("pip install requests==2.28.0"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_unpinned_version() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("pip install requests")); + let shell = ParsedShell::parse("pip install requests"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL3013"); + } + + #[test] + fn test_requirements_file() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("pip install -r requirements.txt")); + let shell = ParsedShell::parse("pip install -r requirements.txt"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_min_version() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("pip install requests>=2.28.0")); + let shell = ParsedShell::parse("pip install requests>=2.28.0"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert!(state.failures.is_empty()); + } +} diff --git a/src/analyzer/hadolint/rules/dl3014.rs b/src/analyzer/hadolint/rules/dl3014.rs new file mode 100644 index 00000000..4c294840 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3014.rs @@ -0,0 +1,78 @@ +//! DL3014: Use the -y switch to avoid manual input +//! +//! apt-get install should use -y to avoid prompts during build. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3014", + Severity::Warning, + "Use the `-y` switch to avoid manual input `apt-get -y install `.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + // Check all apt-get install commands + !shell.any_command(|cmd| { + if cmd.name == "apt-get" && cmd.has_any_arg(&["install"]) { + // Must have -y, --yes, or --assume-yes + !cmd.has_any_flag(&["y", "yes", "assume-yes"]) + } else { + false + } + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_apt_get_without_y() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get install nginx"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3014")); + } + + #[test] + fn test_apt_get_with_y() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get install -y nginx"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3014")); + } + + #[test] + fn test_apt_get_with_yes() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get install --yes nginx"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3014")); + } + + #[test] + fn test_apt_get_with_assume_yes() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get install --assume-yes nginx"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3014")); + } + + #[test] + fn test_apt_get_update_no_y() { + // apt-get update doesn't need -y + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get update"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3014")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3015.rs b/src/analyzer/hadolint/rules/dl3015.rs new file mode 100644 index 00000000..487ba134 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3015.rs @@ -0,0 +1,66 @@ +//! DL3015: Avoid additional packages by specifying --no-install-recommends +//! +//! apt-get install should use --no-install-recommends to avoid +//! installing unnecessary packages. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3015", + Severity::Info, + "Avoid additional packages by specifying `--no-install-recommends`.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + // Check all apt-get install commands + !shell.any_command(|cmd| { + if cmd.name == "apt-get" && cmd.has_any_arg(&["install"]) { + // Must have --no-install-recommends + !cmd.has_any_flag(&["no-install-recommends"]) + } else { + false + } + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_apt_get_without_no_install_recommends() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get install -y nginx"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3015")); + } + + #[test] + fn test_apt_get_with_no_install_recommends() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get install -y --no-install-recommends nginx"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3015")); + } + + #[test] + fn test_apt_get_update_no_flag_needed() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get update"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3015")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3016.rs b/src/analyzer/hadolint/rules/dl3016.rs new file mode 100644 index 00000000..88a27973 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3016.rs @@ -0,0 +1,140 @@ +//! DL3016: Pin versions in npm install +//! +//! npm packages should be pinned to specific versions. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3016", + Severity::Warning, + "Pin versions in npm. Instead of `npm install ` use `npm install @`.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| { + if cmd.name == "npm" && cmd.has_any_arg(&["install", "i"]) { + // Get packages (args after install, excluding flags) + let packages = get_npm_packages(cmd); + // Check if any package is unpinned + packages.iter().any(|pkg| !is_pinned_npm_package(pkg)) + } else { + false + } + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +/// Extract package names from npm install command +fn get_npm_packages(cmd: &crate::analyzer::hadolint::shell::Command) -> Vec<&str> { + let mut packages = Vec::new(); + let mut found_install = false; + + for arg in &cmd.arguments { + if arg == "install" || arg == "i" { + found_install = true; + continue; + } + if found_install && !arg.starts_with('-') { + packages.push(arg.as_str()); + } + } + + packages +} + +/// Check if npm package is pinned +fn is_pinned_npm_package(pkg: &str) -> bool { + // Skip scoped packages check - just check if version is present + // Pinned formats: package@version, package@^version, package@~version + // Also valid: local paths, git URLs, etc. + + // Skip flags + if pkg.starts_with('-') { + return true; + } + + // Local paths are fine + if pkg.starts_with('.') || pkg.starts_with('/') || pkg.starts_with("file:") { + return true; + } + + // Git URLs are fine + if pkg.starts_with("git") || pkg.contains("github.com") || pkg.contains("gitlab.com") { + return true; + } + + // Check for @ version specifier (but not scoped package @org/name) + if pkg.contains('@') { + let parts: Vec<&str> = pkg.split('@').collect(); + // Scoped package: @org/name or @org/name@version + if pkg.starts_with('@') { + // @org/name@version - has 3 parts + parts.len() >= 3 + } else { + // name@version - has 2 parts + parts.len() >= 2 && !parts[1].is_empty() + } + } else { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_npm_install_unpinned() { + let result = lint_dockerfile("FROM node:18\nRUN npm install express"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3016")); + } + + #[test] + fn test_npm_install_pinned() { + let result = lint_dockerfile("FROM node:18\nRUN npm install express@4.18.2"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3016")); + } + + #[test] + fn test_npm_install_pinned_caret() { + let result = lint_dockerfile("FROM node:18\nRUN npm install express@^4.18.0"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3016")); + } + + #[test] + fn test_npm_ci() { + // npm ci uses package-lock.json, so no packages listed + let result = lint_dockerfile("FROM node:18\nRUN npm ci"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3016")); + } + + #[test] + fn test_npm_install_global_unpinned() { + let result = lint_dockerfile("FROM node:18\nRUN npm install -g typescript"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3016")); + } + + #[test] + fn test_npm_install_global_pinned() { + let result = lint_dockerfile("FROM node:18\nRUN npm install -g typescript@5.0.0"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3016")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3017.rs b/src/analyzer/hadolint/rules/dl3017.rs new file mode 100644 index 00000000..d9d4900b --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3017.rs @@ -0,0 +1,60 @@ +//! DL3017: Do not use apk upgrade +//! +//! Using apk upgrade in a Dockerfile is not recommended +//! as it can lead to unpredictable builds. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3017", + Severity::Warning, + "Do not use `apk upgrade`.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| { + cmd.name == "apk" && cmd.has_any_arg(&["upgrade"]) + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_apk_upgrade() { + let result = lint_dockerfile("FROM alpine:3.18\nRUN apk upgrade"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3017")); + } + + #[test] + fn test_apk_update() { + let result = lint_dockerfile("FROM alpine:3.18\nRUN apk update"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3017")); + } + + #[test] + fn test_apk_add() { + let result = lint_dockerfile("FROM alpine:3.18\nRUN apk add --no-cache curl=8.0.0"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3017")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3018.rs b/src/analyzer/hadolint/rules/dl3018.rs new file mode 100644 index 00000000..c2aaab16 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3018.rs @@ -0,0 +1,112 @@ +//! DL3018: Pin versions in apk add +//! +//! Alpine packages should be pinned to specific versions. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3018", + Severity::Warning, + "Pin versions in apk add. Instead of `apk add ` use `apk add =`.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| { + if cmd.name == "apk" && cmd.has_any_arg(&["add"]) { + // Get packages (args after add, excluding flags) + let packages = get_apk_packages(cmd); + // Check if any package is unpinned + packages.iter().any(|pkg| !is_pinned_apk_package(pkg)) + } else { + false + } + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +/// Extract package names from apk add command +fn get_apk_packages(cmd: &crate::analyzer::hadolint::shell::Command) -> Vec<&str> { + let mut packages = Vec::new(); + let mut found_add = false; + + for arg in &cmd.arguments { + if arg == "add" { + found_add = true; + continue; + } + if found_add && !arg.starts_with('-') { + packages.push(arg.as_str()); + } + } + + packages +} + +/// Check if apk package is pinned +fn is_pinned_apk_package(pkg: &str) -> bool { + // Skip flags + if pkg.starts_with('-') { + return true; + } + + // Skip virtual packages (start with .) + if pkg.starts_with('.') { + return true; + } + + // Pinned formats: package=version or package~version + pkg.contains('=') || pkg.contains('~') +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_apk_add_unpinned() { + let result = lint_dockerfile("FROM alpine:3.18\nRUN apk add nginx"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3018")); + } + + #[test] + fn test_apk_add_pinned() { + let result = lint_dockerfile("FROM alpine:3.18\nRUN apk add nginx=1.24.0-r0"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3018")); + } + + #[test] + fn test_apk_add_pinned_tilde() { + let result = lint_dockerfile("FROM alpine:3.18\nRUN apk add nginx~1.24"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3018")); + } + + #[test] + fn test_apk_add_no_cache_unpinned() { + let result = lint_dockerfile("FROM alpine:3.18\nRUN apk add --no-cache curl"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3018")); + } + + #[test] + fn test_apk_update() { + let result = lint_dockerfile("FROM alpine:3.18\nRUN apk update"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3018")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3019.rs b/src/analyzer/hadolint/rules/dl3019.rs new file mode 100644 index 00000000..16f54b78 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3019.rs @@ -0,0 +1,64 @@ +//! DL3019: Use --no-cache for apk add +//! +//! Use `apk add --no-cache` to avoid caching the index locally. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3019", + Severity::Info, + "Use the `--no-cache` switch to avoid the need to use `--update` and remove `/var/cache/apk/*`.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| { + if cmd.name == "apk" && cmd.has_any_arg(&["add"]) { + // Must have --no-cache + !cmd.has_any_flag(&["no-cache"]) + } else { + false + } + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_apk_add_without_no_cache() { + let result = lint_dockerfile("FROM alpine:3.18\nRUN apk add nginx=1.24.0"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3019")); + } + + #[test] + fn test_apk_add_with_no_cache() { + let result = lint_dockerfile("FROM alpine:3.18\nRUN apk add --no-cache nginx=1.24.0"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3019")); + } + + #[test] + fn test_apk_update() { + let result = lint_dockerfile("FROM alpine:3.18\nRUN apk update"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3019")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3020.rs b/src/analyzer/hadolint/rules/dl3020.rs new file mode 100644 index 00000000..eb1ada2a --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3020.rs @@ -0,0 +1,78 @@ +//! DL3020: Use COPY instead of ADD for files/dirs +//! +//! ADD has special behaviors (URL download, tar extraction) that make it +//! less predictable. Use COPY for simply copying files. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3020", + Severity::Error, + "Use COPY instead of ADD for files and folders", + |instr, _shell| { + match instr { + Instruction::Add(args, _) => { + // ADD is OK for URLs and archives + args.has_url() || args.has_archive() + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::parser::instruction::{AddArgs, AddFlags}; + use crate::analyzer::hadolint::rules::{Rule, RuleState}; + + #[test] + fn test_add_file() { + let rule = rule(); + let mut state = RuleState::new(); + + let args = AddArgs::new(vec!["app.js".to_string()], "/app/"); + let instr = Instruction::Add(args, AddFlags::default()); + rule.check(&mut state, 1, &instr, None); + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL3020"); + } + + #[test] + fn test_add_url() { + let rule = rule(); + let mut state = RuleState::new(); + + let args = AddArgs::new(vec!["https://example.com/file.tar.gz".to_string()], "/app/"); + let instr = Instruction::Add(args, AddFlags::default()); + rule.check(&mut state, 1, &instr, None); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_add_archive() { + let rule = rule(); + let mut state = RuleState::new(); + + let args = AddArgs::new(vec!["app.tar.gz".to_string()], "/app/"); + let instr = Instruction::Add(args, AddFlags::default()); + rule.check(&mut state, 1, &instr, None); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_copy_ok() { + let rule = rule(); + let mut state = RuleState::new(); + + // COPY is always OK + let instr = Instruction::Workdir("/app".to_string()); // Different instruction + rule.check(&mut state, 1, &instr, None); + assert!(state.failures.is_empty()); + } +} diff --git a/src/analyzer/hadolint/rules/dl3021.rs b/src/analyzer/hadolint/rules/dl3021.rs new file mode 100644 index 00000000..d77e2421 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3021.rs @@ -0,0 +1,86 @@ +//! DL3021: Use COPY instead of ADD for non-URL archives +//! +//! COPY is preferred over ADD unless you need ADD's special features +//! (URL download or auto-extraction from remote archives). + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3021", + Severity::Error, + "Use `COPY` instead of `ADD` for copying non-archive files.", + |instr, _shell| { + match instr { + Instruction::Add(args, _) => { + // ADD is acceptable if: + // 1. Source is a URL (ADD auto-downloads) + // 2. Source is a local tar archive (ADD auto-extracts) + args.sources.iter().all(|src| { + is_url(src) || is_archive(src) + }) + } + _ => true, + } + }, + ) +} + +/// Check if source is a URL +fn is_url(src: &str) -> bool { + src.starts_with("http://") || src.starts_with("https://") || src.starts_with("ftp://") +} + +/// Check if source is an archive that ADD will extract +fn is_archive(src: &str) -> bool { + // Skip variables + if src.starts_with('$') { + return true; + } + + let archive_extensions = [ + ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz", + ".tar.zst", ".tar.lz", ".tar.lzma", ".gz", ".bz2", ".xz" + ]; + + let lower = src.to_lowercase(); + archive_extensions.iter().any(|ext| lower.ends_with(ext)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_add_regular_file() { + let result = lint_dockerfile("FROM ubuntu:20.04\nADD config.json /etc/app/"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3021")); + } + + #[test] + fn test_add_url() { + let result = lint_dockerfile("FROM ubuntu:20.04\nADD https://example.com/file.tar.gz /tmp/"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3021")); + } + + #[test] + fn test_add_tar_archive() { + let result = lint_dockerfile("FROM ubuntu:20.04\nADD app.tar.gz /app/"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3021")); + } + + #[test] + fn test_add_directory() { + let result = lint_dockerfile("FROM ubuntu:20.04\nADD src/ /app/"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3021")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3022.rs b/src/analyzer/hadolint/rules/dl3022.rs new file mode 100644 index 00000000..6361643d --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3022.rs @@ -0,0 +1,106 @@ +//! DL3022: COPY --from should reference a previously defined FROM alias +//! +//! When using multi-stage builds, COPY --from should reference a stage +//! that was previously defined. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> CustomRule) + Send + Sync> { + custom_rule( + "DL3022", + Severity::Warning, + "`COPY --from` should reference a previously defined `FROM` alias.", + |state, line, instr, _shell| { + match instr { + Instruction::From(base) => { + // Track stage aliases + if let Some(alias) = &base.alias { + state.data.insert_to_set("stage_aliases", alias.as_str()); + } + // Track stage index + let stage_count = state.data.get_int("stage_count"); + state.data.set_int("stage_count", stage_count + 1); + } + Instruction::Copy(_, flags) => { + if let Some(from) = &flags.from { + // Check if it's a stage reference + // It's valid if: + // 1. It's a known alias + // 2. It's a numeric index less than current stage count + // 3. It's an external image reference + + let is_known_alias = state.data.set_contains("stage_aliases", from); + let is_numeric_index = from.parse::().ok() + .map(|n| n < state.data.get_int("stage_count")) + .unwrap_or(false); + + // If it looks like an image name (contains / or :), allow it + let is_external_image = from.contains('/') || from.contains(':'); + + if !is_known_alias && !is_numeric_index && !is_external_image { + state.add_failure( + "DL3022", + Severity::Warning, + format!("`COPY --from={}` references an undefined stage.", from), + line, + ); + } + } + } + _ => {} + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_copy_from_valid_alias() { + let result = lint_dockerfile( + "FROM node:18 AS builder\nRUN npm ci\nFROM node:18-alpine\nCOPY --from=builder /app /app" + ); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3022")); + } + + #[test] + fn test_copy_from_invalid_alias() { + let result = lint_dockerfile( + "FROM node:18\nFROM node:18-alpine\nCOPY --from=nonexistent /app /app" + ); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3022")); + } + + #[test] + fn test_copy_from_numeric_index() { + let result = lint_dockerfile( + "FROM node:18\nRUN npm ci\nFROM node:18-alpine\nCOPY --from=0 /app /app" + ); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3022")); + } + + #[test] + fn test_copy_from_external_image() { + let result = lint_dockerfile( + "FROM node:18\nCOPY --from=nginx:latest /etc/nginx/nginx.conf /etc/nginx/" + ); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3022")); + } + + #[test] + fn test_copy_without_from() { + let result = lint_dockerfile("FROM node:18\nCOPY package.json /app/"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3022")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3023.rs b/src/analyzer/hadolint/rules/dl3023.rs new file mode 100644 index 00000000..57fd16d0 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3023.rs @@ -0,0 +1,95 @@ +//! DL3023: COPY --from cannot reference its own FROM alias +//! +//! A COPY instruction cannot reference the current stage as the source. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> CustomRule) + Send + Sync> { + custom_rule( + "DL3023", + Severity::Error, + "`COPY --from` cannot reference its own `FROM` alias.", + |state, line, instr, _shell| { + match instr { + Instruction::From(base) => { + // Track current stage alias + if let Some(alias) = &base.alias { + state.data.set_string("current_stage", alias.as_str()); + } else { + state.data.strings.remove("current_stage"); + } + // Track current stage index + let stage_count = state.data.get_int("stage_count"); + state.data.set_int("current_stage_index", stage_count); + state.data.set_int("stage_count", stage_count + 1); + } + Instruction::Copy(_, flags) => { + if let Some(from) = &flags.from { + // Check if referencing current stage + let is_current_alias = state.data.get_string("current_stage") + .map(|s| s == from) + .unwrap_or(false); + + let is_current_index = from.parse::().ok() + .map(|n| n == state.data.get_int("current_stage_index")) + .unwrap_or(false); + + if is_current_alias || is_current_index { + state.add_failure( + "DL3023", + Severity::Error, + "`COPY --from` cannot reference its own `FROM` alias.", + line, + ); + } + } + } + _ => {} + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_copy_from_same_stage() { + let result = lint_dockerfile( + "FROM node:18 AS builder\nCOPY --from=builder /app /app" + ); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3023")); + } + + #[test] + fn test_copy_from_same_index() { + let result = lint_dockerfile( + "FROM node:18\nCOPY --from=0 /app /app" + ); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3023")); + } + + #[test] + fn test_copy_from_different_stage() { + let result = lint_dockerfile( + "FROM node:18 AS builder\nRUN npm ci\nFROM node:18-alpine\nCOPY --from=builder /app /app" + ); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3023")); + } + + #[test] + fn test_copy_without_from() { + let result = lint_dockerfile("FROM node:18 AS builder\nCOPY package.json /app/"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3023")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3024.rs b/src/analyzer/hadolint/rules/dl3024.rs new file mode 100644 index 00000000..785e7e90 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3024.rs @@ -0,0 +1,74 @@ +//! DL3024: FROM aliases must be unique +//! +//! Each FROM instruction should have a unique alias. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> CustomRule) + Send + Sync> { + custom_rule( + "DL3024", + Severity::Error, + "`FROM` aliases (stage names) must be unique.", + |state, line, instr, _shell| { + if let Instruction::From(base) = instr { + if let Some(alias) = &base.alias { + let alias_str = alias.as_str(); + if state.data.set_contains("seen_aliases", alias_str) { + state.add_failure( + "DL3024", + Severity::Error, + format!("Duplicate `FROM` alias `{}`.", alias_str), + line, + ); + } else { + state.data.insert_to_set("seen_aliases", alias_str); + } + } + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_duplicate_alias() { + let result = lint_dockerfile( + "FROM node:18 AS builder\nRUN npm ci\nFROM node:18-alpine AS builder\nRUN echo done" + ); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3024")); + } + + #[test] + fn test_unique_aliases() { + let result = lint_dockerfile( + "FROM node:18 AS builder\nRUN npm ci\nFROM node:18-alpine AS runner\nRUN echo done" + ); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3024")); + } + + #[test] + fn test_no_aliases() { + let result = lint_dockerfile( + "FROM node:18\nRUN npm ci\nFROM node:18-alpine\nRUN echo done" + ); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3024")); + } + + #[test] + fn test_single_stage() { + let result = lint_dockerfile("FROM node:18 AS builder\nRUN npm ci"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3024")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3025.rs b/src/analyzer/hadolint/rules/dl3025.rs new file mode 100644 index 00000000..05ed59f1 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3025.rs @@ -0,0 +1,77 @@ +//! DL3025: Use arguments JSON notation for CMD and ENTRYPOINT arguments +//! +//! Using exec form (JSON notation) for CMD and ENTRYPOINT ensures proper +//! signal handling and avoids shell processing issues. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3025", + Severity::Warning, + "Use arguments JSON notation for CMD and ENTRYPOINT arguments", + |instr, _shell| { + match instr { + Instruction::Cmd(args) | Instruction::Entrypoint(args) => { + args.is_exec_form() + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::parser::instruction::Arguments; + use crate::analyzer::hadolint::rules::{Rule, RuleState}; + + #[test] + fn test_exec_form() { + let rule = rule(); + let mut state = RuleState::new(); + + let args = Arguments::List(vec!["node".to_string(), "app.js".to_string()]); + let instr = Instruction::Cmd(args); + rule.check(&mut state, 1, &instr, None); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_shell_form() { + let rule = rule(); + let mut state = RuleState::new(); + + let args = Arguments::Text("node app.js".to_string()); + let instr = Instruction::Cmd(args); + rule.check(&mut state, 1, &instr, None); + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL3025"); + } + + #[test] + fn test_entrypoint_exec() { + let rule = rule(); + let mut state = RuleState::new(); + + let args = Arguments::List(vec!["./entrypoint.sh".to_string()]); + let instr = Instruction::Entrypoint(args); + rule.check(&mut state, 1, &instr, None); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_entrypoint_shell() { + let rule = rule(); + let mut state = RuleState::new(); + + let args = Arguments::Text("./entrypoint.sh".to_string()); + let instr = Instruction::Entrypoint(args); + rule.check(&mut state, 1, &instr, None); + assert_eq!(state.failures.len(), 1); + } +} diff --git a/src/analyzer/hadolint/rules/dl3026.rs b/src/analyzer/hadolint/rules/dl3026.rs new file mode 100644 index 00000000..063f6878 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3026.rs @@ -0,0 +1,53 @@ +//! DL3026: Use only an allowed registry in the FROM image +//! +//! Restricts base images to trusted registries configured in the config file. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3026", + Severity::Error, + "Use only an allowed registry in the FROM image.", + |instr, _shell| { + // This rule requires configuration to be useful + // By default, we allow all registries + // The actual check is done in lint.rs with config.allowed_registries + match instr { + Instruction::From(_) => { + // Always pass by default - config-dependent rule + true + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_docker_hub_default() { + // By default, all registries are allowed + let result = lint_dockerfile("FROM ubuntu:20.04"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3026")); + } + + #[test] + fn test_custom_registry_default() { + // By default, all registries are allowed + let result = lint_dockerfile("FROM gcr.io/my-project/my-image:latest"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3026")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3027.rs b/src/analyzer/hadolint/rules/dl3027.rs new file mode 100644 index 00000000..f8426200 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3027.rs @@ -0,0 +1,59 @@ +//! DL3027: Do not use apt as it is meant for interactive use +//! +//! apt is designed for interactive use. apt-get is more stable for scripts +//! and Dockerfiles. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3027", + Severity::Warning, + "Do not use apt as it is meant to be an end-user tool, use apt-get or apt-cache instead", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| cmd.name == "apt") + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::parser::instruction::RunArgs; + use crate::analyzer::hadolint::rules::{Rule, RuleState}; + + #[test] + fn test_apt_get() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("apt-get update")); + let shell = ParsedShell::parse("apt-get update"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_apt() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("apt update")); + let shell = ParsedShell::parse("apt update"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL3027"); + } +} diff --git a/src/analyzer/hadolint/rules/dl3028.rs b/src/analyzer/hadolint/rules/dl3028.rs new file mode 100644 index 00000000..c4980903 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3028.rs @@ -0,0 +1,104 @@ +//! DL3028: Pin versions in gem install +//! +//! Ruby gems should be pinned to specific versions. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3028", + Severity::Warning, + "Pin versions in gem install. Instead of `gem install ` use `gem install :`.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| { + if cmd.name == "gem" && cmd.has_any_arg(&["install"]) { + // Get gems (args after install, excluding flags) + let gems = get_gem_packages(cmd); + // Check if any gem is unpinned + gems.iter().any(|gem| !is_pinned_gem(gem)) + } else { + false + } + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +/// Extract gem names from gem install command +fn get_gem_packages(cmd: &crate::analyzer::hadolint::shell::Command) -> Vec<&str> { + let mut gems = Vec::new(); + let mut found_install = false; + + for arg in &cmd.arguments { + if arg == "install" { + found_install = true; + continue; + } + if found_install && !arg.starts_with('-') { + gems.push(arg.as_str()); + } + } + + gems +} + +/// Check if gem is pinned +fn is_pinned_gem(gem: &str) -> bool { + // Skip flags + if gem.starts_with('-') { + return true; + } + + // Check for version specifier + // gem install rails:7.0.0 + // gem install rails -v 7.0.0 (handled separately via flag check) + gem.contains(':') +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_gem_install_unpinned() { + let result = lint_dockerfile("FROM ruby:3.2\nRUN gem install rails"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3028")); + } + + #[test] + fn test_gem_install_pinned() { + let result = lint_dockerfile("FROM ruby:3.2\nRUN gem install rails:7.0.0"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3028")); + } + + #[test] + fn test_gem_install_multiple_unpinned() { + let result = lint_dockerfile("FROM ruby:3.2\nRUN gem install bundler rake"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3028")); + } + + #[test] + fn test_bundle_install() { + // bundle install uses Gemfile.lock, not relevant + let result = lint_dockerfile("FROM ruby:3.2\nRUN bundle install"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3028")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3029.rs b/src/analyzer/hadolint/rules/dl3029.rs new file mode 100644 index 00000000..bc5e9bbd --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3029.rs @@ -0,0 +1,55 @@ +//! DL3029: Use --platform flag with FROM for cross-architecture builds +//! +//! When building for multiple architectures, use --platform to be explicit. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3029", + Severity::Warning, + "Do not use --platform flag with FROM unless you're building cross-platform images.", + |instr, _shell| { + // This rule is informational - it's the inverse of what you might expect + // It warns when --platform IS used, suggesting it may not be necessary + // unless specifically building cross-platform images + + // For now, we'll make this a no-op and always pass + // The original hadolint rule is more nuanced about when to warn + match instr { + Instruction::From(_base) => { + // Always pass - this is an informational rule about explicit platform use + true + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_from_without_platform() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN echo hello"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3029")); + } + + #[test] + fn test_from_with_platform() { + let result = lint_dockerfile("FROM --platform=linux/amd64 ubuntu:20.04\nRUN echo hello"); + // This is informational, not an error + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3029")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3030.rs b/src/analyzer/hadolint/rules/dl3030.rs new file mode 100644 index 00000000..7f254d13 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3030.rs @@ -0,0 +1,63 @@ +//! DL3030: Use the --yes switch to avoid prompts for zypper install +//! +//! zypper install should use --non-interactive or -n to avoid prompts. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3030", + Severity::Warning, + "Use the `--non-interactive` switch to avoid prompts during `zypper` install.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| { + if cmd.name == "zypper" && cmd.has_any_arg(&["install", "in"]) { + !cmd.has_any_flag(&["n", "non-interactive", "no-confirm", "y"]) + } else { + false + } + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_zypper_without_flag() { + let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper install nginx"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3030")); + } + + #[test] + fn test_zypper_with_n() { + let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper -n install nginx"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3030")); + } + + #[test] + fn test_zypper_with_non_interactive() { + let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper --non-interactive install nginx"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3030")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3031.rs b/src/analyzer/hadolint/rules/dl3031.rs new file mode 100644 index 00000000..9b5696b4 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3031.rs @@ -0,0 +1,53 @@ +//! DL3031: Do not use yum update +//! +//! Using yum update in a Dockerfile is not recommended. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3031", + Severity::Warning, + "Do not use `yum update`.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| { + cmd.name == "yum" && cmd.has_any_arg(&["update", "upgrade"]) + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_yum_update() { + let result = lint_dockerfile("FROM centos:7\nRUN yum update -y"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3031")); + } + + #[test] + fn test_yum_install() { + let result = lint_dockerfile("FROM centos:7\nRUN yum install -y nginx-1.20.0"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3031")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3032.rs b/src/analyzer/hadolint/rules/dl3032.rs new file mode 100644 index 00000000..ff914060 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3032.rs @@ -0,0 +1,80 @@ +//! DL3032: yum clean all after yum install +//! +//! Clean up yum cache after installing packages to reduce image size. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3032", + Severity::Warning, + "`yum clean all` missing after yum command.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + // Check if yum install is used + let has_yum_install = shell.any_command(|cmd| { + cmd.name == "yum" && cmd.has_any_arg(&["install", "groupinstall", "localinstall"]) + }); + + if !has_yum_install { + return true; + } + + // Check if cleanup is done + let has_cleanup = shell.any_command(|cmd| { + (cmd.name == "yum" && cmd.has_any_arg(&["clean"])) + || (cmd.name == "rm" && cmd.arguments.iter().any(|arg| { + arg.contains("/var/cache/yum") + })) + }); + + has_cleanup + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_yum_install_without_clean() { + let result = lint_dockerfile("FROM centos:7\nRUN yum install -y nginx"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3032")); + } + + #[test] + fn test_yum_install_with_clean() { + let result = lint_dockerfile("FROM centos:7\nRUN yum install -y nginx && yum clean all"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3032")); + } + + #[test] + fn test_yum_install_with_rm_cache() { + let result = lint_dockerfile("FROM centos:7\nRUN yum install -y nginx && rm -rf /var/cache/yum"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3032")); + } + + #[test] + fn test_no_yum_install() { + let result = lint_dockerfile("FROM centos:7\nRUN yum update"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3032")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3033.rs b/src/analyzer/hadolint/rules/dl3033.rs new file mode 100644 index 00000000..f19f8a62 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3033.rs @@ -0,0 +1,114 @@ +//! DL3033: Pin versions in yum install +//! +//! Yum packages should be pinned to specific versions. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3033", + Severity::Warning, + "Specify version with `yum install -y -`.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| { + if cmd.name == "yum" && cmd.has_any_arg(&["install"]) { + // Get packages (args after install, excluding flags) + let packages = get_yum_packages(cmd); + // Check if any package is unpinned + packages.iter().any(|pkg| !is_pinned_yum_package(pkg)) + } else { + false + } + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +/// Extract package names from yum install command +fn get_yum_packages(cmd: &crate::analyzer::hadolint::shell::Command) -> Vec<&str> { + let mut packages = Vec::new(); + let mut found_install = false; + + for arg in &cmd.arguments { + if arg == "install" { + found_install = true; + continue; + } + if found_install && !arg.starts_with('-') { + packages.push(arg.as_str()); + } + } + + packages +} + +/// Check if yum package is pinned +fn is_pinned_yum_package(pkg: &str) -> bool { + // Skip flags + if pkg.starts_with('-') { + return true; + } + + // Skip local RPM files + if pkg.ends_with(".rpm") { + return true; + } + + // Yum version formats: package-version or package-version-release + // Simple heuristic: contains a hyphen followed by a digit + let parts: Vec<&str> = pkg.rsplitn(2, '-').collect(); + if parts.len() >= 2 { + let potential_version = parts[0]; + // Version typically starts with a digit + potential_version.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) + } else { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_yum_install_unpinned() { + let result = lint_dockerfile("FROM centos:7\nRUN yum install -y nginx"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3033")); + } + + #[test] + fn test_yum_install_pinned() { + let result = lint_dockerfile("FROM centos:7\nRUN yum install -y nginx-1.20.1"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3033")); + } + + #[test] + fn test_yum_install_local_rpm() { + let result = lint_dockerfile("FROM centos:7\nRUN yum install -y /tmp/package.rpm"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3033")); + } + + #[test] + fn test_yum_update() { + let result = lint_dockerfile("FROM centos:7\nRUN yum update -y"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3033")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3034.rs b/src/analyzer/hadolint/rules/dl3034.rs new file mode 100644 index 00000000..815f4e8e --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3034.rs @@ -0,0 +1,57 @@ +//! DL3034: Non-interactive switch missing from zypper command +//! +//! zypper commands should use -n or --non-interactive. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3034", + Severity::Warning, + "Non-interactive switch missing from `zypper` command: `-n`.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| { + if cmd.name == "zypper" { + !cmd.has_any_flag(&["n", "non-interactive"]) + } else { + false + } + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_zypper_without_n() { + let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper refresh"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3034")); + } + + #[test] + fn test_zypper_with_n() { + let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper -n refresh"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3034")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3035.rs b/src/analyzer/hadolint/rules/dl3035.rs new file mode 100644 index 00000000..ed562ac8 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3035.rs @@ -0,0 +1,53 @@ +//! DL3035: Do not use zypper update +//! +//! Using zypper update in a Dockerfile is not recommended. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3035", + Severity::Warning, + "Do not use `zypper update`.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| { + cmd.name == "zypper" && cmd.has_any_arg(&["update", "up"]) + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_zypper_update() { + let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper -n update"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3035")); + } + + #[test] + fn test_zypper_install() { + let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper -n install nginx"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3035")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3036.rs b/src/analyzer/hadolint/rules/dl3036.rs new file mode 100644 index 00000000..9411fdc5 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3036.rs @@ -0,0 +1,64 @@ +//! DL3036: zypper clean missing after zypper install +//! +//! Clean up zypper cache after installing packages. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3036", + Severity::Warning, + "`zypper clean` missing after zypper install.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + let has_install = shell.any_command(|cmd| { + cmd.name == "zypper" && cmd.has_any_arg(&["install", "in"]) + }); + + if !has_install { + return true; + } + + let has_clean = shell.any_command(|cmd| { + (cmd.name == "zypper" && cmd.has_any_arg(&["clean", "cc"])) + || (cmd.name == "rm" && cmd.arguments.iter().any(|a| a.contains("/var/cache/zypp"))) + }); + + has_clean + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_zypper_without_clean() { + let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper -n install nginx"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3036")); + } + + #[test] + fn test_zypper_with_clean() { + let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper -n install nginx && zypper clean"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3036")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3037.rs b/src/analyzer/hadolint/rules/dl3037.rs new file mode 100644 index 00000000..74122cba --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3037.rs @@ -0,0 +1,86 @@ +//! DL3037: Pin versions in zypper install +//! +//! zypper packages should be pinned to specific versions. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3037", + Severity::Warning, + "Specify version with `zypper install =`.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| { + if cmd.name == "zypper" && cmd.has_any_arg(&["install", "in"]) { + let packages = get_zypper_packages(cmd); + packages.iter().any(|pkg| !is_pinned_zypper_package(pkg)) + } else { + false + } + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +fn get_zypper_packages(cmd: &crate::analyzer::hadolint::shell::Command) -> Vec<&str> { + let mut packages = Vec::new(); + let mut found_install = false; + + for arg in &cmd.arguments { + if arg == "install" || arg == "in" { + found_install = true; + continue; + } + if found_install && !arg.starts_with('-') { + packages.push(arg.as_str()); + } + } + + packages +} + +fn is_pinned_zypper_package(pkg: &str) -> bool { + if pkg.starts_with('-') { + return true; + } + if pkg.ends_with(".rpm") { + return true; + } + // zypper uses = or >= for version pinning + pkg.contains('=') || pkg.contains(">=") || pkg.contains("<=") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_zypper_unpinned() { + let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper -n install nginx"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3037")); + } + + #[test] + fn test_zypper_pinned() { + let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper -n install nginx=1.20.0"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3037")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3038.rs b/src/analyzer/hadolint/rules/dl3038.rs new file mode 100644 index 00000000..fc7b9bd6 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3038.rs @@ -0,0 +1,57 @@ +//! DL3038: Use the -y switch to avoid prompts for dnf install +//! +//! dnf install should use -y to avoid prompts. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3038", + Severity::Warning, + "Use the `-y` switch to avoid prompts during `dnf install`.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| { + if cmd.name == "dnf" && cmd.has_any_arg(&["install"]) { + !cmd.has_any_flag(&["y", "yes", "assumeyes"]) + } else { + false + } + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_dnf_without_y() { + let result = lint_dockerfile("FROM fedora:latest\nRUN dnf install nginx"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3038")); + } + + #[test] + fn test_dnf_with_y() { + let result = lint_dockerfile("FROM fedora:latest\nRUN dnf install -y nginx"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3038")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3039.rs b/src/analyzer/hadolint/rules/dl3039.rs new file mode 100644 index 00000000..a1e3223b --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3039.rs @@ -0,0 +1,53 @@ +//! DL3039: Do not use dnf update +//! +//! Using dnf update in a Dockerfile is not recommended. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3039", + Severity::Warning, + "Do not use `dnf update`.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| { + cmd.name == "dnf" && cmd.has_any_arg(&["update", "upgrade"]) + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_dnf_update() { + let result = lint_dockerfile("FROM fedora:latest\nRUN dnf update -y"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3039")); + } + + #[test] + fn test_dnf_install() { + let result = lint_dockerfile("FROM fedora:latest\nRUN dnf install -y nginx"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3039")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3040.rs b/src/analyzer/hadolint/rules/dl3040.rs new file mode 100644 index 00000000..7f03b9a5 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3040.rs @@ -0,0 +1,64 @@ +//! DL3040: dnf clean all missing after dnf install +//! +//! Clean up dnf cache after installing packages. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3040", + Severity::Warning, + "`dnf clean all` missing after dnf install.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + let has_install = shell.any_command(|cmd| { + cmd.name == "dnf" && cmd.has_any_arg(&["install"]) + }); + + if !has_install { + return true; + } + + let has_clean = shell.any_command(|cmd| { + (cmd.name == "dnf" && cmd.has_any_arg(&["clean"])) + || (cmd.name == "rm" && cmd.arguments.iter().any(|a| a.contains("/var/cache/dnf"))) + }); + + has_clean + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_dnf_without_clean() { + let result = lint_dockerfile("FROM fedora:latest\nRUN dnf install -y nginx"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3040")); + } + + #[test] + fn test_dnf_with_clean() { + let result = lint_dockerfile("FROM fedora:latest\nRUN dnf install -y nginx && dnf clean all"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3040")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3041.rs b/src/analyzer/hadolint/rules/dl3041.rs new file mode 100644 index 00000000..26204ec1 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3041.rs @@ -0,0 +1,92 @@ +//! DL3041: Pin versions in dnf install +//! +//! dnf packages should be pinned to specific versions. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3041", + Severity::Warning, + "Specify version with `dnf install -`.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| { + if cmd.name == "dnf" && cmd.has_any_arg(&["install"]) { + let packages = get_dnf_packages(cmd); + packages.iter().any(|pkg| !is_pinned_dnf_package(pkg)) + } else { + false + } + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +fn get_dnf_packages(cmd: &crate::analyzer::hadolint::shell::Command) -> Vec<&str> { + let mut packages = Vec::new(); + let mut found_install = false; + + for arg in &cmd.arguments { + if arg == "install" { + found_install = true; + continue; + } + if found_install && !arg.starts_with('-') { + packages.push(arg.as_str()); + } + } + + packages +} + +fn is_pinned_dnf_package(pkg: &str) -> bool { + if pkg.starts_with('-') { + return true; + } + if pkg.ends_with(".rpm") { + return true; + } + // dnf uses - for version: package-version-release + let parts: Vec<&str> = pkg.rsplitn(2, '-').collect(); + if parts.len() >= 2 { + let potential_version = parts[0]; + potential_version.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) + } else { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_dnf_unpinned() { + let result = lint_dockerfile("FROM fedora:latest\nRUN dnf install -y nginx"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3041")); + } + + #[test] + fn test_dnf_pinned() { + let result = lint_dockerfile("FROM fedora:latest\nRUN dnf install -y nginx-1.20.0"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3041")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3042.rs b/src/analyzer/hadolint/rules/dl3042.rs new file mode 100644 index 00000000..261adac5 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3042.rs @@ -0,0 +1,83 @@ +//! DL3042: Avoid use of cache directory with pip +//! +//! Use --no-cache-dir with pip install to reduce image size. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3042", + Severity::Warning, + "Avoid use of cache directory with pip. Use `pip install --no-cache-dir `.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| { + if shell.is_pip_install(cmd) { + // Must have --no-cache-dir + !cmd.has_any_flag(&["no-cache-dir"]) + } else { + false + } + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_pip_install_without_no_cache() { + let result = lint_dockerfile("FROM python:3.11\nRUN pip install flask"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3042")); + } + + #[test] + fn test_pip_install_with_no_cache() { + let result = lint_dockerfile("FROM python:3.11\nRUN pip install --no-cache-dir flask"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3042")); + } + + #[test] + fn test_pip3_install_without_no_cache() { + let result = lint_dockerfile("FROM python:3.11\nRUN pip3 install flask"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3042")); + } + + #[test] + fn test_pip3_install_with_no_cache() { + let result = lint_dockerfile("FROM python:3.11\nRUN pip3 install --no-cache-dir flask"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3042")); + } + + #[test] + fn test_python_m_pip_without_no_cache() { + let result = lint_dockerfile("FROM python:3.11\nRUN python -m pip install flask"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3042")); + } + + #[test] + fn test_pip_freeze() { + // pip freeze doesn't need --no-cache-dir + let result = lint_dockerfile("FROM python:3.11\nRUN pip freeze > requirements.txt"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3042")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3043.rs b/src/analyzer/hadolint/rules/dl3043.rs new file mode 100644 index 00000000..bdbc9bcc --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3043.rs @@ -0,0 +1,65 @@ +//! DL3043: ONBUILD ONBUILD is not allowed +//! +//! Nested ONBUILD instructions are not allowed. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3043", + Severity::Error, + "`ONBUILD` combined with `ONBUILD` is not allowed.", + |instr, _shell| { + match instr { + Instruction::OnBuild(inner) => { + !matches!(inner.as_ref(), Instruction::OnBuild(_)) + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::rules::{Rule, RuleState}; + use crate::analyzer::hadolint::parser::instruction::{Arguments, RunArgs, RunFlags}; + + #[test] + fn test_nested_onbuild() { + let rule = rule(); + let mut state = RuleState::new(); + + // ONBUILD ONBUILD RUN echo hello + let inner_run = Instruction::Run(RunArgs { + arguments: Arguments::Text("echo hello".to_string()), + flags: RunFlags::default(), + }); + let inner_onbuild = Instruction::OnBuild(Box::new(inner_run)); + let instr = Instruction::OnBuild(Box::new(inner_onbuild)); + + rule.check(&mut state, 1, &instr, None); + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL3043"); + } + + #[test] + fn test_valid_onbuild() { + let rule = rule(); + let mut state = RuleState::new(); + + // ONBUILD RUN echo hello + let inner = Instruction::Run(RunArgs { + arguments: Arguments::Text("echo hello".to_string()), + flags: RunFlags::default(), + }); + let instr = Instruction::OnBuild(Box::new(inner)); + + rule.check(&mut state, 1, &instr, None); + assert!(state.failures.is_empty()); + } +} diff --git a/src/analyzer/hadolint/rules/dl3044.rs b/src/analyzer/hadolint/rules/dl3044.rs new file mode 100644 index 00000000..eebab921 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3044.rs @@ -0,0 +1,71 @@ +//! DL3044: Do not refer to an environment variable within the same ENV statement +//! +//! ENV variable references within the same statement may not work as expected. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3044", + Severity::Error, + "Do not refer to an environment variable within the same `ENV` statement where it is defined.", + |instr, _shell| { + match instr { + Instruction::Env(pairs) => { + // Check if any value references a variable defined earlier in the same statement + // For each pair, only check against variables defined BEFORE it + let mut defined_vars: Vec<&str> = Vec::new(); + + for (key, value) in pairs { + for var in &defined_vars { + // Check for $VAR or ${VAR} patterns + if value.contains(&format!("${}", var)) + || value.contains(&format!("${{{}}}", var)) + { + return false; + } + } + // Add this key to defined vars for checking subsequent pairs + defined_vars.push(key.as_str()); + } + true + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_self_reference() { + let result = lint_dockerfile("FROM ubuntu:20.04\nENV PATH=/app:$PATH"); + // Note: PATH is not defined in this statement, so it's OK + // This rule checks for referencing a var defined IN THE SAME statement + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3044")); + } + + #[test] + fn test_same_statement_reference() { + let result = lint_dockerfile("FROM ubuntu:20.04\nENV FOO=bar BAR=$FOO"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3044")); + } + + #[test] + fn test_no_reference() { + let result = lint_dockerfile("FROM ubuntu:20.04\nENV FOO=bar BAR=baz"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3044")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3045.rs b/src/analyzer/hadolint/rules/dl3045.rs new file mode 100644 index 00000000..b65d17e3 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3045.rs @@ -0,0 +1,161 @@ +//! DL3045: COPY to a relative destination without WORKDIR set +//! +//! COPY to a relative path requires WORKDIR to be set to ensure +//! predictable behavior. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> CustomRule) + Send + Sync> { + custom_rule( + "DL3045", + Severity::Warning, + "`COPY` to a relative destination without `WORKDIR` set.", + |state, line, instr, _shell| { + match instr { + Instruction::From(base) => { + // Track current stage + let stage_name = base.alias.as_ref() + .map(|a| a.as_str().to_string()) + .unwrap_or_else(|| base.image.name.clone()); + state.data.set_string("current_stage", &stage_name); + + // Check if parent stage had WORKDIR set + let parent_had_workdir = state.data.set_contains("stages_with_workdir", &base.image.name); + if parent_had_workdir { + state.data.insert_to_set("stages_with_workdir", &stage_name); + } + } + Instruction::Workdir(_) => { + // Mark current stage as having WORKDIR set + let stage = state.data.get_string("current_stage") + .map(|s| s.to_string()) + .unwrap_or_else(|| "__none__".to_string()); + state.data.insert_to_set("stages_with_workdir", &stage); + } + Instruction::Copy(args, _) => { + let dest = &args.dest; + + // Check if current stage has WORKDIR set + let has_workdir = state.data.get_string("current_stage") + .map(|s| state.data.set_contains("stages_with_workdir", s)) + .unwrap_or_else(|| state.data.set_contains("stages_with_workdir", "__none__")); + + // Skip check if WORKDIR is set + if has_workdir { + return; + } + + // Check if destination is absolute + let trimmed = dest.trim_matches(|c| c == '"' || c == '\''); + + // Absolute paths are OK + if trimmed.starts_with('/') { + return; + } + + // Windows absolute paths are OK + if is_windows_absolute(trimmed) { + return; + } + + // Variable references are OK + if trimmed.starts_with('$') { + return; + } + + // Relative path without WORKDIR + state.add_failure( + "DL3045", + Severity::Warning, + "`COPY` to a relative destination without `WORKDIR` set.", + line, + ); + } + _ => {} + } + }, + ) +} + +/// Check if path is a Windows absolute path. +fn is_windows_absolute(path: &str) -> bool { + let chars: Vec = path.chars().collect(); + chars.len() >= 2 && chars[0].is_ascii_alphabetic() && chars[1] == ':' +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::parser::instruction::{BaseImage, CopyArgs, CopyFlags}; + use crate::analyzer::hadolint::rules::Rule; + + #[test] + fn test_absolute_dest() { + let rule = rule(); + let mut state = RuleState::new(); + + let from = Instruction::From(BaseImage::new("ubuntu")); + let copy = Instruction::Copy( + CopyArgs::new(vec!["app.js".to_string()], "/app/"), + CopyFlags::default(), + ); + + rule.check(&mut state, 1, &from, None); + rule.check(&mut state, 2, ©, None); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_relative_dest_without_workdir() { + let rule = rule(); + let mut state = RuleState::new(); + + let from = Instruction::From(BaseImage::new("ubuntu")); + let copy = Instruction::Copy( + CopyArgs::new(vec!["app.js".to_string()], "app/"), + CopyFlags::default(), + ); + + rule.check(&mut state, 1, &from, None); + rule.check(&mut state, 2, ©, None); + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL3045"); + } + + #[test] + fn test_relative_dest_with_workdir() { + let rule = rule(); + let mut state = RuleState::new(); + + let from = Instruction::From(BaseImage::new("ubuntu")); + let workdir = Instruction::Workdir("/app".to_string()); + let copy = Instruction::Copy( + CopyArgs::new(vec!["app.js".to_string()], "."), + CopyFlags::default(), + ); + + rule.check(&mut state, 1, &from, None); + rule.check(&mut state, 2, &workdir, None); + rule.check(&mut state, 3, ©, None); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_variable_dest() { + let rule = rule(); + let mut state = RuleState::new(); + + let from = Instruction::From(BaseImage::new("ubuntu")); + let copy = Instruction::Copy( + CopyArgs::new(vec!["app.js".to_string()], "$APP_DIR"), + CopyFlags::default(), + ); + + rule.check(&mut state, 1, &from, None); + rule.check(&mut state, 2, ©, None); + assert!(state.failures.is_empty()); + } +} diff --git a/src/analyzer/hadolint/rules/dl3046.rs b/src/analyzer/hadolint/rules/dl3046.rs new file mode 100644 index 00000000..4597d88a --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3046.rs @@ -0,0 +1,70 @@ +//! DL3046: useradd without -l flag may result in large layers +//! +//! When adding a user with useradd, use the -l flag to avoid creating +//! large layers due to /var/log/lastlog growing. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3046", + Severity::Warning, + "`useradd` without flag `-l` and target UID not within `/etc/login.defs` may result in excessively large image.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + !shell.any_command(|cmd| { + if cmd.name == "useradd" { + // Check if -l or --no-log-init flag is present + // Also check combined flags like -lm + let has_l_flag = cmd.arguments.iter().any(|a| { + a == "-l" || a == "--no-log-init" || + (a.starts_with('-') && !a.starts_with("--") && a.contains('l')) + }); + !has_l_flag + } else { + false + } + }) + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_useradd_without_l() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN useradd -m myuser"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3046")); + } + + #[test] + fn test_useradd_with_l() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN useradd -l -m myuser"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3046")); + } + + #[test] + fn test_useradd_with_no_log_init() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN useradd --no-log-init -m myuser"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3046")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3047.rs b/src/analyzer/hadolint/rules/dl3047.rs new file mode 100644 index 00000000..5f546d02 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3047.rs @@ -0,0 +1,103 @@ +//! DL3047: wget vs curl consistency +//! +//! Avoid using both wget and curl in the same Dockerfile. +//! Pick one to reduce image size. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> CustomRule) + Send + Sync> { + custom_rule( + "DL3047", + Severity::Info, + "Avoid using both `wget` and `curl` since they serve the same purpose.", + |state, line, instr, shell| { + match instr { + Instruction::From(_) => { + // Reset tracking for new stage + state.data.set_bool("seen_wget", false); + state.data.set_bool("seen_curl", false); + state.data.set_bool("reported_dl3047", false); + } + Instruction::Run(_) => { + if let Some(shell) = shell { + let uses_wget = shell.using_program("wget"); + let uses_curl = shell.using_program("curl"); + + if uses_wget { + state.data.set_bool("seen_wget", true); + } + if uses_curl { + state.data.set_bool("seen_curl", true); + } + + // Report if both are now seen and not already reported + let seen_both = state.data.get_bool("seen_wget") && state.data.get_bool("seen_curl"); + let already_reported = state.data.get_bool("reported_dl3047"); + + if seen_both && !already_reported { + state.add_failure( + "DL3047", + Severity::Info, + "Avoid using both `wget` and `curl` since they serve the same purpose.", + line, + ); + state.data.set_bool("reported_dl3047", true); + } + } + } + _ => {} + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_wget_only() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN wget https://example.com/file"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3047")); + } + + #[test] + fn test_curl_only() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN curl -O https://example.com/file"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3047")); + } + + #[test] + fn test_both_wget_and_curl() { + let result = lint_dockerfile( + "FROM ubuntu:20.04\nRUN wget https://example.com/file1\nRUN curl -O https://example.com/file2" + ); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3047")); + } + + #[test] + fn test_both_in_same_run() { + let result = lint_dockerfile( + "FROM ubuntu:20.04\nRUN wget https://a.com/f && curl -O https://b.com/g" + ); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3047")); + } + + #[test] + fn test_different_stages() { + // Different stages should track separately + let result = lint_dockerfile( + "FROM ubuntu:20.04 AS stage1\nRUN wget https://a.com/f\nFROM ubuntu:20.04 AS stage2\nRUN curl https://b.com/g" + ); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3047")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3048.rs b/src/analyzer/hadolint/rules/dl3048.rs new file mode 100644 index 00000000..44e297e8 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3048.rs @@ -0,0 +1,80 @@ +//! DL3048: Invalid label key +//! +//! Label keys should follow the OCI annotation specification. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3048", + Severity::Style, + "Invalid label key.", + |instr, _shell| { + match instr { + Instruction::Label(pairs) => { + pairs.iter().all(|(key, _)| is_valid_label_key(key)) + } + _ => true, + } + }, + ) +} + +fn is_valid_label_key(key: &str) -> bool { + if key.is_empty() { + return false; + } + + // Label keys must start with a letter or number + let first_char = key.chars().next().unwrap(); + if !first_char.is_ascii_alphanumeric() { + return false; + } + + // Label keys can only contain alphanumeric, -, _, . + key.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_valid_label() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL maintainer=\"test@test.com\""); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3048")); + } + + #[test] + fn test_valid_oci_label() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.title=\"Test\""); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3048")); + } + + #[test] + fn test_invalid_label_special_char() { + // Note: The parser may not accept labels starting with special chars, + // so this test validates the rule itself works with the unit test approach + use crate::analyzer::hadolint::rules::{Rule, RuleState}; + use crate::analyzer::hadolint::parser::instruction::Instruction; + + let rule = rule(); + let mut state = RuleState::new(); + + // Manually test with an invalid key starting with @ + let instr = Instruction::Label(vec![("@invalid".to_string(), "test".to_string())]); + rule.check(&mut state, 1, &instr, None); + + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL3048"); + } +} diff --git a/src/analyzer/hadolint/rules/dl3049.rs b/src/analyzer/hadolint/rules/dl3049.rs new file mode 100644 index 00000000..fa0f19e3 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3049.rs @@ -0,0 +1,47 @@ +//! DL3049: Label `maintainer` is deprecated +//! +//! The maintainer label is deprecated. Use org.opencontainers.image.authors instead. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3049", + Severity::Info, + "Label `maintainer` is deprecated, use `org.opencontainers.image.authors` instead.", + |instr, _shell| { + match instr { + Instruction::Label(pairs) => { + !pairs.iter().any(|(key, _)| key.to_lowercase() == "maintainer") + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_maintainer_label() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL maintainer=\"test@test.com\""); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3049")); + } + + #[test] + fn test_oci_authors_label() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.authors=\"test@test.com\""); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3049")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3050.rs b/src/analyzer/hadolint/rules/dl3050.rs new file mode 100644 index 00000000..64a8df7f --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3050.rs @@ -0,0 +1,68 @@ +//! DL3050: Superfluous label present +//! +//! Some labels are redundant or should use OCI annotation keys. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3050", + Severity::Info, + "Superfluous label present.", + |instr, _shell| { + match instr { + Instruction::Label(pairs) => { + // Check for deprecated/superfluous labels that should use OCI keys + let deprecated_labels = [ + "description", + "version", + "build-date", + "vcs-url", + "vcs-ref", + "vendor", + "name", + "url", + "documentation", + "source", + "licenses", + "title", + "revision", + "created", + ]; + + !pairs.iter().any(|(key, _)| { + let key_lower = key.to_lowercase(); + deprecated_labels.contains(&key_lower.as_str()) + }) + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_deprecated_description() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL description=\"Test image\""); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3050")); + } + + #[test] + fn test_oci_description() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.description=\"Test image\""); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3050")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3051.rs b/src/analyzer/hadolint/rules/dl3051.rs new file mode 100644 index 00000000..45350314 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3051.rs @@ -0,0 +1,124 @@ +//! DL3051: Label `org.opencontainers.image.created` is empty or not a valid date +//! +//! The created label should contain a valid RFC3339 date. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3051", + Severity::Warning, + "Label `org.opencontainers.image.created` is empty or not a valid RFC3339 date.", + |instr, _shell| { + match instr { + Instruction::Label(pairs) => { + for (key, value) in pairs { + if key == "org.opencontainers.image.created" { + if value.is_empty() || !is_valid_rfc3339(value) { + return false; + } + } + } + true + } + _ => true, + } + }, + ) +} + +fn is_valid_rfc3339(date: &str) -> bool { + // Basic RFC3339 validation (YYYY-MM-DDTHH:MM:SSZ or with timezone offset) + // Full format: 2023-01-15T14:30:00Z or 2023-01-15T14:30:00+00:00 + if date.len() < 20 { + return false; + } + + let chars: Vec = date.chars().collect(); + + // Check date part + if chars.len() < 10 { + return false; + } + + // YYYY-MM-DD + if !chars[0..4].iter().all(|c| c.is_ascii_digit()) { return false; } + if chars[4] != '-' { return false; } + if !chars[5..7].iter().all(|c| c.is_ascii_digit()) { return false; } + if chars[7] != '-' { return false; } + if !chars[8..10].iter().all(|c| c.is_ascii_digit()) { return false; } + + // T separator + if chars.get(10) != Some(&'T') && chars.get(10) != Some(&'t') { + return false; + } + + // HH:MM:SS + if chars.len() < 19 { return false; } + if !chars[11..13].iter().all(|c| c.is_ascii_digit()) { return false; } + if chars[13] != ':' { return false; } + if !chars[14..16].iter().all(|c| c.is_ascii_digit()) { return false; } + if chars[16] != ':' { return false; } + if !chars[17..19].iter().all(|c| c.is_ascii_digit()) { return false; } + + // Timezone (Z or +/-HH:MM) + if chars.len() == 20 && chars[19] == 'Z' { + return true; + } + + // Allow fractional seconds before timezone + let tz_start = if chars.get(19) == Some(&'.') { + // Find where fractional seconds end + let mut i = 20; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + i + } else { + 19 + }; + + if chars.len() > tz_start { + let tz_char = chars[tz_start]; + if tz_char == 'Z' || tz_char == 'z' { + return true; + } + if (tz_char == '+' || tz_char == '-') && chars.len() >= tz_start + 6 { + return true; + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_valid_date() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.created=\"2023-01-15T14:30:00Z\""); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3051")); + } + + #[test] + fn test_empty_date() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.created=\"\""); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3051")); + } + + #[test] + fn test_invalid_date() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.created=\"not-a-date\""); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3051")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3052.rs b/src/analyzer/hadolint/rules/dl3052.rs new file mode 100644 index 00000000..8168f152 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3052.rs @@ -0,0 +1,91 @@ +//! DL3052: Label `org.opencontainers.image.licenses` is not a valid SPDX expression +//! +//! The licenses label should contain a valid SPDX license identifier. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3052", + Severity::Warning, + "Label `org.opencontainers.image.licenses` is not a valid SPDX expression.", + |instr, _shell| { + match instr { + Instruction::Label(pairs) => { + for (key, value) in pairs { + if key == "org.opencontainers.image.licenses" { + if value.is_empty() || !is_valid_spdx(value) { + return false; + } + } + } + true + } + _ => true, + } + }, + ) +} + +fn is_valid_spdx(license: &str) -> bool { + // Common SPDX license identifiers + let common_licenses = [ + "MIT", "Apache-2.0", "GPL-2.0", "GPL-2.0-only", "GPL-2.0-or-later", + "GPL-3.0", "GPL-3.0-only", "GPL-3.0-or-later", "BSD-2-Clause", + "BSD-3-Clause", "ISC", "MPL-2.0", "LGPL-2.1", "LGPL-2.1-only", + "LGPL-2.1-or-later", "LGPL-3.0", "LGPL-3.0-only", "LGPL-3.0-or-later", + "AGPL-3.0", "AGPL-3.0-only", "AGPL-3.0-or-later", "Unlicense", + "CC0-1.0", "CC-BY-4.0", "CC-BY-SA-4.0", "WTFPL", "Zlib", "0BSD", + "EPL-1.0", "EPL-2.0", "EUPL-1.2", "PostgreSQL", "OFL-1.1", + "Artistic-2.0", "BSL-1.0", "CDDL-1.0", "CDDL-1.1", "CPL-1.0", + ]; + + // Check for common licenses (case-insensitive) + let license_upper = license.to_uppercase(); + + // Handle compound expressions (AND, OR, WITH) + let parts: Vec<&str> = license_upper + .split(|c| c == '(' || c == ')' || c == ' ') + .filter(|s| !s.is_empty() && *s != "AND" && *s != "OR" && *s != "WITH") + .collect(); + + if parts.is_empty() { + return false; + } + + parts.iter().all(|part| { + common_licenses.iter().any(|l| l.to_uppercase() == *part) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_valid_spdx() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.licenses=\"MIT\""); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3052")); + } + + #[test] + fn test_valid_compound_spdx() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.licenses=\"MIT OR Apache-2.0\""); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3052")); + } + + #[test] + fn test_invalid_spdx() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.licenses=\"NotALicense\""); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3052")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3053.rs b/src/analyzer/hadolint/rules/dl3053.rs new file mode 100644 index 00000000..1401e153 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3053.rs @@ -0,0 +1,52 @@ +//! DL3053: Label `org.opencontainers.image.title` is empty +//! +//! The title label should not be empty. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3053", + Severity::Warning, + "Label `org.opencontainers.image.title` is empty.", + |instr, _shell| { + match instr { + Instruction::Label(pairs) => { + for (key, value) in pairs { + if key == "org.opencontainers.image.title" && value.trim().is_empty() { + return false; + } + } + true + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_valid_title() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.title=\"My App\""); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3053")); + } + + #[test] + fn test_empty_title() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.title=\"\""); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3053")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3054.rs b/src/analyzer/hadolint/rules/dl3054.rs new file mode 100644 index 00000000..95519168 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3054.rs @@ -0,0 +1,52 @@ +//! DL3054: Label `org.opencontainers.image.description` is empty +//! +//! The description label should not be empty. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3054", + Severity::Warning, + "Label `org.opencontainers.image.description` is empty.", + |instr, _shell| { + match instr { + Instruction::Label(pairs) => { + for (key, value) in pairs { + if key == "org.opencontainers.image.description" && value.trim().is_empty() { + return false; + } + } + true + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_valid_description() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.description=\"A description\""); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3054")); + } + + #[test] + fn test_empty_description() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.description=\"\""); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3054")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3055.rs b/src/analyzer/hadolint/rules/dl3055.rs new file mode 100644 index 00000000..16b615af --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3055.rs @@ -0,0 +1,63 @@ +//! DL3055: Label `org.opencontainers.image.documentation` is not a valid URL +//! +//! The documentation label should contain a valid URL. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3055", + Severity::Warning, + "Label `org.opencontainers.image.documentation` is not a valid URL.", + |instr, _shell| { + match instr { + Instruction::Label(pairs) => { + for (key, value) in pairs { + if key == "org.opencontainers.image.documentation" { + if !is_valid_url(value) { + return false; + } + } + } + true + } + _ => true, + } + }, + ) +} + +fn is_valid_url(url: &str) -> bool { + if url.is_empty() { + return false; + } + + // Basic URL validation - must start with http:// or https:// + url.starts_with("http://") || url.starts_with("https://") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_valid_url() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.documentation=\"https://example.com/docs\""); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3055")); + } + + #[test] + fn test_invalid_url() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.documentation=\"not-a-url\""); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3055")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3056.rs b/src/analyzer/hadolint/rules/dl3056.rs new file mode 100644 index 00000000..010d275f --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3056.rs @@ -0,0 +1,63 @@ +//! DL3056: Label `org.opencontainers.image.source` is not a valid URL +//! +//! The source label should contain a valid URL. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3056", + Severity::Warning, + "Label `org.opencontainers.image.source` is not a valid URL.", + |instr, _shell| { + match instr { + Instruction::Label(pairs) => { + for (key, value) in pairs { + if key == "org.opencontainers.image.source" { + if !is_valid_url(value) { + return false; + } + } + } + true + } + _ => true, + } + }, + ) +} + +fn is_valid_url(url: &str) -> bool { + if url.is_empty() { + return false; + } + + // Basic URL validation - must start with http:// or https:// + url.starts_with("http://") || url.starts_with("https://") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_valid_url() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.source=\"https://github.com/example/repo\""); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3056")); + } + + #[test] + fn test_invalid_url() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.source=\"not-a-url\""); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3056")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3057.rs b/src/analyzer/hadolint/rules/dl3057.rs new file mode 100644 index 00000000..fa497d36 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3057.rs @@ -0,0 +1,70 @@ +//! DL3057: HEALTHCHECK instruction missing +//! +//! Images should have a HEALTHCHECK instruction to allow the container orchestrator +//! to monitor the health of the container. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{very_custom_rule, VeryCustomRule, RuleState, CheckFailure}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> VeryCustomRule< + impl Fn(&mut RuleState, u32, &Instruction, Option<&ParsedShell>) + Send + Sync, + impl Fn(RuleState) -> Vec + Send + Sync +> { + very_custom_rule( + "DL3057", + Severity::Info, + "HEALTHCHECK instruction missing.", + // Step function + |state, _line, instr, _shell| { + if matches!(instr, Instruction::Healthcheck(_)) { + state.data.set_bool("has_healthcheck", true); + } + // Track if we have any real instructions (not just FROM) + if !matches!(instr, Instruction::From(_) | Instruction::Comment(_)) { + state.data.set_bool("has_instructions", true); + } + }, + // Finalize function - add failure if no healthcheck found + |state| { + // Only report if there are actual instructions beyond FROM + if !state.data.get_bool("has_healthcheck") && state.data.get_bool("has_instructions") { + let mut failures = state.failures; + failures.push(CheckFailure::new("DL3057", Severity::Info, "HEALTHCHECK instruction missing.", 1)); + failures + } else { + state.failures + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_missing_healthcheck() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN echo hello"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3057")); + } + + #[test] + fn test_has_healthcheck() { + let result = lint_dockerfile("FROM ubuntu:20.04\nHEALTHCHECK CMD curl -f http://localhost/ || exit 1"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3057")); + } + + #[test] + fn test_healthcheck_none() { + let result = lint_dockerfile("FROM ubuntu:20.04\nHEALTHCHECK NONE"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3057")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3058.rs b/src/analyzer/hadolint/rules/dl3058.rs new file mode 100644 index 00000000..15129efc --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3058.rs @@ -0,0 +1,63 @@ +//! DL3058: Label `org.opencontainers.image.url` is not a valid URL +//! +//! The url label should contain a valid URL. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3058", + Severity::Warning, + "Label `org.opencontainers.image.url` is not a valid URL.", + |instr, _shell| { + match instr { + Instruction::Label(pairs) => { + for (key, value) in pairs { + if key == "org.opencontainers.image.url" { + if !is_valid_url(value) { + return false; + } + } + } + true + } + _ => true, + } + }, + ) +} + +fn is_valid_url(url: &str) -> bool { + if url.is_empty() { + return false; + } + + // Basic URL validation - must start with http:// or https:// + url.starts_with("http://") || url.starts_with("https://") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_valid_url() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.url=\"https://example.com\""); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3058")); + } + + #[test] + fn test_invalid_url() { + let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.url=\"not-a-url\""); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3058")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3059.rs b/src/analyzer/hadolint/rules/dl3059.rs new file mode 100644 index 00000000..a5029ce3 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3059.rs @@ -0,0 +1,98 @@ +//! DL3059: Multiple consecutive RUN instructions +//! +//! Combine consecutive RUN instructions to reduce the number of layers. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> CustomRule) + Send + Sync> { + custom_rule( + "DL3059", + Severity::Info, + "Multiple consecutive `RUN` instructions. Consider consolidation.", + |state, line, instr, _shell| { + match instr { + Instruction::From(_) => { + // Reset tracking for new stage + state.data.set_int("consecutive_runs", 0); + state.data.set_int("last_run_line", 0); + } + Instruction::Run(_) => { + let consecutive = state.data.get_int("consecutive_runs"); + state.data.set_int("consecutive_runs", consecutive + 1); + state.data.set_int("last_run_line", line as i64); + + // Report on the second consecutive RUN + if consecutive >= 1 { + state.add_failure( + "DL3059", + Severity::Info, + "Multiple consecutive `RUN` instructions. Consider consolidation.", + line, + ); + } + } + // Other instructions reset the counter + _ => { + state.data.set_int("consecutive_runs", 0); + } + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_consecutive_runs() { + let result = lint_dockerfile( + "FROM ubuntu:20.04\nRUN apt-get update\nRUN apt-get install -y nginx" + ); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3059")); + } + + #[test] + fn test_single_run() { + let result = lint_dockerfile( + "FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y nginx" + ); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3059")); + } + + #[test] + fn test_runs_separated_by_other() { + let result = lint_dockerfile( + "FROM ubuntu:20.04\nRUN apt-get update\nENV DEBIAN_FRONTEND=noninteractive\nRUN apt-get install -y nginx" + ); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3059")); + } + + #[test] + fn test_three_consecutive_runs() { + let result = lint_dockerfile( + "FROM ubuntu:20.04\nRUN echo 1\nRUN echo 2\nRUN echo 3" + ); + // Should report on 2nd and 3rd RUN + let count = result.failures.iter().filter(|f| f.code.as_str() == "DL3059").count(); + assert_eq!(count, 2); + } + + #[test] + fn test_different_stages() { + let result = lint_dockerfile( + "FROM ubuntu:20.04 AS stage1\nRUN echo 1\nFROM ubuntu:20.04 AS stage2\nRUN echo 2" + ); + // Different stages, no consecutive RUNs + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3059")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3060.rs b/src/analyzer/hadolint/rules/dl3060.rs new file mode 100644 index 00000000..32e89aa8 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3060.rs @@ -0,0 +1,70 @@ +//! DL3060: yarn cache clean missing after yarn install +//! +//! Clean up yarn cache after installing packages. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3060", + Severity::Info, + "`yarn cache clean` missing after `yarn install`.", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + let has_install = shell.any_command(|cmd| { + (cmd.name == "yarn" && cmd.has_any_arg(&["install", "add"])) + }); + + if !has_install { + return true; + } + + let has_clean = shell.any_command(|cmd| { + (cmd.name == "yarn" && cmd.has_any_arg(&["cache"]) && cmd.arguments.iter().any(|a| a == "clean")) + || (cmd.name == "rm" && cmd.arguments.iter().any(|a| a.contains("yarn") && a.contains("cache"))) + }); + + has_clean + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_yarn_without_clean() { + let result = lint_dockerfile("FROM node:18\nRUN yarn install"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3060")); + } + + #[test] + fn test_yarn_with_clean() { + let result = lint_dockerfile("FROM node:18\nRUN yarn install && yarn cache clean"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3060")); + } + + #[test] + fn test_yarn_add_without_clean() { + let result = lint_dockerfile("FROM node:18\nRUN yarn add express"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3060")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3061.rs b/src/analyzer/hadolint/rules/dl3061.rs new file mode 100644 index 00000000..15be18c4 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3061.rs @@ -0,0 +1,93 @@ +//! DL3061: Invalid image name in FROM +//! +//! The image name in FROM should be valid. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL3061", + Severity::Error, + "Invalid image name in `FROM`.", + |instr, _shell| { + match instr { + Instruction::From(base_image) => { + is_valid_image_name(&base_image.image.name) + } + _ => true, + } + }, + ) +} + +fn is_valid_image_name(name: &str) -> bool { + if name.is_empty() { + return false; + } + + // Allow scratch as a special case + if name == "scratch" { + return true; + } + + // Allow variable expansion + if name.starts_with('$') { + return true; + } + + // Image name can have: + // - Registry prefix: registry.example.com/ + // - Namespace: namespace/ + // - Name: imagename + + // Basic validation: should contain only valid chars + let valid_chars = |c: char| { + c.is_ascii_lowercase() + || c.is_ascii_digit() + || c == '-' + || c == '_' + || c == '.' + || c == '/' + || c == ':' + }; + + name.chars().all(valid_chars) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_valid_image() { + let result = lint_dockerfile("FROM ubuntu:20.04"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3061")); + } + + #[test] + fn test_valid_registry_image() { + let result = lint_dockerfile("FROM registry.example.com/myimage:latest"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3061")); + } + + #[test] + fn test_scratch() { + let result = lint_dockerfile("FROM scratch"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3061")); + } + + #[test] + fn test_variable_image() { + let result = lint_dockerfile("ARG BASE=ubuntu\nFROM $BASE"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3061")); + } +} diff --git a/src/analyzer/hadolint/rules/dl3062.rs b/src/analyzer/hadolint/rules/dl3062.rs new file mode 100644 index 00000000..7124bed6 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl3062.rs @@ -0,0 +1,84 @@ +//! DL3062: COPY --from should reference a defined stage +//! +//! When using COPY --from, the source should be a defined build stage. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> CustomRule) + Send + Sync> { + custom_rule( + "DL3062", + Severity::Warning, + "`COPY --from` should reference a defined build stage or an external image.", + |state, line, instr, _shell| { + match instr { + Instruction::From(base_image) => { + // Track stage aliases + if let Some(alias) = &base_image.alias { + state.data.insert_to_set("stages", alias.as_str().to_string()); + } + // Track stage count + let count = state.data.get_int("stage_count"); + state.data.insert_to_set("stages", count.to_string()); + state.data.set_int("stage_count", count + 1); + } + Instruction::Copy(_, flags) => { + if let Some(from) = &flags.from { + let from_str = from.as_str(); + + // It's valid if: + // 1. It references a defined stage alias + // 2. It references a stage by index + // 3. It's an external image (contains / or . or : for tags) + + let is_stage_alias = state.data.set_contains("stages", from_str); + let is_stage_index = from_str.parse::().is_ok(); + let is_external = from_str.contains('/') || from_str.contains('.') || from_str.contains(':'); + + if !is_stage_alias && !is_stage_index && !is_external { + state.add_failure("DL3062", Severity::Warning, "`COPY --from` should reference a defined build stage or an external image.", line); + } + } + } + _ => {} + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_copy_from_defined_stage() { + let result = lint_dockerfile("FROM ubuntu:20.04 AS builder\nRUN echo hello\nFROM alpine:3.14\nCOPY --from=builder /app /app"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3062")); + } + + #[test] + fn test_copy_from_stage_index() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN echo hello\nFROM alpine:3.14\nCOPY --from=0 /app /app"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3062")); + } + + #[test] + fn test_copy_from_external_image() { + let result = lint_dockerfile("FROM ubuntu:20.04\nCOPY --from=nginx:latest /etc/nginx /etc/nginx"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3062")); + } + + #[test] + fn test_copy_from_undefined_stage() { + let result = lint_dockerfile("FROM ubuntu:20.04\nCOPY --from=nonexistent /app /app"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3062")); + } +} diff --git a/src/analyzer/hadolint/rules/dl4000.rs b/src/analyzer/hadolint/rules/dl4000.rs new file mode 100644 index 00000000..1835c779 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl4000.rs @@ -0,0 +1,46 @@ +//! DL4000: MAINTAINER is deprecated +//! +//! The MAINTAINER instruction is deprecated. Use LABEL instead. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL4000", + Severity::Error, + "MAINTAINER is deprecated", + |instr, _shell| { + !matches!(instr, Instruction::Maintainer(_)) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::rules::{Rule, RuleState}; + + #[test] + fn test_no_maintainer() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::User("node".to_string()); + rule.check(&mut state, 1, &instr, None); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_with_maintainer() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Maintainer("John Doe ".to_string()); + rule.check(&mut state, 1, &instr, None); + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL4000"); + } +} diff --git a/src/analyzer/hadolint/rules/dl4001.rs b/src/analyzer/hadolint/rules/dl4001.rs new file mode 100644 index 00000000..8668da50 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl4001.rs @@ -0,0 +1,91 @@ +//! DL4001: Either use wget or curl, but not both +//! +//! When downloading files, use either wget or curl consistently, not both. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{very_custom_rule, VeryCustomRule, RuleState, CheckFailure}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> VeryCustomRule< + impl Fn(&mut RuleState, u32, &Instruction, Option<&ParsedShell>) + Send + Sync, + impl Fn(RuleState) -> Vec + Send + Sync +> { + very_custom_rule( + "DL4001", + Severity::Warning, + "Either use `wget` or `curl`, but not both.", + |state, line, instr, shell| { + if let Instruction::Run(_) = instr { + if let Some(shell) = shell { + if shell.any_command(|cmd| cmd.name == "wget") { + // Store wget lines as comma-separated string + let existing = state.data.get_string("wget_lines").unwrap_or("").to_string(); + let new = if existing.is_empty() { + line.to_string() + } else { + format!("{},{}", existing, line) + }; + state.data.set_string("wget_lines", new); + } + if shell.any_command(|cmd| cmd.name == "curl") { + let existing = state.data.get_string("curl_lines").unwrap_or("").to_string(); + let new = if existing.is_empty() { + line.to_string() + } else { + format!("{},{}", existing, line) + }; + state.data.set_string("curl_lines", new); + } + } + } + }, + |state| { + let wget_lines = state.data.get_string("wget_lines").unwrap_or(""); + let curl_lines = state.data.get_string("curl_lines").unwrap_or(""); + + // If both wget and curl are used, report failures + if !wget_lines.is_empty() && !curl_lines.is_empty() { + let mut failures = state.failures; + for line in wget_lines.split(',').filter_map(|s| s.parse::().ok()) { + failures.push(CheckFailure::new("DL4001", Severity::Warning, "Either use `wget` or `curl`, but not both.", line)); + } + for line in curl_lines.split(',').filter_map(|s| s.parse::().ok()) { + failures.push(CheckFailure::new("DL4001", Severity::Warning, "Either use `wget` or `curl`, but not both.", line)); + } + failures + } else { + state.failures + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_only_wget() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN wget http://example.com/file"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL4001")); + } + + #[test] + fn test_only_curl() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN curl http://example.com/file"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL4001")); + } + + #[test] + fn test_both_wget_and_curl() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN wget http://example.com/file\nRUN curl http://example.com/other"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL4001")); + } +} diff --git a/src/analyzer/hadolint/rules/dl4003.rs b/src/analyzer/hadolint/rules/dl4003.rs new file mode 100644 index 00000000..84eb4b1f --- /dev/null +++ b/src/analyzer/hadolint/rules/dl4003.rs @@ -0,0 +1,92 @@ +//! DL4003: Multiple CMD instructions +//! +//! Only one CMD instruction should be present. If multiple are present, +//! only the last one takes effect. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> CustomRule) + Send + Sync> { + custom_rule( + "DL4003", + Severity::Warning, + "Multiple `CMD` instructions found. If you list more than one `CMD` then only the last `CMD` will take effect", + |state, line, instr, _shell| { + match instr { + Instruction::From(_) => { + // Reset count for each stage + state.data.set_int("cmd_count", 0); + } + Instruction::Cmd(_) => { + let count = state.data.get_int("cmd_count") + 1; + state.data.set_int("cmd_count", count); + + if count > 1 { + state.add_failure( + "DL4003", + Severity::Warning, + "Multiple `CMD` instructions found. If you list more than one `CMD` then only the last `CMD` will take effect", + line, + ); + } + } + _ => {} + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::parser::instruction::{Arguments, BaseImage}; + use crate::analyzer::hadolint::rules::Rule; + + #[test] + fn test_single_cmd() { + let rule = rule(); + let mut state = RuleState::new(); + + let from = Instruction::From(BaseImage::new("ubuntu")); + let cmd = Instruction::Cmd(Arguments::List(vec!["node".to_string()])); + + rule.check(&mut state, 1, &from, None); + rule.check(&mut state, 2, &cmd, None); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_multiple_cmds() { + let rule = rule(); + let mut state = RuleState::new(); + + let from = Instruction::From(BaseImage::new("ubuntu")); + let cmd1 = Instruction::Cmd(Arguments::List(vec!["node".to_string()])); + let cmd2 = Instruction::Cmd(Arguments::List(vec!["npm".to_string()])); + + rule.check(&mut state, 1, &from, None); + rule.check(&mut state, 2, &cmd1, None); + rule.check(&mut state, 3, &cmd2, None); + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL4003"); + } + + #[test] + fn test_multiple_stages_ok() { + let rule = rule(); + let mut state = RuleState::new(); + + let from1 = Instruction::From(BaseImage::new("node")); + let cmd1 = Instruction::Cmd(Arguments::List(vec!["npm".to_string()])); + let from2 = Instruction::From(BaseImage::new("alpine")); + let cmd2 = Instruction::Cmd(Arguments::List(vec!["node".to_string()])); + + rule.check(&mut state, 1, &from1, None); + rule.check(&mut state, 2, &cmd1, None); + rule.check(&mut state, 3, &from2, None); + rule.check(&mut state, 4, &cmd2, None); + assert!(state.failures.is_empty()); + } +} diff --git a/src/analyzer/hadolint/rules/dl4004.rs b/src/analyzer/hadolint/rules/dl4004.rs new file mode 100644 index 00000000..3f6f791b --- /dev/null +++ b/src/analyzer/hadolint/rules/dl4004.rs @@ -0,0 +1,75 @@ +//! DL4004: Multiple ENTRYPOINT instructions +//! +//! Only one ENTRYPOINT instruction should be present. If multiple are present, +//! only the last one takes effect. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> CustomRule) + Send + Sync> { + custom_rule( + "DL4004", + Severity::Error, + "Multiple `ENTRYPOINT` instructions found. If you list more than one `ENTRYPOINT` then only the last `ENTRYPOINT` will take effect", + |state, line, instr, _shell| { + match instr { + Instruction::From(_) => { + // Reset count for each stage + state.data.set_int("entrypoint_count", 0); + } + Instruction::Entrypoint(_) => { + let count = state.data.get_int("entrypoint_count") + 1; + state.data.set_int("entrypoint_count", count); + + if count > 1 { + state.add_failure( + "DL4004", + Severity::Error, + "Multiple `ENTRYPOINT` instructions found. If you list more than one `ENTRYPOINT` then only the last `ENTRYPOINT` will take effect", + line, + ); + } + } + _ => {} + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::parser::instruction::{Arguments, BaseImage}; + use crate::analyzer::hadolint::rules::Rule; + + #[test] + fn test_single_entrypoint() { + let rule = rule(); + let mut state = RuleState::new(); + + let from = Instruction::From(BaseImage::new("ubuntu")); + let ep = Instruction::Entrypoint(Arguments::List(vec!["./entrypoint.sh".to_string()])); + + rule.check(&mut state, 1, &from, None); + rule.check(&mut state, 2, &ep, None); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_multiple_entrypoints() { + let rule = rule(); + let mut state = RuleState::new(); + + let from = Instruction::From(BaseImage::new("ubuntu")); + let ep1 = Instruction::Entrypoint(Arguments::List(vec!["./script1.sh".to_string()])); + let ep2 = Instruction::Entrypoint(Arguments::List(vec!["./script2.sh".to_string()])); + + rule.check(&mut state, 1, &from, None); + rule.check(&mut state, 2, &ep1, None); + rule.check(&mut state, 3, &ep2, None); + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL4004"); + } +} diff --git a/src/analyzer/hadolint/rules/dl4005.rs b/src/analyzer/hadolint/rules/dl4005.rs new file mode 100644 index 00000000..6e363e33 --- /dev/null +++ b/src/analyzer/hadolint/rules/dl4005.rs @@ -0,0 +1,65 @@ +//! DL4005: Use SHELL to change the default shell +//! +//! Instead of using shell commands to change the shell, use the SHELL instruction. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL4005", + Severity::Warning, + "Use `SHELL` to change the default shell.", + |instr, _shell| { + match instr { + Instruction::Run(args) => { + let cmd_text = match &args.arguments { + crate::analyzer::hadolint::parser::instruction::Arguments::Text(t) => t.as_str(), + crate::analyzer::hadolint::parser::instruction::Arguments::List(l) => { + if l.is_empty() { + return true; + } + l.first().map(|s| s.as_str()).unwrap_or("") + } + }; + + // Check for commands that try to change shell + !cmd_text.contains("ln -s") + || !cmd_text.contains("/bin/sh") + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::lint::{lint, LintResult}; + use crate::analyzer::hadolint::config::HadolintConfig; + + fn lint_dockerfile(content: &str) -> LintResult { + lint(content, &HadolintConfig::default()) + } + + #[test] + fn test_shell_instruction() { + let result = lint_dockerfile("FROM ubuntu:20.04\nSHELL [\"/bin/bash\", \"-c\"]"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL4005")); + } + + #[test] + fn test_ln_s_shell() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN ln -s /bin/bash /bin/sh"); + assert!(result.failures.iter().any(|f| f.code.as_str() == "DL4005")); + } + + #[test] + fn test_normal_run() { + let result = lint_dockerfile("FROM ubuntu:20.04\nRUN echo hello"); + assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL4005")); + } +} diff --git a/src/analyzer/hadolint/rules/dl4006.rs b/src/analyzer/hadolint/rules/dl4006.rs new file mode 100644 index 00000000..23bd9b1d --- /dev/null +++ b/src/analyzer/hadolint/rules/dl4006.rs @@ -0,0 +1,62 @@ +//! DL4006: Set the SHELL option -o pipefail before RUN with a pipe in it +//! +//! If a pipe is used in RUN, the shell option pipefail should be set +//! to ensure the entire pipeline fails if any command fails. + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule}; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::Severity; + +pub fn rule() -> SimpleRule) -> bool + Send + Sync> { + simple_rule( + "DL4006", + Severity::Warning, + "Set the SHELL option -o pipefail before RUN with a pipe in it. If you are using /bin/sh in an alpine image or if your shell is symlinked to busybox then consider explicitly setting your SHELL to /bin/ash, or disable this check", + |instr, shell| { + match instr { + Instruction::Run(_) => { + if let Some(shell) = shell { + // If there are pipes, this rule fails + // (should have set pipefail) + // In a real implementation, we'd track if SHELL with pipefail was set + !shell.has_pipes + } else { + true + } + } + _ => true, + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyzer::hadolint::parser::instruction::RunArgs; + use crate::analyzer::hadolint::rules::{Rule, RuleState}; + + #[test] + fn test_no_pipe() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("apt-get update")); + let shell = ParsedShell::parse("apt-get update"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert!(state.failures.is_empty()); + } + + #[test] + fn test_with_pipe() { + let rule = rule(); + let mut state = RuleState::new(); + + let instr = Instruction::Run(RunArgs::shell("cat file | grep pattern")); + let shell = ParsedShell::parse("cat file | grep pattern"); + rule.check(&mut state, 1, &instr, Some(&shell)); + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "DL4006"); + } +} diff --git a/src/analyzer/hadolint/rules/mod.rs b/src/analyzer/hadolint/rules/mod.rs new file mode 100644 index 00000000..2da4120e --- /dev/null +++ b/src/analyzer/hadolint/rules/mod.rs @@ -0,0 +1,497 @@ +//! Rule system framework for hadolint-rs. +//! +//! Provides the infrastructure for defining and running Dockerfile linting rules. +//! The design matches hadolint's fold-based architecture: +//! +//! - `simple_rule` - Stateless rules that check each instruction independently +//! - `custom_rule` - Stateful rules that accumulate state across instructions +//! - `very_custom_rule` - Rules with custom finalization logic +//! - `onbuild` - Wrapper to also check ONBUILD-wrapped instructions + +use crate::analyzer::hadolint::parser::instruction::Instruction; +use crate::analyzer::hadolint::shell::ParsedShell; +use crate::analyzer::hadolint::types::{CheckFailure, RuleCode, Severity}; + +pub mod dl1001; +pub mod dl3000; +pub mod dl3001; +pub mod dl3002; +pub mod dl3003; +pub mod dl3004; +pub mod dl3005; +pub mod dl3006; +pub mod dl3007; +pub mod dl3008; +pub mod dl3009; +pub mod dl3010; +pub mod dl3011; +pub mod dl3012; +pub mod dl3013; +pub mod dl3014; +pub mod dl3015; +pub mod dl3016; +pub mod dl3017; +pub mod dl3018; +pub mod dl3019; +pub mod dl3020; +pub mod dl3021; +pub mod dl3022; +pub mod dl3023; +pub mod dl3024; +pub mod dl3025; +pub mod dl3026; +pub mod dl3027; +pub mod dl3028; +pub mod dl3029; +pub mod dl3030; +pub mod dl3031; +pub mod dl3032; +pub mod dl3033; +pub mod dl3034; +pub mod dl3035; +pub mod dl3036; +pub mod dl3037; +pub mod dl3038; +pub mod dl3039; +pub mod dl3040; +pub mod dl3041; +pub mod dl3042; +pub mod dl3043; +pub mod dl3044; +pub mod dl3045; +pub mod dl3046; +pub mod dl3047; +pub mod dl3048; +pub mod dl3049; +pub mod dl3050; +pub mod dl3051; +pub mod dl3052; +pub mod dl3053; +pub mod dl3054; +pub mod dl3055; +pub mod dl3056; +pub mod dl3057; +pub mod dl3058; +pub mod dl3059; +pub mod dl3060; +pub mod dl3061; +pub mod dl3062; +pub mod dl4000; +pub mod dl4001; +pub mod dl4003; +pub mod dl4004; +pub mod dl4005; +pub mod dl4006; + +/// A rule that can check Dockerfile instructions. +pub trait Rule: Send + Sync { + /// Check an instruction and potentially add failures to the state. + fn check(&self, state: &mut RuleState, line: u32, instruction: &Instruction, shell: Option<&ParsedShell>); + + /// Finalize the rule and return any additional failures. + /// Called after all instructions have been processed. + fn finalize(&self, state: RuleState) -> Vec { + state.failures + } + + /// Get the rule code. + fn code(&self) -> &RuleCode; + + /// Get the default severity. + fn severity(&self) -> Severity; + + /// Get the rule message. + fn message(&self) -> &str; +} + +/// State for rule execution. +#[derive(Debug, Clone, Default)] +pub struct RuleState { + /// Accumulated failures. + pub failures: Vec, + /// Custom state data (serialized). + pub data: RuleData, +} + +impl RuleState { + /// Create a new empty state. + pub fn new() -> Self { + Self::default() + } + + /// Add a failure. + pub fn add_failure(&mut self, code: impl Into, severity: Severity, message: impl Into, line: u32) { + self.failures.push(CheckFailure::new(code, severity, message, line)); + } +} + +/// Custom data storage for stateful rules. +#[derive(Debug, Clone, Default)] +pub struct RuleData { + /// Integer values. + pub ints: std::collections::HashMap<&'static str, i64>, + /// Boolean values. + pub bools: std::collections::HashMap<&'static str, bool>, + /// String values. + pub strings: std::collections::HashMap<&'static str, String>, + /// String set values. + pub string_sets: std::collections::HashMap<&'static str, std::collections::HashSet>, +} + +impl RuleData { + pub fn get_int(&self, key: &'static str) -> i64 { + self.ints.get(key).copied().unwrap_or(0) + } + + pub fn set_int(&mut self, key: &'static str, value: i64) { + self.ints.insert(key, value); + } + + pub fn get_bool(&self, key: &'static str) -> bool { + self.bools.get(key).copied().unwrap_or(false) + } + + pub fn set_bool(&mut self, key: &'static str, value: bool) { + self.bools.insert(key, value); + } + + pub fn get_string(&self, key: &'static str) -> Option<&str> { + self.strings.get(key).map(|s| s.as_str()) + } + + pub fn set_string(&mut self, key: &'static str, value: impl Into) { + self.strings.insert(key, value.into()); + } + + pub fn get_string_set(&self, key: &'static str) -> Option<&std::collections::HashSet> { + self.string_sets.get(key) + } + + pub fn insert_to_set(&mut self, key: &'static str, value: impl Into) { + self.string_sets.entry(key).or_default().insert(value.into()); + } + + pub fn set_contains(&self, key: &'static str, value: &str) -> bool { + self.string_sets.get(key).map(|s| s.contains(value)).unwrap_or(false) + } +} + +/// A simple stateless rule. +pub struct SimpleRule +where + F: Fn(&Instruction, Option<&ParsedShell>) -> bool + Send + Sync, +{ + code: RuleCode, + severity: Severity, + message: String, + check_fn: F, +} + +impl SimpleRule +where + F: Fn(&Instruction, Option<&ParsedShell>) -> bool + Send + Sync, +{ + /// Create a new simple rule. + pub fn new(code: impl Into, severity: Severity, message: impl Into, check_fn: F) -> Self { + Self { + code: code.into(), + severity, + message: message.into(), + check_fn, + } + } +} + +impl Rule for SimpleRule +where + F: Fn(&Instruction, Option<&ParsedShell>) -> bool + Send + Sync, +{ + fn check(&self, state: &mut RuleState, line: u32, instruction: &Instruction, shell: Option<&ParsedShell>) { + if !(self.check_fn)(instruction, shell) { + state.add_failure(self.code.clone(), self.severity, self.message.clone(), line); + } + } + + fn code(&self) -> &RuleCode { + &self.code + } + + fn severity(&self) -> Severity { + self.severity + } + + fn message(&self) -> &str { + &self.message + } +} + +/// Create a simple stateless rule. +pub fn simple_rule( + code: impl Into, + severity: Severity, + message: impl Into, + check_fn: F, +) -> SimpleRule +where + F: Fn(&Instruction, Option<&ParsedShell>) -> bool + Send + Sync, +{ + SimpleRule::new(code, severity, message, check_fn) +} + +/// A stateful rule with custom step function. +pub struct CustomRule +where + F: Fn(&mut RuleState, u32, &Instruction, Option<&ParsedShell>) + Send + Sync, +{ + code: RuleCode, + severity: Severity, + message: String, + step_fn: F, +} + +impl CustomRule +where + F: Fn(&mut RuleState, u32, &Instruction, Option<&ParsedShell>) + Send + Sync, +{ + /// Create a new custom rule. + pub fn new(code: impl Into, severity: Severity, message: impl Into, step_fn: F) -> Self { + Self { + code: code.into(), + severity, + message: message.into(), + step_fn, + } + } +} + +impl Rule for CustomRule +where + F: Fn(&mut RuleState, u32, &Instruction, Option<&ParsedShell>) + Send + Sync, +{ + fn check(&self, state: &mut RuleState, line: u32, instruction: &Instruction, shell: Option<&ParsedShell>) { + (self.step_fn)(state, line, instruction, shell); + } + + fn code(&self) -> &RuleCode { + &self.code + } + + fn severity(&self) -> Severity { + self.severity + } + + fn message(&self) -> &str { + &self.message + } +} + +/// Create a custom stateful rule. +pub fn custom_rule( + code: impl Into, + severity: Severity, + message: impl Into, + step_fn: F, +) -> CustomRule +where + F: Fn(&mut RuleState, u32, &Instruction, Option<&ParsedShell>) + Send + Sync, +{ + CustomRule::new(code, severity, message, step_fn) +} + +/// A rule with custom finalization. +pub struct VeryCustomRule +where + F: Fn(&mut RuleState, u32, &Instruction, Option<&ParsedShell>) + Send + Sync, + D: Fn(RuleState) -> Vec + Send + Sync, +{ + code: RuleCode, + severity: Severity, + message: String, + step_fn: F, + done_fn: D, +} + +impl VeryCustomRule +where + F: Fn(&mut RuleState, u32, &Instruction, Option<&ParsedShell>) + Send + Sync, + D: Fn(RuleState) -> Vec + Send + Sync, +{ + /// Create a new very custom rule. + pub fn new( + code: impl Into, + severity: Severity, + message: impl Into, + step_fn: F, + done_fn: D, + ) -> Self { + Self { + code: code.into(), + severity, + message: message.into(), + step_fn, + done_fn, + } + } +} + +impl Rule for VeryCustomRule +where + F: Fn(&mut RuleState, u32, &Instruction, Option<&ParsedShell>) + Send + Sync, + D: Fn(RuleState) -> Vec + Send + Sync, +{ + fn check(&self, state: &mut RuleState, line: u32, instruction: &Instruction, shell: Option<&ParsedShell>) { + (self.step_fn)(state, line, instruction, shell); + } + + fn finalize(&self, state: RuleState) -> Vec { + (self.done_fn)(state) + } + + fn code(&self) -> &RuleCode { + &self.code + } + + fn severity(&self) -> Severity { + self.severity + } + + fn message(&self) -> &str { + &self.message + } +} + +/// Create a rule with custom finalization. +pub fn very_custom_rule( + code: impl Into, + severity: Severity, + message: impl Into, + step_fn: F, + done_fn: D, +) -> VeryCustomRule +where + F: Fn(&mut RuleState, u32, &Instruction, Option<&ParsedShell>) + Send + Sync, + D: Fn(RuleState) -> Vec + Send + Sync, +{ + VeryCustomRule::new(code, severity, message, step_fn, done_fn) +} + +/// Get all enabled rules. +pub fn all_rules() -> Vec> { + vec![ + // DL1xxx rules (deprecation warnings) + Box::new(dl1001::rule()), + // Simple DL3xxx rules + Box::new(dl3000::rule()), + Box::new(dl3001::rule()), + Box::new(dl3003::rule()), + Box::new(dl3004::rule()), + Box::new(dl3005::rule()), + Box::new(dl3007::rule()), + Box::new(dl3010::rule()), + Box::new(dl3011::rule()), + Box::new(dl3017::rule()), + Box::new(dl3020::rule()), + Box::new(dl3021::rule()), + Box::new(dl3025::rule()), + Box::new(dl3026::rule()), + Box::new(dl3027::rule()), + Box::new(dl3029::rule()), + Box::new(dl3031::rule()), + Box::new(dl3035::rule()), + Box::new(dl3039::rule()), + Box::new(dl3043::rule()), + Box::new(dl3044::rule()), + Box::new(dl3046::rule()), + Box::new(dl3048::rule()), + Box::new(dl3049::rule()), + Box::new(dl3050::rule()), + Box::new(dl3051::rule()), + Box::new(dl3052::rule()), + Box::new(dl3053::rule()), + Box::new(dl3054::rule()), + Box::new(dl3055::rule()), + Box::new(dl3056::rule()), + Box::new(dl3058::rule()), + Box::new(dl3061::rule()), + // DL4xxx simple rules + Box::new(dl4000::rule()), + Box::new(dl4005::rule()), + Box::new(dl4006::rule()), + // Stateful rules + Box::new(dl3002::rule()), + Box::new(dl3006::rule()), + Box::new(dl3012::rule()), + Box::new(dl3022::rule()), + Box::new(dl3023::rule()), + Box::new(dl3024::rule()), + Box::new(dl3045::rule()), + Box::new(dl3047::rule()), + Box::new(dl3057::rule()), + Box::new(dl3059::rule()), + Box::new(dl3062::rule()), + Box::new(dl4001::rule()), + Box::new(dl4003::rule()), + Box::new(dl4004::rule()), + // Shell-dependent rules + Box::new(dl3008::rule()), + Box::new(dl3009::rule()), + Box::new(dl3013::rule()), + Box::new(dl3014::rule()), + Box::new(dl3015::rule()), + Box::new(dl3016::rule()), + Box::new(dl3018::rule()), + Box::new(dl3019::rule()), + Box::new(dl3028::rule()), + Box::new(dl3030::rule()), + Box::new(dl3032::rule()), + Box::new(dl3033::rule()), + Box::new(dl3034::rule()), + Box::new(dl3036::rule()), + Box::new(dl3037::rule()), + Box::new(dl3038::rule()), + Box::new(dl3040::rule()), + Box::new(dl3041::rule()), + Box::new(dl3042::rule()), + Box::new(dl3060::rule()), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_rule() { + let rule = simple_rule( + "TEST001", + Severity::Warning, + "Test message", + |instr, _| !matches!(instr, Instruction::Maintainer(_)), + ); + + let mut state = RuleState::new(); + let instr = Instruction::Maintainer("test".to_string()); + rule.check(&mut state, 1, &instr, None); + + assert_eq!(state.failures.len(), 1); + assert_eq!(state.failures[0].code.as_str(), "TEST001"); + } + + #[test] + fn test_rule_data() { + let mut data = RuleData::default(); + + data.set_int("count", 5); + assert_eq!(data.get_int("count"), 5); + + data.set_bool("seen", true); + assert!(data.get_bool("seen")); + + data.set_string("name", "test"); + assert_eq!(data.get_string("name"), Some("test")); + + data.insert_to_set("aliases", "builder"); + assert!(data.set_contains("aliases", "builder")); + assert!(!data.set_contains("aliases", "runner")); + } +} diff --git a/src/analyzer/hadolint/shell/mod.rs b/src/analyzer/hadolint/shell/mod.rs new file mode 100644 index 00000000..5bb1feae --- /dev/null +++ b/src/analyzer/hadolint/shell/mod.rs @@ -0,0 +1,447 @@ +//! Shell parsing module for hadolint-rs. +//! +//! Provides: +//! - Shell command extraction from RUN instructions +//! - ShellCheck integration for deeper analysis +//! +//! This module handles parsing shell commands from Dockerfile RUN instructions +//! and provides utilities for rule implementations to analyze them. + +pub mod shellcheck; + +use crate::analyzer::hadolint::parser::instruction::{Arguments, RunArgs}; + +/// Parsed shell command information. +#[derive(Debug, Clone, Default)] +pub struct ParsedShell { + /// Original shell script text. + pub original: String, + /// Extracted commands. + pub commands: Vec, + /// Whether the shell has pipes. + pub has_pipes: bool, +} + +impl ParsedShell { + /// Parse a shell command string. + pub fn parse(script: &str) -> Self { + let original = script.to_string(); + let commands = extract_commands(script); + let has_pipes = script.contains('|'); + + Self { + original, + commands, + has_pipes, + } + } + + /// Parse from RUN instruction arguments. + pub fn from_run_args(args: &RunArgs) -> Self { + match &args.arguments { + Arguments::Text(text) => Self::parse(text), + Arguments::List(list) => { + // Exec form - join for analysis + let script = list.join(" "); + Self::parse(&script) + } + } + } + + /// Check if any command matches the predicate. + pub fn any_command(&self, pred: F) -> bool + where + F: Fn(&Command) -> bool, + { + self.commands.iter().any(pred) + } + + /// Check if all commands match the predicate. + pub fn all_commands(&self, pred: F) -> bool + where + F: Fn(&Command) -> bool, + { + self.commands.iter().all(pred) + } + + /// Check if no commands match the predicate. + pub fn no_commands(&self, pred: F) -> bool + where + F: Fn(&Command) -> bool, + { + !self.any_command(pred) + } + + /// Find command names in the script. + pub fn find_command_names(&self) -> Vec<&str> { + self.commands.iter().map(|c| c.name.as_str()).collect() + } + + /// Check if using a specific program. + pub fn using_program(&self, prog: &str) -> bool { + self.commands.iter().any(|c| c.name == prog) + } + + /// Check if any command is a pip install. + pub fn is_pip_install(&self, cmd: &Command) -> bool { + cmd.is_pip_install() + } +} + +/// A single command extracted from a shell script. +#[derive(Debug, Clone)] +pub struct Command { + /// Command name (e.g., "apt-get", "pip"). + pub name: String, + /// All arguments including flags. + pub arguments: Vec, + /// Extracted flags (e.g., ["-y", "--no-cache"]). + pub flags: Vec, +} + +impl Command { + /// Create a new command. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + arguments: Vec::new(), + flags: Vec::new(), + } + } + + /// Check if the command has specific arguments. + pub fn has_args(&self, expected_name: &str, expected_args: &[&str]) -> bool { + if self.name != expected_name { + return false; + } + expected_args.iter().all(|arg| self.arguments.iter().any(|a| a == *arg)) + } + + /// Check if the command has any of the specified arguments. + pub fn has_any_arg(&self, args: &[&str]) -> bool { + args.iter().any(|arg| self.arguments.iter().any(|a| a == *arg)) + } + + /// Check if the command has a specific flag. + pub fn has_flag(&self, flag: &str) -> bool { + self.flags.iter().any(|f| f == flag) + } + + /// Check if the command has any of the specified flags. + pub fn has_any_flag(&self, flags: &[&str]) -> bool { + flags.iter().any(|f| self.has_flag(f)) + } + + /// Get arguments without flags. + pub fn args_no_flags(&self) -> Vec<&str> { + self.arguments + .iter() + .filter(|a| !a.starts_with('-')) + .map(|s| s.as_str()) + .collect() + } + + /// Get the value for a flag (e.g., "-t" returns "release" for "-t=release"). + pub fn get_flag_value(&self, flag: &str) -> Option<&str> { + // Check for --flag=value format + for arg in &self.arguments { + if let Some(stripped) = arg.strip_prefix(&format!("--{}=", flag)) { + return Some(stripped); + } + if let Some(stripped) = arg.strip_prefix(&format!("-{}=", flag)) { + return Some(stripped); + } + } + + // Check for --flag value format + let mut iter = self.arguments.iter(); + while let Some(arg) = iter.next() { + if arg == &format!("--{}", flag) || arg == &format!("-{}", flag) { + return iter.next().map(|s| s.as_str()); + } + } + + None + } + + /// Check if this is a pip install command. + pub fn is_pip_install(&self) -> bool { + // Standard pip install + if (self.name.starts_with("pip") && !self.name.starts_with("pipenv")) + && self.arguments.iter().any(|a| a == "install") + { + return true; + } + + // python -m pip install + if self.name.starts_with("python") { + let args: Vec<&str> = self.arguments.iter().map(|s| s.as_str()).collect(); + if args.windows(3).any(|w| w == ["-m", "pip", "install"]) { + return true; + } + } + + false + } + + /// Check if this is an apt-get install command. + pub fn is_apt_get_install(&self) -> bool { + self.name == "apt-get" && self.arguments.iter().any(|a| a == "install") + } + + /// Check if this is an apk add command. + pub fn is_apk_add(&self) -> bool { + self.name == "apk" && self.arguments.iter().any(|a| a == "add") + } +} + +/// Extract commands from a shell script. +fn extract_commands(script: &str) -> Vec { + let mut commands = Vec::new(); + + // Simple tokenization: split by command separators + let separators = ["&&", "||", ";", "|", "\n"]; + + let mut remaining = script.trim(); + + while !remaining.is_empty() { + // Find the next separator + let next_sep = separators + .iter() + .filter_map(|sep| remaining.find(sep).map(|pos| (pos, sep.len()))) + .min_by_key(|(pos, _)| *pos); + + let cmd_str = match next_sep { + Some((pos, len)) => { + let cmd = &remaining[..pos]; + remaining = &remaining[pos + len..]; + cmd + } + None => { + let cmd = remaining; + remaining = ""; + cmd + } + }; + + // Parse the command + if let Some(cmd) = parse_single_command(cmd_str.trim()) { + commands.push(cmd); + } + + remaining = remaining.trim_start(); + } + + commands +} + +/// Parse a single command string into a Command. +fn parse_single_command(cmd_str: &str) -> Option { + let cmd_str = cmd_str.trim(); + if cmd_str.is_empty() { + return None; + } + + // Handle subshells and command substitution + let cmd_str = cmd_str + .trim_start_matches('(') + .trim_end_matches(')') + .trim(); + + // Simple word splitting + let words: Vec<&str> = shell_words(cmd_str); + + if words.is_empty() { + return None; + } + + let name = words[0].to_string(); + let arguments: Vec = words[1..].iter().map(|s| s.to_string()).collect(); + let flags = extract_flags(&arguments); + + Some(Command { + name, + arguments, + flags, + }) +} + +/// Simple shell word splitting. +fn shell_words(input: &str) -> Vec<&str> { + let mut words = Vec::new(); + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut word_start = None; + let mut escaped = false; + + for (i, c) in input.char_indices() { + if escaped { + escaped = false; + continue; + } + + if c == '\\' && !in_single_quote { + escaped = true; + if word_start.is_none() { + word_start = Some(i); + } + continue; + } + + if c == '\'' && !in_double_quote { + in_single_quote = !in_single_quote; + if word_start.is_none() { + word_start = Some(i); + } + continue; + } + + if c == '"' && !in_single_quote { + in_double_quote = !in_double_quote; + if word_start.is_none() { + word_start = Some(i); + } + continue; + } + + if c.is_whitespace() && !in_single_quote && !in_double_quote { + if let Some(start) = word_start { + let word = &input[start..i]; + let word = word.trim_matches(|c| c == '\'' || c == '"'); + if !word.is_empty() { + words.push(word); + } + word_start = None; + } + } else if word_start.is_none() { + word_start = Some(i); + } + } + + // Don't forget the last word + if let Some(start) = word_start { + let word = &input[start..]; + let word = word.trim_matches(|c| c == '\'' || c == '"'); + if !word.is_empty() { + words.push(word); + } + } + + words +} + +/// Extract flags from arguments. +fn extract_flags(arguments: &[String]) -> Vec { + let mut flags = Vec::new(); + + for arg in arguments { + if arg == "--" || arg == "-" { + continue; + } + + if let Some(stripped) = arg.strip_prefix("--") { + // Long flag + let flag = stripped.split('=').next().unwrap_or(stripped); + flags.push(flag.to_string()); + } else if let Some(stripped) = arg.strip_prefix('-') { + // Short flag(s) + for c in stripped.chars() { + if c == '=' { + break; + } + flags.push(c.to_string()); + } + } + } + + flags +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_command() { + let shell = ParsedShell::parse("apt-get update"); + assert_eq!(shell.commands.len(), 1); + assert_eq!(shell.commands[0].name, "apt-get"); + assert_eq!(shell.commands[0].arguments, vec!["update"]); + } + + #[test] + fn test_parse_chained_commands() { + let shell = ParsedShell::parse("apt-get update && apt-get install -y nginx"); + assert_eq!(shell.commands.len(), 2); + assert_eq!(shell.commands[0].name, "apt-get"); + assert_eq!(shell.commands[1].name, "apt-get"); + assert!(shell.commands[1].has_flag("y")); + } + + #[test] + fn test_parse_pipe() { + let shell = ParsedShell::parse("cat file | grep pattern"); + assert!(shell.has_pipes); + assert_eq!(shell.commands.len(), 2); + } + + #[test] + fn test_command_has_args() { + let cmd = Command { + name: "apt-get".to_string(), + arguments: vec!["install".to_string(), "-y".to_string(), "nginx".to_string()], + flags: vec!["y".to_string()], + }; + + assert!(cmd.has_args("apt-get", &["install"])); + assert!(cmd.has_flag("y")); + assert!(!cmd.has_flag("q")); + } + + #[test] + fn test_is_pip_install() { + let cmd = Command { + name: "pip".to_string(), + arguments: vec!["install".to_string(), "requests".to_string()], + flags: vec![], + }; + assert!(cmd.is_pip_install()); + + let cmd2 = Command { + name: "pipenv".to_string(), + arguments: vec!["install".to_string()], + flags: vec![], + }; + assert!(!cmd2.is_pip_install()); + } + + #[test] + fn test_is_apt_get_install() { + let cmd = Command { + name: "apt-get".to_string(), + arguments: vec!["install".to_string(), "-y".to_string(), "nginx".to_string()], + flags: vec!["y".to_string()], + }; + assert!(cmd.is_apt_get_install()); + } + + #[test] + fn test_args_no_flags() { + let cmd = Command { + name: "apt-get".to_string(), + arguments: vec!["install".to_string(), "-y".to_string(), "nginx".to_string(), "curl".to_string()], + flags: vec!["y".to_string()], + }; + + let args = cmd.args_no_flags(); + assert_eq!(args, vec!["install", "nginx", "curl"]); + } + + #[test] + fn test_using_program() { + let shell = ParsedShell::parse("apt-get update && curl -O http://example.com/file"); + assert!(shell.using_program("apt-get")); + assert!(shell.using_program("curl")); + assert!(!shell.using_program("wget")); + } +} diff --git a/src/analyzer/hadolint/shell/shellcheck.rs b/src/analyzer/hadolint/shell/shellcheck.rs new file mode 100644 index 00000000..92a0f517 --- /dev/null +++ b/src/analyzer/hadolint/shell/shellcheck.rs @@ -0,0 +1,178 @@ +//! ShellCheck integration for shell analysis. +//! +//! Calls the external shellcheck binary to get detailed shell script analysis. +//! Requires shellcheck to be installed on the system. + +use std::process::Command; +use serde::Deserialize; + +/// A ShellCheck warning/error. +#[derive(Debug, Clone, Deserialize)] +pub struct ShellCheckComment { + /// File path (usually "-" for stdin). + pub file: String, + /// Line number. + pub line: u32, + /// End line number. + #[serde(rename = "endLine")] + pub end_line: u32, + /// Column number. + pub column: u32, + /// End column number. + #[serde(rename = "endColumn")] + pub end_column: u32, + /// Severity level. + pub level: String, + /// ShellCheck code (e.g., 2086). + pub code: u32, + /// Warning message. + pub message: String, +} + +impl ShellCheckComment { + /// Get the rule code as a string (e.g., "SC2086"). + pub fn rule_code(&self) -> String { + format!("SC{}", self.code) + } +} + +/// Run shellcheck on a script and return warnings. +/// +/// # Arguments +/// * `script` - The shell script to analyze +/// * `shell` - The shell to use (e.g., "bash", "sh") +/// +/// # Returns +/// A vector of ShellCheck comments/warnings, or an empty vector if shellcheck +/// is not available or fails. +pub fn run_shellcheck(script: &str, shell: &str) -> Vec { + // Build the shellcheck command + let output = Command::new("shellcheck") + .args([ + "--format=json", + &format!("--shell={}", shell), + "-e", "2187", // Exclude ash shell warning + "-e", "1090", // Exclude source directive warning + "-e", "1091", // Exclude source directive warning + "-", // Read from stdin + ]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn(); + + let mut child = match output { + Ok(child) => child, + Err(_) => { + // shellcheck not installed or not in PATH + return Vec::new(); + } + }; + + // Write script to stdin + if let Some(stdin) = child.stdin.as_mut() { + use std::io::Write; + let _ = stdin.write_all(script.as_bytes()); + } + + // Wait for output + let output = match child.wait_with_output() { + Ok(output) => output, + Err(_) => return Vec::new(), + }; + + // Parse JSON output + // ShellCheck returns exit code 1 if there are warnings, but still outputs valid JSON + let stdout = String::from_utf8_lossy(&output.stdout); + + match serde_json::from_str::>(&stdout) { + Ok(comments) => comments, + Err(_) => Vec::new(), + } +} + +/// Check if shellcheck is available on the system. +pub fn is_shellcheck_available() -> bool { + Command::new("shellcheck") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Get the shellcheck version if available. +pub fn shellcheck_version() -> Option { + let output = Command::new("shellcheck") + .arg("--version") + .output() + .ok()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Parse version from output like "ShellCheck - shell script analysis tool\nversion: 0.9.0\n..." + for line in stdout.lines() { + if line.starts_with("version:") { + return Some(line.trim_start_matches("version:").trim().to_string()); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_shellcheck_available() { + // This test will pass if shellcheck is installed, skip otherwise + let available = is_shellcheck_available(); + println!("ShellCheck available: {}", available); + } + + #[test] + fn test_shellcheck_version() { + if is_shellcheck_available() { + let version = shellcheck_version(); + println!("ShellCheck version: {:?}", version); + assert!(version.is_some()); + } + } + + #[test] + fn test_run_shellcheck() { + if !is_shellcheck_available() { + println!("Skipping test: shellcheck not available"); + return; + } + + // Script with a known shellcheck warning (SC2086: Double quote to prevent globbing) + let script = r#"#!/bin/bash +echo $foo +"#; + + let comments = run_shellcheck(script, "bash"); + + // Should have at least one warning about unquoted variable + let has_sc2086 = comments.iter().any(|c| c.code == 2086); + assert!(has_sc2086 || comments.is_empty(), "Expected SC2086 warning or empty (if shellcheck behaves differently)"); + } + + #[test] + fn test_shellcheck_comment_rule_code() { + let comment = ShellCheckComment { + file: "-".to_string(), + line: 1, + end_line: 1, + column: 1, + end_column: 10, + level: "warning".to_string(), + code: 2086, + message: "Double quote to prevent globbing".to_string(), + }; + + assert_eq!(comment.rule_code(), "SC2086"); + } +} diff --git a/src/analyzer/hadolint/types.rs b/src/analyzer/hadolint/types.rs new file mode 100644 index 00000000..2aa864c9 --- /dev/null +++ b/src/analyzer/hadolint/types.rs @@ -0,0 +1,311 @@ +//! Core types for the hadolint-rs linter. +//! +//! These types match the Haskell hadolint implementation for compatibility: +//! - `Severity` - Rule violation severity levels +//! - `RuleCode` - Rule identifiers (e.g., "DL3008") +//! - `CheckFailure` - A single rule violation +//! - `State` - Stateful rule accumulator + +use std::cmp::Ordering; +use std::fmt; + +/// Severity levels for rule violations. +/// +/// Ordered from most severe to least severe: +/// `Error > Warning > Info > Style > Ignore` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Severity { + /// Critical issues that should always be fixed + Error, + /// Important issues that should usually be fixed + Warning, + /// Informational suggestions for improvement + Info, + /// Style recommendations + Style, + /// Ignored (rule disabled) + Ignore, +} + +impl Severity { + /// Parse a severity from a string (case-insensitive). + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "error" => Some(Self::Error), + "warning" => Some(Self::Warning), + "info" => Some(Self::Info), + "style" => Some(Self::Style), + "ignore" | "none" => Some(Self::Ignore), + _ => None, + } + } + + /// Get the string representation. + pub fn as_str(&self) -> &'static str { + match self { + Self::Error => "error", + Self::Warning => "warning", + Self::Info => "info", + Self::Style => "style", + Self::Ignore => "ignore", + } + } +} + +impl fmt::Display for Severity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl Default for Severity { + fn default() -> Self { + Self::Info + } +} + +impl Ord for Severity { + fn cmp(&self, other: &Self) -> Ordering { + // Higher severity = lower numeric value for Ord + let self_val = match self { + Self::Error => 0, + Self::Warning => 1, + Self::Info => 2, + Self::Style => 3, + Self::Ignore => 4, + }; + let other_val = match other { + Self::Error => 0, + Self::Warning => 1, + Self::Info => 2, + Self::Style => 3, + Self::Ignore => 4, + }; + // Reverse so Error > Warning > Info > Style > Ignore + other_val.cmp(&self_val) + } +} + +impl PartialOrd for Severity { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// A rule code identifier (e.g., "DL3008", "SC2086"). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RuleCode(pub String); + +impl RuleCode { + /// Create a new rule code. + pub fn new(code: impl Into) -> Self { + Self(code.into()) + } + + /// Get the code as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Check if this is a Dockerfile rule (DL prefix). + pub fn is_dockerfile_rule(&self) -> bool { + self.0.starts_with("DL") + } + + /// Check if this is a ShellCheck rule (SC prefix). + pub fn is_shellcheck_rule(&self) -> bool { + self.0.starts_with("SC") + } +} + +impl fmt::Display for RuleCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From<&str> for RuleCode { + fn from(s: &str) -> Self { + Self::new(s) + } +} + +impl From for RuleCode { + fn from(s: String) -> Self { + Self(s) + } +} + +/// A check failure (rule violation) found during linting. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CheckFailure { + /// The rule code that was violated. + pub code: RuleCode, + /// The severity of the violation. + pub severity: Severity, + /// A human-readable message describing the violation. + pub message: String, + /// The line number where the violation occurred (1-indexed). + pub line: u32, + /// Optional column number (1-indexed). + pub column: Option, +} + +impl CheckFailure { + /// Create a new check failure. + pub fn new( + code: impl Into, + severity: Severity, + message: impl Into, + line: u32, + ) -> Self { + Self { + code: code.into(), + severity, + message: message.into(), + line, + column: None, + } + } + + /// Create a check failure with column information. + pub fn with_column( + code: impl Into, + severity: Severity, + message: impl Into, + line: u32, + column: u32, + ) -> Self { + Self { + code: code.into(), + severity, + message: message.into(), + line, + column: Some(column), + } + } +} + +impl Ord for CheckFailure { + fn cmp(&self, other: &Self) -> Ordering { + // Sort by line number first + self.line.cmp(&other.line) + } +} + +impl PartialOrd for CheckFailure { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// State accumulator for stateful rules. +/// +/// Used by `custom_rule` and `very_custom_rule` to track state across +/// multiple instructions during the analysis pass. +#[derive(Debug, Clone)] +pub struct State { + /// Accumulated failures found during analysis. + pub failures: Vec, + /// Custom state for the rule. + pub state: T, +} + +impl Default for State { + fn default() -> Self { + Self { + failures: Vec::new(), + state: T::default(), + } + } +} + +impl State { + /// Create a new state with the given initial state. + pub fn new(state: T) -> Self { + Self { + failures: Vec::new(), + state, + } + } + + /// Add a failure to the state. + pub fn add_failure(&mut self, failure: CheckFailure) { + self.failures.push(failure); + } + + /// Modify the state with a function. + pub fn modify(&mut self, f: F) + where + F: FnOnce(&mut T), + { + f(&mut self.state); + } + + /// Replace the state entirely. + pub fn replace_state(&mut self, new_state: T) { + self.state = new_state; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_severity_ordering() { + assert!(Severity::Error > Severity::Warning); + assert!(Severity::Warning > Severity::Info); + assert!(Severity::Info > Severity::Style); + assert!(Severity::Style > Severity::Ignore); + } + + #[test] + fn test_severity_from_str() { + assert_eq!(Severity::from_str("error"), Some(Severity::Error)); + assert_eq!(Severity::from_str("WARNING"), Some(Severity::Warning)); + assert_eq!(Severity::from_str("Info"), Some(Severity::Info)); + assert_eq!(Severity::from_str("style"), Some(Severity::Style)); + assert_eq!(Severity::from_str("ignore"), Some(Severity::Ignore)); + assert_eq!(Severity::from_str("none"), Some(Severity::Ignore)); + assert_eq!(Severity::from_str("invalid"), None); + } + + #[test] + fn test_rule_code() { + let dl_code = RuleCode::new("DL3008"); + assert!(dl_code.is_dockerfile_rule()); + assert!(!dl_code.is_shellcheck_rule()); + + let sc_code = RuleCode::new("SC2086"); + assert!(!sc_code.is_dockerfile_rule()); + assert!(sc_code.is_shellcheck_rule()); + } + + #[test] + fn test_check_failure_ordering() { + let f1 = CheckFailure::new("DL3008", Severity::Warning, "msg1", 5); + let f2 = CheckFailure::new("DL3009", Severity::Info, "msg2", 10); + let f3 = CheckFailure::new("DL3010", Severity::Error, "msg3", 3); + + let mut failures = vec![f1.clone(), f2.clone(), f3.clone()]; + failures.sort(); + + assert_eq!(failures[0].line, 3); + assert_eq!(failures[1].line, 5); + assert_eq!(failures[2].line, 10); + } + + #[test] + fn test_state() { + let mut state: State = State::new(0); + assert_eq!(state.state, 0); + assert!(state.failures.is_empty()); + + state.modify(|s| *s += 10); + assert_eq!(state.state, 10); + + state.add_failure(CheckFailure::new("DL3008", Severity::Warning, "test", 1)); + assert_eq!(state.failures.len(), 1); + } +} diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index c0590348..8d635423 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -25,6 +25,7 @@ pub mod runtime; pub mod monorepo; pub mod docker_analyzer; pub mod display; +pub mod hadolint; // Re-export dependency analysis types pub use dependency_parser::{