commit 114ef62dd99f9a210761c6124df6d5b2ebc8cde9 Author: JGZYES Date: Sun Aug 30 18:11:21 2026 +0800 ParlzPackageManger (PMM): cross-platform package manager + mirror - C11, static MinGW build, single exe (pmm/pdm) - apt-style multi-mirror install with sha256/sha1 verify - .pdm package format (pdm pack/install/remove), deb-like - github/gitlab/gitea/forgejo release install with --git/--github etc. - python/pip-style version selection (==,>=,<=,>,< , comma) - auto platform selection (windows/linux variants) - Windows Installed Apps registration + PATH auto-add - download progress bar (tqdm-style, ASCII-safe) - mirror/ : zero-dep Node.js registry server (variants, multi-version) - Licensed under GNU GPL v3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..938310e --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# build artifacts +*.exe +*.o +/dist/ + +# packaged binaries / large upstream files (keep them out of git) +*.pdm +*.msi +*.tar.xz +*.tar.gz +*.zip + +# mirror runtime data (the browser-station packages are regenerated at deploy) +mirror/server.log +mirror/packages/ +mirror/files/ + +# editors +.vscode/ +.idea/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c75bb48 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +CC ?= gcc +CFLAGS ?= -O2 -Wall -static -Wextra -std=c11 +PREFIX ?= /usr/local + +CORE = src/main.c src/json.c src/ini.c src/pmm.c src/http.c src/repo.c \ + src/install.c src/sha256.c src/sha1.c src/mirrors.c src/pdm.c + +all: pmm pdm + +pmm: $(CORE) $(wildcard src/*.h) + $(CC) $(CFLAGS) -o $@ $(CORE) + +PDM_DEPS = src/pdm_main.c src/pdm.c src/pmm.c src/sha256.c src/install.c \ + src/http.c src/json.c src/ini.c src/mirrors.c src/sha1.c + +pdm: $(PDM_DEPS) $(wildcard src/*.h) + $(CC) $(CFLAGS) -o $@ $(PDM_DEPS) + +install: all + install -m 755 pmm $(PREFIX)/bin/pmm + install -m 755 pdm $(PREFIX)/bin/pdm + +uninstall: + rm -f $(PREFIX)/bin/pmm $(PREFIX)/bin/pdm + +clean: + rm -f pmm pdm pmm.exe pdm.exe + +.PHONY: all install uninstall clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7f0bc7 --- /dev/null +++ b/README.md @@ -0,0 +1,247 @@ +# ParlzPackageManger (PMM) + +用 C 语言编写的跨平台包管理器(Windows / Linux / macOS),零第三方库依赖 +(下载通过系统自带的 `curl` 完成,Windows 10+、macOS、主流 Linux 均预装)。 + +## 构建 + +```sh +make # Linux / macOS(或任何有 make 的环境),生成 pmm + pdm 两个二进制 +build.bat # Windows(只需 MinGW gcc),同样生成 pmm.exe + pdm.exe +make install # 安装到 /usr/local/bin(可选) +``` + +产物是单文件 `pmm` / `pdm`(或 `.exe`),无 DLL/第三方库依赖。 + +> 重要:Windows 构建须用**静态链接**(`-static`)。否则产物会依赖 MinGW 的 +> `libmcfgthread-2.dll`,在没装 MinGW 的普通 PowerShell 里会因为找不到该 DLL 而 +> **静默不输出**(加载即退出)。`Makefile`/`build.bat` 已默认加 `-static`。 + +## 使用 + +```sh +# 从镜像站(apt 式多镜像)安装 +pmm install # 按优先级从 registry 镜像查找并安装 +pmm install # 安装本地 .pdm 包 +pmm pack [out.pdm] # 把文件夹打包成 .pdm(模拟 dpkg deb) +pmm pdm install|list|remove|info ... # 管理 .pdm 包 + +# 从 git 托管仓库的最新 release 安装 +pmm install --git https://host/owner/repo.git # 任意 git 仓库(自动探测 API 形状) +pmm install --github owner/repo # GitHub +pmm install --gitlab owner/repo # GitLab +pmm install --gitea https://git.example.com/owner/repo # Gitea/Forgejo(含私人部署) +pmm install --git owner/repo --host forgejo # 强制指定主机类型 + +pmm mirror list / add <名> <源> / use <名> / remove <名> +pmm list +pmm help / version +pmm install -pd # 安装到 D:\.pmm(可换成 -pc 回到 C 盘,-px 任意盘) +``` + +## 换安装盘(-p,避免塞爆 C 盘) + +**默认安装在 `D:\.pmm`**(config + cache + bin + root 全在这里,避免占满 C 盘)。 +用 `-p<盘符>` 可以改变 PMM 的**整个家目录**: + +- 默认(不带 `-p`)→ `D:\.pmm\`(node 等 .exe 装到 `D:\.pmm\bin`,`.pdm` 装到 `D:\.pmm\root`) +- `pmm install xxx -pc` → 回到用户目录 `C:\Users\<你>\.pmm` +- `pmm install xxx -pe` → 任意其它盘(`E:\.pmm\`);也支持 `-p D`(空格分隔)写法 + +**记忆规则**:第一次用某个 `-p<盘>` 会被记住(写入 `~/.pmm/install-drive`), +之后的命令不写也会自动用那个盘;想换盘用新的 `-p<盘>` 即可。 + +> 注意:换盘会把配置/镜像源也挪到对应盘的 `.pmm`(如 `D:\.pmm\mirror.ini`)。 +> 切换后如要装包,先在该盘目录放好镜像配置,或重新 `pmm mirror add ...`。 + +`--git` 对任意 git 仓库地址自动探测 API 形状(Gitea/Forgejo → GitLab → GitHub), +GitHub.com、GitLab.com、Gitea、Forgejo 及一切私有部署实例都支持。 + +## 下载校验(SHA-256 / SHA-1) + +每次下载完成后自动计算并打印 SHA-256 与 SHA-1;若 release 资源旁有 +`<资源名>.sha256` 或 `<资源名>.sha1` 伴随文件,会自动核对,不一致时拒绝安装 +并删除缓存文件。registry 条目里若带 `sha256` 字段,也会作为独立校验来源。 +SHA 实现为纯 C 内置(FIPS 180-4),无外部依赖。 + +## .pdm 包格式(模拟 dpkg 的 deb) + +`pdm pack ./目录` 会把目录打包成一个 `.pdm`(一个 tar 归档,含 +`control.tar.gz` / `data.tar.gz` / `sha256sums` 三个成员,与 deb 的 +`control.tar`+`data.tar` 思路一致): + +- 目录里需有一个 `pdm-control` 文件,格式为 `KEY: value` 行: + `Package` / `Version` / `Architecture` / `Description` / `Maintainer` +- 其余文件全部作为文件有效载荷,安装时解包到安装根目录(`~/.pmm/root`) +- `pdm install ` / `pmm install ` 安装,`pdm remove` 卸载 +- 安装记录写入 `~/.pmm/installed/<包名>.info`,含文件清单,用于卸载 + +## apt 式镜像(mirror.ini / mirror.conf) + +镜像配置文件位于 `~/.pmm/`(Windows 为 `%USERPROFILE%\.pmm\`),查找顺序 +`mirror.ini` → `mirror.conf`。每个 `[名]` 是一个镜像,字段如下: + +```ini +# ~/.pmm/mirror.ini +[官方] +registry = https://mirror.example.com/packages # registry 索引:{registry}/{包}.json +download = https://ghfast.example-mirror.dev # 下载前缀(gh-proxy 风格,接到原始 URL 前) +api = https://api.mirror.example # git 主机 API 基地址(整体替换) +priority = 10 # 越小越优先(apt 式) +default = true + +[自建] +registry = https://git.example.local/packages +priority = 20 +``` + +- `pmm install <包>` 会**按 priority 从小到大依次尝试**每个镜像的 + `{registry}/{包}.json`,第一个成功的镜像命中,否则回退到下一个(apt 的逻辑)。 +- 下载文件时也会把镜像的 `download` 前缀按优先级排在原始 URL 之前逐个尝试。 +- 每家的 `priority` 相同则按出现顺序;`pmm mirror use <名>` 可强制优先用某个镜像。 + +## 镜像站(mirror/ 网页版) + +`mirror/` 目录是一个零依赖的 Node.js 镜像站,提供网页浏览、搜索、按平台筛选、 +上传包并自动生成 registry 索引。 + +**默认站点:`https://pmm.parlz.com/mirror`**(上传生成的下载链接和 `mirror.ini` +都指向它)。本地开发/测试可用 `PMM_BASE_URL` 覆盖: + +```sh +cd mirror && node server.js # 生产默认 https://pmm.parlz.com/mirror +PMM_BASE_URL=http://127.0.0.1:8080 node server.js # 本地调试 +PMM_PORT=9000 node server.js # 仅改监听端口 +``` + +- 网页 `/`:浏览/搜索包、按 Windows/Linux/macOS 筛选、复制安装命令 +- `POST /upload`:上传 `.pdm`/`.deb`/`.exe`/`.dmg`/`.tar.gz` 等,自动算 sha256/sha1 +- 上传带 `x-pkg-name`、`x-pkg-version`、`x-pkg-os`、`x-pkg-desc` 头(或表单包名) + 会写 registry。**包文件存到 `packages/<包>/<版本>.pdm`,元数据 `packages/<包>/<版本>.json`, + `<包>.json` 是 latest 指针(含 `versions` 列表)**;不再有独立的 `files/` 目录。 +- `GET /packages.json`:registry 聚合索引(只列 latest,供 `pmm install` 无版本时用) +- `GET /mirror.ini`:可直接复制到 `~/.pmm/mirror.ini` 的镜像配置 +- 目录约定:`packages/<包>.json`(latest)+ `packages/<包>/<版本>.pdm|.json` + +把 `mirror.ini` 指向这个镜像后,`pmm install <包>` 装 latest,也支持 +**Python/pip 风格的版本限定**: + +```sh +pmm install nodejs # latest +pmm install nodejs==24.20.0 # 精确版本 +pmm install nodejs>=24,<25 # 版本区间(选出范围内最高) +pmm install nodejs<=24.20.0 # <= / >= / < / > / != , 逗号组合 +pmm install "nodejs>=24,<25" # 带空格/组合时加引号 +``` + +## Release 资源与平台对应 + +| 系统 | 优先匹配的扩展名 | +|---|---| +| windows | `.exe` `.msi` `.zip` `.7z` | +| linux | `.deb` `.rpm` `.appimage` `.tar.gz` `.tgz` `.tar.xz` `.tar.bz2` `.pkg.tar.zst` | +| macos | `.dmg` `.pkg` `.app.zip` `.tar.gz` `.tgz` | + +校验文件 / 源码包(`.sha256`、`checksum*`、`-src` 等)会被自动跳过。 + +## 主机支持 + +- **GitHub**:`https://api.github.com/repos/{repo}/releases/latest` +- **GitLab**:`https://gitlab.com/projects/{repo}/releases/permalink/latest` +- **Gitea / Forgejo**(含私人部署):`{origin}/api/v1/repos/{repo}/releases/latest` + +镜像源会整体替换 API 基地址,加速代理和私有实例都走同一条路。 + +## 安装行为(下载后自动安装) + +下载完成并通过校验后,pmm 会自动执行对应平台的安装动作,无需再手动操作: + +- `.deb` → `sudo dpkg -i`;`.rpm` → `sudo rpm -Uvh`(Linux) +- `.msi` → `msiexec /i /qn`(Windows,静默安装) +- `.exe` → 自动运行安装器(Windows,带常见静默旗标 `/S /VERYSILENT /quiet --silent`) +- `.dmg` → `hdiutil attach` + `sudo cp` 复制 `.app` 到 `/Applications`;`.pkg` → `sudo installer`(macOS) +- `.tar.*` / `.zip` / `.7z` → 解压到安装目录(`~/.pmm/bin`) +- `.pdm` → 通过内置 .pdm 管理器解包到 `~/.pmm/root`;`.appimage` → 加执行权限后放入安装目录 + +> Windows 下 pmm 通过系统 `cmd.exe` 执行命令,因此 `.exe` 用 cmd 兼容的单条静默命令。 + +## 已安装软件登记到注册表(Installed Apps) + +安装 `.pdm` 包后,pmm 会模仿 **MSI/NSIS 安装器** 的做法,把软件写入 **每用户 +注册表卸载项** `HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\<包名>`, +于是它会出现在 Windows「应用 / Installed Apps」里,带: + +- `DisplayName` / `DisplayVersion` / `Publisher`(取自控制信息) +- `DisplayIcon`(包内找到的第一个 `.exe`) +- `UninstallString` → `"" remove <包名>`,让「卸载」按钮真正调用我们 +- `InstallLocation`、`EstimatedSize`、`InstallDate`、`NoModify`、`NoRepair` + +`pmm remove <包>`(或控制面板"卸载"按钮)会删除文件并清理该注册表项。 +(每用户 HKCU 写入,无需管理员权限,重启终端/刷新设置可见。) + +## 自动加入 PATH + +每次成功安装后,pmm/pdm 会把 PMM 目录 **自动加入用户 PATH**(幂等,不重复): + +- 始终加入 `~/.pmm/bin` 与 `~/.pmm/root`; +- 还会扫描 `~/.pmm/root/` 下含有可执行文件的子目录并加入,例如安装 Node.js 后 + 会自动加入 `~/.pmm/root/nodejs`;对 `pkg/cmd` 布局(如 Git/MinGit 的 + `git/cmd/git.exe`)会识别到第二层并加入 `~/.pmm/root/git/cmd`。 +- Windows 通过用户注册表 PATH 持久化(`setx`),新开终端即生效;Linux/macOS + 写 `~/.bashrc`(重新加载 shell 生效)。 + +## 现成的 .pdm 包(dist/) + +- `nodejs-24.20.0.pdm` — Node.js 24.20.0(Windows,源自官方 MSI) +- `nodejs-linux-24.20.0.pdm` — Node.js 24.20.0(Linux x64,源自官方 .tar.xz) +- `git-2.55.0.5.pdm` — Git for Windows 2.55.0.5(MinGit 官方发行版) +- `pureftpd-1.0.51.pdm` — **Pure-FTPd 1.0.51(Linux)**:源码分发,装到 Linux 的 `~/.pmm/root` + 后需 `./configure && make && sudo make install` 编译使用(官方只发布源码)。 + +## 自动识别平台 + +镜像站把同一软件做成**多平台变体**(`variants` 数组,每项带 `os`)。客户端安装时 +**自动按当前系统选平台**: + +```sh +pdmm install nodejs # Windows 上装 node.exe 版,Linux 上自动装 ELF 版 +pmm install nodejs +``` + +- Windows 机 `install nodejs` → 拉取 `os=windows` 变体(`packages/nodejs/24.20.0-windows.pdm`) +- Linux 机 `install nodejs` → 拉取 `os=linux` 变体(`packages/nodejs/24.20.0-linux.pdm`) +- 版本语义仍支持 `==` / `>=` / `<=` / `,` 组合,且在**匹配当前平台**的版本里选最高。 + +## 跨环境可靠性 + +`.pdm`/`.deb` 等 tar 相关安装动作先切换到 PMM 目录再用**相对路径**执行,避免 +git-bash 的 MSYS GNU tar(认 `/c/...`)与 Windows 自带的 bsdtar(认 `C:\...`) +对盘符路径解析不一致的问题。 + +## registry 格式(pmm install xxx) + +每个镜像的 `{registry}/{包名}.json` 描述一个包;`url` + `file` 指向发布文件, +`sha256`(可选)作为该文件的独立校验来源: + +```json +{ "version": "1.2.3", "url": "https://.../foo-1.2.3-win64.exe", + "file": "foo-1.2.3-win64.exe", "sha256": "..." } +``` + +## 目录结构 + +``` +src/main.c 命令入口(pmm) +src/json.c/.h 内置 JSON 解析器 +src/ini.c/.h INI/.conf 解析器 +src/http.c/.h 系统 curl 封装 +src/repo.c/.h 仓库适配(GitHub/GitLab/Gitea/Forgejo + 通用 URL 自动探测) +src/sha256.c/.h SHA-256(FIPS 180-4) +src/sha1.c/.h SHA-1(FIPS 180-4) +src/mirrors.c/.h apt 式镜像列表(priority 排序 + 下载回退) +src/pdm.c/.h .pdm 打包/安装/卸载(模拟 deb) +src/pdm_main.c 独立 pdm 工具 +src/install.c/.h 下载、校验与各平台安装 +mirror/ 镜像站(Node.js 网页版) +examples/ 配置文件示例 +``` diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..b3c643b --- /dev/null +++ b/build.bat @@ -0,0 +1,8 @@ +@echo off +rem One-shot Windows build: produces pmm.exe and pdm.exe (needs MinGW gcc + curl in PATH) +set SRC=src\main.c src\json.c src\ini.c src\pmm.c src\http.c src\repo.c src\install.c src\sha256.c src\sha1.c src\mirrors.c src\pdm.c +gcc -O2 -Wall -static -Wextra -std=c11 -o pmm.exe %SRC% +if errorlevel 1 (echo pmm build failed & exit /b 1) +gcc -O2 -Wall -static -Wextra -std=c11 -o pdm.exe src\pdm_main.c src\pdm.c src\pmm.c src\sha256.c src\install.c src\http.c src\json.c src\ini.c src\mirrors.c src\sha1.c +if errorlevel 1 (echo pdm build failed & exit /b 1) +echo built pmm.exe and pdm.exe diff --git a/examples/mirror.conf b/examples/mirror.conf new file mode 100644 index 0000000..7ec0221 --- /dev/null +++ b/examples/mirror.conf @@ -0,0 +1,7 @@ +# PMM 镜像源配置 (与 mirror.ini 格式完全相同) +[ghfast] +api = https://ghfast.example-mirror.dev +default = true + +[mygitea] +api = https://git.mycompany.local diff --git a/examples/mirror.ini b/examples/mirror.ini new file mode 100644 index 0000000..8dfcba6 --- /dev/null +++ b/examples/mirror.ini @@ -0,0 +1,14 @@ +# PMM 镜像源配置 (mirror.conf 与本文件格式相同) +# apt 式:priority 越小越优先;registry 为 pmm install <包> 的索引镜像; +# download 为下载前缀;api 替换 git 主机 API 基地址。 +# +[官方] +registry = https://pmm.parlz.com/mirror/packages +download = https://pmm.parlz.com/mirror/files +priority = 1 +default = true + +[自建Gitea] +registry = https://git.mycompany.local/packages +api = https://git.mycompany.local +priority = 20 diff --git a/examples/pdm-control b/examples/pdm-control new file mode 100644 index 0000000..8539086 --- /dev/null +++ b/examples/pdm-control @@ -0,0 +1,5 @@ +Package: hello +Version: 1.0.0 +Architecture: all +Description: A demo .pdm package +Maintainer: PMM example diff --git a/examples/pmm.conf b/examples/pmm.conf new file mode 100644 index 0000000..1ec0339 --- /dev/null +++ b/examples/pmm.conf @@ -0,0 +1,3 @@ +# PMM 配置 (与 pmm.json / pmm.ini 等价) +registry = https://packages.example.com/pmm +mirror = ghfast diff --git a/examples/pmm.ini b/examples/pmm.ini new file mode 100644 index 0000000..c87c34e --- /dev/null +++ b/examples/pmm.ini @@ -0,0 +1,3 @@ +# PMM 配置 (与 pmm.json 等价,二选一,查找顺序: pmm.json > pmm.ini > pmm.conf) +registry = https://packages.example.com/pmm +mirror = ghfast diff --git a/examples/pmm.json b/examples/pmm.json new file mode 100644 index 0000000..072e588 --- /dev/null +++ b/examples/pmm.json @@ -0,0 +1,4 @@ +{ + "registry": "https://packages.example.com/pmm", + "mirror": "ghfast" +} diff --git a/mirror/index.html b/mirror/index.html new file mode 100644 index 0000000..6cf28fd --- /dev/null +++ b/mirror/index.html @@ -0,0 +1,172 @@ + + + + + +PMM 镜像站 + + + +
+

PMM 镜像站

+ +
+
+
+ + + +
+ +
+

上传包(.pdm / .deb / .exe / .dmg / .tar.gz ...)

+
+ + + + +
+
+
+ +
+
+
+ 客户端使用:pmm mirror use mainpmm install <包名>, + 或复制本页 mirror.ini~/.pmm/。 +
+
+
ParlzPackageManger · 镜像站
+ + + + diff --git a/mirror/server.js b/mirror/server.js new file mode 100644 index 0000000..d943704 --- /dev/null +++ b/mirror/server.js @@ -0,0 +1,216 @@ +// PMM 镜像站(网页版,Node.js 无依赖) +// +// 用自带的 Node 运行,或把这些静态文件放到任意 web 服务器(nginx/静态托管)上。 +// +// node server.js // 默认 http://localhost:8080 +// PMM_PORT=9000 node server.js // 自定义端口 +// +// 目录约定(见 README): +// packages/ registry 索引(packages.json 聚合 + .json 单包元数据) +// files/ 实际发布文件(deb/exe/dmg/pdm/tar.gz ...) +// mirror.ini 提供给 pmm 的镜像配置(server.js 会生成) +// +// 网页 / /index.html 浏览器浏览、搜索、按平台筛选 +// /api/packages.json registry 索引 +// /api/packages/.json 单包元数据 +// /packages.json 同 /api/packages.json(让 pmm 直接用 / 也行) +// /files/ 文件下载 +// /mirror.ini 可直接复制到 ~/.pmm/mirror.ini +// /upload POST 上传(multipart 或 raw) +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const os = require('os'); + +const ROOT = __dirname; +const PKG_DIR = path.join(ROOT, 'packages'); +const PORT = process.env.PMM_PORT ? parseInt(process.env.PMM_PORT, 10) : 8080; +const HOST = process.env.PMM_HOST || '127.0.0.1'; +/* Default public base for generated download URLs / mirror.ini. + * Override with PMM_BASE_URL (e.g. http://127.0.0.1:8080 for local testing). */ +const BASE_URL = (process.env.PMM_BASE_URL || 'https://pmm.parlz.com/mirror').replace(/\/+$/, ''); + +function ensureDirs() { + if (!fs.existsSync(PKG_DIR)) fs.mkdirSync(PKG_DIR, { recursive: true }); +} + +/* ---- parse the .json latest pointers into an index ---- */ +function loadIndex() { + const index = { generated: new Date().toISOString(), packages: [] }; + if (!fs.existsSync(PKG_DIR)) return index; + for (const f of fs.readdirSync(PKG_DIR)) { + if (!f.endsWith('.json')) continue; /* .json only (list, not pkg/ dir) */ + if (fs.statSync(path.join(PKG_DIR, f)).isDirectory()) continue; + try { + const meta = JSON.parse(fs.readFileSync(path.join(PKG_DIR, f), 'utf8')); + if (!meta.variants) continue; + index.packages.push(meta); + } catch (e) { /* skip corrupt */ } + } + index.packages.sort((a, b) => (a.name || '').localeCompare(b.name || '')); + return index; +} + +/* ---- helpers ---- */ +function sha256(p) { return crypto.createHash('sha256').update(p).digest('hex'); } +function sha1(p) { return crypto.createHash('sha1').update(p).digest('hex'); } + +function baseUrl() { + return BASE_URL; +} + +/* ---- upload a file into packages//.pdm, then write registry metadata ---- */ +function handleUpload(req, res) { + let body = []; + req.on('data', c => body.push(c)); + req.on('end', () => { + body = Buffer.concat(body); + let fname = null; + const ct = req.headers['content-type'] || ''; + if (/multipart\/form-data/.test(ct)) { + const b = body.toString('latin1'); + const disp = /filename="([^"]+)"/.exec(b); + fname = disp ? disp[1] : null; + const marker = `\r\n\r\n`; + const idx = b.indexOf(marker); + body = idx >= 0 ? Buffer.from(b.substring(idx + marker.length, b.length - 4 || b.length), 'latin1') : body; + } else { + fname = req.headers['x-file-name'] || urlQuery(req.url, 'file') || 'file.pdm'; + } + if (!fname) { res.writeHead(400); return res.end('no filename'); } + fname = path.basename(fname.replace(/[\\/]/g, '/')); + const pkg = (req.headers['x-pkg-name'] || urlQuery(req.url, 'pkg') || '').trim(); + let ver = (req.headers['x-pkg-version'] || urlQuery(req.url, 'ver') || '').trim(); + if (!pkg) { res.writeHead(400); return res.end('no x-pkg-name'); } + if (!ver) ver = sha256(body).slice(0, 8); + + /* store packages//-.pdm (os lets one package carry multiple platforms) */ + const dir = path.join(PKG_DIR, pkg); + fs.mkdirSync(dir, { recursive: true }); + const os = (req.headers['x-pkg-os'] || urlQuery(req.url, 'os') || 'any').trim(); + const storeName = `${ver}-${os}.pdm`; + const outPath = path.join(dir, storeName); + fs.writeFileSync(outPath, body); + + const info = { + size: body.length, + sha256: sha256(body), + sha1: sha1(body), + file: storeName, + url: `${baseUrl()}/packages/${encodeURIComponent(pkg)}/${encodeURIComponent(storeName)}`, + os, + description: (req.headers['x-pkg-desc'] || urlQuery(req.url, 'desc') || `Uploaded via PMM mirror (${baseUrl()})`).trim(), + }; + writeRegistryEntry(pkg, ver, info); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, file: storeName, version: ver, size: info.size, + sha256: info.sha256, sha1: info.sha1, pkg, url: info.url, mirror: baseUrl() })); + }); +} + +function writeRegistryEntry(pkg, ver, info) { + const meta = { + name: pkg, version: ver, + file: info.file, url: info.url, sha256: info.sha256, + os: info.os || 'any', + description: info.description || `Uploaded via PMM mirror (${baseUrl()})`, + }; + /* per-version-per-os metadata: packages//-.json */ + fs.writeFileSync(path.join(PKG_DIR, pkg, `${ver}-${info.os}.json`), JSON.stringify(meta, null, 2)); + /* latest pointer: packages/.json with a versions list */ + const latestPath = path.join(PKG_DIR, pkg + '.json'); + let last = {}; + if (fs.existsSync(latestPath)) { + try { last = JSON.parse(fs.readFileSync(latestPath, 'utf8')); } catch (e) { last = {}; } + } + /* variants: one entry per (version, os) so the client can pick by platform */ + let variants = Array.isArray(last.variants) ? last.variants : []; + variants = variants.filter(v => !(v.version === ver && v.os === (info.os || 'any'))); + variants.push({ version: ver, os: info.os || 'any', file: info.file, url: info.url, + sha256: info.sha256, description: info.description || '' }); + const latest = Object.assign({}, meta, { variants }); + fs.writeFileSync(latestPath, JSON.stringify(latest, null, 2)); + console.log(` [registry] ${pkg} @ ${ver} (os=${meta.os}) -> packages/${pkg}/${ver}-${meta.os}.pdm`); +} + +function urlQuery(url, key) { + const q = url.split('?')[1] || ''; + for (const kv of q.split('&')) { + const [k, v] = kv.split('='); + if (k === key) return decodeURIComponent(v || ''); + } + return null; +} + +const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', + '.ini': 'text/plain', '.txt': 'text/plain', '.css': 'text/css', '.pdm': 'application/octet-stream' }; + +function serveStatic(res, p) { + const ext = path.extname(p); + fs.readFile(p, (err, data) => { + if (err) { res.writeHead(404); return res.end('not found'); } + res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream', + 'Content-Length': data.length }); /* lets the client show total size */ + res.end(data); + }); +} + +function generateMirrorIni() { + return `# 由 PMM 镜像站 ${baseUrl()} 自动生成 +# 复制到 ~/.pmm/mirror.ini(或 mirror.conf)即可用 pmm install 从本镜像安装 +[main] +registry = ${baseUrl()}/packages +priority = 1 +`; +} + +const server = http.createServer((req, res) => { + const u = new URL(req.url, baseUrl()); + const pathname = decodeURIComponent(u.pathname); + + // CORS for browser-based tools + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Headers', '*'); + if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); } + + if (req.method === 'POST' && pathname === '/upload') return handleUpload(req, res); + + if (pathname === '/' || pathname === '/index.html') return serveStatic(res, path.join(ROOT, 'index.html')); + if (pathname === '/packages.json' || pathname === '/api/packages.json') + return serveJson(res, loadIndex()); + if (pathname === '/mirror.ini' || pathname === '/mirror.conf') { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + return res.end(generateMirrorIni()); + } + const pm = /^\/api\/packages\/([^/]+)\.json$/.exec(pathname) || /^\/packages\/([^/]+)\.json$/.exec(pathname); + if (pm) { + const f = path.join(PKG_DIR, pm[1] + '.json'); + if (fs.existsSync(f)) return serveStatic(res, f); + res.writeHead(404); return res.end('not found'); + } + // versioned metadata / package file: + // /packages//-.json -> packages//-.json + // /packages//-.pdm -> packages//-.pdm (download) + const pv = /^\/api\/packages\/([^/]+)\/([^/]+)\.(json|pdm)$/.exec(pathname) || + /^\/packages\/([^/]+)\/([^/]+)\.(json|pdm)$/.exec(pathname); + if (pv) { + const ext = '.' + pv[3]; + const f = path.join(PKG_DIR, pv[1], pv[2] + ext); + if (fs.existsSync(f)) return serveStatic(res, f); + res.writeHead(404); return res.end('not found'); + } + if (pathname === '/api/health') { res.writeHead(200); return res.end('ok'); } + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('not found'); +}); + +function serveJson(res, obj) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj, null, 2)); } + +ensureDirs(); +console.log(`PMM mirror (web) serving ${ROOT}`); +console.log(` web UI: ${baseUrl()}/`); +console.log(` registry: ${baseUrl()}/packages.json`); +console.log(` mirror.ini: ${baseUrl()}/mirror.ini`); +server.listen(PORT, HOST); diff --git a/src/http.c b/src/http.c new file mode 100644 index 0000000..0d3d2f6 --- /dev/null +++ b/src/http.c @@ -0,0 +1,228 @@ +/* http.c - HTTP(S) via the system curl executable */ +#include "http.h" +#include +#include +#include + +#ifdef _WIN32 +#include +#define PMM_POPEN _popen +#define PMM_PCLOSE _pclose +#define PMM_POPEN_READ(cmd) _popen(cmd, "r") /* text: header/body capture */ +#define PMM_PCLOSE_READ(p) _pclose(p) +#define PMM_POPEN_READ_X(cmd) _popen(cmd, "rb") /* binary: download streams */ +#define PMM_PCLOSE_READ_X(p) _pclose(p) +#define STDERR_FD _fileno(stderr) +#define IS_TTY(fd) _isatty(fd) +#else +#include +#define PMM_POPEN popen +#define PMM_PCLOSE pclose +#define PMM_POPEN_READ(cmd) popen(cmd, "r") +#define PMM_PCLOSE_READ(p) pclose(p) +#define PMM_POPEN_READ_X(cmd) popen(cmd, "r") +#define PMM_PCLOSE_READ_X(p) pclose(p) +#define STDERR_FD fileno(stderr) +#define IS_TTY(fd) isatty(fd) +#endif + +/* Read all output of `cmd` into a malloc'd buffer. */ +static char *run_capture(const char *cmd, size_t *out_len) { + FILE *f = PMM_POPEN(cmd, "rb"); + if (!f) return NULL; + size_t cap = 64 * 1024, len = 0; + char *buf = malloc(cap); + if (!buf) { PMM_PCLOSE(f); return NULL; } + for (;;) { + if (len + 4096 > cap) { + cap *= 2; + char *nb = realloc(buf, cap); + if (!nb) { free(buf); PMM_PCLOSE(f); return NULL; } + buf = nb; + } + size_t n = fread(buf + len, 1, 4096, f); + len += n; + if (n == 0) break; + } + int rc = PMM_PCLOSE(f); + if (rc != 0 && len == 0) { free(buf); return NULL; } + if (out_len) *out_len = len; + buf[len] = '\0'; + return buf; +} + +/* Run `curl ` with the URL appended; return captured output. */ +static char *curl_capture(const char *args, const char *url, size_t *out_len) { + size_t cmdlen = strlen(args) + strlen(url) * 3 + 64; + char *cmd = malloc(cmdlen); + if (!cmd) return NULL; + /* wrap URL in double quotes, escaping embedded quotes */ + snprintf(cmd, cmdlen, "curl %s \"", args); + char *p = cmd + strlen(cmd); + for (const char *u = url; *u && (size_t)(p - cmd) < cmdlen - 4; u++) { + if (*u == '"') *p++ = '\\'; + if (*u == '$') *p++ = '\\'; + *p++ = *u; + } + *p++ = '"'; + *p = '\0'; + char *res = run_capture(cmd, out_len); + free(cmd); + return res; +} + +char *http_get(const char *url, int *status) { + /* -w appends the HTTP status code on its own last line */ + size_t len = 0; + char *body = curl_capture("-sSL --max-time 60 -w \\n__PMM_HTTP__%{http_code}", url, &len); + if (!body) return NULL; + char *marker = body + len - 1; + while (marker > body && *marker != '\n') marker--; + if (strncmp(marker + 1, "__PMM_HTTP__", 12) == 0) { + int code = atoi(marker + 13); + *marker = '\0'; /* strip marker + newline */ + if (code <= 0) { /* 000 = failed transfer (reset/refused/DNS), not success */ + if (status) *status = -1; + free(body); + return NULL; + } + if (status) *status = code; + } else { + if (status) *status = -1; + } + return body; +} + +/* ---------- python/tqdm-style download progress ---------- */ + +#ifdef _WIN32 +#include +static unsigned long long now_ms(void) { return (unsigned long long)GetTickCount64(); } +#else +#include +static unsigned long long now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (unsigned long long)ts.tv_sec * 1000 + (unsigned long long)ts.tv_nsec / 1000000; } +#endif + +/* human size: B / KB / MB / GB with 1 decimal */ +static void hs(unsigned long long v, char *out, size_t n) { + const char *u[] = { "B", "KB", "MB", "GB", "TB" }; + int i = 0; double d = (double)v; + while (d >= 1024.0 && i < 4) { d /= 1024.0; i++; } + if (i == 0) snprintf(out, n, "%llu B", v); + else snprintf(out, n, "%.1f%s", d, u[i]); +} + +static void hms(double sec, char *out, size_t n) { + int s = (int)sec; + if (s >= 3600) snprintf(out, n, "%02d:%02d:%02d", s / 3600, (s % 3600) / 60, s % 60); + else snprintf(out, n, "%02d:%02d", s / 60, s % 60); +} + +/* python-style bar, ASCII-safe (no unicode block glyphs, so it can't mojibake + * on GBK/936 or other non-UTF-8 Windows console codepages): + * 93%|##############--------| 36.1MB/38.8MB [01:30<00:05, 560.7KB/s] */ +static void render_progress(unsigned long long fetched, unsigned long long total, + double elapsed, double speed, int done) { + int w = 28; + char line[240]; + if (total == 0) { /* unknown total */ + char f[32], sp[32], et[32]; + hs(fetched, f, sizeof(f)); + hs(speed > 0 ? speed : 0, sp, sizeof(sp)); + hms(elapsed, et, sizeof(et)); + snprintf(line, sizeof(line), "\r %s [%s, %s/s]", f, et, sp); + } else { + double pct = (double)fetched / (double)total * 100.0; + int fill = (int)(pct / 100.0 * w); + char bar[32]; + for (int i = 0; i < w; i++) bar[i] = (i < fill) ? '#' : ' '; + bar[w] = '\0'; + char f[32], t[32], sp[32], e[32], eta[32]; + hs(fetched, f, sizeof(f)); + hs(total, t, sizeof(t)); + hs(speed > 0 ? speed : 0, sp, sizeof(sp)); + hms(elapsed, e, sizeof(e)); + double rem = speed > 0 ? ((double)total - (double)fetched) / speed : 0; + hms(rem, eta, sizeof(eta)); + snprintf(line, sizeof(line), "\r%3.0f%%|%s| %s/%s [%s<%s, %s/s]", + pct >= 100.0 ? 100.0 : pct, bar, f, t, e, eta, sp); + } + /* pad to a fixed width that ALWAYS fits in a normal terminal (~72 cols) so + * the frame overwrites in place; ≥200 spaces would wrap on narrow consoles + * and that is what makes it look like the bar is spamming many lines. */ + int L = (int)strlen(line); + while (L < 72) line[L++] = ' '; + line[L] = '\0'; + fprintf(stderr, "%s", line); + if (done) fputc('\n', stderr); + fflush(stderr); +} + +/* fetch Content-Length (bytes) via HEAD; returns 0 if unknown */ +static unsigned long long remote_size(const char *url) { + unsigned long long sz = 0; + char cmd[2100]; + snprintf(cmd, sizeof(cmd), "curl -sIL --max-time 30 \"%s\" 2>nul", url); + FILE *f = PMM_POPEN_READ(cmd); + if (f) { + char line[1024]; + char low[1024]; + while (fgets(line, sizeof(line), f)) { + strncpy(low, line, sizeof(low) - 1); low[sizeof(low) - 1] = '\0'; + for (char *p = low; *p; p++) if (*p >= 'A' && *p <= 'Z') *p += 32; + char *v = strstr(low, "content-length:"); + if (v) { sz = (unsigned long long)strtoull(v + 15, NULL, 10); break; } + } + PMM_PCLOSE_READ(f); + } + return sz; +} + +int http_download(const char *url, const char *out_path) { + int tty = IS_TTY(STDERR_FD); + if (getenv("PMM_FORCE_PROGRESS")) tty = 1; /* debug/testing override */ + + if (!tty) { + /* background/redirect: silent, simple */ + size_t cmdlen = strlen(url) * 3 + strlen(out_path) * 3 + 160; + char *cmd = malloc(cmdlen); + if (!cmd) return -1; + snprintf(cmd, cmdlen, "curl -L --fail --retry 3 --max-time 3600 -S --silent -o \"%s\" \"%s\"", out_path, url); + int rc = system(cmd); + free(cmd); + return (rc == 0) ? 0 : -1; + } + + /* terminal: stream via popen and paint a python-style bar */ + unsigned long long total = remote_size(url); + char cmd[2100]; + snprintf(cmd, sizeof(cmd), "curl -sL --fail --retry 3 --max-time 3600 -o - \"%s\"", url); + FILE *pf = PMM_POPEN_READ_X(cmd); /* binary read */ + if (!pf) return -1; + FILE *outf = fopen(out_path, "wb"); + if (!outf) { PMM_PCLOSE_READ_X(pf); return -1; } + + char buf[65536]; + size_t r; + unsigned long long fetched = 0, last = 0, last_n = now_ms(); + double speed = 0, el = 0; + double start = (double)now_ms(); + while ((r = fread(buf, 1, sizeof(buf), pf)) > 0) { + fwrite(buf, 1, r, outf); + fetched += r; + unsigned long long n = now_ms(); + double dt = (n - last_n) / 1000.0; + if (dt >= 0.25) { + double inst = (fetched - last) / dt; + speed = (speed <= 0) ? inst : (speed * 0.7 + inst * 0.3); /* EMA smooth */ + last = fetched; last_n = n; + } + el = ((double)n - start) / 1000.0; + render_progress(fetched, total, el, speed, 0); + } + fclose(outf); + int rc = PMM_PCLOSE_READ_X(pf); + if (rc != 0) { remove(out_path); return -1; } + render_progress(fetched, total, el, speed, 1); + return 0; +} diff --git a/src/http.h b/src/http.h new file mode 100644 index 0000000..9a3fcd9 --- /dev/null +++ b/src/http.h @@ -0,0 +1,13 @@ +/* http.h - HTTP(S) via system curl (curl is preinstalled on Win10+/macOS/most Linux) */ +#ifndef PMM_HTTP_H +#define PMM_HTTP_H + +#include + +/* GET a URL, return malloc'd body or NULL on failure. Sets *status (may be NULL). */ +char *http_get(const char *url, int *status); + +/* Download URL to file path. Returns 0 on success. */ +int http_download(const char *url, const char *out_path); + +#endif diff --git a/src/ini.c b/src/ini.c new file mode 100644 index 0000000..deaf7c0 --- /dev/null +++ b/src/ini.c @@ -0,0 +1,81 @@ +/* ini.c - tiny INI / .conf parser */ +#include "ini.h" +#include +#include +#include +#include + +static char *trim(char *s) { + while (*s && isspace((unsigned char)*s)) s++; + char *end = s + strlen(s); + while (end > s && isspace((unsigned char)end[-1])) *--end = '\0'; + return s; +} + +Ini *ini_load(const char *path) { + FILE *f = fopen(path, "r"); + if (!f) return NULL; + Ini *ini = calloc(1, sizeof(Ini)); + if (!ini) { fclose(f); return NULL; } + char line[4096]; + char section[512] = ""; + IniEntry *tail = NULL; + while (fgets(line, sizeof(line), f)) { + char *s = trim(line); + if (*s == '\0' || *s == '#' || *s == ';') continue; + if (*s == '[') { + char *close = strchr(s, ']'); + if (close) { + *close = '\0'; + strncpy(section, s + 1, sizeof(section) - 1); + section[sizeof(section) - 1] = '\0'; + char *t = trim(section); + memmove(section, t, strlen(t) + 1); + } + continue; + } + char *eq = strchr(s, '='); + if (!eq) eq = strchr(s, ':'); + if (!eq) continue; + *eq = '\0'; + char *key = trim(s); + char *value = trim(eq + 1); + IniEntry *e = calloc(1, sizeof(IniEntry)); + e->section = strdup(*section ? section : ""); + e->key = strdup(key); + e->value = strdup(value); + if (tail) tail->next = e; else ini->head = e; + tail = e; + } + fclose(f); + return ini; +} + +void ini_free(Ini *ini) { + if (!ini) return; + IniEntry *e = ini->head; + while (e) { + IniEntry *n = e->next; + free(e->section); free(e->key); free(e->value); free(e); + e = n; + } + free(ini); +} + +const char *ini_get(const Ini *ini, const char *section, const char *key) { + if (!ini) return NULL; + if (!section) section = ""; + for (IniEntry *e = ini->head; e; e = e->next) + if (strcmp(e->section, section) == 0 && strcmp(e->key, key) == 0) + return e->value; + return NULL; +} + +const char *ini_find_key_by_value(const Ini *ini, const char *section, const char *value) { + if (!ini) return NULL; + if (!section) section = ""; + for (IniEntry *e = ini->head; e; e = e->next) + if (strcmp(e->section, section) == 0 && strcmp(e->value, value) == 0) + return e->key; + return NULL; +} diff --git a/src/ini.h b/src/ini.h new file mode 100644 index 0000000..37cc51b --- /dev/null +++ b/src/ini.h @@ -0,0 +1,28 @@ +/* ini.h - tiny INI / .conf parser for PMM. + * Format: + * [section] + * key = value (# or ; comment) + * Also accepts key = value lines without a section (section = ""). + */ +#ifndef PMM_INI_H +#define PMM_INI_H + +typedef struct IniEntry { + char *section; + char *key; + char *value; + struct IniEntry *next; +} IniEntry; + +typedef struct { + IniEntry *head; +} Ini; + +Ini *ini_load(const char *path); +void ini_free(Ini *ini); +/* Lookup value; section may be NULL or "" for top-level. Returns NULL if absent. */ +const char *ini_get(const Ini *ini, const char *section, const char *key); +/* First key in given section whose value matches; returns key name or NULL. */ +const char *ini_find_key_by_value(const Ini *ini, const char *section, const char *value); + +#endif diff --git a/src/install.c b/src/install.c new file mode 100644 index 0000000..5edaba8 --- /dev/null +++ b/src/install.c @@ -0,0 +1,425 @@ +/* install.c - download + per-OS install logic + * + * Install methods: + * windows: .exe -> run installer silently where known, else copy to bin + * .msi -> msiexec /i /qn + * .zip/.7z -> extract into install dir + * linux: .deb -> dpkg -i .rpm -> rpm -Uvh + * .appimage -> chmod +x, copy to bin + * .tar.gz/.tgz/.tar.xz -> extract into install dir + * macos: .dmg -> hdiutil attach, copy .app to /Applications, detach + * .pkg -> installer -pkg + * .tar.gz/.tgz -> extract into install dir + */ +#include "install.h" +#include "pmm.h" +#include "http.h" +#include "json.h" +#include "sha256.h" +#include "sha1.h" +#include "mirrors.h" +#include "pdm.h" +#include +#include +#include + +#ifdef _WIN32 +#include +#define PMM_MKDIR(p) _mkdir(p) +#else +#include +#define PMM_MKDIR(p) mkdir((p), 0755) +#endif + +static int has_suffix(const char *s, const char *suf) { + size_t sl = strlen(s), xl = strlen(suf); + return sl >= xl && strcasecmp(s + sl - xl, suf) == 0; +} + +static const char *base_name(const char *path) { + const char *a = strrchr(path, '/'); + const char *b = strrchr(path, '\\'); + const char *p = (a > b) ? a : b; + return p ? p + 1 : path; +} + +static int run_cmd_quiet(const char *cmd) { + int rc = system(cmd); + return rc == 0 ? 0 : -1; +} + +/* Strip " *path" style prefix from checksum files; keep last token's hex. */ +static void extract_hex(const char *text, const char *filename, char *hex, size_t hexsize) { + hex[0] = '\0'; + const char *line = text; + while (line && *line) { + const char *eol = strchr(line, '\n'); + size_t ll = eol ? (size_t)(eol - line) : strlen(line); + char buf[1024]; + if (ll >= sizeof(buf)) ll = sizeof(buf) - 1; + memcpy(buf, line, ll); + buf[ll] = '\0'; + /* trim */ + char *s = buf; + while (*s == ' ' || *s == '\t' || *s == '\r') s++; + size_t sl = strlen(s); + while (sl && (s[sl-1] == ' ' || s[sl-1] == '\t' || s[sl-1] == '\r')) s[--sl] = '\0'; + /* does this line mention the file (or is a bare hash)? */ + char *hend = s; + while (*hend && *hend != ' ' && *hend != '\t' && *hend != '*') hend++; + int is_hex_prefix = 1; + for (char *p = s; p < hend; p++) + if (!((*p >= '0' && *p <= '9') || (*p >= 'a' && *p <= 'f') || (*p >= 'A' && *p <= 'F'))) + { is_hex_prefix = 0; break; } + if (is_hex_prefix && hend > s) { + /* bare "" or " *file" */ + if (!*hend || strstr(hend, filename)) { + snprintf(hex, hexsize, "%.*s", (int)(hend - s), s); + return; + } + } else if (strstr(s, filename)) { + /* "hash file" reversed order unlikely; take first token */ + char tok[256]; size_t t = 0; + while (s[t] && s[t] != ' ' && s[t] != '\t' && t < sizeof(tok) - 1) { tok[t] = s[t]; t++; } + tok[t] = '\0'; + int ok = *tok; + for (char *p = tok; *p; p++) + if (!((*p >= '0' && *p <= '9') || (*p >= 'a' && *p <= 'f') || (*p >= 'A' && *p <= 'F'))) + { ok = 0; break; } + if (ok) { snprintf(hex, hexsize, "%s", tok); return; } + } + line = eol ? eol + 1 : NULL; + } +} + +/* Best-effort sha256/sha1 verification against sidecar checksum files + * (.sha256 / .sha1). Returns 0 on ok-or-no-checksum, -1 on mismatch. */ +static int verify_checksums(const char *url, const char *path, const char *name) { + char hex[128]; + struct { const char *ext; int algo; } algos[] = { + { ".sha256", 256 }, { ".sha1", 1 }, { NULL, 0 } + }; + for (int i = 0; algos[i].ext; i++) { + char csum_url[2400]; + snprintf(csum_url, sizeof(csum_url), "%s%s", url, algos[i].ext); + int status = 0; + char *text = http_get(csum_url, &status); + if (!text || (status != 200 && status != 0)) { free(text); continue; } + if (algos[i].algo == 256) { + if (pmm_sha256_file(path, hex) != 0) { free(text); return -1; } + } else { + if (pmm_sha1_file(path, hex) != 0) { free(text); return -1; } + } + char expect[128]; + extract_hex(text, name, expect, sizeof(expect)); + free(text); + if (!expect[0]) { + printf("pmm: warning: could not parse %s checksum file\n", algos[i].ext); + continue; + } + /* case-insensitive compare */ + if (strcasecmp(expect, hex) != 0) { + fprintf(stderr, "pmm: CHECKSUM MISMATCH (%s)!\n expected: %s\n actual: %s\n", + algos[i].ext, expect, hex); + return -1; + } + printf("pmm: %s checksum OK: %s\n", algos[i].algo == 256 ? "sha256" : "sha1", hex); + } + return 0; +} + +int install_file(const char *url, const char *name) { + PmmOS os = pmm_detect_os(); + char cache[1024], path[1200], cmd[2600]; + pmm_cache_dir(cache, sizeof(cache)); + snprintf(path, sizeof(path), "%s/%s", cache, name); + + /* apt-style fallback: mirrors (by priority) first, then the origin URL */ + MirrorList *ml = mirrors_load(); + int ncand = 0; + char **cands = mirrors_download_candidates(ml, url, &ncand); + int ok = -1; + for (int i = 0; i < ncand; i++) { + printf("pmm: downloading %s%s\n", cands[i], + i < ncand - 1 ? " (mirror)" : ""); + if (http_download(cands[i], path) == 0) { ok = 0; break; } + fprintf(stderr, "pmm: download failed, trying next source...\n"); + remove(path); + } + for (int i = 0; i < ncand; i++) free(cands[i]); + free(cands); + mirrors_free(ml); + if (ok != 0) { + fprintf(stderr, "pmm: all download sources failed: %s\n", url); + return -1; + } + printf("pmm: downloaded %s\n", path); + + /* integrity: sha256 + sha1, verified against sidecar checksums when present */ + char hex[128]; + if (pmm_sha256_file(path, hex) == 0) + printf("pmm: sha256: %s\n", hex); + if (pmm_sha1_file(path, hex) == 0) + printf("pmm: sha1: %s\n", hex); + if (verify_checksums(url, path, name) != 0) { + fprintf(stderr, "pmm: refusing to install: checksum verification failed\n"); + remove(path); + return -1; + } + + const char *bname = base_name(name); + char dest[1024]; + pmm_install_dir(dest, sizeof(dest)); + + /* forward-slash clone of the cached path (native exes/tools accept it and the + * git-bash shell won't mangle a command word like "C:/...") */ + char pwin[1200]; + snprintf(pwin, sizeof(pwin), "%s", path); + for (char *q = pwin; *q; q++) if (*q == '\\') *q = '/'; + + /* .pdm packages are installed through the deb-like manager */ + if (has_suffix(bname, ".pdm") || has_suffix(bname, ".PDM")) { + extern int pdm_install_file(const char *path); + printf("pmm: installing .pdm %s ...\n", bname); + if (pdm_install_file(path) != 0) { + fprintf(stderr, "pmm: .pdm install failed: %s\n", bname); + return -1; + } + printf("pmm: installed .pdm %s\n", bname); + pmm_add_to_path(); + return 0; + } + + if (has_suffix(bname, ".deb") && os == OS_LINUX) { + snprintf(cmd, sizeof(cmd), "sudo dpkg -i \"%s\"", path); + } else if (has_suffix(bname, ".rpm") && os == OS_LINUX) { + snprintf(cmd, sizeof(cmd), "sudo rpm -Uvh \"%s\"", path); + } else if (has_suffix(bname, ".appimage") && os == OS_LINUX) { + snprintf(cmd, sizeof(cmd), "chmod +x \"%s\" && mv -f \"%s\" \"%s/\"", path, path, dest); + } else if ((has_suffix(bname, ".tar.gz") || has_suffix(bname, ".tgz") || + has_suffix(bname, ".tar.xz") || has_suffix(bname, ".tar.bz2") || + has_suffix(bname, ".zip") || has_suffix(bname, ".7z"))) { + if (has_suffix(bname, ".zip")) + snprintf(cmd, sizeof(cmd), "unzip -o \"%s\" -d \"%s\"", path, dest); + else if (has_suffix(bname, ".7z")) + snprintf(cmd, sizeof(cmd), "7z x -y -o\"%s\" \"%s\"", dest, path); + else + snprintf(cmd, sizeof(cmd), "tar -xf \"%s\" -C \"%s\"", path, dest); + } else if (has_suffix(bname, ".dmg") && os == OS_MACOS) { + snprintf(cmd, sizeof(cmd), + "h=$(hdiutil attach \"%s\" | grep -o '/Volumes/.*' | head -1); " + "sudo cp -R \"$h\"/*.app /Applications/ 2>/dev/null; hdiutil detach \"$h\"", + path); + } else if (has_suffix(bname, ".pkg") && os == OS_MACOS) { + snprintf(cmd, sizeof(cmd), "sudo installer -pkg \"%s\" -target /", path); + } else if (has_suffix(bname, ".msi") && os == OS_WINDOWS) { + /* silent install; note system() runs cmd.exe on Windows, so use single + * slashes and a forward-slash path (cmd accepts both) */ + snprintf(cmd, sizeof(cmd), "msiexec /i \"%s\" /qn", pwin); + } else if (has_suffix(bname, ".exe") && os == OS_WINDOWS) { + /* auto-run the installer. system() runs cmd.exe, which chokes on shell + * control operators (|| / &) here, so issue ONE silent invocation + * (common silent flags; vendors ignore the ones they don't know). */ + snprintf(cmd, sizeof(cmd), + "\"%s\" /S /VERYSILENT /quiet --silent 2>nul", pwin); + } else { + /* unknown type for this OS: just leave in cache and copy if a single file */ + snprintf(cmd, sizeof(cmd), "cp -f \"%s\" \"%s/\" 2>/dev/null || true", path, dest); + } + + printf("pmm: installing %s ...\n", bname); + if (run_cmd_quiet(cmd) != 0) { + fprintf(stderr, "pmm: install command failed: %s\n", cmd); + return -1; + } + printf("pmm: installed %s\n", bname); + pmm_add_to_path(); + return 0; +} + +/* ---------- python-style version selection (==,>=,<=,>,<,!= , comma list) ---------- */ + +/* Compare dotted version strings numerically ("24.20.0" vs "24.2.0"). */ +static int vcmp(const char *a, const char *b) { + const char *pa = a, *pb = b; + while (*pa || *pb) { + double va = 0, vb = 0; int ha = 0, hb = 0; + while (*pa && *pa != '.' && *pa != '-') { va = va * 10 + (*pa - '0'); pa++; ha = 1; } + while (*pb && *pb != '.' && *pb != '-') { vb = vb * 10 + (*pb - '0'); pb++; hb = 1; } + if (ha != hb) return ha < hb ? -1 : 1; + if (va != vb) return va < vb ? -1 : 1; + if (*pa == '.') pa++; if (*pb == '.') pb++; + } + return 0; +} + +/* Does `ver` satisfy one `cond` like ">=24.20.0"? (bare "24.20.0" means ==) */ +static int cond_match(const char *ver, const char *cond) { + while (*cond == ' ' || *cond == '\t') cond++; + char op[3] = {0}; int oi = 0; + if (cond[0] == '=' && cond[1] == '=') { op[0] = '='; op[1] = '='; oi = 2; } + else if (cond[0] == '!' && cond[1] == '=') { op[0] = '!'; op[1] = '='; oi = 2; } + else if (cond[0] == '>' && cond[1] == '=') { op[0] = '>'; op[1] = '='; oi = 2; } + else if (cond[0] == '<' && cond[1] == '=') { op[0] = '<'; op[1] = '='; oi = 2; } + else if (cond[0] == '>') { op[0] = '>'; oi = 1; } + else if (cond[0] == '<') { op[0] = '<'; oi = 1; } + else { op[0] = '='; op[1] = '='; oi = 2; } /* bare version == */ + const char *num = cond + oi; + while (*num == ' ' || *num == '\t') num++; + int c = vcmp(ver, num); + if (oi == 2 && op[0] == '=' && op[1] == '=') return c == 0; + if (oi == 2 && op[0] == '!' && op[1] == '=') return c != 0; + if (oi == 2 && op[0] == '>' && op[1] == '=') return c >= 0; + if (oi == 2 && op[0] == '<' && op[1] == '=') return c <= 0; + if (op[0] == '>') return c > 0; + if (op[0] == '<') return c < 0; + return 0; +} + +/* ver satisfies a comma-separated spec like ">=24,<25" */ +static int spec_match(const char *ver, const char *spec) { + char tmp[256]; + strncpy(tmp, spec, 255); + tmp[255] = '\0'; + char *p = tmp; + while (*p) { + while (*p == ' ' || *p == '\t' || *p == ',') p++; + if (!*p) break; + char *start = p; + while (*p && *p != ',') p++; + char hold = *p; + *p = '\0'; + if (!cond_match(ver, start)) return 0; + if (hold) *p = hold; + } + return 1; +} + +/* Pick the HIGHEST variant matching current OS + version spec from a `variants` + * array; returns the selected variant JsonValue* or NULL. */ +static const char *cur_os_name(void) { + const char *ov = getenv("PMM_OS_OVERRIDE"); /* debug/testing */ + if (ov && *ov) return ov; + switch (pmm_detect_os()) { case OS_WINDOWS: return "windows"; case OS_LINUX: return "linux"; + case OS_MACOS: return "macos"; default: return "any"; } +} + +int install_from_registry(const char *name, const char *spec, const char *mirror_active) { + if (!name || !*name) return -1; + MirrorList *ml = mirrors_load(); + int has_reg = 0; + for (int i = 0; i < ml->count; i++) + if (ml->items[i].registry && *ml->items[i].registry) has_reg = 1; + + if (!has_reg) { + fprintf(stderr, + "pmm: no registry mirror configured. Add to ~/.pmm/mirror.ini:\n" + " [name]\n registry = https://host/pmm\n" + " (apt-style: mirrors are tried by priority; the first with the\n" + " package wins, else plain 'pmm install --git owner/repo')\n"); + mirrors_free(ml); + return -1; + } + + /* Build registry base order: active mirror first, then by priority, deduped. */ + char *bases[128]; + int nb = 0; + int seen[128] = {0}; + if (mirror_active && *mirror_active) { + for (int i = 0; i < ml->count; i++) + if (ml->items[i].registry && *ml->items[i].registry && + strcmp(ml->items[i].name, mirror_active) == 0) + { bases[nb++] = ml->items[i].registry; seen[i] = 1; break; } + } + for (int i = 0; i < ml->count && nb < 128; i++) + if (!seen[i] && ml->items[i].registry && *ml->items[i].registry) + bases[nb++] = ml->items[i].registry; + + if (nb == 0) { fprintf(stderr, "pmm: no registry mirrors\n"); mirrors_free(ml); return -1; } + + /* fetch the .json latest pointer (carries the variants list) */ + char url[2048]; + int status = 0; + char *body = NULL; + char *used_base = NULL; + for (int i = 0; i < nb; i++) { + snprintf(url, sizeof(url), "%s/%s.json", bases[i], name); + printf("pmm: looking up %s in mirror %s\n", name, bases[i]); + body = http_get(url, &status); + if (body && status == 200) { used_base = bases[i]; break; } + free(body); body = NULL; + } + if (!body) { + fprintf(stderr, "pmm: package '%s' not found in any registry mirror\n", name); + mirrors_free(ml); + return -1; + } + JsonValue *meta = json_parse(body); + free(body); + if (!meta || meta->type != JSON_OBJECT) { + fprintf(stderr, "pmm: bad registry entry for '%s' (%s)\n", name, used_base); + json_free(meta); + mirrors_free(ml); + return -1; + } + + const char *osn = cur_os_name(); + JsonValue *variants = json_get(meta, "variants"); + const char *dl = NULL, *file = NULL; + char *want_sha = NULL; + const char *chosen = NULL; + if (variants && variants->count > 0) { + /* pick highest variant that matches OS (or 'any') + version spec */ + const char *bestver = NULL; + for (int i = 0; i < variants->count; i++) { + JsonValue *v = json_at(variants, i); + if (!v || v->type != JSON_OBJECT) continue; + const char *vos = json_str(v, "os"); if (!vos) continue; + if (strcmp(vos, osn) != 0 && strcmp(vos, "any") != 0) continue; + const char *ver = json_str(v, "version"); if (!ver) continue; + if (spec && *spec && !spec_match(ver, spec)) continue; + if (!bestver || vcmp(ver, bestver) > 0) { bestver = ver; chosen = ver; + dl = json_str(v, "url"); file = json_str(v, "file"); + const char *s = json_str(v, "sha256"); free(want_sha); want_sha = s ? strdup(s) : NULL; } + } + if (!chosen) { + fprintf(stderr, "pmm: no version of '%s' for os=%s%s%s\n", name, osn, + (spec && *spec) ? " satisfying '" : "", (spec && *spec) ? spec : ""); + json_free(meta); mirrors_free(ml); return -1; + } + printf("pmm: selected %s@%s (os=%s)\n", name, chosen, osn); + } else { + /* legacy single-platform entry */ + dl = json_str(meta, "url"); file = json_str(meta, "file"); + const char *s = json_str(meta, "sha256"); want_sha = s ? strdup(s) : NULL; + printf("pmm: selected %s@%s (os=%s)\n", name, json_str(meta, "version"), osn); + } + if (!dl) { + fprintf(stderr, "pmm: registry entry for '%s' has no url\n", name); + json_free(meta); mirrors_free(ml); return -1; + } + char *url_cp = strdup(dl); + char *file_cp = file ? strdup(file) : strdup(base_name(url_cp)); + json_free(meta); + mirrors_free(ml); + + int rc = install_file(url_cp, file_cp); + + if (want_sha && rc == 0) { + char cache[1024], path[1200], hex[128]; + pmm_cache_dir(cache, sizeof(cache)); + snprintf(path, sizeof(path), "%s/%s", cache, file_cp); + if (pmm_sha256_file(path, hex) == 0) { + if (strcasecmp(want_sha, hex) != 0) { + fprintf(stderr, "pmm: CHECKSUM MISMATCH (%s registry entry)!\n" + " expected: %s\n actual: %s\n", name, want_sha, hex); + remove(path); + rc = -1; + } else { + printf("pmm: registry sha256 OK: %s\n", hex); + } + } + } + free(url_cp); free(file_cp); free(want_sha); + return rc; +} diff --git a/src/install.h b/src/install.h new file mode 100644 index 0000000..1ce41fa --- /dev/null +++ b/src/install.h @@ -0,0 +1,16 @@ +/* install.h */ +#ifndef PMM_INSTALL_H +#define PMM_INSTALL_H + +/* Download `url` into cache and install it according to file type. + * name is the asset file name used to decide the install method. */ +int install_file(const char *url, const char *name); + +/* Install from a remote registry (apt-style multi-mirror fallback): + * looks up package `name` in the configured registry mirrors by priority. + * If `version` is non-NULL, fetches {registry}/{name}/{version}.json, otherwise + * the latest pointer {registry}/{name}.json. + * mirror_active is the name of the explicitly selected mirror, or NULL. */ +int install_from_registry(const char *name, const char *version, const char *mirror_active); + +#endif diff --git a/src/json.c b/src/json.c new file mode 100644 index 0000000..dd6f944 --- /dev/null +++ b/src/json.c @@ -0,0 +1,222 @@ +/* json.c - minimal recursive-descent JSON parser (no external deps) */ +#include "json.h" +#include +#include +#include +#include + +typedef struct { const char *p; const char *end; } Parser; + +static JsonValue *parse_value(Parser *ps); + +static JsonValue *jnew(JsonType t) { + JsonValue *v = calloc(1, sizeof(JsonValue)); + if (!v) { fprintf(stderr, "pmm: out of memory\n"); exit(1); } + v->type = t; + return v; +} + +void json_free(JsonValue *v) { + if (!v) return; + free(v->string); + for (int i = 0; i < v->count; i++) { + if (v->keys) free(v->keys[i]); + json_free(v->items[i]); + } + free(v->keys); + free(v->items); + free(v); +} + +static void jadd(JsonValue *v, char *key, JsonValue *item) { + if (v->count >= v->capacity) { + v->capacity = v->capacity ? v->capacity * 2 : 8; + v->items = realloc(v->items, v->capacity * sizeof(JsonValue *)); + if (v->type == JSON_OBJECT) + v->keys = realloc(v->keys, v->capacity * sizeof(char *)); + if (!v->items || (v->type == JSON_OBJECT && !v->keys)) { + fprintf(stderr, "pmm: out of memory\n"); exit(1); + } + } + if (v->type == JSON_OBJECT) v->keys[v->count] = key; + v->items[v->count++] = item; +} + +static void skip_ws(Parser *ps) { + while (ps->p < ps->end && isspace((unsigned char)*ps->p)) ps->p++; +} + +static void fail(const char *msg) { + fprintf(stderr, "pmm: JSON parse error: %s\n", msg); + exit(1); +} + +static char *parse_string_raw(Parser *ps) { + if (ps->p >= ps->end || *ps->p != '"') fail("expected string"); + ps->p++; + size_t cap = 16, len = 0; + char *buf = malloc(cap); + if (!buf) fail("out of memory"); + while (ps->p < ps->end && *ps->p != '"') { + char c = *ps->p++; + char out[4]; int n = 1; + if (c == '\\') { + if (ps->p >= ps->end) fail("bad escape"); + char e = *ps->p++; + switch (e) { + case '"': out[0] = '"'; break; + case '\\': out[0] = '\\'; break; + case '/': out[0] = '/'; break; + case 'b': out[0] = '\b'; break; + case 'f': out[0] = '\f'; break; + case 'n': out[0] = '\n'; break; + case 'r': out[0] = '\r'; break; + case 't': out[0] = '\t'; break; + case 'u': { + if (ps->end - ps->p < 4) fail("bad \\u escape"); + unsigned cp = 0; + for (int i = 0; i < 4; i++) { + char h = *ps->p++; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= (unsigned)(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= (unsigned)(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= (unsigned)(h - 'A' + 10); + else fail("bad \\u escape"); + } + /* UTF-8 encode (surrogate pairs not combined: good enough for names/URLs) */ + if (cp < 0x80) { out[0] = (char)cp; n = 1; } + else if (cp < 0x800) { out[0] = (char)(0xC0 | (cp >> 6)); out[1] = (char)(0x80 | (cp & 0x3F)); n = 2; } + else { out[0] = (char)(0xE0 | (cp >> 12)); out[1] = (char)(0x80 | ((cp >> 6) & 0x3F)); out[2] = (char)(0x80 | (cp & 0x3F)); n = 3; } + break; + } + default: fail("bad escape"); + } + } else { + out[0] = c; + } + if (len + (size_t)n + 1 > cap) { + cap *= 2; + buf = realloc(buf, cap); + if (!buf) fail("out of memory"); + } + memcpy(buf + len, out, (size_t)n); + len += (size_t)n; + } + if (ps->p >= ps->end) fail("unterminated string"); + ps->p++; /* closing quote */ + buf[len] = '\0'; + return buf; +} + +static JsonValue *parse_number(Parser *ps) { + char *endp; + double d = strtod(ps->p, &endp); + if (endp == ps->p) fail("bad number"); + ps->p = endp; + JsonValue *v = jnew(JSON_NUMBER); + v->number = d; + return v; +} + +static JsonValue *parse_value(Parser *ps) { + skip_ws(ps); + if (ps->p >= ps->end) fail("unexpected end"); + char c = *ps->p; + if (c == '{') { + ps->p++; + JsonValue *v = jnew(JSON_OBJECT); + skip_ws(ps); + if (ps->p < ps->end && *ps->p == '}') { ps->p++; return v; } + for (;;) { + skip_ws(ps); + char *key = parse_string_raw(ps); + skip_ws(ps); + if (ps->p >= ps->end || *ps->p != ':') fail("expected ':'"); + ps->p++; + JsonValue *item = parse_value(ps); + jadd(v, key, item); + skip_ws(ps); + if (ps->p < ps->end && *ps->p == ',') { ps->p++; continue; } + if (ps->p < ps->end && *ps->p == '}') { ps->p++; return v; } + fail("expected ',' or '}'"); + } + } else if (c == '[') { + ps->p++; + JsonValue *v = jnew(JSON_ARRAY); + skip_ws(ps); + if (ps->p < ps->end && *ps->p == ']') { ps->p++; return v; } + for (;;) { + JsonValue *item = parse_value(ps); + jadd(v, NULL, item); + skip_ws(ps); + if (ps->p < ps->end && *ps->p == ',') { ps->p++; continue; } + if (ps->p < ps->end && *ps->p == ']') { ps->p++; return v; } + fail("expected ',' or ']'"); + } + } else if (c == '"') { + JsonValue *v = jnew(JSON_STRING); + v->string = parse_string_raw(ps); + return v; + } else if (c == 't') { + if (ps->end - ps->p < 4 || strncmp(ps->p, "true", 4)) fail("bad literal"); + ps->p += 4; + JsonValue *v = jnew(JSON_BOOL); v->boolean = 1; return v; + } else if (c == 'f') { + if (ps->end - ps->p < 5 || strncmp(ps->p, "false", 5)) fail("bad literal"); + ps->p += 5; + JsonValue *v = jnew(JSON_BOOL); return v; + } else if (c == 'n') { + if (ps->end - ps->p < 4 || strncmp(ps->p, "null", 4)) fail("bad literal"); + ps->p += 4; + return jnew(JSON_NULL); + } else { + return parse_number(ps); + } +} + +JsonValue *json_parse(const char *text) { + Parser ps = { text, text + strlen(text) }; + JsonValue *v = parse_value(&ps); + return v; +} + +char *read_whole_file(const char *path, long *out_len) { + FILE *f = fopen(path, "rb"); + if (!f) return NULL; + fseek(f, 0, SEEK_END); + long len = ftell(f); + fseek(f, 0, SEEK_SET); + if (len < 0) { fclose(f); return NULL; } + char *buf = malloc((size_t)len + 1); + if (!buf) { fclose(f); return NULL; } + size_t rd = fread(buf, 1, (size_t)len, f); + fclose(f); + buf[rd] = '\0'; + if (out_len) *out_len = (long)rd; + return buf; +} + +JsonValue *json_parse_file(const char *path) { + char *text = read_whole_file(path, NULL); + if (!text) return NULL; + JsonValue *v = json_parse(text); + free(text); + return v; +} + +JsonValue *json_get(const JsonValue *obj, const char *key) { + if (!obj || obj->type != JSON_OBJECT) return NULL; + for (int i = 0; i < obj->count; i++) + if (strcmp(obj->keys[i], key) == 0) return obj->items[i]; + return NULL; +} + +JsonValue *json_at(const JsonValue *arr, int i) { + if (!arr || arr->type != JSON_ARRAY || i < 0 || i >= arr->count) return NULL; + return arr->items[i]; +} + +const char *json_str(const JsonValue *obj, const char *key) { + JsonValue *v = json_get(obj, key); + return (v && v->type == JSON_STRING) ? v->string : NULL; +} diff --git a/src/json.h b/src/json.h new file mode 100644 index 0000000..64b6732 --- /dev/null +++ b/src/json.h @@ -0,0 +1,31 @@ +/* json.h - minimal JSON parser for PMM */ +#ifndef PMM_JSON_H +#define PMM_JSON_H + +typedef enum { + JSON_NULL, JSON_BOOL, JSON_NUMBER, JSON_STRING, JSON_ARRAY, JSON_OBJECT +} JsonType; + +typedef struct JsonValue JsonValue; +struct JsonValue { + JsonType type; + int boolean; + double number; + char *string; /* decoded string (JSON_STRING) */ + JsonValue **items; /* array/object members (values) */ + char **keys; /* object member keys */ + int count; + int capacity; +}; + +JsonValue *json_parse(const char *text); +JsonValue *json_parse_file(const char *path); +void json_free(JsonValue *v); +/* Object member lookup by key; returns NULL if missing or not an object. */ +JsonValue *json_get(const JsonValue *obj, const char *key); +/* Array index; returns NULL if out of range. */ +JsonValue *json_at(const JsonValue *arr, int i); +/* Convenience: nested get, e.g. json_path(v, "assets", 0-ish) not supported; use chain. */ +const char *json_str(const JsonValue *obj, const char *key); /* NULL if absent */ + +#endif diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..2ccb5ea --- /dev/null +++ b/src/main.c @@ -0,0 +1,440 @@ +/* main.c - ParlzPackageManger (PMM) + * + * Usage: + * pmm install install from configured registry + * pmm install --git install latest release asset for this OS + * pmm install --git full URL: github/gitlab/gitea/forgejo + * pmm install --host --git force host (github|gitlab|gitea|forgejo) + * pmm mirror list list configured mirrors + * pmm mirror add add a mirror (writes mirror.ini) + * pmm mirror remove remove a mirror + * pmm mirror use set active mirror + * pmm list list installed packages + * pmm version | help + * + * Config: ~/.pmm/pmm.json | pmm.ini | pmm.conf (first found wins) + * Mirrors: ~/.pmm/mirror.ini | mirror.conf + */ +#include +#include +#include + +#include "pmm.h" +#include "json.h" +#include "ini.h" +#include "http.h" +#include "repo.h" +#include "install.h" +#include "pdm.h" + +typedef struct { + char *registry_url; /* package registry base URL */ + char *mirror_name; /* active mirror name ("" = none) */ +} PmmConfig; + +typedef struct { + char *name; /* active mirror name or NULL */ + char *api_base; /* mirror API base, NULL = use default host URLs */ +} MirrorSel; + +static char *xstrdup(const char *s) { return s ? strdup(s) : NULL; } + +/* ---------- config loading (json / ini / conf) ---------- */ + +static void load_config(PmmConfig *cfg) { + char dir[1024]; + pmm_config_dir(dir, sizeof(dir)); + memset(cfg, 0, sizeof(*cfg)); + char *path = pmm_find_config(dir, "pmm"); + if (!path) return; + if (strstr(path, ".json")) { + JsonValue *root = json_parse_file(path); + if (root) { + const char *u = json_str(root, "registry"); + if (u) cfg->registry_url = xstrdup(u); + const char *m = json_str(root, "mirror"); + if (m) cfg->mirror_name = xstrdup(m); + json_free(root); + } + } else { + Ini *ini = ini_load(path); + if (ini) { + const char *u = ini_get(ini, NULL, "registry"); + if (!u) u = ini_get(ini, "pmm", "registry"); + if (u) cfg->registry_url = xstrdup(u); + const char *m = ini_get(ini, NULL, "mirror"); + if (!m) m = ini_get(ini, "pmm", "mirror"); + if (m) cfg->mirror_name = xstrdup(m); + ini_free(ini); + } + } +} + +static const char *mirror_section_name(void) { +#if defined(_WIN32) + return pmm_os_name(pmm_detect_os()); /* per-OS section falls back below */ +#else + return pmm_os_name(pmm_detect_os()); +#endif +} + +/* Load mirrors from mirror.ini / mirror.conf; pick active mirror (by cfg + * name, first "default=true", or first entry). Format: + * [name] + * api = https://mirror.example.com (rewrites github api; or gitea api base) + * default = true|false + */ +static void load_mirror(PmmConfig *cfg, MirrorSel *sel) { + char dir[1024]; + pmm_config_dir(dir, sizeof(dir)); + memset(sel, 0, sizeof(*sel)); + char *path = pmm_find_config(dir, "mirror"); + if (!path) return; + Ini *ini = ini_load(path); + if (!ini) return; + + /* discover section names (order of appearance) */ + const char *first = NULL, *chosen = NULL; + for (IniEntry *e = ini->head; e; e = e->next) { + if (!*e->section) continue; + if (!first) first = e->section; + const char *dflt = ini_get(ini, e->section, "default"); + if (dflt && (strcmp(dflt, "true") == 0 || strcmp(dflt, "1") == 0) && !chosen) + chosen = e->section; + } + const char *want = (cfg->mirror_name && *cfg->mirror_name) ? cfg->mirror_name : chosen; + if (!want) want = first; + if (!want) { ini_free(ini); return; } + + sel->name = xstrdup(want); + const char *api = ini_get(ini, want, "api"); + if (!api) api = ini_get(ini, mirror_section_name(), "api"); + sel->api_base = xstrdup(api); + ini_free(ini); +} + +/* ---------- commands ---------- */ + +static const char *host_name(PmmHost h) { + switch (h) { + case HOST_GITHUB: return "github"; + case HOST_GITLAB: return "gitlab"; + case HOST_GITEA: return "gitea"; + case HOST_FORGEJO: return "forgejo"; + default: return "auto"; + } +} + +static PmmHost host_from_name(const char *h) { + if (strcmp(h, "github") == 0) return HOST_GITHUB; + if (strcmp(h, "gitlab") == 0) return HOST_GITLAB; + if (strcmp(h, "gitea") == 0) return HOST_GITEA; + if (strcmp(h, "forgejo") == 0) return HOST_FORGEJO; + return HOST_UNKNOWN; +} + +/* [--host ] */ +static int cmd_install_git(int argc, char **argv, const char *flag) { + const char *repo = NULL; + PmmHost host = HOST_AUTO; + PmmConfig cfg; MirrorSel mirror; + load_config(&cfg); + load_mirror(&cfg, &mirror); + + if (strcmp(flag, "github") == 0) host = HOST_GITHUB; + else if (strcmp(flag, "gitlab") == 0) host = HOST_GITLAB; + else if (strcmp(flag, "gitea") == 0) host = HOST_GITEA; + else if (strcmp(flag, "forgejo") == 0) host = HOST_FORGEJO; + + for (int i = 0; i < argc; i++) { + if (strcmp(argv[i], "--host") == 0 && i + 1 < argc) { + host = host_from_name(argv[++i]); + if (host == HOST_UNKNOWN) { + fprintf(stderr, "pmm: unknown host '%s'\n", argv[i]); + return 1; + } + } else if (!repo) { + repo = argv[i]; + } + } + if (!repo) { + fprintf(stderr, + "pmm: usage:\n" + " pmm install --git any git host (auto-detected API)\n" + " pmm install --github GitHub\n" + " pmm install --gitlab GitLab\n" + " pmm install --gitea Gitea / Forgejo (incl. self-hosted)\n" + " [--host github|gitlab|gitea|forgejo] force host type\n"); + return 1; + } + if (host == HOST_AUTO) host = repo_detect_host(repo); + + RepoContext *ctx = repo_open(host, repo, mirror.api_base); + if (!ctx) return 1; + printf("pmm: host=%s repo=%s%s%s\n", host_name(host), repo, + mirror.name ? " mirror=" : "", mirror.name ? mirror.name : ""); + + PmmOS os = pmm_detect_os(); + ReleaseAsset *asset = repo_latest_asset(ctx, os); + if (!asset && host == HOST_AUTO) + fprintf(stderr, "pmm: could not reach a known release API for %s " + "(tried gitea/gitlab/github shapes)\n", repo); + repo_close(ctx); + if (!asset) { + if (host != HOST_AUTO) + fprintf(stderr, "pmm: no suitable %s asset found in latest release of %s\n", + pmm_os_name(os), repo); + return 1; + } + printf("pmm: %s -> %s (tag %s)\n", pmm_os_name(os), asset->name, asset->tag); + int rc = install_file(asset->url, asset->name); + repo_asset_free(asset); + return rc == 0 ? 0 : 1; +} + +static int cmd_mirror(int argc, char **argv) { + char dir[1024], path[1200]; + pmm_config_dir(dir, sizeof(dir)); + snprintf(path, sizeof(path), "%s/mirror.ini", dir); + + if (argc == 0 || strcmp(argv[0], "list") == 0) { + Ini *ini = ini_load(path); + if (!ini || !ini->head) { printf("(no mirrors configured)\n"); ini_free(ini); return 0; } + printf("mirrors in %s:\n", path); + /* one line per unique section */ + char lastsec[512] = ""; + for (IniEntry *e = ini->head; e; e = e->next) { + if (!*e->section) continue; + if (strcmp(e->section, lastsec) == 0) continue; + strncpy(lastsec, e->section, sizeof(lastsec) - 1); + const char *api = ini_get(ini, e->section, "api"); + const char *reg = ini_get(ini, e->section, "registry"); + const char *dl = ini_get(ini, e->section, "download"); + const char *dflt = ini_get(ini, e->section, "default"); + printf(" [%s] registry=%s api=%s download=%s%s\n", e->section, + reg ? reg : "-", api ? api : "-", dl ? dl : "-", + dflt && strcmp(dflt, "true") == 0 ? " (default)" : ""); + } + ini_free(ini); + return 0; + } + if (strcmp(argv[0], "add") == 0 && argc >= 3) { + FILE *f = fopen(path, "a"); + if (!f) { fprintf(stderr, "pmm: cannot write %s\n", path); return 1; } + fprintf(f, "\n[%s]\napi = %s\n", argv[1], argv[2]); + fclose(f); + printf("pmm: mirror '%s' added\n", argv[1]); + return 0; + } + if (strcmp(argv[0], "use") == 0 && argc >= 2) { + /* set mirror= in pmm.conf (always writable text format) */ + char cfgpath[1200]; + snprintf(cfgpath, sizeof(cfgpath), "%s/pmm.conf", dir); + FILE *rf = fopen(cfgpath, "r"); + char lines[256][1024]; int n = 0; + if (rf) { + char line[1024]; + while (fgets(line, sizeof(line), rf) && n < 256) { + if (strncmp(line, "mirror", 6) != 0) strncpy(lines[n++], line, sizeof(lines[0]) - 1); + } + fclose(rf); + } + FILE *wf = fopen(cfgpath, "w"); + if (!wf) { fprintf(stderr, "pmm: cannot write %s\n", cfgpath); return 1; } + int wrote = 0; + for (int i = 0; i < n; i++) fputs(lines[i], wf); + fprintf(wf, "mirror = %s\n", argv[1]); + (void)wrote; + fclose(wf); + printf("pmm: active mirror set to '%s'\n", argv[1]); + return 0; + } + if (strcmp(argv[0], "remove") == 0 && argc >= 2) { + Ini *ini = ini_load(path); + if (!ini) { fprintf(stderr, "pmm: no mirror file\n"); return 1; } + FILE *f = fopen(path, "w"); + if (!f) { fprintf(stderr, "pmm: cannot write %s\n", path); ini_free(ini); return 1; } + char cursec[512] = ""; + for (IniEntry *e = ini->head; e; e = e->next) { + if (strcmp(e->section, cursec) != 0) { + strncpy(cursec, e->section, sizeof(cursec) - 1); + if (strcmp(e->section, argv[1]) != 0) + fprintf(f, "\n[%s]\n", e->section); + } + if (strcmp(e->section, argv[1]) != 0) + fprintf(f, "%s = %s\n", e->key, e->value); + } + fclose(f); + ini_free(ini); + printf("pmm: mirror '%s' removed\n", argv[1]); + return 0; + } + fprintf(stderr, "pmm: usage: pmm mirror list|add|use|remove ...\n"); + return 1; +} + +static int cmd_list(void) { + char dir[1024]; + pmm_install_dir(dir, sizeof(dir)); + printf("install dir: %s\n", dir); +#ifdef _WIN32 + char cmd[1200]; + snprintf(cmd, sizeof(cmd), "dir /b \"%s\" 2>nul", dir); +#else + char cmd[1200]; + snprintf(cmd, sizeof(cmd), "ls -1 \"%s\" 2>/dev/null", dir); +#endif + return system(cmd) == 0 ? 0 : 0; +} + +static void print_help(void) { + printf("ParlzPackageManger (PMM) v%s\n\n", PMM_VERSION); + printf("usage:\n"); + printf(" pmm install install from registry mirrors (apt-style)\n"); + printf(" pmm install install a local .pdm package\n"); + printf(" pmm pack [out.pdm] pack a folder into a .pdm\n"); + printf(" pmm pdm install|list|remove|info ... manage .pdm packages\n"); + printf(" pmm install --git any git host, API auto-detected\n"); + printf(" pmm install --github GitHub latest release\n"); + printf(" pmm install --gitlab GitLab latest release\n"); + printf(" pmm install --gitea Gitea/Forgejo (incl. self-hosted)\n"); + printf(" [--host github|gitlab|gitea|forgejo]\n"); + printf(" pmm mirror list|add |use |remove \n"); + printf(" pmm list list installed files\n"); + printf(" pmm version | help\n\n"); + printf("options:\n"); + printf(" -p install under :\\\\.pmm (e.g. -pd -> D:\\\\.pmm, -pc -> C:\\\\.pmm)\n"); + printf(" 第一个 -p 会被记住,后续命令无需再写;用 -pc 切回 C 盘\n\n"); + printf("config: /pmm.json | pmm.ini | pmm.conf (base = :\\\\.pmm 或 ~/.pmm)\n"); + printf("mirrors: /mirror.ini | mirror.conf\n"); + printf("asset mapping: windows=exe/msi/zip/7z linux=deb/rpm/appimage/tar.* macos=dmg/pkg\n"); +} + +/* Parse "-p" / "-p " flags (e.g. -pd -> D:\.pmm) anywhere in + * argv, record the drive, and compact the argument list so subcommands don't + * see the flag. Returns new argc. */ +static int consume_drive_flag(int argc, char **argv) { + int w = 1; /* argv[0] is the program name */ + for (int r = 1; r < argc; r++) { + const char *a = argv[r]; + if (a[0] == '-' && a[1] == 'p' && (a[2] >= 'A' && a[2] <= 'Z' || a[2] >= 'a' && a[2] <= 'z')) { + char dr[2] = { a[2], '\0' }; + pmm_set_install_drive(dr); + continue; + } + if (strcmp(a, "-p") == 0 && r + 1 < argc) { + const char *nxt = argv[r + 1]; + if (nxt[0] && (nxt[0] >= 'A' && nxt[0] <= 'Z' || nxt[0] >= 'a' && nxt[0] <= 'z') && nxt[1] == '\0') { + char dr[2] = { nxt[0], '\0' }; + pmm_set_install_drive(dr); + r++; /* consume the drive arg too */ + continue; + } + } + argv[w++] = argv[r]; + } + return w; +} + +int main(int argc, char **argv) { + if (argc < 2) { print_help(); return 0; } + pmm_set_self_path(argv[0]); + argc = consume_drive_flag(argc, argv); + if (argc < 2) { print_help(); return 0; } + + if (strcmp(argv[1], "help") == 0 || strcmp(argv[1], "--help") == 0 || strcmp(argv[1], "-h") == 0) { + print_help(); return 0; + } + if (strcmp(argv[1], "version") == 0 || strcmp(argv[1], "--version") == 0) { + printf("pmm %s (%s)\n", PMM_VERSION, pmm_os_name(pmm_detect_os())); + return 0; + } + if (strcmp(argv[1], "list") == 0) return cmd_list(); + if (strcmp(argv[1], "mirror") == 0) return cmd_mirror(argc - 2, argv + 2); + /* pmm remove (also used by the Windows "Uninstall" registration) */ + if (strcmp(argv[1], "remove") == 0) { + if (argc < 3) { fprintf(stderr, "pmm: usage: pmm remove \n"); return 1; } + return pdm_remove(argv[2]) == 0 ? 0 : 1; + } + + if (strcmp(argv[1], "install") == 0) { + if (argc >= 3 && (strcmp(argv[2], "--git") == 0 || strcmp(argv[2], "--github") == 0 || + strcmp(argv[2], "--gitlab") == 0 || strcmp(argv[2], "--gitea") == 0 || + strcmp(argv[2], "--forgejo") == 0)) + return cmd_install_git(argc - 3, argv + 3, argv[2] + 2); + if (argc < 3) { + fprintf(stderr, "pmm: usage: pmm install | pmm install --git \n"); + return 1; + } + /* parse package name + optional version spec: + * pmm install nodejs==24.20.0 + * pmm install "nodejs>=24,<25" + * pmm install nodejs -v 24.20.0 / --version ">=24,<25" */ + const char *raw_pkg = NULL, *version = NULL; + for (int i = 2; i < argc; i++) { + const char *a = argv[i]; + if (strncmp(a, "-v", 2) == 0 && a[2]) { version = a + 2; continue; } + if (strcmp(a, "-v") == 0 && i + 1 < argc) { version = argv[++i]; continue; } + if (strcmp(a, "--version") == 0 && i + 1 < argc) { version = argv[++i]; continue; } + if (strncmp(a, "--version=", 10) == 0) { version = a + 10; continue; } + if (!raw_pkg && a[0] != '-') { raw_pkg = a; continue; } + } + if (!raw_pkg) raw_pkg = argv[2]; + /* split "nodejs>=24,<25" -> pkg="nodejs", spec=">=24,<25"; else pkg only */ + char pkg[256], spec[256]; + if (version && *version) { + strncpy(pkg, raw_pkg, sizeof(pkg) - 1); + strncpy(spec, version, sizeof(spec) - 1); + } else { + const char *ops[] = { ">=", "<=", "==", "!=", ">", "<", NULL }; + const char *pos = NULL; + for (int i = 0; ops[i]; i++) { pos = strstr(raw_pkg, ops[i]); if (pos) break; } + if (pos) { + size_t n = (size_t)(pos - raw_pkg); + if (n >= sizeof(pkg)) n = sizeof(pkg) - 1; + memcpy(pkg, raw_pkg, n); pkg[n] = '\0'; + strncpy(spec, pos, sizeof(spec) - 1); + } else { + strncpy(pkg, raw_pkg, sizeof(pkg) - 1); + spec[0] = '\0'; + } + } + pkg[sizeof(pkg) - 1] = '\0'; spec[sizeof(spec) - 1] = '\0'; + /* local .pdm file? */ + size_t la = strlen(pkg); + if (la > 4 && (strcmp(pkg + la - 4, ".pdm") == 0 || strcmp(pkg + la - 4, ".PDM") == 0)) + return pdm_install_file(pkg) == 0 ? 0 : 1; + PmmConfig cfg; MirrorSel mirror; + load_config(&cfg); + load_mirror(&cfg, &mirror); + /* apt-style: mirrors.first-wins by priority, active mirror if named */ + int rc = install_from_registry(pkg, spec[0] ? spec : NULL, mirror.name); + free(cfg.registry_url); free(cfg.mirror_name); + free(mirror.name); free(mirror.api_base); + return rc == 0 ? 0 : 1; +} + + if (strcmp(argv[1], "pack") == 0) { + if (argc < 3) { + fprintf(stderr, "pmm: usage: pmm pack [output.pdm]\n"); + return 1; + } + return pdm_pack(argv[2], argc >= 4 ? argv[3] : NULL) == 0 ? 0 : 1; + } + + if (strcmp(argv[1], "pdm") == 0) { + /* pmm pdm install/list/remove/info (sugar for the standalone pdm tool) */ + if (argc >= 3 && strcmp(argv[2], "install") == 0) + return pdm_install_file(argc >= 4 ? argv[3] : NULL) == 0 ? 0 : 1; + if (argc >= 3 && strcmp(argv[2], "list") == 0) { pdm_list_installed(); return 0; } + if (argc >= 3 && strcmp(argv[2], "remove") == 0) + return pdm_remove(argc >= 4 ? argv[3] : NULL) == 0 ? 0 : 1; + if (argc >= 3 && strcmp(argv[2], "info") == 0) + return pdm_info(argc >= 4 ? argv[3] : NULL) == 0 ? 0 : 1; + fprintf(stderr, "pmm: usage: pmm pdm install|list|remove|info ...\n"); + return 1; + } + + fprintf(stderr, "pmm: unknown command '%s' (try 'pmm help')\n", argv[1]); + return 1; +} diff --git a/src/mirrors.c b/src/mirrors.c new file mode 100644 index 0000000..94c8861 --- /dev/null +++ b/src/mirrors.c @@ -0,0 +1,100 @@ +/* mirrors.c - apt-style mirror handling + * + * mirror.ini / mirror.conf format: + * [name] + * api = https://mirror.example.com # replaces git-host API base + * download = https://ghfast.example # gh-proxy style download prefix + * registry = https://mirror.example/pmm # registry mirror ({base}/{pkg}.json) + * priority = 1 # lower tried first (apt-like order) + */ +#include "mirrors.h" +#include "ini.h" +#include "pmm.h" +#include +#include +#include + +static char *xstrdup_or(const char *s, const char *dflt) { + return strdup(s ? s : dflt); +} + +static int cmp_priority(const void *a, const void *b) { + const Mirror *ma = a, *mb = b; + return ma->priority - mb->priority; +} + +MirrorList *mirrors_load(void) { + char dir[1024]; + pmm_config_dir(dir, sizeof(dir)); + char *path = pmm_find_config(dir, "mirror"); + if (!path) return calloc(1, sizeof(MirrorList)); + Ini *ini = ini_load(path); + MirrorList *list = calloc(1, sizeof(MirrorList)); + if (!ini) return list; + + int cap = 0; + char cursec[512] = ""; + for (IniEntry *e = ini->head; e; e = e->next) { + if (strcmp(e->section, cursec) != 0) { + strncpy(cursec, e->section, sizeof(cursec) - 1); + /* new mirror section begins */ + if (list->count >= cap) { + cap = cap ? cap * 2 : 8; + list->items = realloc(list->items, cap * sizeof(Mirror)); + } + Mirror *m = &list->items[list->count++]; + memset(m, 0, sizeof(*m)); + m->name = xstrdup_or(e->section, ""); + m->priority = 100; + } + Mirror *m = list->count ? &list->items[list->count - 1] : NULL; + if (!m) continue; + if (strcmp(e->key, "api") == 0) m->api = xstrdup_or(e->value, ""); + else if (strcmp(e->key, "download") == 0) m->download = xstrdup_or(e->value, ""); + else if (strcmp(e->key, "registry") == 0) m->registry = xstrdup_or(e->value, ""); + else if (strcmp(e->key, "priority") == 0) m->priority = atoi(e->value); + } + ini_free(ini); + qsort(list->items, list->count, sizeof(Mirror), cmp_priority); + return list; +} + +void mirrors_free(MirrorList *list) { + if (!list) return; + for (int i = 0; i < list->count; i++) { + free(list->items[i].name); + free(list->items[i].api); + free(list->items[i].download); + free(list->items[i].registry); + } + free(list->items); + free(list); +} + +Mirror *mirrors_active(const MirrorList *list, const char *active_name) { + if (!active_name || !*active_name) return NULL; + for (int i = 0; i < list->count; i++) + if (strcmp(list->items[i].name, active_name) == 0) + return &list->items[i]; + return NULL; +} + +char **mirrors_download_candidates(const MirrorList *list, const char *url, int *out_n) { + int cap = list->count + 1, n = 0; + char **cands = malloc(cap * sizeof(char *)); + for (int i = 0; i < list->count && n < cap - 1; i++) { + const char *dl = list->items[i].download; + if (!dl || !*dl) continue; + size_t len = strlen(dl) + strlen(url) + 2; + cands[n] = malloc(len); + /* gh-proxy convention: prefix + full original URL */ + if (dl[strlen(dl) - 1] == '=' || dl[strlen(dl) - 1] == '/') + snprintf(cands[n], len, "%s%s", dl, url); + else + snprintf(cands[n], len, "%s/%s", dl, url); + n++; + } + cands[n++] = strdup(url); /* direct last, like apt falling back to origin */ + *out_n = n; + return cands; +} diff --git a/src/mirrors.h b/src/mirrors.h new file mode 100644 index 0000000..1fc0f6c --- /dev/null +++ b/src/mirrors.h @@ -0,0 +1,28 @@ +/* mirrors.h - apt-style mirror list with priorities and download fallback */ +#ifndef PMM_MIRRORS_H +#define PMM_MIRRORS_H + +typedef struct Mirror { + char *name; + char *api; /* API base (rewrites github/gitlab/gitea API endpoints) */ + char *download; /* download prefix (gh-proxy style: prefix + original URL) */ + char *registry; /* apt-style registry mirror: serves {registry}/{pkg}.json */ + int priority; /* lower = tried first (default 100) */ +} Mirror; + +typedef struct { + Mirror *items; + int count; +} MirrorList; + +/* Load mirrors from ~/.pmm/mirror.ini / mirror.conf, sorted by priority. */ +MirrorList *mirrors_load(void); +void mirrors_free(MirrorList *list); +/* Mirror whose name matches the active selection in pmm config, or NULL. */ +Mirror *mirrors_active(const MirrorList *list, const char *active_name); + +/* Build candidate download URLs for `url` (caller frees each string and array). + * Order: prefix-style mirrors by priority, then the original URL. */ +char **mirrors_download_candidates(const MirrorList *list, const char *url, int *out_n); + +#endif diff --git a/src/pdm.c b/src/pdm.c new file mode 100644 index 0000000..a3af524 --- /dev/null +++ b/src/pdm.c @@ -0,0 +1,528 @@ +/* pdm.c - .pdm pack/install/remove (deb-like, built on system tar) */ +#include "pdm.h" +#include "pmm.h" +#include "sha256.h" +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#include +#define PMM_MKDIR(p) _mkdir(p) +#define RMDIR(p) _rmdir(p) +#define PMM_POPEN_READ(cmd) _popen(cmd, "r") +#define PMM_PCLOSE_READ(p) _pclose(p) +#define GETCWD(p,n) (_getcwd((p),(n))) +#define CHDIR(p) _chdir(p) +#else +#include +#include +#include +#include +#define PMM_MKDIR(p) mkdir((p), 0755) +#define RMDIR(p) rmdir(p) +#define PMM_POPEN_READ(cmd) popen(cmd, "r") +#define PMM_PCLOSE_READ(p) pclose(p) +#define GETCWD(p,n) getcwd((p),(n)) +#define CHDIR(p) chdir(p) +#endif + +/* ---------- small helpers ---------- */ + +static int ends_with(const char *s, const char *suf) { + size_t sl = strlen(s), xl = strlen(suf); + return sl >= xl && strcmp(s + sl - xl, suf) == 0; +} + +static const char *base_name(const char *path) { + const char *a = strrchr(path, '/'); + const char *b = strrchr(path, '\\'); + const char *p = (a > b) ? a : b; + return p ? p + 1 : path; +} + +static void mkdir_p(const char *path) { + char tmp[1024]; + strncpy(tmp, path, sizeof(tmp) - 1); + tmp[sizeof(tmp) - 1] = '\0'; + for (char *p = tmp + 1; *p; p++) { + if (*p == '/' || *p == '\\') { + char c = *p; *p = '\0'; PMM_MKDIR(tmp); *p = c; + } + } + PMM_MKDIR(tmp); +} + +/* Convert backslashes to forward slashes (so cmd/bash don't treat "C:\x" as host). */ +static void rmtree(const char *dir) { + /* Pure-C recursive delete: no shell, so no path-flavour (bsdtar/GNU) issues. */ +#ifdef _WIN32 + char patt[1200]; + snprintf(patt, sizeof(patt), "%s\\*", dir); + struct _finddata_t fd; + intptr_t h = _findfirst(patt, &fd); + if (h != -1) { + do { + if (strcmp(fd.name, ".") == 0 || strcmp(fd.name, "..") == 0) continue; + char full[1400]; + snprintf(full, sizeof(full), "%s\\%s", dir, fd.name); + if (fd.attrib & _A_SUBDIR) rmtree(full); + else remove(full); + } while (_findnext(h, &fd) == 0); + _findclose(h); + } + RMDIR(dir); +#else + DIR *d = opendir(dir); + if (!d) return; + struct dirent *e; + while ((e = readdir(d)) != NULL) { + if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0) continue; + char full[1400]; + snprintf(full, sizeof(full), "%s/%s", dir, e->d_name); + struct stat st; + if (stat(full, &st) == 0 && S_ISDIR(st.st_mode)) rmtree(full); + else remove(full); + } + closedir(d); + RMDIR(dir); +#endif +} + +/* KEY: value lookup in control text; returns malloc'd value or NULL. */ +static char *control_get(const char *text, const char *key) { + size_t klen = strlen(key); + const char *line = text; + while (line && *line) { + const char *eol = strchr(line, '\n'); + size_t ll = eol ? (size_t)(eol - line) : strlen(line); + if (ll > klen && strncasecmp(line, key, klen) == 0 && line[klen] == ':') { + const char *v = line + klen + 1; + while (v < line + ll && (*v == ' ' || *v == '\t')) v++; + size_t vl = (size_t)(line + ll - v); + while (vl && (v[vl-1] == '\r' || v[vl-1] == ' ')) vl--; + char *out = malloc(vl + 1); + memcpy(out, v, vl); + out[vl] = '\0'; + return out; + } + line = eol ? eol + 1 : NULL; + } + return NULL; +} + +static char *read_file(const char *path, long *len) { + FILE *f = fopen(path, "rb"); + if (!f) return NULL; + fseek(f, 0, SEEK_END); + long n = ftell(f); + fseek(f, 0, SEEK_SET); + char *buf = malloc((size_t)n + 1); + size_t rd = fread(buf, 1, (size_t)n, f); + fclose(f); + buf[rd] = '\0'; + if (len) *len = (long)rd; + return buf; +} + +/* ---------- pack ---------- */ + +int pdm_pack(const char *dir, const char *out) { + char cp_[1100]; + snprintf(cp_, sizeof(cp_), "%s/pdm-control", dir); + char *control = read_file(cp_, NULL); + if (!control) { + fprintf(stderr, "pdm: %s has no pdm-control file\n", dir); + return -1; + } + char *pkg = control_get(control, "Package"); + char *ver = control_get(control, "Version"); + if (!pkg || !*pkg) { + fprintf(stderr, "pdm: pdm-control missing 'Package:' field\n"); + free(control); return -1; + } + if (!ver || !*ver) ver = strdup("0.0.0"); + + /* Output defaults to _.pdm in the current directory. + * Everything below uses paths relative to the CWD so the shell never sees a + * Windows drive-letter path (which MSYS bash would mangle). */ + char outbuf[1100]; + if (!out) { + snprintf(outbuf, sizeof(outbuf), "%s_%s.pdm", pkg, ver); + out = outbuf; + } + + const char *stage = ".pdm-stage"; + char cmd[2800]; + rmtree(stage); + PMM_MKDIR(stage); + + /* control.tar.gz (contains pdm-control; packed from the source dir) */ + snprintf(cmd, sizeof(cmd), "tar -czf \"%s/control.tar.gz\" -C \"%s\" pdm-control", stage, dir); + if (system(cmd) != 0) { fprintf(stderr, "pdm: tar (control) failed\n"); rmtree(stage); return -1; } + + /* data.tar.gz (everything except pdm-control and the scratch dir) */ + snprintf(cmd, sizeof(cmd), + "tar -czf \"%s/data.tar.gz\" -C \"%s\" --exclude pdm-control --exclude %s .", + stage, dir, stage); + if (system(cmd) != 0) { fprintf(stderr, "pdm: tar (data) failed\n"); rmtree(stage); return -1; } + + /* sha256sums of the two members */ + char sums_path[1100], hex[128]; + snprintf(sums_path, sizeof(sums_path), "%s/sha256sums", stage); + FILE *sf = fopen(sums_path, "w"); + if (sf) { + char mp[1200]; + snprintf(mp, sizeof(mp), "%s/control.tar.gz", stage); + if (pmm_sha256_file(mp, hex) == 0) fprintf(sf, "%s control.tar.gz\n", hex); + snprintf(mp, sizeof(mp), "%s/data.tar.gz", stage); + if (pmm_sha256_file(mp, hex) == 0) fprintf(sf, "%s data.tar.gz\n", hex); + fclose(sf); + } + + /* outer archive */ + snprintf(cmd, sizeof(cmd), "tar -cf \"%s\" -C \"%s\" control.tar.gz data.tar.gz sha256sums", out, stage); + int rc = system(cmd); + rmtree(stage); + if (rc != 0) { fprintf(stderr, "pdm: failed to write %s\n", out); return -1; } + + printf("pdm: packed %s (%s %s) -> %s\n", dir, pkg, ver, out); + free(control); free(pkg); + if (strcmp(ver, "0.0.0") != 0) free(ver); + return 0; +} + +/* ---------- install ---------- */ + +static void db_dirs(char *db, size_t dbs, char *root, size_t roots) { + char home[900]; + pmm_config_dir(home, sizeof(home) - 32); + snprintf(db, dbs, "%s/installed", home); + mkdir_p(db); + snprintf(root, roots, "%s/root", home); + mkdir_p(root); +} + +static int copy_file(const char *src, const char *dst) { + FILE *in = fopen(src, "rb"); + if (!in) return -1; + FILE *out = fopen(dst, "wb"); + if (!out) { fclose(in); return -1; } + char buf[65536]; + size_t n; + while ((n = fread(buf, 1, sizeof(buf), in)) > 0) + if (fwrite(buf, 1, n, out) != n) { fclose(in); fclose(out); return -1; } + fclose(in); + fclose(out); + return 0; +} + +/* Recursively find the first .exe under dir (for the app icon); malloc'd path or NULL. */ +static char *find_first_exe(const char *dir) { +#ifdef _WIN32 + char patt[1300]; + snprintf(patt, sizeof(patt), "%s\\*", dir); + struct _finddata_t fd; + intptr_t h = _findfirst(patt, &fd); + if (h != -1) { + do { + if (strcmp(fd.name, ".") == 0 || strcmp(fd.name, "..") == 0) continue; + char full[1400]; + snprintf(full, sizeof(full), "%s\\%s", dir, fd.name); + if ((fd.attrib & _A_SUBDIR)) { + char *r = find_first_exe(full); + if (r) { _findclose(h); return r; } + } else { + size_t l = strlen(fd.name); + if (l > 4 && strcasecmp(fd.name + l - 4, ".exe") == 0) { + char *r = malloc(1400); + if (r) snprintf(r, 1400, "%s\\%s", dir, fd.name); + _findclose(h); + return r; + } + } + } while (_findnext(h, &fd) == 0); + _findclose(h); + } +#else + DIR *d = opendir(dir); + if (d) { + struct dirent *e; + while ((e = readdir(d)) != NULL) { + if (e->d_name[0] == '.') continue; + char full[1400]; + snprintf(full, sizeof(full), "%s/%s", dir, e->d_name); + struct stat st; + if (stat(full, &st) == 0) { + if (S_ISDIR(st.st_mode)) { char *r = find_first_exe(full); if (r) { closedir(d); return r; } } + } + } + closedir(d); + } +#endif + return NULL; +} + +/* Recursively sum file sizes under dir, in bytes. */ +static unsigned long long dir_size(const char *dir) { + unsigned long long total = 0; +#ifdef _WIN32 + char patt[1300]; + snprintf(patt, sizeof(patt), "%s\\*", dir); + struct _finddata_t fd; + intptr_t h = _findfirst(patt, &fd); + if (h != -1) { + do { + if (strcmp(fd.name, ".") == 0 || strcmp(fd.name, "..") == 0) continue; + char full[1400]; + snprintf(full, sizeof(full), "%s\\%s", dir, fd.name); + if (fd.attrib & _A_SUBDIR) total += dir_size(full); + else total += (unsigned long long)fd.size; + } while (_findnext(h, &fd) == 0); + _findclose(h); + } +#else + DIR *d = opendir(dir); + if (d) { + struct dirent *e; + while ((e = readdir(d)) != NULL) { + if (e->d_name[0] == '.') continue; + char full[1400]; + snprintf(full, sizeof(full), "%s/%s", dir, e->d_name); + struct stat st; + if (stat(full, &st) == 0) { + if (S_ISDIR(st.st_mode)) total += dir_size(full); + else total += (unsigned long long)st.st_size; + } + } + closedir(d); + } +#endif + return total; +} + +static char saved_cwd[1400]; +static int chdir_save(const char *dir) { + if (!GETCWD(saved_cwd, sizeof(saved_cwd))) saved_cwd[0] = '\0'; + return CHDIR(dir) == 0 ? 0 : -1; +} +static void chdir_restore(void) { + if (saved_cwd[0]) CHDIR(saved_cwd); +} + +int pdm_info(const char *pdmfile) { + printf("pdm: %s\n", base_name(pdmfile)); + /* Copy into the pm dir + chdir, then use relative names (works with both + * MSYS GNU tar and System32 bsdtar). */ + char home[1024]; + pmm_config_dir(home, sizeof(home)); + char info_tmp[1400]; + snprintf(info_tmp, sizeof(info_tmp), "%s/_pmm_info.pdm", home); + if (copy_file(pdmfile, info_tmp) != 0) return -1; + if (chdir_save(home) != 0) { remove(info_tmp); return -1; } + char cmd[2800]; + snprintf(cmd, sizeof(cmd), "tar -xOf _pmm_info.pdm control.tar.gz | tar -xzOf - pdm-control"); + int rc = system(cmd); + chdir_restore(); + remove(info_tmp); + return rc == 0 ? 0 : -1; +} + +int pdm_install_file(const char *pdmfile) { + if (!ends_with(pdmfile, ".pdm")) { + fprintf(stderr, "pdm: '%s' is not a .pdm file\n", pdmfile); + return -1; + } + char home[1024], db[1024], root[1024], cmd[2600]; + pmm_config_dir(home, sizeof(home)); + snprintf(db, sizeof(db), "%s/installed", home); + mkdir_p(db); + snprintf(root, sizeof(root), "%s/root", home); + mkdir_p(root); + + /* Copy the package into the PM dir, chdir there, and run tar with ONLY + * relative paths. Relative paths are understood identically by MSYS GNU tar + * and System32 bsdtar, so we don't have to guess a drive-letter style. */ + char tmpname[1400], tmprel[] = "_pmm_install.pdm"; + snprintf(tmpname, sizeof(tmpname), "%s/_pmm_install.pdm", home); + if (copy_file(pdmfile, tmpname) != 0) { + fprintf(stderr, "pdm: cannot read %s\n", pdmfile); + return -1; + } + if (chdir_save(home) != 0) { remove(tmpname); return -1; } + if (system(NULL) == 0) { fprintf(stderr, "pdm: cannot run tar\n"); chdir_restore(); remove(tmpname); return -1; } + + const char *stage = "installed/.stage"; + rmtree(stage); + PMM_MKDIR(stage); + + /* extract members (all relative) */ + snprintf(cmd, sizeof(cmd), "tar -xf \"%s\" -C \"%s\"", tmprel, stage); + if (system(cmd) != 0) { + fprintf(stderr, "pdm: not a valid .pdm archive: %s\n", pdmfile); + chdir_restore(); remove(tmpname); return -1; + } + + /* verify member checksums (relative to pm dir) */ + char hex[128]; + FILE *sf = fopen("installed/.stage/sha256sums", "r"); + if (sf) { + char line[256], fname[128], expect[128]; + while (fgets(line, sizeof(line), sf)) { + if (sscanf(line, "%127s %127s", expect, fname) != 2) continue; + char mp[1200]; + snprintf(mp, sizeof(mp), "installed/.stage/%s", fname); + if (pmm_sha256_file(mp, hex) != 0 || strcasecmp(hex, expect) != 0) { + fprintf(stderr, "pdm: CHECKSUM MISMATCH for %s in %s\n", fname, pdmfile); + fclose(sf); chdir_restore(); remove(tmpname); return -1; + } + } + fclose(sf); + printf("pdm: sha256 checksums OK\n"); + } else { + printf("pdm: warning: no sha256sums member, skipping integrity check\n"); + } + + /* read control */ + snprintf(cmd, sizeof(cmd), "tar -xzOf \"%s/control.tar.gz\" pdm-control", stage); + FILE *cf = PMM_POPEN_READ(cmd); + if (!cf) { + fprintf(stderr, "pdm: missing control.tar.gz in %s\n", pdmfile); + chdir_restore(); remove(tmpname); return -1; + } + char ctl[4096]; + size_t got = fread(ctl, 1, sizeof(ctl) - 1, cf); + PMM_PCLOSE_READ(cf); + ctl[got] = '\0'; + char *pkg = control_get(ctl, "Package"); + char *ver = control_get(ctl, "Version"); + if (!pkg || !*pkg) { + fprintf(stderr, "pdm: package has no Package: field\n"); + chdir_restore(); remove(tmpname); return -1; + } + + /* extract data into root */ + snprintf(cmd, sizeof(cmd), "tar -xzf \"%s/data.tar.gz\" -C \"root\"", stage); + if (system(cmd) != 0) { + fprintf(stderr, "pdm: data extraction failed\n"); + chdir_restore(); remove(tmpname); return -1; + } + + /* record file list + control in db */ + snprintf(cmd, sizeof(cmd), "%s/%s.info", db, pkg); + FILE *dbf = fopen(cmd, "wb"); /* binary: keep \n, avoid Windows CRLF translation */ + if (dbf) { + fprintf(dbf, "Package: %s\nVersion: %s\nSource: %s\n\n%s\n", + pkg, ver ? ver : "0.0.0", base_name(pdmfile), ctl); + fprintf(dbf, "Files:\n"); + char ltcmd[2600]; + snprintf(ltcmd, sizeof(ltcmd), "tar -tzf \"%s/data.tar.gz\"", stage); + FILE *tf = PMM_POPEN_READ(ltcmd); + if (tf) { + char ln[2048]; + while (fgets(ln, sizeof(ln), tf)) + fputs(ln, dbf); + PMM_PCLOSE_READ(tf); + } else { + fprintf(stderr, "pdm: warning: could not record file list\n"); + } + fclose(dbf); + } + + rmtree(stage); + chdir_restore(); + remove(tmpname); + printf("pdm: installed %s %s into %s\n", pkg, ver ? ver : "0.0.0", root); + + /* register in the Windows per-user "Installed Apps" (Uninstall) hive, + * like an MSI installer would, so it shows in Settings > Installed apps */ + { + char rootpkg[1400]; + snprintf(rootpkg, sizeof(rootpkg), "%s/%s", root, pkg); + char *icon = find_first_exe(rootpkg); + const char *pub = control_get(ctl, "Maintainer"); + const char *self = pmm_self_path(); + pmm_reg_uninstall(pkg, ver ? ver : "", pub ? pub : "", + root, icon, self && *self ? self : "pmm", + dir_size(rootpkg)); + if (pub) free((char *)pub); + free(icon); + } + + pmm_add_to_path(); /* auto-put PMM dirs (e.g. root/nodejs) on PATH */ + +#ifndef _WIN32 + /* On Linux/macOS, mark anything in /bin executable. Windows doesn't + * record unix exec bits, so ELF/scripts packaged on Windows lose +x; restore + * them at install time so e.g. a Linux Node.js binary actually runs. */ + { + char cx[1600]; + snprintf(cx, sizeof(cx), "chmod +x \"%s/bin\"/* 2>/dev/null || true", root); + system(cx); + } +#endif + + free(pkg); if (ver) free(ver); + return 0; +} + +/* ---------- list / remove ---------- */ + +void pdm_list_installed(void) { + char db[1024], root[1024]; + db_dirs(db, sizeof(db), root, sizeof(root)); +#ifdef _WIN32 + char cmd[1200]; + snprintf(cmd, sizeof(cmd), "dir /b \"%s\\*.info\" 2>nul", db); +#else + char cmd[1200]; + snprintf(cmd, sizeof(cmd), "ls -1 \"%s\"/*.info 2>/dev/null", db); +#endif + printf("installed .pdm packages (root: %s):\n", root); + fflush(stdout); + system(cmd); +} + +int pdm_remove(const char *name) { + char db[1024], root[1024]; + db_dirs(db, sizeof(db), root, sizeof(root)); + char ipath[1100]; + snprintf(ipath, sizeof(ipath), "%s/%s.info", db, name); + long len = 0; + char *info = read_file(ipath, &len); + if (!info) { + fprintf(stderr, "pdm: package '%s' is not installed\n", name); + return -1; + } + char *files = strstr(info, "Files:\n"); + int n = 0; + if (files) { + char path[1024]; + char *line = files + 7; + while (*line) { + char *eol = strchr(line, '\n'); + if (!eol) eol = line + strlen(line); + size_t ll = (size_t)(eol - line); + while (ll && (line[ll-1] == '\r' || line[ll-1] == ' ')) ll--; + if (ll > 0 && ll < sizeof(path)) { + memcpy(path, line, ll); path[ll] = '\0'; + /* normalize "./x" -> "x" */ + char *p = path; + while (p[0] == '.' && p[1] == '/') p += 2; + char full[1200]; + snprintf(full, sizeof(full), "%s/%s", root, p); + remove(full); + n++; + } + line = *eol ? eol + 1 : eol; + } + } + remove(ipath); + free(info); + printf("pdm: removed %s (%d files)\n", name, n); + pmm_reg_uninstall_clear(name); /* drop it from Installed Apps */ + return 0; +} diff --git a/src/pdm.h b/src/pdm.h new file mode 100644 index 0000000..6c4a46c --- /dev/null +++ b/src/pdm.h @@ -0,0 +1,37 @@ +/* pdm.h - .pdm package format (deb-like) + * + * A .pdm file is a tar archive with three members: + * control.tar.gz -> contains "pdm-control" text file (Package/Version/...) + * data.tar.gz -> the package payload, extracted into the pm root + * sha256sums -> " control.tar.gz" lines for both members + * + * pdm-control fields (KEY: value lines): + * Package: name + * Version: 1.0.0 + * Architecture: all | windows | linux | macos + * Description: text + * Maintainer: text + * + * The folder passed to `pdm pack ./dir` must contain a "pdm-control" file; + * everything else in the folder becomes data.tar.gz. + */ +#ifndef PMM_PDM_H +#define PMM_PDM_H + +/* Pack directory (containing pdm-control) into out.pdm. out may be NULL + * (defaults to _.pdm next to the dir). */ +int pdm_pack(const char *dir, const char *out); + +/* Install a local .pdm into root dir (~/.pmm/root), record in db (~/.pmm/installed). */ +int pdm_install_file(const char *pdmfile); + +/* Remove installed package by name (dpkg -r style: delete data files). */ +int pdm_remove(const char *name); + +/* Print installed packages from the db. */ +void pdm_list_installed(void); + +/* Print control fields of a .pdm file. */ +int pdm_info(const char *pdmfile); + +#endif diff --git a/src/pdm_main.c b/src/pdm_main.c new file mode 100644 index 0000000..e78525c --- /dev/null +++ b/src/pdm_main.c @@ -0,0 +1,108 @@ +/* pdm_main.c - standalone `pdm` tool (pack/install/remove/info/list) + * + * usage: + * pdm pack [output.pdm] pack a folder (with pdm-control) into .pdm + * pdm install install local .pdm + * pdm info show package control info + * pdm list list installed packages + * pdm remove remove installed package + */ +#include +#include +#include +#include "pdm.h" +#include "pmm.h" +#include "install.h" + +static void usage(void) { + printf("pdm - Parlz .pdm package tool\n\n" + "usage:\n" + " pdm pack [output.pdm] pack folder (needs pdm-control) into .pdm\n" + " pdm install install local .pdm\n" + " pdm install [-v ] install from registry (pmm mirror)\n" + " pdm info show control info\n" + " pdm list list installed packages\n" + " pdm remove remove installed package\n" + "options: -p install under :\\.pdm (e.g. -pd -> D:\\.pdm)\n"); +} + +/* Parse "-p" / "-p " and compact argv; records the drive so + * pdm installs under :\.pmm. */ +static int consume_drive_flag(int argc, char **argv) { + int w = 1; + for (int r = 1; r < argc; r++) { + const char *a = argv[r]; + if (a[0] == '-' && a[1] == 'p' && (a[2] >= 'A' && a[2] <= 'Z' || a[2] >= 'a' && a[2] <= 'z')) { + char dr[2] = { a[2], '\0' }; + pmm_set_install_drive(dr); + continue; + } + if (strcmp(a, "-p") == 0 && r + 1 < argc) { + const char *nxt = argv[r + 1]; + if (nxt[0] && (nxt[0] >= 'A' && nxt[0] <= 'Z' || nxt[0] >= 'a' && nxt[0] <= 'z') && nxt[1] == '\0') { + char dr[2] = { nxt[0], '\0' }; + pmm_set_install_drive(dr); + r++; + continue; + } + } + argv[w++] = argv[r]; + } + return w; +} + +int main(int argc, char **argv) { + if (argc < 2) { usage(); return 0; } + pmm_set_self_path(argv[0]); + argc = consume_drive_flag(argc, argv); + if (argc < 2) { usage(); return 0; } + if (strcmp(argv[1], "pack") == 0) { + if (argc < 3) { fprintf(stderr, "pdm: usage: pdm pack [output.pdm]\n"); return 1; } + return pdm_pack(argv[2], argc >= 4 ? argv[3] : NULL) == 0 ? 0 : 1; + } + if (strcmp(argv[1], "install") == 0 || strcmp(argv[1], "-i") == 0) { + if (argc < 3) { fprintf(stderr, "pdm: usage: pdm install [-v ver]\n"); return 1; } + /* parse [pkg] [ -v ] ; pkg may be "nodejs==24.20.0" / "nodejs>=24,<25" */ + const char *raw = NULL, *version = NULL; + for (int i = 2; i < argc; i++) { + const char *a = argv[i]; + if (strncmp(a, "-v", 2) == 0 && a[2]) { version = a + 2; continue; } + if (strcmp(a, "-v") == 0 && i + 1 < argc) { version = argv[++i]; continue; } + if (strcmp(a, "--version") == 0 && i + 1 < argc) { version = argv[++i]; continue; } + if (strncmp(a, "--version=", 10) == 0) { version = a + 10; continue; } + if (!raw && a[0] != '-') { raw = a; continue; } + } + if (!raw) raw = argv[2]; + char pkg[256], spec[256]; + if (version && *version) { + strncpy(pkg, raw, sizeof(pkg) - 1); + strncpy(spec, version, sizeof(spec) - 1); + } else { + const char *ops[] = { ">=", "<=", "==", "!=", ">", "<", NULL }; + const char *pos = NULL; + for (int i = 0; ops[i]; i++) { pos = strstr(raw, ops[i]); if (pos) break; } + if (pos) { size_t n = (size_t)(pos - raw); if (n >= sizeof(pkg)) n = sizeof(pkg) - 1; memcpy(pkg, raw, n); pkg[n] = '\0'; strncpy(spec, pos, sizeof(spec) - 1); } + else { strncpy(pkg, raw, sizeof(pkg) - 1); spec[0] = '\0'; } + } + pkg[sizeof(pkg) - 1] = '\0'; spec[sizeof(spec) - 1] = '\0'; + size_t l = strlen(pkg); + if (l > 4 && (strcmp(pkg + l - 4, ".pdm") == 0 || strcmp(pkg + l - 4, ".PDM") == 0)) + return pdm_install_file(pkg) == 0 ? 0 : 1; + /* otherwise treat as a registry package name */ + return install_from_registry(pkg, spec[0] ? spec : NULL, NULL) == 0 ? 0 : 1; + } + if (strcmp(argv[1], "info") == 0) { + if (argc < 3) { fprintf(stderr, "pdm: usage: pdm info \n"); return 1; } + return pdm_info(argv[2]) == 0 ? 0 : 1; + } + if (strcmp(argv[1], "list") == 0 || strcmp(argv[1], "-l") == 0) { + pdm_list_installed(); + return 0; + } + if (strcmp(argv[1], "remove") == 0 || strcmp(argv[1], "-r") == 0) { + if (argc < 3) { fprintf(stderr, "pdm: usage: pdm remove \n"); return 1; } + return pdm_remove(argv[2]) == 0 ? 0 : 1; + } + usage(); + return 1; +} diff --git a/src/pmm.c b/src/pmm.c new file mode 100644 index 0000000..b999e6d --- /dev/null +++ b/src/pmm.c @@ -0,0 +1,483 @@ +/* pmm.c - platform & config-dir helpers */ +#include "pmm.h" +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#include +#define PMM_MKDIR(p) _mkdir(p) +#define PMM_POPEN_READ(cmd) _popen(cmd, "r") +#define PMM_PCLOSE_READ(p) _pclose(p) +#else +#include +#include +#include /* strcasecmp/strncasecmp */ +#define PMM_MKDIR(p) mkdir((p), 0755) +#define PMM_POPEN_READ(cmd) popen(cmd, "r") +#define PMM_PCLOSE_READ(p) pclose(p) +#endif + +static char g_self_path[1400] = ""; +void pmm_set_self_path(const char *argv0) { + if (!argv0 || !*argv0) return; +#ifdef _WIN32 + if (_fullpath(g_self_path, argv0, sizeof(g_self_path)) == NULL) + snprintf(g_self_path, sizeof(g_self_path), "%s", argv0); +#else + if (realpath(argv0, g_self_path) == NULL) + snprintf(g_self_path, sizeof(g_self_path), "%s", argv0); +#endif +} +const char *pmm_self_path(void) { + return g_self_path[0] ? g_self_path : NULL; +} + +PmmOS pmm_detect_os(void) { +#if defined(_WIN32) + return OS_WINDOWS; +#elif defined(__APPLE__) + return OS_MACOS; +#elif defined(__linux__) + return OS_LINUX; +#else + return OS_UNKNOWN; +#endif +} + +const char *pmm_os_name(PmmOS os) { + switch (os) { + case OS_WINDOWS: return "windows"; + case OS_LINUX: return "linux"; + case OS_MACOS: return "macos"; + default: return "unknown"; + } +} + +static void mkdir_p(const char *path) { + char tmp[1024]; + strncpy(tmp, path, sizeof(tmp) - 1); + tmp[sizeof(tmp) - 1] = '\0'; + for (char *p = tmp + 1; *p; p++) { + if (*p == '/' || *p == '\\') { + char c = *p; + *p = '\0'; + PMM_MKDIR(tmp); + *p = c; + } + } + PMM_MKDIR(tmp); +} + +static const char *home_dir(void) { + const char *h = getenv("USERPROFILE"); /* windows */ + if (!h || !*h) h = getenv("HOME"); + return (h && *h) ? h : "."; +} + +/* ---- install-drive override (e.g. "pmm install x -pd" -> D:\.pmm) ---- + * The chosen drive is persisted in the always-reachable home dir so later + * commands remember it without needing the flag again. */ +static char g_drive[4] = ""; /* "D", or "" = use home */ + +static const char *drive_state_path(char *buf, size_t size) { + snprintf(buf, size, "%s/.pmm/install-drive", home_dir()); + return buf; +} + +void pmm_set_install_drive(const char *letter) { + if (!letter || !*letter) { g_drive[0] = '\0'; return; } + char c = (char)((*letter >= 'a' && *letter <= 'z') ? *letter - 'a' + 'A' : *letter); + if (c < 'A' || c > 'Z') return; /* only a single A-Z drive letter */ + g_drive[0] = c; + g_drive[1] = '\0'; + /* persist so it becomes the default for later runs */ + char state[1024]; + drive_state_path(state, sizeof(state)); + char dir[1024]; + snprintf(dir, sizeof(dir), "%s/.pmm", home_dir()); + mkdir_p(dir); + FILE *f = fopen(state, "w"); + if (f) { fprintf(f, "%c", c); fclose(f); } + printf("pmm: install drive set to %c:\n", c); +} + +/* Resolve the .pmm base dir: explicit drive override > persisted drive > + * home/.pmm (when that drive is picked) > DEFAULT = D:\.pmm. + * Defaulting to D:\ spares the C: drive (user's primary diskspace concern); + * use -pc to go back to the user-home .pmm, or -p for any other drive. */ +static char home_drive_letter(void) { + const char *h = home_dir(); + if (h[0] && h[1] == ':' && ((h[0] >= 'A' && h[0] <= 'Z') || (h[0] >= 'a' && h[0] <= 'z'))) + return (char)((h[0] >= 'a' && h[0] <= 'z') ? h[0] - 'a' + 'A' : h[0]); + return 0; /* not a drive-letter home (unix) */ +} + +static const char *pmm_base_dir(char *buf, size_t size) { + char drive[4] = ""; + if (g_drive[0]) { + drive[0] = g_drive[0]; + } else { + char state[1024]; + drive_state_path(state, sizeof(state)); + FILE *f = fopen(state, "r"); + if (f) { + char c = (char)fgetc(f); + fclose(f); + if (c >= 'A' && c <= 'Z') drive[0] = c; + else if (c >= 'a' && c <= 'z') drive[0] = (char)(c - 'a' + 'A'); + } + } + char hd = home_drive_letter(); + if (drive[0] && hd && drive[0] != hd) { + snprintf(buf, size, "%c:\\.pmm", drive[0]); /* -p */ + } else if (drive[0] && hd && drive[0] == hd) { + snprintf(buf, size, "%s/.pmm", home_dir()); /* -pc -> home */ + } else { +#ifdef _WIN32 + snprintf(buf, size, "D:\\.pmm"); /* Windows DEFAULT -> D: */ +#else + snprintf(buf, size, "%s/.pmm", home_dir()); /* unix default -> ~/.pmm */ +#endif + } + return buf; +} + +const char *pmm_config_dir(char *buf, size_t size) { + pmm_base_dir(buf, size); + mkdir_p(buf); + return buf; +} + +const char *pmm_cache_dir(char *buf, size_t size) { + char base[1024]; + pmm_base_dir(base, sizeof(base)); + snprintf(buf, size, "%s/cache", base); + mkdir_p(buf); + return buf; +} + +const char *pmm_install_dir(char *buf, size_t size) { + const char *env = getenv("PMM_INSTALL_DIR"); + if (env && *env) { snprintf(buf, size, "%s", env); } + else { + char base[1024]; + pmm_base_dir(base, sizeof(base)); + snprintf(buf, size, "%s/bin", base); + } + mkdir_p(buf); + return buf; +} + +char *pmm_find_config(const char *dir, const char *base) { + static const char *exts[] = { ".json", ".ini", ".conf", NULL }; + static char out[1024]; + for (int i = 0; exts[i]; i++) { + snprintf(out, sizeof(out), "%s/%s%s", dir, base, exts[i]); + struct stat st; + if (stat(out, &st) == 0 && !S_ISDIR(st.st_mode)) + return out; + } + return NULL; +} + +/* ---------- PATH auto-add ---------- */ + +static int dir_has_exec(const char *dir) { +#ifdef _WIN32 + char patt[1300]; + snprintf(patt, sizeof(patt), "%s\\*", dir); + struct _finddata_t fd; + intptr_t h = _findfirst(patt, &fd); + if (h == -1) return 0; + int found = 0; + do { + if (fd.attrib & _A_SUBDIR) continue; + size_t l = strlen(fd.name); + if ((l > 4 && strcasecmp(fd.name + l - 4, ".exe") == 0) || + (l > 4 && strcasecmp(fd.name + l - 4, ".cmd") == 0) || + (l > 4 && strcasecmp(fd.name + l - 4, ".bat") == 0)) { found = 1; break; } + } while (_findnext(h, &fd) == 0); + _findclose(h); + return found; +#else + DIR *d = opendir(dir); + if (!d) return 0; + struct dirent *e; + int found = 0; + while ((e = readdir(d)) != NULL) { + if (e->d_name[0] == '.') continue; + char full[1300]; + snprintf(full, sizeof(full), "%s/%s", dir, e->d_name); + struct stat st; + if (stat(full, &st) == 0 && !S_ISDIR(st.st_mode)) { found = 1; break; } + } + closedir(d); + return found; +#endif +} + +/* case-insensitive "dir" present in a ';'-separated PATH (win) / ':' (unix) */ +static int path_has(const char *path, const char *dir, char sep) { + if (!path || !*path) return 0; + size_t dl = strlen(dir); + const char *p = path; + for (;;) { + const char *e = strchr(p, sep); + size_t n = e ? (size_t)(e - p) : strlen(p); + while (n && (p[n-1]==' '||p[n-1]=='"')) n--; + const char *q = p; + while (*q==' '||*q=='"') q++; + if (n && strncasecmp(q, dir, dl) == 0 && (n == dl)) return 1; + if (!e) break; + p = e + 1; + } + return 0; +} + +/* Add a dir to the candidate list if not already listed. */ +static void add_path_candidate(char list[][1200], int *n, int max, const char *dir) { + for (int i = 0; i < *n; i++) + if (strcmp(list[i], dir) == 0) return; + if (*n < max) snprintf(list[(*n)++], 1200, "%s", dir); +} + +void pmm_add_to_path(void) { + char home[1024]; + pmm_config_dir(home, sizeof(home)); + char base[1024]; + snprintf(base, sizeof(base), "%s/root", home); + + /* collect dirs: ~/.pmm/bin, ~/.pmm/root, and root subdirs holding executables */ + char list[160][1200]; + int n = 0; + char bin[1100]; + snprintf(bin, sizeof(bin), "%s/bin", home); + mkdir_p(bin); + snprintf(list[n++], sizeof(list[0]), "%s", bin); + snprintf(list[n++], sizeof(list[0]), "%s", base); + mkdir_p(base); + +#ifdef _WIN32 + { + char patt[1600]; + snprintf(patt, sizeof(patt), "%s\\*", base); + char cmd[1700]; + snprintf(cmd, sizeof(cmd), "dir /b /ad \"%s\" 2>nul", patt); + FILE *f = PMM_POPEN_READ(cmd); + if (f) { + char sub[1200]; + while (fgets(sub, sizeof(sub), f)) { + size_t l = strlen(sub); + while (l && (sub[l-1]=='\r'||sub[l-1]=='\n')) sub[--l] = 0; + if (!l) continue; + char full[1300]; + snprintf(full, sizeof(full), "%s/%s", base, sub); + if (dir_has_exec(full)) { + add_path_candidate(list, &n, 160, full); + } else { + /* depth-2: pkg/cmd convention (e.g. git/cmd/git.exe) */ + char cmddir[1400]; + snprintf(cmddir, sizeof(cmddir), "%s/cmd", full); + if (dir_has_exec(cmddir)) + add_path_candidate(list, &n, 160, cmddir); + } + } + PMM_PCLOSE_READ(f); + } + } +#else + { + char cmd[1700]; + snprintf(cmd, sizeof(cmd), "ls -1 \"%s\" 2>/dev/null", base); + FILE *f = PMM_POPEN_READ(cmd); + if (f) { + char sub[1200]; + while (fgets(sub, sizeof(sub), f)) { + size_t l = strlen(sub); + while (l && (sub[l-1]=='\r'||sub[l-1]=='\n')) sub[--l] = 0; + char full[1300]; + snprintf(full, sizeof(full), "%s/%s", base, sub); + if (dir_has_exec(full)) { + add_path_candidate(list, &n, 160, full); + } else { + char cmddir[1400]; + snprintf(cmddir, sizeof(cmddir), "%s/cmd", full); + if (dir_has_exec(cmddir)) + add_path_candidate(list, &n, 160, cmddir); + } + } + PMM_PCLOSE_READ(f); + } + } +#endif + + /* read current user PATH */ + char cur[16384] = ""; +#ifdef _WIN32 + { + FILE *f = PMM_POPEN_READ("reg query \"HKCU\\Environment\" /v Path 2>nul"); + if (f) { + char line[8192]; + char *val = NULL; + while (fgets(line, sizeof(line), f)) { + char *r = strstr(line, "REG_SZ"); + if (r) { val = r + 6; while (*val==' '||*val=='\t'||*val=='\r'||*val=='\n') val++; break; } + } + PMM_PCLOSE_READ(f); + if (val) { + size_t v = strlen(val); + while (v && (val[v-1]=='\r'||val[v-1]=='\n')) val[--v] = 0; + snprintf(cur, sizeof(cur), "%s", val); + } + } + } +#else + { + const char *env = getenv("PATH"); + if (env) snprintf(cur, sizeof(cur), "%s", env); + } +#endif + + /* append missing dirs */ + char next[17000] = ""; + snprintf(next, sizeof(next), "%s", cur); + int added = 0; + for (int i = 0; i < n; i++) { + if (!list[i][0]) continue; + if (path_has(next, list[i], ';') || path_has(next, list[i], ':')) continue; + size_t l = strlen(next); + if (l && next[l-1] != ';' && next[l-1] != ':') strncat(next, ";", sizeof(next)-l-1); + strncat(next, list[i], sizeof(next)-strlen(next)-1); + added++; + printf("pmm: adding to PATH: %s\n", list[i]); + } + if (!added) { printf("pmm: PATH already contains PMM dirs\n"); return; } + +#ifdef _WIN32 + { + char cmd[17001]; + /* drive letters and ';' are fine for setx; quote the value */ + snprintf(cmd, sizeof(cmd), "setx Path \"%s\" >nul 2>&1", next); + system(cmd); + printf("pmm: PATH updated for new shells (reopen a terminal or run: setx) (user-level)\n"); + } +#else + { + const char *she = getenv("SHELL"); + const char *rc = "~/.bashrc"; + char rcp[1100]; + snprintf(rcp, sizeof(rcp), "%s/.bashrc", home_dir()); + FILE *f = fopen(rcp, "a"); + if (f) { + fprintf(f, "\n# PMM PATH\n"); + for (int i = 0; i < n; i++) if (list[i][0]) fprintf(f, "export PATH=\"%s:$PATH\"\n", list[i]); + fclose(f); + } + (void)she; + printf("pmm: updated %s (reload your shell)\n", rcp); + } +#endif +} + +/* ---------- register installed packages in the Windows "apps" (Uninstall) ---------- + * Mimics what an MSI installer writes: HKCU\Software\Microsoft\ + * Windows\CurrentVersion\Uninstall\, so the package appears in + * "Installed apps" / Add-Remove Programs and can be uninstalled via pmm. */ + +/* Escape a string for a single-quoted PowerShell literal (backslashes are + * literal there; only a single quote needs doubling: ' -> ''). */ +static void ps_squote(const char *in, char *out, size_t outsz) { + size_t o = 0; + for (const char *p = in; *p && o + 3 < outsz; p++) { + if (*p == '\'') { out[o++] = '\''; out[o++] = '\''; } + out[o++] = *p; + } + out[o] = '\0'; +} + +int pmm_reg_uninstall(const char *pkg, const char *ver, const char *pub, + const char *install_location, const char *icon, + const char *uninstaller, unsigned long long size_bytes) { +#ifndef _WIN32 + (void)pkg; (void)ver; (void)pub; (void)install_location; (void)icon; + (void)uninstaller; (void)size_bytes; + return 0; /* only Windows registers software in the Uninstall hive */ +#else + if (!pkg || !*pkg) return -1; + char base[1024]; + pmm_config_dir(base, sizeof(base)); + char cfg[1100]; + snprintf(cfg, sizeof(cfg), "%s\\cache", base); + mkdir_p(base); mkdir_p(cfg); + char ps1[1300]; + snprintf(ps1, sizeof(ps1), "%s\\pmm-uninst-%s.ps1", cfg, pkg); + /* PowerShell-escape the values */ + char e_nm[700], e_ver[700], e_pub[700], e_loc[2400], e_icon[2600], e_un[4200]; + ps_squote(pkg, e_nm, sizeof(e_nm)); + ps_squote(ver ? ver : "", e_ver, sizeof(e_ver)); + ps_squote(pub ? pub : "", e_pub, sizeof(e_pub)); + ps_squote(install_location ? install_location : "", e_loc, sizeof(e_loc)); + ps_squote(icon ? icon : "", e_icon, sizeof(e_icon)); + char uninst[2048]; + if (uninstaller && *uninstaller) snprintf(uninst, sizeof(uninst), "\"%s\" remove %s", uninstaller, pkg); + else snprintf(uninst, sizeof(uninst), "pmm remove %s", pkg); + ps_squote(uninst, e_un, sizeof(e_un)); + + FILE *f = fopen(ps1, "wb"); + if (!f) return -1; + fprintf(f, "$ErrorActionPreference='SilentlyContinue'\r\n"); + fprintf(f, "$k='HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\%s'\r\n", pkg); + fprintf(f, "New-Item -Path $k -Force | Out-Null\r\n"); + fprintf(f, "Set-ItemProperty -Path $k -Name DisplayName -Value '%s' -Force\r\n", e_nm); + if (ver && *ver) fprintf(f, "Set-ItemProperty -Path $k -Name DisplayVersion -Value '%s' -Force\r\n", e_ver); + if (pub && *pub) fprintf(f, "Set-ItemProperty -Path $k -Name Publisher -Value '%s' -Force\r\n", e_pub); + if (icon && *icon) fprintf(f, "Set-ItemProperty -Path $k -Name DisplayIcon -Value '%s' -Force\r\n", e_icon); + fprintf(f, "Set-ItemProperty -Path $k -Name UninstallString -Value '%s' -Force\r\n", e_un); + if (install_location && *install_location) + fprintf(f, "Set-ItemProperty -Path $k -Name InstallLocation -Value '%s' -Force\r\n", e_loc); + fprintf(f, "New-ItemProperty -Path $k -Name EstimatedSize -Value %llu -PropertyType DWord -Force | Out-Null\r\n", + (unsigned long long)(size_bytes / 1024ULL)); + { /* InstallDate YYYYMMDD */ + time_t t = time(NULL); struct tm *tm = localtime(&t); + char date[32]; + if (tm) snprintf(date, sizeof(date), "%04d%02d%02d", tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday); + else snprintf(date, sizeof(date), "0"); + fprintf(f, "Set-ItemProperty -Path $k -Name InstallDate -Value '%s' -Force\r\n", date); + } + fprintf(f, "New-ItemProperty -Path $k -Name NoModify -Value 1 -PropertyType DWord -Force | Out-Null\r\n"); + fprintf(f, "New-ItemProperty -Path $k -Name NoRepair -Value 1 -PropertyType DWord -Force | Out-Null\r\n"); + fclose(f); + + char cmd[2600]; + snprintf(cmd, sizeof(cmd), + "%%SystemRoot%%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"%s\" >nul 2>&1", + ps1); + int rc = system(cmd); + remove(ps1); + if (rc != 0) + fprintf(stderr, "pmm: warning: could not register '%s' in Installed Apps\n", pkg); + else + printf("pmm: registered '%s' %s in Installed Apps (uninstall via pmm remove %s)\n", + pkg, ver ? ver : "", pkg); + return rc == 0 ? 0 : -1; +#endif +} + +void pmm_reg_uninstall_clear(const char *pkg) { +#ifndef _WIN32 + (void)pkg; +#else + if (!pkg || !*pkg) return; + char cmd[2200]; + snprintf(cmd, sizeof(cmd), + "%%SystemRoot%%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -Command \"Remove-Item -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\%s' -Recurse -Force -ErrorAction SilentlyContinue\" 2>nul", + pkg); + system(cmd); + printf("pmm: removed '%s' from Installed Apps\n", pkg); +#endif +} diff --git a/src/pmm.h b/src/pmm.h new file mode 100644 index 0000000..c116e58 --- /dev/null +++ b/src/pmm.h @@ -0,0 +1,61 @@ +/* pmm.h - shared definitions */ +#ifndef PMM_H +#define PMM_H + +#include + +/* Add PMM install dirs (~/.pmm/bin, ~/.pmm/root, and executable subdirs like + * root/nodejs) to the user's PATH if not already present. Idempotent. + * On Windows persists via the user registry PATH; on Unix appends to shell rc. */ +void pmm_add_to_path(void); + +/* Set the install location by drive letter ("D" -> D:\.pmm). An empty string + * clears back to the user-home default. Persisted across runs. */ +void pmm_set_install_drive(const char *letter); + +/* File-association record written to the per-user registry on install. */ +typedef struct { + const char *progid; /* e.g. "Node.JSFile" */ + const char *icon; /* e.g. "C:\...\node.exe,0" or NULL */ + const char *cmd; /* open command, e.g. "C:\..\node.exe" "%1" */ + const char **exts; /* NULL-terminated list of ".js", ".mjs", ... */ +} PmmAssoc; + +/* Write HKCU\Software\Classes associations for the given package. + * Backs up any pre-existing per-user entries so clear() can restore them. */ +int pmm_assoc_apply(const PmmAssoc *a, const char *backup_dir); + +/* Remove the associations for a package; restores backups if present. */ +int pmm_assoc_clear(const PmmAssoc *a, const char *backup_dir); + +/* Register an installed package in the Windows per-user "Uninstall" (Installed + * Apps) hive, mimicking what an MSI installer writes. uninstaller is the + * absolute path to the pmm/pdm executable used to uninstall. */ +int pmm_reg_uninstall(const char *pkg, const char *ver, const char *pub, + const char *install_location, const char *icon, + const char *uninstaller, unsigned long long size_bytes); +void pmm_reg_uninstall_clear(const char *pkg); + +/* Record the running executable path (argv[0], resolved to absolute) so + * registration can point the "Uninstall" button back at us. */ +void pmm_set_self_path(const char *argv0); +const char *pmm_self_path(void); + +#define PMM_VERSION "0.1.0" + +typedef enum { OS_WINDOWS, OS_LINUX, OS_MACOS, OS_UNKNOWN } PmmOS; + +typedef enum { HOST_GITHUB, HOST_GITLAB, HOST_GITEA, HOST_FORGEJO, HOST_AUTO, HOST_UNKNOWN } PmmHost; + +PmmOS pmm_detect_os(void); +const char *pmm_os_name(PmmOS os); + +/* Resolve ~/.pmm or %USERPROFILE%\.pmm into buf; returns buf or NULL. */ +const char *pmm_config_dir(char *buf, size_t size); +const char *pmm_cache_dir(char *buf, size_t size); +const char *pmm_install_dir(char *buf, size_t size); + +/* Find first existing config file among given extensions inside dir. */ +char *pmm_find_config(const char *dir, const char *base); /* malloc'd path or NULL */ + +#endif diff --git a/src/repo.c b/src/repo.c new file mode 100644 index 0000000..a3894ab --- /dev/null +++ b/src/repo.c @@ -0,0 +1,306 @@ +/* repo.c - git hosting adapters: GitHub / GitLab / Gitea / Forgejo + * + * API endpoints used (latest release): + * GitHub: {api}/repos/{repo}/releases/latest + * GitLab: {api}/projects/{url-encoded repo}/releases/permalink/latest + * Gitea/Forgejo: {api}/api/v1/repos/{repo}/releases/latest + * + * Asset selection by OS (first match wins, in priority order): + * windows: .exe .msi .zip .7z + * linux: .deb .rpm .appimage .tar.gz .tgz .tar.xz + * macos: .dmg .pkg .app.zip .tar.gz + */ +#include "repo.h" +#include "http.h" +#include "json.h" +#include +#include +#include +#include + +static char *strdup_or(const char *s, const char *dflt) { + return strdup(s ? s : dflt); +} + +static char *url_encode(const char *s) { + size_t cap = strlen(s) * 3 + 1; + char *out = malloc(cap); + size_t o = 0; + for (const char *p = s; *p && o + 4 < cap; p++) { + unsigned char c = (unsigned char)*p; + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { + out[o++] = (char)c; + } else if (c == '/') { + out[o++] = '%'; out[o++] = '2'; out[o++] = 'F'; + } else { + char tmp[8]; + snprintf(tmp, sizeof(tmp), "%%%02X", c); + out[o++] = tmp[0]; out[o++] = tmp[1]; out[o++] = tmp[2]; + } + } + out[o] = '\0'; + return out; +} + +/* Normalize "https://host/owner/repo(.git)" or "owner/repo" into "owner/repo". */ +static char *normalize_repo(const char *in) { + const char *s = in; + int is_url = 0; + if (strncmp(s, "https://", 8) == 0) { s += 8; is_url = 1; } + else if (strncmp(s, "http://", 7) == 0) { s += 7; is_url = 1; } + /* skip hostname only for full URLs */ + if (is_url) { + const char *slash = strchr(s, '/'); + if (slash && slash != s) s = slash + 1; + } + size_t len = strlen(s); + if (len > 4 && strcmp(s + len - 4, ".git") == 0) len -= 4; + /* strip trailing slashes */ + while (len > 0 && s[len - 1] == '/') len--; + char *out = malloc(len + 1); + memcpy(out, s, len); + out[len] = '\0'; + return out; +} + +PmmHost repo_detect_host(const char *repo_or_url) { + if (strstr(repo_or_url, "github.com")) return HOST_GITHUB; + if (strstr(repo_or_url, "gitlab.")) return HOST_GITLAB; + if (strstr(repo_or_url, "gitea.")) return HOST_GITEA; + if (strstr(repo_or_url, "forgejo.")) return HOST_FORGEJO; + /* full URL with unknown host: try to infer from path; default gitea-compatible */ + if (strncmp(repo_or_url, "http", 4) == 0) return HOST_GITEA; + return HOST_GITHUB; /* bare slug defaults to github */ +} + +/* Extract "scheme://host[:port]" from a URL; empty string if none. */ +static void extract_origin(const char *repo, char *origin, size_t size) { + const char *s = repo; + origin[0] = '\0'; + if (strncmp(s, "https://", 8) == 0) s += 8; + else if (strncmp(s, "http://", 7) == 0) { s += 7; } + else return; /* bare slug: no origin */ + s -= (strncmp(repo, "https://", 8) == 0) ? 8 : 7; + const char *slash = strchr(s + 8, '/'); + if (!slash) slash = s + strlen(s); + size_t n = (size_t)(slash - s); + if (n >= size) n = size - 1; + memcpy(origin, s, n); + origin[n] = '\0'; +} + +RepoContext *repo_open(PmmHost host, const char *repo, const char *mirror_api_base) { + RepoContext *ctx = calloc(1, sizeof(RepoContext)); + if (!ctx) return NULL; + ctx->host = host; + char *norm = normalize_repo(repo); + char api[1024]; + ctx->repo_norm = strdup(norm); + + char origin[512]; + extract_origin(repo, origin, sizeof(origin)); + ctx->origin = strdup(origin); + + const char *base = NULL; + if (mirror_api_base && *mirror_api_base) { + base = mirror_api_base; /* mirror overrides host entirely */ + } else { + switch (host) { + case HOST_GITHUB: base = "https://api.github.com"; break; + case HOST_GITLAB: base = "https://gitlab.com"; break; + default: base = NULL; break; /* gitea/forgejo/auto need a full URL */ + } + } + + if (host == HOST_AUTO) { + /* Generic git URL: api_url is built per-candidate in repo_latest_asset */ + ctx->page_url = strdup_or(origin[0] ? origin : "", ""); + ctx->api_url = NULL; + free(norm); + return ctx; + } + + if (!base) { + /* Gitea/Forgejo require a full URL in the repo argument, e.g. + * https://git.example.com/owner/repo or configured in mirror file. */ + if (!origin[0]) + strncpy(origin, "http://localhost:3000", sizeof(origin) - 1); + snprintf(api, sizeof(api), "%s/api/v1/repos/%s", origin, norm); + ctx->page_url = strdup_or(origin, ""); + } else { + switch (host) { + case HOST_GITHUB: + snprintf(api, sizeof(api), "%s/repos/%s", base, norm); + break; + case HOST_GITLAB: { + char *enc = url_encode(norm); + snprintf(api, sizeof(api), "%s/projects/%s", base, enc); + free(enc); + break; + } + default: /* gitea/forgejo via mirror base */ + snprintf(api, sizeof(api), "%s/api/v1/repos/%s", base, norm); + break; + } + ctx->page_url = strdup(base); + } + ctx->api_url = strdup(api); + free(norm); + return ctx; +} + +void repo_close(RepoContext *ctx) { + if (!ctx) return; + free(ctx->api_url); + free(ctx->page_url); + free(ctx->origin); + free(ctx->repo_norm); + free(ctx); +} + +static int has_ext(const char *name, const char *ext) { + size_t nl = strlen(name), el = strlen(ext); + if (nl < el) return 0; + const char *tail = name + nl - el; + if (strcasecmp(tail, ext) != 0) return 0; + /* ".tar.gz" style: for ".gz" only match after ".tar"? keep simple: exact suffix */ + return 1; +} + +int asset_matches_os(const char *filename, PmmOS os) { + static const char *win_exts[] = { ".exe", ".msi", ".zip", ".7z", NULL }; + static const char *linux_exts[] = { ".deb", ".rpm", ".appimage", ".tar.gz", ".tgz", ".tar.xz", ".tar.bz2", ".pkg.tar.zst", NULL }; + static const char *mac_exts[] = { ".dmg", ".pkg", ".app.zip", ".tar.gz", ".tgz", NULL }; + const char **exts = NULL; + switch (os) { + case OS_WINDOWS: exts = win_exts; break; + case OS_LINUX: exts = linux_exts; break; + case OS_MACOS: exts = mac_exts; break; + default: return 0; + } + for (int i = 0; exts[i]; i++) + if (has_ext(filename, exts[i])) return 1; + return 0; +} + +/* Reject common junk assets like sources, checksums, sigs. */ +static int asset_is_junk(const char *name) { + static const char *junk[] = { ".sha256", ".sha512", ".sig", ".asc", ".sbom", + ".json", ".txt", ".yml", ".yaml", ".xml", ".sb", ".pem", NULL }; + for (int i = 0; junk[i]; i++) + if (has_ext(name, junk[i])) return 1; + if (strstr(name, "checksum") || strstr(name, "source") || strstr(name, "-src")) return 1; + return 0; +} + +static ReleaseAsset *asset_from_json(const JsonValue *a, const char *tag) { + ReleaseAsset *ra = calloc(1, sizeof(ReleaseAsset)); + const char *name = json_str(a, "name"); + const char *url = json_str(a, "browser_download_url"); + if (!url) url = json_str(a, "direct_asset_url"); + ra->name = strdup_or(name, "asset"); + ra->url = strdup_or(url, ""); + ra->tag = strdup_or(tag, ""); + return ra; +} + +static ReleaseAsset *pick_from_assets_array(const JsonValue *assets, const char *tag, PmmOS os) { + for (int i = 0; i < assets->count; i++) { + JsonValue *a = json_at(assets, i); + if (!a) continue; + const char *name = json_str(a, "name"); + if (!name || asset_is_junk(name)) continue; + if (!asset_matches_os(name, os)) continue; + return asset_from_json(a, tag); + } + return NULL; +} + +static ReleaseAsset *fetch_latest(const char *url, PmmHost host, PmmOS os) { + int status = 0; + char *body = http_get(url, &status); + if (!body || (status != 200 && status != 0)) { + free(body); + return NULL; + } + JsonValue *root = json_parse(body); + free(body); + if (!root) return NULL; + + const char *tag = json_str(root, "tag_name"); + if (!tag) tag = json_str(root, "tag"); + + ReleaseAsset *found = NULL; + + /* GitHub: assets is at root; Gitea: same; GitLab: assets.links[] */ + JsonValue *assets = json_get(root, "assets"); + if (host == HOST_GITLAB && assets) { + JsonValue *links = json_get(assets, "links"); + if (links) { + for (int i = 0; i < links->count && !found; i++) { + JsonValue *a = json_at(links, i); + const char *name = json_str(a, "name"); + if (!name) name = json_str(a, "direct_asset_url"); + if (!name || asset_is_junk(name) || !asset_matches_os(name, os)) continue; + found = calloc(1, sizeof(ReleaseAsset)); + found->name = strdup(name); + found->url = strdup_or(json_str(a, "direct_asset_url"), json_str(a, "url") ? json_str(a, "url") : ""); + found->tag = strdup_or(tag, ""); + } + } + } else if (assets) { + found = pick_from_assets_array(assets, tag, os); + } + + json_free(root); + if (found && (!found->url || !*found->url)) { + repo_asset_free(found); + return NULL; + } + return found; +} + +ReleaseAsset *repo_latest_asset(RepoContext *ctx, PmmOS os) { + char url[2048]; + + if (ctx->host == HOST_AUTO) { + /* Generic git repo: probe known API shapes on the given origin. */ + const char *origin = (ctx->origin && *ctx->origin) ? ctx->origin : NULL; + const char *norm = ctx->repo_norm; + ReleaseAsset *a = NULL; + if (origin) { + snprintf(url, sizeof(url), "%s/api/v1/repos/%s/releases/latest", origin, norm); + if ((a = fetch_latest(url, HOST_GITEA, os))) { ctx->host = HOST_GITEA; return a; } + char *enc = url_encode(norm); + snprintf(url, sizeof(url), "%s/api/v4/projects/%s/releases/permalink/latest", origin, enc); + free(enc); + if ((a = fetch_latest(url, HOST_GITLAB, os))) { ctx->host = HOST_GITLAB; return a; } + if (strstr(origin, "github.com")) { + snprintf(url, sizeof(url), "https://api.github.com/repos/%s/releases/latest", norm); + if ((a = fetch_latest(url, HOST_GITHUB, os))) { ctx->host = HOST_GITHUB; return a; } + } + } else { + snprintf(url, sizeof(url), "https://api.github.com/repos/%s/releases/latest", norm); + if ((a = fetch_latest(url, HOST_GITHUB, os))) { ctx->host = HOST_GITHUB; return a; } + snprintf(url, sizeof(url), "https://gitlab.com/api/v4/projects/%s/releases/permalink/latest", url_encode(norm)); + if ((a = fetch_latest(url, HOST_GITLAB, os))) { ctx->host = HOST_GITLAB; return a; } + } + return NULL; + } + + switch (ctx->host) { + case HOST_GITLAB: + snprintf(url, sizeof(url), "%s/releases/permalink/latest", ctx->api_url); + break; + default: + snprintf(url, sizeof(url), "%s/releases/latest", ctx->api_url); + break; + } + return fetch_latest(url, ctx->host, os); +} + +void repo_asset_free(ReleaseAsset *a) { + if (!a) return; + free(a->url); free(a->name); free(a->tag); + free(a); +} diff --git a/src/repo.h b/src/repo.h new file mode 100644 index 0000000..2b70b70 --- /dev/null +++ b/src/repo.h @@ -0,0 +1,37 @@ +/* repo.h - git hosting adapters: GitHub / GitLab / Gitea / Forgejo */ +#ifndef PMM_REPO_H +#define PMM_REPO_H + +#include "pmm.h" + +typedef struct { + char *url; /* asset download URL */ + char *name; /* asset file name */ + char *tag; /* release tag */ +} ReleaseAsset; + +typedef struct { + PmmHost host; /* resolved host (HOST_AUTO is resolved during latest_asset) */ + char *api_url; /* resolved API endpoint (after mirror rewriting) */ + char *page_url; /* resolved raw/home endpoint */ + char *origin; /* scheme://host for full URLs (HOST_AUTO probing) */ + char *repo_norm; /* normalized "owner/repo" */ +} RepoContext; + +/* Detect host from a repo slug like "owner/repo" or a full URL. */ +PmmHost repo_detect_host(const char *repo_or_url); + +/* Build context for host + repo. repo may be "owner/repo" or full URL. */ +RepoContext *repo_open(PmmHost host, const char *repo, const char *mirror_api_base); + +/* Get the download URL of the best asset for the current OS from the latest + * release. Returns NULL if none found. Caller frees via repo_asset_free. */ +ReleaseAsset *repo_latest_asset(RepoContext *ctx, PmmOS os); + +void repo_asset_free(ReleaseAsset *a); +void repo_close(RepoContext *ctx); + +/* Asset-extension <-> OS matching used by repo_latest_asset (exposed for tests). */ +int asset_matches_os(const char *filename, PmmOS os); + +#endif diff --git a/src/sha1.c b/src/sha1.c new file mode 100644 index 0000000..f4a4585 --- /dev/null +++ b/src/sha1.c @@ -0,0 +1,89 @@ +/* sha1.c - FIPS 180-4 SHA-1 */ +#include "sha1.h" +#include +#include + +#define ROTL(x,n) (((x) << (n)) | ((x) >> (32 - (n)))) + +static void transform(PmmSha1 *c, const unsigned char block[64]) { + unsigned int w[80], a, b, d, e, f, k, tmp; + unsigned int cc = c->state[2]; /* avoid shadowing */ + for (int i = 0, j = 0; i < 16; i++, j += 4) + w[i] = ((unsigned int)block[j] << 24) | ((unsigned int)block[j+1] << 16) | + ((unsigned int)block[j+2] << 8) | (unsigned int)block[j+3]; + for (int i = 16; i < 80; i++) + w[i] = ROTL(w[i-3] ^ w[i-8] ^ w[i-14] ^ w[i-16], 1); + a = c->state[0]; b = c->state[1]; unsigned int c2 = cc; d = c->state[3]; e = c->state[4]; + for (int i = 0; i < 80; i++) { + if (i < 20) { f = (b & c2) | ((~b) & d); k = 0x5A827999; } + else if (i < 40) { f = b ^ c2 ^ d; k = 0x6ED9EBA1; } + else if (i < 60) { f = (b & c2) | (b & d) | (c2 & d); k = 0x8F1BBCDC; } + else { f = b ^ c2 ^ d; k = 0xCA62C1D6; } + tmp = ROTL(a, 5) + f + e + k + w[i]; + e = d; d = c2; c2 = ROTL(b, 30); b = a; a = tmp; + } + c->state[0]+=a; c->state[1]+=b; c->state[2]+=c2; c->state[3]+=d; c->state[4]+=e; +} + +void pmm_sha1_init(PmmSha1 *c) { + c->count = 0; + c->state[0]=0x67452301; c->state[1]=0xEFCDAB89; c->state[2]=0x98BADCFE; + c->state[3]=0x10325476; c->state[4]=0xC3D2E1F0; +} + +void pmm_sha1_update(PmmSha1 *c, const unsigned char *data, size_t len) { + size_t i = 0, j = (size_t)(c->count & 63); + c->count += len; + if (j + len > 63) { + size_t fill = 64 - j; + memcpy(c->buffer + j, data, fill); + transform(c, c->buffer); + for (i = fill; i + 63 < len; i += 64) + transform(c, data + i); + j = 0; + } + memcpy(c->buffer + j, data + i, len - i); +} + +void pmm_sha1_final(PmmSha1 *c, unsigned char out[20]) { + unsigned char finalcount[8]; + unsigned long long bits = c->count * 8; /* bit length before padding */ + for (int i = 0; i < 8; i++) + finalcount[i] = (unsigned char)((bits >> (56 - 8 * i)) & 0xFF); + unsigned char pad = 0x80; + pmm_sha1_update(c, &pad, 1); + pad = 0x00; + while ((c->count & 63) != 56) + pmm_sha1_update(c, &pad, 1); + pmm_sha1_update(c, finalcount, 8); + for (int i = 0; i < 5; i++) { + out[i*4] = (unsigned char)(c->state[i] >> 24); + out[i*4+1] = (unsigned char)(c->state[i] >> 16); + out[i*4+2] = (unsigned char)(c->state[i] >> 8); + out[i*4+3] = (unsigned char)(c->state[i]); + } +} + +void pmm_sha1_hex(const unsigned char digest[20], char *hex) { + static const char *d = "0123456789abcdef"; + for (int i = 0; i < 20; i++) { + hex[i*2] = d[digest[i] >> 4]; + hex[i*2+1] = d[digest[i] & 15]; + } + hex[40] = '\0'; +} + +int pmm_sha1_file(const char *path, char *hex) { + FILE *f = fopen(path, "rb"); + if (!f) return -1; + PmmSha1 c; + unsigned char buf[65536], digest[20]; + size_t n; + pmm_sha1_init(&c); + while ((n = fread(buf, 1, sizeof(buf), f)) > 0) + pmm_sha1_update(&c, buf, n); + fclose(f); + pmm_sha1_final(&c, digest); + pmm_sha1_hex(digest, hex); + return 0; +} diff --git a/src/sha1.h b/src/sha1.h new file mode 100644 index 0000000..86ffe68 --- /dev/null +++ b/src/sha1.h @@ -0,0 +1,18 @@ +/* sha1.h */ +#ifndef PMM_SHA1_H +#define PMM_SHA1_H +#include + +typedef struct { + unsigned int state[5]; + unsigned long long count; + unsigned char buffer[64]; +} PmmSha1; + +void pmm_sha1_init(PmmSha1 *c); +void pmm_sha1_update(PmmSha1 *c, const unsigned char *data, size_t len); +void pmm_sha1_final(PmmSha1 *c, unsigned char out[20]); +int pmm_sha1_file(const char *path, char *hex); /* 41 bytes incl NUL */ +void pmm_sha1_hex(const unsigned char digest[20], char *hex); + +#endif diff --git a/src/sha256.c b/src/sha256.c new file mode 100644 index 0000000..5a8f2b4 --- /dev/null +++ b/src/sha256.c @@ -0,0 +1,103 @@ +/* sha256.c - FIPS 180-4 SHA-256 */ +#include "sha256.h" +#include +#include + +static const unsigned int K[64] = { + 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, + 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, + 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, + 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, + 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, + 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, + 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, + 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2 +}; + +#define ROTR(x,n) (((x) >> (n)) | ((x) << (32 - (n)))) +#define CH(x,y,z) (((x) & (y)) ^ (~(x) & (z))) +#define MAJ(x,y,z) (((x) & (y)) ^ ((x) & (z)) ^ ((y) & (z))) +#define EP0(x) (ROTR(x,2) ^ ROTR(x,13) ^ ROTR(x,22)) +#define EP1(x) (ROTR(x,6) ^ ROTR(x,11) ^ ROTR(x,25)) +#define SIG0(x) (ROTR(x,7) ^ ROTR(x,18) ^ ((x) >> 3)) +#define SIG1(x) (ROTR(x,17) ^ ROTR(x,19) ^ ((x) >> 10)) + +static void transform(PmmSha256 *c, const unsigned char data[64]) { + unsigned int m[64], a, b, cc, d, e, f, g, h, t1, t2; + for (int i = 0, j = 0; i < 16; i++, j += 4) + m[i] = ((unsigned int)data[j] << 24) | ((unsigned int)data[j+1] << 16) | + ((unsigned int)data[j+2] << 8) | (unsigned int)data[j+3]; + for (int i = 16; i < 64; i++) + m[i] = SIG1(m[i-2]) + m[i-7] + SIG0(m[i-15]) + m[i-16]; + a=c->state[0]; b=c->state[1]; cc=c->state[2]; d=c->state[3]; + e=c->state[4]; f=c->state[5]; g=c->state[6]; h=c->state[7]; + for (int i = 0; i < 64; i++) { + t1 = h + EP1(e) + CH(e,f,g) + K[i] + m[i]; + t2 = EP0(a) + MAJ(a,b,cc); + h=g; g=f; f=e; e=d+t1; d=cc; cc=b; b=a; a=t1+t2; + } + c->state[0]+=a; c->state[1]+=b; c->state[2]+=cc; c->state[3]+=d; + c->state[4]+=e; c->state[5]+=f; c->state[6]+=g; c->state[7]+=h; +} + +void pmm_sha256_init(PmmSha256 *c) { + c->datalen = 0; + c->bitlen = 0; + c->state[0]=0x6a09e667; c->state[1]=0xbb67ae85; c->state[2]=0x3c6ef372; c->state[3]=0xa54ff53a; + c->state[4]=0x510e527f; c->state[5]=0x9b05688c; c->state[6]=0x1f83d9ab; c->state[7]=0x5be0cd19; +} + +void pmm_sha256_update(PmmSha256 *c, const unsigned char *data, size_t len) { + for (size_t i = 0; i < len; i++) { + c->data[c->datalen++] = data[i]; + if (c->datalen == 64) { + transform(c, c->data); + c->bitlen += 512; + c->datalen = 0; + } + } +} + +void pmm_sha256_final(PmmSha256 *c, unsigned char out[32]) { + unsigned int i = c->datalen; + if (c->datalen < 56) { + c->data[i++] = 0x80; + while (i < 56) c->data[i++] = 0x00; + } else { + c->data[i++] = 0x80; + while (i < 64) c->data[i++] = 0x00; + transform(c, c->data); + memset(c->data, 0, 56); + } + c->bitlen += (unsigned long long)c->datalen * 8; + for (int j = 0; j < 8; j++) + c->data[63 - j] = (unsigned char)(c->bitlen >> (8 * j)); + transform(c, c->data); + for (int j = 0; j < 4; j++) + for (int k = 0; k < 8; k++) + out[j + 4*k] = (unsigned char)(c->state[k] >> (24 - j * 8)); +} + +void pmm_sha256_hex(const unsigned char digest[32], char *hex) { + static const char *d = "0123456789abcdef"; + for (int i = 0; i < 32; i++) { + hex[i*2] = d[digest[i] >> 4]; + hex[i*2+1] = d[digest[i] & 15]; + } + hex[64] = '\0'; +} + +int pmm_sha256_file(const char *path, char *hex) { + FILE *f = fopen(path, "rb"); + if (!f) return -1; + PmmSha256 c; + unsigned char buf[65536], digest[32]; + size_t n; + pmm_sha256_init(&c); + while ((n = fread(buf, 1, sizeof(buf), f)) > 0) + pmm_sha256_update(&c, buf, n); + fclose(f); + pmm_sha256_final(&c, digest); + pmm_sha256_hex(digest, hex); + return 0; +} diff --git a/src/sha256.h b/src/sha256.h new file mode 100644 index 0000000..874401f --- /dev/null +++ b/src/sha256.h @@ -0,0 +1,20 @@ +/* sha256.h */ +#ifndef PMM_SHA256_H +#define PMM_SHA256_H +#include + +typedef struct { + unsigned int state[8]; + unsigned long long bitlen; + unsigned char data[64]; + unsigned int datalen; +} PmmSha256; + +void pmm_sha256_init(PmmSha256 *c); +void pmm_sha256_update(PmmSha256 *c, const unsigned char *data, size_t len); +void pmm_sha256_final(PmmSha256 *c, unsigned char out[32]); +/* Hex digest (65 bytes incl NUL). Returns 0 on success. */ +int pmm_sha256_file(const char *path, char *hex); +void pmm_sha256_hex(const unsigned char digest[32], char *hex); + +#endif