diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index c62b908..d9f92fb 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,5 +1,4 @@ -# This workflow will build a .NET project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net +# This workflow will build, test, measure coverage, and pack the library. name: .NET @@ -22,7 +21,7 @@ jobs: dotnet-version: 8.0.x - name: Restore dependencies run: dotnet restore - - name: Build - run: dotnet build --no-restore - - name: Test - run: dotnet test --no-build --verbosity normal + - name: Test with coverage + run: dotnet test --configuration Release --no-restore --verbosity normal -p:ContinuousIntegrationBuild=true + - name: Pack + run: dotnet pack StringExtensionLibrary/StringExtensionLibrary.csproj --configuration Release --no-restore --output ./nupkg -p:ContinuousIntegrationBuild=true diff --git a/.gitignore b/.gitignore index 8cd79d1..e64353a 100644 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,7 @@ $RECYCLE.BIN/ # Mac desktop service store files .DS_Store packages/ +coverage*.xml +coverage.json +TestResults/ +nupkg/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8c40023 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +## 2.0.0 + +Breaking changes from the .NET Framework 4.5.1 / Visual Studio 2013 package: + +- **Target framework.** The library is now **.NET Standard 2.0** (SDK-style project). It no longer targets .NET Framework 4.5.1 only. +- **Encrypt / Decrypt.** Windows RSA key-container crypto (`CspParameters` / `RSACryptoServiceProvider`) is replaced with **AES-256-CBC**, **HMAC-SHA256** integrity, and **PBKDF2-HMAC-SHA256** (100,000 iterations) for key derivation. Ciphertext is a hyphen-separated hex payload: salt + IV + ciphertext + tag. Payloads produced by 1.x cannot be decrypted. Wrong passphrase or a tampered payload throws `CryptographicException`. +- **CreateParameters removed.** The SQL-concatenation helper is gone. Use parameterized queries. +- **IsValidIPv4.** Uses `IPAddress.TryParse` and requires a canonical dotted-quad (`AddressFamily.InterNetwork`). Scheme-prefixed strings, IPv6, and non-canonical forms such as `127.00.0.1` are rejected. +- **JsonToObject.** Throws `InvalidOperationException` when JSON deserializes to null instead of returning `default(T)`. +- **ReplaceLineFeeds.** Removes CR/LF sequences only. It no longer strips `.` characters (the previous regex matched a literal period). +- **QueryStringToDictionary.** Reads the query after the first `?`, strips a `#` fragment, URL-decodes keys and values (`Uri.UnescapeDataString`; `+` is a space), and last-wins on duplicate keys. + +Other notable changes: + +- Hash helpers (`CreateHashSha256`, `CreateHashSha512`) and `GetLength` are extension methods (`this`). +- Several P0 contract fixes: `IsLength` honors the maximum bound; `RemoveSuffix` returns the original string when the suffix is missing; `CountOccurrences` treats the needle as a literal; `Capitalize` / `IsEmailAddress` are null-safe. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5cb1064 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing + +## Null policy + +Keep these contracts consistent when adding methods: + +- **Predicates** (`Is*`, `DoesNot*`): accept null and never throw. `IsNull` is true for null; other `Is*` methods return false; `DoesNotStartWith` / `DoesNotEndWith` return true when either side is null. +- **Ignore-case start/end**: throw `ArgumentNullException` when `val` or the prefix/suffix is null. +- **Transforms** (`Reverse`, `Left`, `ToBytes`, `SplitTo`, `Replace`, …): throw `ArgumentNullException` (or `ArgumentException` when empty is invalid, e.g. encrypt/hash). +- **Null-coalescing helpers** (`GetEmptyStringIfNull`, `GetDefaultIfEmpty`, `Truncate`): defined results for null (empty string or the provided default). +- Use `nameof` in exception arguments. Use invariant culture for case conversion unless the caller passes a culture. + +## Tests + +Every public method needs a happy path, a null/empty case, and the documented error case. Split tests by concern (`ConversionTests`, `ValidationTests`, `MutationTests`, `JsonAndQueryTests`, `CryptoTests`). + +## SQL + +Do not add helpers that concatenate untrusted strings into SQL. + +## Crypto + +`Encrypt` / `Decrypt` use AES-256-CBC with HMAC-SHA256 over the payload and PBKDF2-HMAC-SHA256 (100,000 iterations) for key derivation on all target frameworks. Ciphertext from the old Windows RSA key-container implementation will not decrypt. This is passphrase-based authenticated encryption for application data, not a key-management system. + +## Email and IPv4 + +`IsEmailAddress` is a practical heuristic (plus-tags allowed, input is trimmed). It is not RFC 5322-complete. `IsValidIPv4` requires a canonical dotted-quad. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..6bbdc89 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,12 @@ + + + latest + enable + true + $(WarningsAsErrors);NU1605 + latest + Recommended + true + true + + 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/README.md b/README.md index eb9ce77..db19e51 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,18 @@ # StringExtensions -c# StringExtensions Library provides comprehensive string extension methods that go behold just the common string validation methods extending the .Net System.string class. The idea to create such a library was motivated by the lack of such a StringUtil library such as org.apache.commons.lang3.StringUtils in the the .Net realm. The aim of this library is to serve as a goto library for those wishing to have such a library readily available to incorporate in to new or existing projects. +c# StringExtensions Library provides comprehensive string extension methods that go beyond common string validation, extending `System.String`. It was motivated by the lack of a StringUtils-style library such as `org.apache.commons.lang3.StringUtils` in .NET. +The library targets **.NET Standard 2.0**, so it works with modern .NET 8 / 9 / 10 apps as well as .NET Core 2.0+ and .NET Framework 4.6.1+. Licensed under GPL-3.0-or-later. See [CHANGELOG.md](CHANGELOG.md) for 2.0.0 breaking changes and [CONTRIBUTING.md](CONTRIBUTING.md) for the null-handling policy. + +## Production contract + +Version **2.0.0** is intended for production use as a string helper library within these limits: + +- **License:** GPL-3.0-or-later (copyleft). Proprietary products must comply with GPL terms. +- **Email / IPv4:** practical checks, not full RFC 5322 or every IPv4 textual form. `IsValidIPv4` requires a canonical dotted-quad. +- **Encrypt / Decrypt:** passphrase-based AES-256-CBC with HMAC-SHA256 and PBKDF2-HMAC-SHA256 (100,000 iterations). Not a key-management system. 1.x RSA payloads will not decrypt. +- **JSON:** Newtonsoft.Json 13.x. `JsonToObject` throws if deserialize is null. +- **SQL:** there is no SQL-building API. Do not concatenate untrusted strings into queries. ## Installation @@ -31,28 +42,28 @@ Once you have installed the String extension library within your project. String | ToInt16 | Converts the string representation of a number to its 16-bit signed integer equivalent | | ToDecimal | Converts the string representation of a number to its System.Decimal equivalent | | ToBoolean | Converts string to its boolean equivalent | -| ToBytes | Convert a string to its equivalent byte array | +| ToBytes | Convert a string to its equivalent UTF-16 little-endian byte array (`Buffer.BlockCopy` of chars; not UTF-8). Hash helpers use the same encoding | | SplitTo | Returns an enumerable collection of the specified type containing the substrings in this instance that are delimited by elements of a specified Char array | | ToEnum | Converts string to its Enum type,,Checks if string is a member of type T enum before converting. if fails returns default enum | -| Format | Replaces one or more format items in a specified string with the string representation of a specified object | +| Format | Extension `string.Format` helper (`"{0}".Format(arg)`). Distinct from the static `string.Format` overloads | | GetEmptyStringIfNull | Gets empty String if passed value is of type Null | | GetDefaultIfEmpty | Returns a default String value if given value is null or empty | | IsInteger | IsInteger Function checks if a string is a valid int32 value | | IsNumeric | Checks if a string is a valid floating value | | IsAlpha | Checks if String contains only Unicode letters | | IsAlphaNumeric | Checks if the String contains only Unicode letters & digits. | -| IsValidIPv4 | Checks if a string is valid IPv4 | -| IsEmailAddress | checks if string is a valid email address | +| IsValidIPv4 | Checks if a string is a canonical dotted-quad IPv4 address | +| IsEmailAddress | Practical email check (plus-tags allowed). Not RFC 5322-complete | | Truncate | Truncate String and appends trailing ... | | Capitalize | Reads in a sequence of words from standard input and capitalize each,one (make first letter uppercase; make rest lowercase | -| FristCharacter | Gets the first character in string | +| FirstCharacter | Gets the first character in string | | LastCharacter | Gets last character in string | -| Replace | Replace specified characters with an empty string | +| Replace | Delete listed characters (`params char[]`). Distinct from `string.Replace(char, char)`, which substitutes a replacement character | | RemoveChars | Remove Characters from string | | Reverse | Reverse string | | ParseStringToCsv | Escapes string by appending quotes for csv output | -| Encrypt | Encrypt a string using the supplied key. Encoding is done using RSA encryption | -| Decrypt | Decrypt a string using the supplied key. Decoding is done using RSA encryption | +| Encrypt | Encrypt a string using AES-256-CBC and HMAC-SHA256 (passphrase-derived key) | +| Decrypt | Decrypt a string produced by Encrypt; wrong key or tampered payload throws CryptographicException | | CountOccurrences | Count number of occurrences in string based on the string to match | | JsonToDictionary | Converts a Json string to dictionary object. function is only applicable for single hierarchy objects i.e no parent child relationships, for parent child relationships JsonToExpanderObject | | JsonToExpanderObject | Converts a Json string to ExpandoObject method applicable for multi hierarchy objects i.e,having zero or many parent child relationships | @@ -67,16 +78,16 @@ Once you have installed the String extension library within your project. String | AppendPrefixIfMissing | Appends the prefix to the start of the string if the string does not already start with prefix | | CreateHashSha512 | Convert string to Hash using Sha512 | | CreateHashSha256 | Convert string to Hash using Sha256 | -| QueryStringToDictionary | Convert url query string to IDictionary value key pair | +| QueryStringToDictionary | Convert a URL query string to an IDictionary; keys and values are URL-decoded (`+` as space) | | ReverseSlash | Reverse back or forward slashes | -| ReplaceLineFeeds | Replace Line Feeds | +| ReplaceLineFeeds | Remove CR/LF sequences; other characters (including `.`) are unchanged | | GetByteSize | Calculates the amount of bytes occupied by the input string based on the specified encoding argument | | Left | Extracts the left part of the input string limited by the length argument | | Right | Extracts the right part of the input string limited by the length argument | | ToTextElements | Converts a string to an Enumerable collection type of string elements | | IsNull | Checks if a string is null | +| IsNullOrEmpty | Instance wrapper around `string.IsNullOrEmpty`; prefer the static method in new code if both are in scope | | IsMinLength | Checks if string length is a certain minimum number of characters, does not ignore leading and trailing,white-space.,null strings will always evaluate to false. | | IsMaxLength | Checks if string length consists of the specified allowable maximum char length | | IsLength | Checks if string length satisfies minimum and maximum allowable char length. does not ignore leading and,trailing white-space | | GetLength | Gets the number of characters in string checks if string is null | -| CreateParameters | Create basic dynamic SQL where parameters from a JSON key value pair string | diff --git a/StringExtensionLibrary/Properties/AssemblyInfo.cs b/StringExtensionLibrary/Properties/AssemblyInfo.cs deleted file mode 100644 index f65890c..0000000 --- a/StringExtensionLibrary/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,48 +0,0 @@ -/*StringExtensions Library provides comprehensive string extension methods that go behold just the common string validation methods extending the .Net System.string class. The idea to create such a library was motivated by the lack of such a StringUtil library such as org.apache.commons.lang3.StringUtils in the the .Net realm. The aim of this library is to serve as a goto library for those wishing to have such a library readily available to incorporate in to new or existing projects. - -Copyright (C) 2015 Timothy Mugayi -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 .*/ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("StringExtensionLibrary")] -[assembly: AssemblyDescription("String Extension Library")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("Timothy Mugayi")] -[assembly: AssemblyProduct("StringExtensionLibrary")] -[assembly: AssemblyCopyright("Copyright © 2015")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("741db6b4-1f05-4a4e-ad6f-c631d7de511c")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/StringExtensionLibrary/StringExtensionLibrary.csproj b/StringExtensionLibrary/StringExtensionLibrary.csproj index d04a05f..d0ba1dc 100644 --- a/StringExtensionLibrary/StringExtensionLibrary.csproj +++ b/StringExtensionLibrary/StringExtensionLibrary.csproj @@ -1,62 +1,45 @@ - - - + + - Debug - AnyCPU - {91DC5C7A-7DD0-4E71-B6D9-5D3DD76D2C77} - Library - Properties + netstandard2.0 StringExtensionLibrary StringExtensionLibrary - v4.5.1 - 512 - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - bin\Debug\StringExtensionLibrary.XML - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 + disable + true + Timothy Mugayi + Timothy Mugayi + String extension methods for validation, conversion, hashing, JSON, and formatting. + Copyright © 2015 Timothy Mugayi + StringExtensionsLibrary + string;extensions;utilities + GPL-3.0-or-later + README.md + https://github.com/timothymugayi/StringExtensions + https://github.com/timothymugayi/StringExtensions + git + 2.0.0 + false + en + true + true + true + snupkg + See CHANGELOG.md. 2.0.0 retargets netstandard2.0, replaces RSA encrypt with AES-256-CBC+HMAC, and removes CreateParameters. + - - False - ..\packages\Newtonsoft.Json.6.0.8\lib\net45\Newtonsoft.Json.dll - - - - - - - - + + - - + + + + - - + + - - - \ No newline at end of file + + diff --git a/StringExtensionLibrary/StringExtensions.cs b/StringExtensionLibrary/StringExtensions.cs index 6015c58..a766a38 100644 --- a/StringExtensionLibrary/StringExtensions.cs +++ b/StringExtensionLibrary/StringExtensions.cs @@ -20,6 +20,8 @@ using System.Dynamic; using System.Globalization; using System.Linq; +using System.Net; +using System.Net.Sockets; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; @@ -33,13 +35,85 @@ namespace StringExtensionLibrary /// public static class StringExtensions { + private const int AesSaltSize = 16; + private const int AesIvSize = 16; + private const int AesKeySizeBytes = 32; + private const int AesHmacKeySizeBytes = 32; + private const int AesHmacTagSize = 32; + private const int AesKeyIterations = 100000; + private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromMilliseconds(250); + + private static readonly Regex EmailRegex = new Regex( + @"^[a-zA-Z0-9][\w\.\+\-]*[a-zA-Z0-9]@[a-zA-Z0-9][\w\.\-]*[a-zA-Z0-9]\.[a-zA-Z][a-zA-Z\.]*[a-zA-Z]$", + RegexOptions.CultureInvariant | RegexOptions.Compiled, + RegexMatchTimeout); + + private static readonly Regex LineFeedRegex = new Regex( + @"[\r\n]+", + RegexOptions.CultureInvariant | RegexOptions.Compiled, + RegexMatchTimeout); + + private static readonly char[] HexByteSeparator = { '-' }; + private static readonly char[] QueryEqualsSeparator = { '=' }; + + private static byte[] DeriveBytesFromPassphrase(string password, byte[] salt, int byteCount) + { + byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + try + { + return Pbkdf2HmacSha256(passwordBytes, salt, AesKeyIterations, byteCount); + } + finally + { + Array.Clear(passwordBytes, 0, passwordBytes.Length); + } + } + + internal static byte[] Pbkdf2HmacSha256(byte[] password, byte[] salt, int iterations, int byteCount) + { + using (var hmac = new HMACSHA256(password)) + { + int hashLength = hmac.HashSize / 8; + int blockCount = (byteCount + hashLength - 1) / hashLength; + var derived = new byte[byteCount]; + var saltAndBlock = new byte[salt.Length + 4]; + Buffer.BlockCopy(salt, 0, saltAndBlock, 0, salt.Length); + + int offset = 0; + for (int block = 1; block <= blockCount; block++) + { + saltAndBlock[salt.Length] = (byte)(block >> 24); + saltAndBlock[salt.Length + 1] = (byte)(block >> 16); + saltAndBlock[salt.Length + 2] = (byte)(block >> 8); + saltAndBlock[salt.Length + 3] = (byte)block; + + byte[] u = hmac.ComputeHash(saltAndBlock); + var t = (byte[])u.Clone(); + for (int i = 1; i < iterations; i++) + { + u = hmac.ComputeHash(u); + for (int j = 0; j < t.Length; j++) + { + t[j] ^= u[j]; + } + } + + int toCopy = Math.Min(hashLength, byteCount - offset); + Buffer.BlockCopy(t, 0, derived, offset, toCopy); + offset += toCopy; + } + + return derived; + } + } + /// /// Checks if date with dateFormat is parse-able to System.DateTime format returns boolean value if true else false /// /// String date /// date format example dd/MM/yyyy HH:mm:ss /// boolean True False if is valid System.DateTime - public static bool IsDateTime(this string data, string dateFormat) + public static bool IsDateTime(this string? data, string dateFormat) { // ReSharper disable once RedundantAssignment DateTime dateVal = default(DateTime); @@ -56,10 +130,13 @@ public static bool IsDateTime(this string data, string dateFormat) /// The conversion fails if the string parameter is null, is not of the correct format, or represents a number /// less than System.Int32.MinValue or greater than System.Int32.MaxValue /// - public static int ToInt32(this string value) + public static int ToInt32(this string? value) { int number; - Int32.TryParse(value, out number); + if (!int.TryParse(value, out number)) + { + return 0; + } return number; } @@ -72,10 +149,13 @@ public static int ToInt32(this string value) /// The conversion fails if the string parameter is null, is not of the correct format, or represents a number /// less than System.Int64.MinValue or greater than System.Int64.MaxValue /// - public static long ToInt64(this string value) + public static long ToInt64(this string? value) { long number; - Int64.TryParse(value, out number); + if (!long.TryParse(value, out number)) + { + return 0; + } return number; } @@ -88,10 +168,13 @@ public static long ToInt64(this string value) /// The conversion fails if the string parameter is null, is not of the correct format, or represents a number /// less than System.Int16.MinValue or greater than System.Int16.MaxValue /// - public static short ToInt16(this string value) + public static short ToInt16(this string? value) { short number; - Int16.TryParse(value, out number); + if (!short.TryParse(value, out number)) + { + return 0; + } return number; } @@ -104,10 +187,13 @@ public static short ToInt16(this string value) /// The conversion fails if the s parameter is null, is not a number in a valid format, or represents a number /// less than System.Decimal.MinValue or greater than System.Decimal.MaxValue /// - public static Decimal ToDecimal(this string value) + public static decimal ToDecimal(this string? value) { - Decimal number; - Decimal.TryParse(value, out number); + decimal number; + if (!decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out number)) + { + return 0; + } return number; } @@ -124,11 +210,11 @@ public static Decimal ToDecimal(this string value) /// public static bool ToBoolean(this string value) { - if (string.IsNullOrEmpty(value) || string.IsNullOrWhiteSpace(value)) + if (string.IsNullOrWhiteSpace(value)) { - throw new ArgumentException("value"); + throw new ArgumentException("Value is null, empty, or whitespace.", nameof(value)); } - string val = value.ToLower().Trim(); + string val = value.ToLowerInvariant().Trim(); switch (val) { case "false": @@ -148,7 +234,7 @@ public static bool ToBoolean(this string value) case "n": return false; default: - throw new ArgumentException("Invalid boolean"); + throw new ArgumentException("Value is not a recognized boolean.", nameof(value)); } } @@ -170,7 +256,11 @@ public static bool ToBoolean(this string value) /// public static IEnumerable SplitTo(this string str, params char[] separator) where T : IConvertible { - return str.Split(separator, StringSplitOptions.None).Select(s => (T) Convert.ChangeType(s, typeof (T))); + if (str == null) + { + throw new ArgumentNullException(nameof(str)); + } + return str.Split(separator, StringSplitOptions.None).Select(s => (T) Convert.ChangeType(s, typeof (T), CultureInfo.InvariantCulture)); } /// @@ -193,7 +283,11 @@ public static IEnumerable SplitTo(this string str, params char[] separator public static IEnumerable SplitTo(this string str, StringSplitOptions options, params char[] separator) where T : IConvertible { - return str.Split(separator, options).Select(s => (T) Convert.ChangeType(s, typeof (T))); + if (str == null) + { + throw new ArgumentNullException(nameof(str)); + } + return str.Split(separator, options).Select(s => (T) Convert.ChangeType(s, typeof (T), CultureInfo.InvariantCulture)); } /// @@ -213,9 +307,9 @@ public static IEnumerable SplitTo(this string str, StringSplitOptions opti /// public static T ToEnum(this string value, T defaultValue = default(T)) where T : struct { - if (!typeof (T).IsEnum) + if (!typeof(T).IsEnum) { - throw new ArgumentException("Type T Must of type System.Enum"); + throw new ArgumentException("Type T must be an enum.", nameof(T)); } T result; @@ -225,6 +319,7 @@ public static IEnumerable SplitTo(this string str, StringSplitOptions opti /// /// Replaces one or more format items in a specified string with the string representation of a specified object. + /// Named Format as an extension; call as "{0}".Format(arg) to avoid ambiguity with . /// /// A composite format string /// An System.Object to format @@ -236,7 +331,7 @@ public static IEnumerable SplitTo(this string str, StringSplitOptions opti /// public static string Format(this string value, object arg0) { - return string.Format(value, arg0); + return string.Format(CultureInfo.InvariantCulture, value, arg0); } /// @@ -256,7 +351,7 @@ public static string Format(this string value, object arg0) /// public static string Format(this string value, params object[] args) { - return string.Format(value, args); + return string.Format(CultureInfo.InvariantCulture, value, args); } /// @@ -265,7 +360,7 @@ public static string Format(this string value, params object[] args) /// val /// System.String /// - public static string GetEmptyStringIfNull(this string val) + public static string GetEmptyStringIfNull(this string? val) { return (val != null ? val.Trim() : ""); } @@ -276,7 +371,7 @@ public static string GetEmptyStringIfNull(this string val) /// String value /// null/nothing if String IsEmpty /// - public static string GetNullIfEmptyString(this string myValue) + public static string? GetNullIfEmptyString(this string? myValue) { if (myValue == null || myValue.Length <= 0) { @@ -295,7 +390,7 @@ public static string GetNullIfEmptyString(this string myValue) /// /// val /// Boolean True if isInteger else False - public static bool IsInteger(this string val) + public static bool IsInteger(this string? val) { // Variable to collect the Return value of the TryParse method. @@ -309,18 +404,17 @@ public static bool IsInteger(this string val) } /// - /// Read in a sequence of words from standard input and capitalize each - /// one (make first letter uppercase; make rest lowercase). + /// Capitalizes the first character and lowercases the remainder. Null and empty strings are returned unchanged. /// /// string /// Word with capitalization - public static string Capitalize(this string s) + public static string? Capitalize(this string? s) { - if (s.Length == 0) + if (s is null || s.Length == 0) { return s; } - return s.Substring(0, 1).ToUpper() + s.Substring(1).ToLower(); + return s.Substring(0, 1).ToUpperInvariant() + s.Substring(1).ToLowerInvariant(); } /// @@ -328,13 +422,13 @@ public static string Capitalize(this string s) /// /// val /// System.string - public static string FirstCharacter(this string val) + public static string? FirstCharacter(this string? val) { - return (!string.IsNullOrEmpty(val)) - ? (val.Length >= 1) - ? val.Substring(0, 1) - : val - : null; + if (val is null || val.Length == 0) + { + return null; + } + return val.Substring(0, 1); } /// @@ -342,13 +436,13 @@ public static string FirstCharacter(this string val) /// /// val /// System.string - public static string LastCharacter(this string val) + public static string? LastCharacter(this string? val) { - return (!string.IsNullOrEmpty(val)) - ? (val.Length >= 1) - ? val.Substring(val.Length - 1, 1) - : val - : null; + if (val is null || val.Length == 0) + { + return null; + } + return val.Substring(val.Length - 1, 1); } /// @@ -361,11 +455,11 @@ public static bool EndsWithIgnoreCase(this string val, string suffix) { if (val == null) { - throw new ArgumentNullException("val", "val parameter is null"); + throw new ArgumentNullException(nameof(val)); } if (suffix == null) { - throw new ArgumentNullException("suffix", "suffix parameter is null"); + throw new ArgumentNullException(nameof(suffix)); } if (val.Length < suffix.Length) { @@ -384,11 +478,11 @@ public static bool StartsWithIgnoreCase(this string val, string prefix) { if (val == null) { - throw new ArgumentNullException("val", "val parameter is null"); + throw new ArgumentNullException(nameof(val)); } if (prefix == null) { - throw new ArgumentNullException("prefix", "prefix parameter is null"); + throw new ArgumentNullException(nameof(prefix)); } if (val.Length < prefix.Length) { @@ -398,7 +492,8 @@ public static bool StartsWithIgnoreCase(this string val, string prefix) } /// - /// Replace specified characters with an empty string. + /// Replace specified characters with an empty string. This overload is distinct from + /// : it deletes each listed character rather than substituting a replacement. /// /// the string /// list of characters to replace from the string @@ -409,6 +504,14 @@ public static bool StartsWithIgnoreCase(this string val, string prefix) /// System.string public static string Replace(this string s, params char[] chars) { + if (s == null) + { + throw new ArgumentNullException(nameof(s)); + } + if (chars == null) + { + throw new ArgumentNullException(nameof(chars)); + } return chars.Aggregate(s, (current, c) => current.Replace(c.ToString(CultureInfo.InvariantCulture), "")); } @@ -420,6 +523,14 @@ public static string Replace(this string s, params char[] chars) /// System.string public static string RemoveChars(this string s, params char[] chars) { + if (s == null) + { + throw new ArgumentNullException(nameof(s)); + } + if (chars == null) + { + throw new ArgumentNullException(nameof(chars)); + } var sb = new StringBuilder(s.Length); foreach (char c in s.Where(c => !chars.Contains(c))) { @@ -429,15 +540,32 @@ public static string RemoveChars(this string s, params char[] chars) } /// - /// Validate email address + /// Practical email check (allows plus-tags). Not RFC 5322-complete; do not use as the sole + /// gate for security-sensitive identity. /// /// string email address /// true or false if email if valid - public static bool IsEmailAddress(this string email) + public static bool IsEmailAddress(this string? email) { - string pattern = - "^[a-zA-Z][\\w\\.-]*[a-zA-Z0-9]@[a-zA-Z0-9][\\w\\.-]*[a-zA-Z0-9]\\.[a-zA-Z][a-zA-Z\\.]*[a-zA-Z]$"; - return Regex.Match(email, pattern).Success; + if (email is null) + { + return false; + } + + string candidate = email.Trim(); + if (candidate.Length == 0) + { + return false; + } + + try + { + return EmailRegex.IsMatch(candidate); + } + catch (RegexMatchTimeoutException) + { + return false; + } } /// @@ -446,7 +574,7 @@ public static bool IsEmailAddress(this string email) /// /// Boolean True if isNumeric else False /// - public static bool IsNumeric(this string val) + public static bool IsNumeric(this string? val) { // Variable to collect the Return value of the TryParse method. @@ -466,9 +594,9 @@ public static bool IsNumeric(this string val) /// number of chars to truncate /// /// - public static string Truncate(this string s, int maxLength) + public static string Truncate(this string? s, int maxLength) { - if (String.IsNullOrEmpty(s) || maxLength <= 0) + if (s is null || s.Length == 0 || maxLength <= 0) { return String.Empty; } @@ -486,23 +614,29 @@ public static string Truncate(this string s, int maxLength) /// default value to return if String value isEmpty /// returns either String value or default value if IsEmpty /// - public static string GetDefaultIfEmpty(this string myValue, string defaultValue) + public static string GetDefaultIfEmpty(this string? myValue, string defaultValue) { - if (!String.IsNullOrEmpty(myValue)) + if (myValue is null || myValue.Length == 0) { - myValue = myValue.Trim(); - return myValue.Length > 0 ? myValue : defaultValue; + return defaultValue; } - return defaultValue; + + myValue = myValue.Trim(); + return myValue.Length > 0 ? myValue : defaultValue; } /// - /// Convert a string to its equivalent byte array + /// Convert a string to its equivalent UTF-16 little-endian byte array (each char is two bytes via + /// ). This is not UTF-8. Hash helpers use the same encoding. /// /// string to convert /// System.byte array public static byte[] ToBytes(this string val) { + if (val == null) + { + throw new ArgumentNullException(nameof(val)); + } var bytes = new byte[val.Length*sizeof (char)]; Buffer.BlockCopy(val.ToCharArray(), 0, bytes, 0, bytes.Length); return bytes; @@ -515,6 +649,10 @@ public static byte[] ToBytes(this string val) /// System.string public static string Reverse(this string val) { + if (val == null) + { + throw new ArgumentNullException(nameof(val)); + } var chars = new char[val.Length]; for (int i = val.Length - 1, j = 0; i >= 0; --i, ++j) { @@ -530,44 +668,188 @@ public static string Reverse(this string val) /// val /// /// - public static string ParseStringToCsv(this string val) + public static string ParseStringToCsv(this string? val) { return '"' + GetEmptyStringIfNull(val).Replace("\"", "\"\"") + '"'; } /// - /// Encrypt a string using the supplied key. Encoding is done using RSA encryption. + /// Encrypt a string using the supplied passphrase. Encoding uses AES-256-CBC with HMAC-SHA256 + /// integrity and a PBKDF2-HMAC-SHA256-derived key (100,000 iterations). + /// The result is a hyphen-separated hex string (salt + IV + ciphertext + tag). + /// Passphrase-based authenticated encryption for application data; not a key-management system. /// /// String that must be encrypted. - /// Encryption key - /// A string representing a byte array separated by a minus sign. + /// Passphrase used to derive the encryption key. + /// A hyphen-separated hex string representing salt, IV, ciphertext, and HMAC tag. /// Occurs when stringToEncrypt or key is null or empty. public static string Encrypt(this string stringToEncrypt, string key) { - var cspParameter = new CspParameters {KeyContainerName = key}; - var rsaServiceProvider = new RSACryptoServiceProvider(cspParameter) {PersistKeyInCsp = true}; - byte[] bytes = rsaServiceProvider.Encrypt(Encoding.UTF8.GetBytes(stringToEncrypt), true); - return BitConverter.ToString(bytes); - } + if (string.IsNullOrEmpty(stringToEncrypt)) + { + throw new ArgumentException("Value is null or empty.", nameof(stringToEncrypt)); + } + if (string.IsNullOrEmpty(key)) + { + throw new ArgumentException("Value is null or empty.", nameof(key)); + } + var salt = new byte[AesSaltSize]; + using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(salt); + } + + byte[] derivedBytes = DeriveBytesFromPassphrase(key, salt, AesKeySizeBytes + AesHmacKeySizeBytes); + var aesKey = new byte[AesKeySizeBytes]; + var hmacKey = new byte[AesHmacKeySizeBytes]; + try + { + Buffer.BlockCopy(derivedBytes, 0, aesKey, 0, aesKey.Length); + Buffer.BlockCopy(derivedBytes, aesKey.Length, hmacKey, 0, hmacKey.Length); + + using (Aes aes = Aes.Create()) + { + aes.KeySize = 256; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + aes.Key = aesKey; + aes.GenerateIV(); + + using (ICryptoTransform encryptor = aes.CreateEncryptor()) + { + byte[] plainBytes = Encoding.UTF8.GetBytes(stringToEncrypt); + byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); + var payload = new byte[salt.Length + aes.IV.Length + cipherBytes.Length]; + Buffer.BlockCopy(salt, 0, payload, 0, salt.Length); + Buffer.BlockCopy(aes.IV, 0, payload, salt.Length, aes.IV.Length); + Buffer.BlockCopy(cipherBytes, 0, payload, salt.Length + aes.IV.Length, cipherBytes.Length); + + byte[] tag; + using (var hmac = new HMACSHA256(hmacKey)) + { + tag = hmac.ComputeHash(payload); + } + + var result = new byte[payload.Length + tag.Length]; + Buffer.BlockCopy(payload, 0, result, 0, payload.Length); + Buffer.BlockCopy(tag, 0, result, payload.Length, tag.Length); + return BitConverter.ToString(result); + } + } + } + finally + { + Array.Clear(derivedBytes, 0, derivedBytes.Length); + Array.Clear(aesKey, 0, aesKey.Length); + Array.Clear(hmacKey, 0, hmacKey.Length); + } + } /// - /// Decrypt a string using the supplied key. Decoding is done using RSA encryption. + /// Decrypt a string using the supplied passphrase. Decoding uses AES-256-CBC with HMAC-SHA256 + /// and a PBKDF2-HMAC-SHA256-derived key. /// - /// String that must be decrypted. - /// Decryption key. - /// The decrypted string or null if decryption failed. + /// Hyphen-separated hex string produced by . + /// Passphrase used to derive the decryption key. + /// The decrypted string. /// Occurs when stringToDecrypt or key is null or empty. + /// Occurs when the payload is invalid or the key is wrong. public static string Decrypt(this string stringToDecrypt, string key) { - var cspParamters = new CspParameters {KeyContainerName = key}; - var rsaServiceProvider = new RSACryptoServiceProvider(cspParamters) {PersistKeyInCsp = true}; - string[] decryptArray = stringToDecrypt.Split(new[] {"-"}, StringSplitOptions.None); - byte[] decryptByteArray = Array.ConvertAll(decryptArray, - (s => Convert.ToByte(byte.Parse(s, NumberStyles.HexNumber)))); - byte[] bytes = rsaServiceProvider.Decrypt(decryptByteArray, true); - string result = Encoding.UTF8.GetString(bytes); - return result; + if (string.IsNullOrEmpty(stringToDecrypt)) + { + throw new ArgumentException("Value is null or empty.", nameof(stringToDecrypt)); + } + if (string.IsNullOrEmpty(key)) + { + throw new ArgumentException("Value is null or empty.", nameof(key)); + } + + byte[] fullBytes; + try + { + fullBytes = Array.ConvertAll(stringToDecrypt.Split(HexByteSeparator, StringSplitOptions.None), + hex => byte.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture)); + } + catch (Exception ex) when (ex is FormatException || ex is OverflowException || ex is ArgumentException) + { + throw new CryptographicException("Invalid encrypted string.", ex); + } + int minLength = AesSaltSize + AesIvSize + AesHmacTagSize; + if (fullBytes.Length < minLength) + { + throw new CryptographicException("Invalid encrypted string."); + } + + var payload = new byte[fullBytes.Length - AesHmacTagSize]; + var tag = new byte[AesHmacTagSize]; + Buffer.BlockCopy(fullBytes, 0, payload, 0, payload.Length); + Buffer.BlockCopy(fullBytes, payload.Length, tag, 0, tag.Length); + + var salt = new byte[AesSaltSize]; + Buffer.BlockCopy(payload, 0, salt, 0, salt.Length); + + byte[] derivedBytes = DeriveBytesFromPassphrase(key, salt, AesKeySizeBytes + AesHmacKeySizeBytes); + var aesKey = new byte[AesKeySizeBytes]; + var hmacKey = new byte[AesHmacKeySizeBytes]; + try + { + Buffer.BlockCopy(derivedBytes, 0, aesKey, 0, aesKey.Length); + Buffer.BlockCopy(derivedBytes, aesKey.Length, hmacKey, 0, hmacKey.Length); + + byte[] expectedTag; + using (var hmac = new HMACSHA256(hmacKey)) + { + expectedTag = hmac.ComputeHash(payload); + } + + if (!FixedTimeEquals(tag, expectedTag)) + { + throw new CryptographicException("Invalid encrypted string or key."); + } + + var iv = new byte[AesIvSize]; + var cipherBytes = new byte[payload.Length - AesSaltSize - AesIvSize]; + Buffer.BlockCopy(payload, salt.Length, iv, 0, iv.Length); + Buffer.BlockCopy(payload, salt.Length + iv.Length, cipherBytes, 0, cipherBytes.Length); + + using (Aes aes = Aes.Create()) + { + aes.KeySize = 256; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + aes.Key = aesKey; + aes.IV = iv; + + using (ICryptoTransform decryptor = aes.CreateDecryptor()) + { + byte[] plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); + return Encoding.UTF8.GetString(plainBytes); + } + } + } + finally + { + Array.Clear(derivedBytes, 0, derivedBytes.Length); + Array.Clear(aesKey, 0, aesKey.Length); + Array.Clear(hmacKey, 0, hmacKey.Length); + } + } + + private static bool FixedTimeEquals(byte[] a, byte[] b) + { + if (a == null || b == null || a.Length != b.Length) + { + return false; + } + + int diff = 0; + for (int i = 0; i < a.Length; i++) + { + diff |= a[i] ^ b[i]; + } + return diff == 0; } /// @@ -576,9 +858,14 @@ public static string Decrypt(this string stringToDecrypt, string key) /// string containing text /// string or pattern find /// - public static int CountOccurrences(this string val, string stringToMatch) + public static int CountOccurrences(this string? val, string? stringToMatch) { - return Regex.Matches(val, stringToMatch, RegexOptions.IgnoreCase).Count; + if (string.IsNullOrEmpty(val) || string.IsNullOrEmpty(stringToMatch)) + { + return 0; + } + + return Regex.Matches(val, Regex.Escape(stringToMatch), RegexOptions.IgnoreCase, RegexMatchTimeout).Count; } /// @@ -594,10 +881,14 @@ public static IDictionary JsonToDictionary(this string val) { if (string.IsNullOrEmpty(val)) { - throw new ArgumentNullException("val"); + throw new ArgumentNullException(nameof(val)); } - return - (Dictionary) JsonConvert.DeserializeObject(val, typeof (Dictionary)); + Dictionary? result = JsonConvert.DeserializeObject>(val); + if (result == null) + { + throw new InvalidOperationException("JSON did not deserialize to a dictionary."); + } + return result; } /// @@ -606,10 +897,11 @@ public static IDictionary JsonToDictionary(this string val) /// /// string formated as Json /// System.Dynamic.ExpandoObject Json objectExpandoObject - public static dynamic JsonToExpanderObject(this string json) + public static ExpandoObject JsonToExpanderObject(this string json) { var converter = new ExpandoObjectConverter(); - return JsonConvert.DeserializeObject(json, converter); + return JsonConvert.DeserializeObject(json, converter) + ?? throw new InvalidOperationException("JSON did not deserialize to an ExpandoObject."); } /// @@ -622,7 +914,8 @@ public static dynamic JsonToExpanderObject(this string json) public static T JsonToObject(this string json) { var settings = new JsonSerializerSettings {ReferenceLoopHandling = ReferenceLoopHandling.Ignore}; - return JsonConvert.DeserializeObject(json, settings); + return JsonConvert.DeserializeObject(json, settings) + ?? throw new InvalidOperationException("JSON did not deserialize to the requested type."); } /// @@ -632,9 +925,13 @@ public static T JsonToObject(this string json) /// prefix /// Indicates whether the compare should ignore case /// trimmed string with no prefix or original string - public static string RemovePrefix(this string val, string prefix, bool ignoreCase = true) + public static string? RemovePrefix(this string? val, string prefix, bool ignoreCase = true) { - if (!string.IsNullOrEmpty(val) && (ignoreCase ? val.StartsWithIgnoreCase(prefix) : val.StartsWith(prefix))) + if (val is null || val.Length == 0) + { + return val; + } + if (ignoreCase ? val.StartsWithIgnoreCase(prefix) : val.StartsWith(prefix, StringComparison.Ordinal)) { return val.Substring(prefix.Length, val.Length - prefix.Length); } @@ -648,13 +945,17 @@ public static string RemovePrefix(this string val, string prefix, bool ignoreCas /// suffix /// Indicates whether the compare should ignore case /// trimmed string with no suffix or original string - public static string RemoveSuffix(this string val, string suffix, bool ignoreCase = true) + public static string? RemoveSuffix(this string? val, string suffix, bool ignoreCase = true) { - if (!string.IsNullOrEmpty(val) && (ignoreCase ? val.EndsWithIgnoreCase(suffix) : val.EndsWith(suffix))) + if (val is null || val.Length == 0) + { + return val; + } + if (ignoreCase ? val.EndsWithIgnoreCase(suffix) : val.EndsWith(suffix, StringComparison.Ordinal)) { return val.Substring(0, val.Length - suffix.Length); } - return null; + return val; } /// @@ -663,10 +964,14 @@ public static string RemoveSuffix(this string val, string suffix, bool ignoreCas /// string to append suffix /// suffix /// Indicates whether the compare should ignore case - /// - public static string AppendSuffixIfMissing(this string val, string suffix, bool ignoreCase = true) + /// The original string, or the string with suffix appended. + public static string? AppendSuffixIfMissing(this string? val, string suffix, bool ignoreCase = true) { - if (string.IsNullOrEmpty(val) || (ignoreCase ? val.EndsWithIgnoreCase(suffix) : val.EndsWith(suffix))) + if (val is null || val.Length == 0) + { + return val; + } + if (ignoreCase ? val.EndsWithIgnoreCase(suffix) : val.EndsWith(suffix, StringComparison.Ordinal)) { return val; } @@ -680,9 +985,13 @@ public static string AppendSuffixIfMissing(this string val, string suffix, bool /// prefix /// Indicates whether the compare should ignore case /// - public static string AppendPrefixIfMissing(this string val, string prefix, bool ignoreCase = true) + public static string? AppendPrefixIfMissing(this string? val, string prefix, bool ignoreCase = true) { - if (string.IsNullOrEmpty(val) || (ignoreCase ? val.StartsWithIgnoreCase(prefix) : val.StartsWith(prefix))) + if (val is null || val.Length == 0) + { + return val; + } + if (ignoreCase ? val.StartsWithIgnoreCase(prefix) : val.StartsWith(prefix, StringComparison.Ordinal)) { return val; } @@ -695,9 +1004,9 @@ public static string AppendPrefixIfMissing(this string val, string prefix, bool /// /// string to check if is Alpha /// true if only contains letters, and is non-null - public static bool IsAlpha(this string val) + public static bool IsAlpha(this string? val) { - if (string.IsNullOrEmpty(val)) + if (val is null || val.Length == 0) { return false; } @@ -710,9 +1019,9 @@ public static bool IsAlpha(this string val) /// /// string to check if is Alpha or Numeric /// - public static bool IsAlphaNumeric(this string val) + public static bool IsAlphaNumeric(this string? val) { - if (string.IsNullOrEmpty(val)) + if (val is null || val.Length == 0) { return false; } @@ -725,11 +1034,11 @@ public static bool IsAlphaNumeric(this string val) /// string to hash /// Hashed string /// - public static string CreateHashSha512(string val) + public static string CreateHashSha512(this string val) { if (string.IsNullOrEmpty(val)) { - throw new ArgumentException("val"); + throw new ArgumentException("Value is null or empty.", nameof(val)); } var sb = new StringBuilder(); using (SHA512 hash = SHA512.Create()) @@ -737,7 +1046,7 @@ public static string CreateHashSha512(string val) byte[] data = hash.ComputeHash(val.ToBytes()); foreach (byte b in data) { - sb.Append(b.ToString("x2")); + sb.Append(b.ToString("x2", CultureInfo.InvariantCulture)); } } return sb.ToString(); @@ -748,11 +1057,11 @@ public static string CreateHashSha512(string val) /// /// string to hash /// Hashed string - public static string CreateHashSha256(string val) + public static string CreateHashSha256(this string val) { if (string.IsNullOrEmpty(val)) { - throw new ArgumentException("val"); + throw new ArgumentException("Value is null or empty.", nameof(val)); } var sb = new StringBuilder(); using (SHA256 hash = SHA256.Create()) @@ -760,34 +1069,74 @@ public static string CreateHashSha256(string val) byte[] data = hash.ComputeHash(val.ToBytes()); foreach (byte b in data) { - sb.Append(b.ToString("x2")); + sb.Append(b.ToString("x2", CultureInfo.InvariantCulture)); } } return sb.ToString(); } /// - /// Convert url query string to IDictionary value key pair + /// Convert url query string to IDictionary value key pair. Keys and values are URL-decoded + /// (; + is treated as a space). /// /// query string value /// IDictionary value key pair - public static IDictionary QueryStringToDictionary(this string queryString) + public static IDictionary? QueryStringToDictionary(this string? queryString) { - if (string.IsNullOrWhiteSpace(queryString)) + if (queryString is null || queryString.Trim().Length == 0) { return null; } - if (!queryString.Contains("?")) + + int queryStart = queryString.IndexOf('?'); + if (queryStart < 0 || queryStart == queryString.Length - 1) { return null; } - string query = queryString.Replace("?", ""); - if (!query.Contains("=")) + + string query = queryString.Substring(queryStart + 1); + int fragment = query.IndexOf('#'); + if (fragment >= 0) + { + query = query.Substring(0, fragment); + } + + if (query.IndexOf('=') < 0) { return null; } - return query.Split('&').Select(p => p.Split('=')).ToDictionary( - key => key[0].ToLower().Trim(), value => value[1]); + + var result = new Dictionary(StringComparer.Ordinal); + foreach (string pair in query.Split('&')) + { + string[] parts = pair.Split(QueryEqualsSeparator, 2); + if (parts.Length != 2 || string.IsNullOrEmpty(parts[0])) + { + continue; + } + + string key = DecodeQueryComponent(parts[0]).ToLowerInvariant().Trim(); + if (key.Length == 0) + { + continue; + } + + result[key] = DecodeQueryComponent(parts[1]); + } + + return result; + } + + private static string DecodeQueryComponent(string value) + { + try + { + return Uri.UnescapeDataString(value.Replace("+", " ")); + } + catch (UriFormatException) + { + return value.Replace("+", " "); + } } /// @@ -801,6 +1150,10 @@ public static IDictionary QueryStringToDictionary(this string qu /// public static string ReverseSlash(this string val, int direction) { + if (val == null) + { + throw new ArgumentNullException(nameof(val)); + } switch (direction) { case 0: @@ -813,30 +1166,47 @@ public static string ReverseSlash(this string val, int direction) } /// - /// Replace Line Feeds + /// Replace CR/LF sequences with an empty string. Periods and other characters are left unchanged. /// /// string to remove line feeds /// System.string public static string ReplaceLineFeeds(this string val) { - return Regex.Replace(val, @"^[\r\n]+|\.|[\r\n]+$", ""); + if (val == null) + { + throw new ArgumentNullException(nameof(val)); + } + return LineFeedRegex.Replace(val, ""); } /// - /// Validates if a string is valid IPv4 - /// Regular expression taken from Regex reference + /// Validates if a string is a dotted-quad IPv4 address. /// /// string IP address /// true if string matches valid IP address else false - public static bool IsValidIPv4(this string val) + public static bool IsValidIPv4(this string? val) { - if (string.IsNullOrEmpty(val)) + if (val is null) { return false; } - return Regex.Match(val, - @"(?:^|\s)([a-z]{3,6}(?=://))?(://)?((?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?))(?::(\d{2,5}))?(?:\s|$)") - .Success; + + string trimmed = val.Trim(); + if (trimmed.Length == 0) + { + return false; + } + + string[] octets = trimmed.Split('.'); + if (octets.Length != 4) + { + return false; + } + + return IPAddress.TryParse(trimmed, out IPAddress? address) + && address != null + && address.AddressFamily == AddressFamily.InterNetwork + && string.Equals(address.ToString(), trimmed, StringComparison.Ordinal); } /// @@ -851,11 +1221,11 @@ public static int GetByteSize(this string val, Encoding encoding) { if (val == null) { - throw new ArgumentNullException("val"); + throw new ArgumentNullException(nameof(val)); } if (encoding == null) { - throw new ArgumentNullException("encoding"); + throw new ArgumentNullException(nameof(encoding)); } return encoding.GetByteCount(val); } @@ -872,11 +1242,11 @@ public static string Left(this string val, int length) { if (string.IsNullOrEmpty(val)) { - throw new ArgumentNullException("val"); + throw new ArgumentNullException(nameof(val)); } if (length < 0 || length > val.Length) { - throw new ArgumentOutOfRangeException("length", + throw new ArgumentOutOfRangeException(nameof(length), "length cannot be higher than total string length or less than 0"); } return val.Substring(0, length); @@ -894,11 +1264,11 @@ public static string Right(this string val, int length) { if (string.IsNullOrEmpty(val)) { - throw new ArgumentNullException("val"); + throw new ArgumentNullException(nameof(val)); } if (length < 0 || length > val.Length) { - throw new ArgumentOutOfRangeException("length", + throw new ArgumentOutOfRangeException(nameof(length), "length cannot be higher than total string length or less than 0"); } return val.Substring(val.Length - length); @@ -913,7 +1283,7 @@ public static IEnumerable ToTextElements(this string val) { if (val == null) { - throw new ArgumentNullException("val"); + throw new ArgumentNullException(nameof(val)); } TextElementEnumerator elementEnumerator = StringInfo.GetTextElementEnumerator(val); while (elementEnumerator.MoveNext()) @@ -928,8 +1298,8 @@ public static IEnumerable ToTextElements(this string val) /// /// string to evaluate /// prefix - /// true if string does not match prefix else false, null values will always evaluate to false - public static bool DoesNotStartWith(this string val, string prefix) + /// true if string does not match prefix else false; null or evaluates to true. + public static bool DoesNotStartWith(this string? val, string? prefix) { return val == null || prefix == null || !val.StartsWith(prefix, StringComparison.InvariantCulture); @@ -940,8 +1310,8 @@ public static bool DoesNotStartWith(this string val, string prefix) /// /// string to evaluate /// suffix - /// true if string does not match prefix else false, null values will always evaluate to false - public static bool DoesNotEndWith(this string val, string suffix) + /// true if string does not match suffix else false; null or evaluates to true. + public static bool DoesNotEndWith(this string? val, string? suffix) { return val == null || suffix == null || !val.EndsWith(suffix, StringComparison.InvariantCulture); @@ -952,17 +1322,18 @@ public static bool DoesNotEndWith(this string val, string suffix) /// /// string to evaluate /// true if string is null else false - public static bool IsNull(this string val) + public static bool IsNull(this string? val) { return val == null; } /// - /// Checks if a string is null or empty + /// Checks if a string is null or empty. Instance-style wrapper around + /// ; prefer the static method in new code if both are in scope. /// /// string to evaluate /// true if string is null or is empty else false - public static bool IsNullOrEmpty(this string val) + public static bool IsNullOrEmpty(this string? val) { return String.IsNullOrEmpty(val); } @@ -975,7 +1346,7 @@ public static bool IsNullOrEmpty(this string val) /// string to evaluate minimum length /// minimum allowable string length /// true if string is of specified minimum length - public static bool IsMinLength(this string val, int minCharLength) + public static bool IsMinLength(this string? val, int minCharLength) { return val != null && val.Length >= minCharLength; } @@ -988,7 +1359,7 @@ public static bool IsMinLength(this string val, int minCharLength) /// string to evaluate maximum length /// maximum allowable string length /// true if string has specified maximum char length - public static bool IsMaxLength(this string val, int maxCharLength) + public static bool IsMaxLength(this string? val, int maxCharLength) { return val != null && val.Length <= maxCharLength; } @@ -1001,9 +1372,9 @@ public static bool IsMaxLength(this string val, int maxCharLength) /// minimum char length /// maximum char length /// true if string satisfies minimum and maximum allowable length - public static bool IsLength(this string val, int minCharLength, int maxCharLength) + public static bool IsLength(this string? val, int minCharLength, int maxCharLength) { - return val != null && val.Length >= minCharLength && val.Length <= minCharLength; + return val != null && val.Length >= minCharLength && val.Length <= maxCharLength; } /// @@ -1011,47 +1382,9 @@ public static bool IsLength(this string val, int minCharLength, int maxCharLengt /// /// string to evaluate length /// total number of chars or null if string is null - public static int? GetLength(string val) + public static int? GetLength(this string? val) { return val == null ? (int?) null : val.Length; } - - /// - /// Create basic dynamic SQL where parameters from a JSON key value pair string - /// - /// json key value pair string - /// if true constructs parameters using or statement if false and - /// - public static string CreateParameters(this string value, bool useOr) - { - if (string.IsNullOrEmpty(value)) - { - return string.Empty; - } - IDictionary searchParamters = value.JsonToDictionary(); - var @params = new StringBuilder(""); - if (searchParamters == null) - { - return @params.ToString(); - } - for (int i = 0; i <= searchParamters.Count() - 1; i++) - { - string key = searchParamters.Keys.ElementAt(i); - var val = (string) searchParamters[key]; - if (!string.IsNullOrEmpty(key)) - { - @params.Append(key).Append(" like '").Append(val.Trim()).Append("%' "); - if (i < searchParamters.Count() - 1 && useOr) - { - @params.Append(" or "); - } - else if (i < searchParamters.Count() - 1) - { - @params.Append(" and "); - } - } - } - return @params.ToString(); - } } } diff --git a/StringExtensionLibrary/packages.config b/StringExtensionLibrary/packages.config deleted file mode 100644 index af70bc8..0000000 --- a/StringExtensionLibrary/packages.config +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/StringExtensions.sln b/StringExtensions.sln index ba77254..234c208 100644 --- a/StringExtensions.sln +++ b/StringExtensions.sln @@ -1,11 +1,11 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 2013 -VisualStudioVersion = 12.0.30723.0 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StringExtensions", "StringExtensions\StringExtensions.csproj", "{44BA3FC1-8BFF-413C-B272-01193769DE0F}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StringExtensionLibrary", "StringExtensionLibrary\StringExtensionLibrary.csproj", "{91DC5C7A-7DD0-4E71-B6D9-5D3DD76D2C77}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StringExtensionLibrary", "StringExtensionLibrary\StringExtensionLibrary.csproj", "{91DC5C7A-7DD0-4E71-B6D9-5D3DD76D2C77}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StringExtensions", "StringExtensions\StringExtensions.csproj", "{44BA3FC1-8BFF-413C-B272-01193769DE0F}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -13,14 +13,14 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {44BA3FC1-8BFF-413C-B272-01193769DE0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {44BA3FC1-8BFF-413C-B272-01193769DE0F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {44BA3FC1-8BFF-413C-B272-01193769DE0F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {44BA3FC1-8BFF-413C-B272-01193769DE0F}.Release|Any CPU.Build.0 = Release|Any CPU {91DC5C7A-7DD0-4E71-B6D9-5D3DD76D2C77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {91DC5C7A-7DD0-4E71-B6D9-5D3DD76D2C77}.Debug|Any CPU.Build.0 = Debug|Any CPU {91DC5C7A-7DD0-4E71-B6D9-5D3DD76D2C77}.Release|Any CPU.ActiveCfg = Release|Any CPU {91DC5C7A-7DD0-4E71-B6D9-5D3DD76D2C77}.Release|Any CPU.Build.0 = Release|Any CPU + {44BA3FC1-8BFF-413C-B272-01193769DE0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44BA3FC1-8BFF-413C-B272-01193769DE0F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44BA3FC1-8BFF-413C-B272-01193769DE0F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44BA3FC1-8BFF-413C-B272-01193769DE0F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/StringExtensions/ConversionTests.cs b/StringExtensions/ConversionTests.cs new file mode 100644 index 0000000..b9b64f7 --- /dev/null +++ b/StringExtensions/ConversionTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using StringExtensionLibrary; + +namespace StringExtensions.Tests +{ + [TestClass] + public class ConversionTests + { + [TestMethod] + public void ToInt32_ParsesValidAndInvalid() + { + Assert.AreEqual(42, "42".ToInt32()); + Assert.AreEqual(0, "nope".ToInt32()); + Assert.AreEqual(0, ((string)null).ToInt32()); + Assert.AreEqual(0, "".ToInt32()); + } + + [TestMethod] + public void ToInt16_And_ToInt64_Parse() + { + Assert.AreEqual((short)12, "12".ToInt16()); + Assert.AreEqual(0, "x".ToInt16()); + Assert.AreEqual(9L, "9".ToInt64()); + Assert.AreEqual(0L, ((string)null).ToInt64()); + } + + [TestMethod] + public void ToDecimal_Parses() + { + Assert.AreEqual(1.5m, "1.5".ToDecimal()); + Assert.AreEqual(0m, "abc".ToDecimal()); + } + + [TestMethod] + [DataRow("true", true)] + [DataRow("T", true)] + [DataRow("yes", true)] + [DataRow("Y", true)] + [DataRow("false", false)] + [DataRow("f", false)] + [DataRow("no", false)] + [DataRow("N", false)] + public void ToBoolean_RecognizedValues(string input, bool expected) + { + Assert.AreEqual(expected, input.ToBoolean()); + } + + [TestMethod] + public void ToBoolean_RejectsEmptyAndUnknown() + { + Assert.ThrowsExactly(() => "".ToBoolean()); + Assert.ThrowsExactly(() => " ".ToBoolean()); + Assert.ThrowsExactly(() => ((string)null).ToBoolean()); + Assert.ThrowsExactly(() => "maybe".ToBoolean()); + } + + [TestMethod] + public void SplitTo_SplitsAndConverts() + { + CollectionAssert.AreEqual(new[] { 1, 2, 3 }, "1,2,3".SplitTo(',').ToArray()); + CollectionAssert.AreEqual(new[] { "a", "b" }, "a,,b".SplitTo(StringSplitOptions.RemoveEmptyEntries, ',').ToArray()); + Assert.ThrowsExactly(() => ((string)null).SplitTo(',').ToArray()); + } + + internal enum Color + { + Unknown, + Red, + Blue + } + + [TestMethod] + public void ToEnum_ParsesIgnoresCaseAndFallsBack() + { + Assert.AreEqual(Color.Red, "red".ToEnum(Color.Unknown)); + Assert.AreEqual(Color.Unknown, "pink".ToEnum(Color.Unknown)); + Assert.AreEqual(Color.Blue, "Blue".ToEnum()); + Assert.ThrowsExactly(() => "red".ToEnum()); + } + + [TestMethod] + public void Format_ReplacesItems() + { + Assert.AreEqual("Hello world", "Hello {0}".Format("world")); + Assert.AreEqual("1-2", "{0}-{1}".Format(1, 2)); + } + + [TestMethod] + public void ToBytes_CopiesUtf16Payload() + { + const string value = "ab"; + byte[] bytes = value.ToBytes(); + Assert.AreEqual(value.Length * sizeof(char), bytes.Length); + Assert.ThrowsExactly(() => ((string)null).ToBytes()); + } + } +} diff --git a/StringExtensions/CryptoTests.cs b/StringExtensions/CryptoTests.cs new file mode 100644 index 0000000..c6a69f7 --- /dev/null +++ b/StringExtensions/CryptoTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using StringExtensionLibrary; + +namespace StringExtensions.Tests +{ + [TestClass] + public class CryptoTests + { + [TestMethod] + public void EncryptDecrypt_RoundTripAndWrongKey() + { + const string key = "1234567890!@#$%^&*()_+"; + const string stringToEncrypt = "In my opinion best movie released 2014 is prometheus"; + string encryptedString = stringToEncrypt.Encrypt(key); + Assert.AreEqual(stringToEncrypt, encryptedString.Decrypt(key)); + Assert.ThrowsExactly(() => encryptedString.Decrypt("wrongkey")); + } + + [TestMethod] + public void Encrypt_RejectsEmptyInputs() + { + Assert.ThrowsExactly(() => "".Encrypt("key")); + Assert.ThrowsExactly(() => "data".Encrypt("")); + Assert.ThrowsExactly(() => ((string)null).Encrypt("key")); + Assert.ThrowsExactly(() => "AA".Decrypt("")); + Assert.ThrowsExactly(() => "".Decrypt("key")); + } + + [TestMethod] + public void Decrypt_RejectsTruncatedAndTamperedPayload() + { + string encrypted = "hello".Encrypt("secret-key"); + Assert.ThrowsExactly(() => "00-11".Decrypt("secret-key")); + Assert.ThrowsExactly(() => "ZZ-not-hex".Decrypt("secret-key")); + + string[] parts = encrypted.Split('-'); + parts[parts.Length - 1] = parts[parts.Length - 1] == "00" ? "01" : "00"; + string tampered = string.Join("-", parts); + Assert.ThrowsExactly(() => tampered.Decrypt("secret-key")); + } + + [TestMethod] + public void Pbkdf2HmacSha256_MatchesFrameworkAndPublishedVectors() + { + byte[] password = Encoding.UTF8.GetBytes("password"); + byte[] salt = Encoding.UTF8.GetBytes("salt"); + + CollectionAssert.AreEqual( + ParseHex("120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b"), + StringExtensionLibrary.StringExtensions.Pbkdf2HmacSha256(password, salt, 1, 32)); + CollectionAssert.AreEqual( + ParseHex("ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43"), + StringExtensionLibrary.StringExtensions.Pbkdf2HmacSha256(password, salt, 2, 32)); + CollectionAssert.AreEqual( + ParseHex("c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a"), + StringExtensionLibrary.StringExtensions.Pbkdf2HmacSha256(password, salt, 4096, 32)); + + byte[] framework = Rfc2898DeriveBytes.Pbkdf2(password, salt, 4096, HashAlgorithmName.SHA256, 64); + byte[] custom = StringExtensionLibrary.StringExtensions.Pbkdf2HmacSha256(password, salt, 4096, 64); + CollectionAssert.AreEqual(framework, custom); + } + + [TestMethod] + public void CreateParameters_IsNotPartOfPublicApi() + { + Assert.IsNull(typeof(StringExtensionLibrary.StringExtensions).GetMethod( + "CreateParameters", + BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance)); + } + + private static byte[] ParseHex(string hex) + { + return Convert.FromHexString(hex); + } + } +} diff --git a/StringExtensions/JsonAndQueryTests.cs b/StringExtensions/JsonAndQueryTests.cs new file mode 100644 index 0000000..434e5af --- /dev/null +++ b/StringExtensions/JsonAndQueryTests.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using StringExtensionLibrary; + +namespace StringExtensions.Tests +{ + [TestClass] + public class JsonAndQueryTests + { + [TestMethod] + public void JsonToObject_StandardAndNewtonsoftQuotes() + { + const string standardJson = + "{\"name\":\"Widget\",\"expiryDate\":\"2010-12-20T18:01Z\",\"price\":9.99,\"sizes\":[\"Small\",\"Medium\",\"Large\"]}"; + Product product = standardJson.JsonToObject(); + Assert.AreEqual("Widget", product.Name); + Assert.AreEqual(9.99m, product.Price); + CollectionAssert.AreEqual(new[] { "Small", "Medium", "Large" }, product.Sizes); + + const string newtonsoftQuotes = "{'name':'Gadget','price':1.5,'sizes':['S']}"; + Product gadget = newtonsoftQuotes.JsonToObject(); + Assert.AreEqual("Gadget", gadget.Name); + Assert.ThrowsExactly(() => "null".JsonToObject()); + } + + [TestMethod] + public void JsonToExpanderObject_ReadsNestedArrays() + { + const string productString = "{'name':'Widget','expiryDate':'2010-12-20T18:01Z'," + + "'price':9.99,'sizes':['Small','Medium','Large']}"; + dynamic product = productString.JsonToExpanderObject(); + Assert.IsInstanceOfType(product, typeof(ExpandoObject)); + Assert.AreEqual("Widget", (string)product.name); + var sizes = (List)product.sizes; + Assert.AreEqual(3, sizes.Count); + } + + [TestMethod] + public void JsonToDictionary_RequiresContent() + { + IDictionary map = "{\"a\":\"b\"}".JsonToDictionary(); + Assert.AreEqual("b", map["a"]); + Assert.ThrowsExactly(() => "".JsonToDictionary()); + Assert.ThrowsExactly(() => ((string)null).JsonToDictionary()); + } + + [TestMethod] + public void QueryStringToDictionary_ParsesAndRejectsBadInput() + { + const string url = "?name=ferret&field1=value1&field2=value2&field3=value3"; + IDictionary queryValues = url.QueryStringToDictionary(); + Assert.AreEqual("ferret", queryValues["name"]); + Assert.AreEqual("value2", queryValues["field2"]); + IDictionary decoded = "?name=Ada%20Lovelace&q=hello+world".QueryStringToDictionary(); + Assert.AreEqual("Ada Lovelace", decoded["name"]); + Assert.AreEqual("hello world", decoded["q"]); + IDictionary fromUrl = "https://example.com/search?name=Ada%20Lovelace#top".QueryStringToDictionary(); + Assert.AreEqual("Ada Lovelace", fromUrl["name"]); + IDictionary lastWins = "?a=1&a=2".QueryStringToDictionary(); + Assert.AreEqual("2", lastWins["a"]); + IDictionary malformed = "?x=%".QueryStringToDictionary(); + Assert.AreEqual("%", malformed["x"]); + Assert.IsNull(((string)null).QueryStringToDictionary()); + Assert.IsNull("no-question".QueryStringToDictionary()); + Assert.IsNull("?onlykeys".QueryStringToDictionary()); + } + + internal class Product + { + public string Name { get; set; } + public DateTime ExpiryDate { get; set; } + public decimal Price { get; set; } + public string[] Sizes { get; set; } + } + } +} diff --git a/StringExtensions/MutationTests.cs b/StringExtensions/MutationTests.cs new file mode 100644 index 0000000..a5970a3 --- /dev/null +++ b/StringExtensions/MutationTests.cs @@ -0,0 +1,148 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using StringExtensionLibrary; + +namespace StringExtensions.Tests +{ + [TestClass] + public class MutationTests + { + [TestMethod] + public void GetEmptyStringIfNull_And_GetNullIfEmpty() + { + Assert.AreEqual("", ((string)null).GetEmptyStringIfNull()); + Assert.AreEqual("hi", " hi ".GetEmptyStringIfNull()); + Assert.IsNull(((string)null).GetNullIfEmptyString()); + Assert.IsNull(" ".GetNullIfEmptyString()); + Assert.AreEqual("hi", " hi ".GetNullIfEmptyString()); + } + + [TestMethod] + public void GetDefaultIfEmpty() + { + Assert.AreEqual("fallback", ((string)null).GetDefaultIfEmpty("fallback")); + Assert.AreEqual("fallback", " ".GetDefaultIfEmpty("fallback")); + Assert.AreEqual("kept", " kept ".GetDefaultIfEmpty("fallback")); + } + + [TestMethod] + public void Capitalize_NullEmptyAndInvariant() + { + Assert.IsNull(((string)null).Capitalize()); + Assert.AreEqual("", "".Capitalize()); + Assert.AreEqual("Hello", "hELLo".Capitalize()); + CultureInfo original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("tr-TR"); + Assert.AreEqual("Title", "TITLE".Capitalize()); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [TestMethod] + public void FirstAndLastCharacter() + { + Assert.AreEqual("H", "Hello".FirstCharacter()); + Assert.AreEqual("o", "Hello".LastCharacter()); + Assert.IsNull(((string)null).FirstCharacter()); + Assert.IsNull("".LastCharacter()); + } + + [TestMethod] + public void ReplaceAndRemoveChars() + { + Assert.AreEqual("end", "Friends".Replace('F', 'r', 'i', 's')); + Assert.AreEqual("end", "Friends".RemoveChars('F', 'r', 'i', 's')); + Assert.ThrowsExactly(() => ((string)null).Replace('a')); + Assert.ThrowsExactly(() => "a".RemoveChars(null)); + } + + [TestMethod] + public void Truncate_Reverse_Csv() + { + Assert.AreEqual("Hel...", "Hello".Truncate(3)); + Assert.AreEqual("Hi", "Hi".Truncate(10)); + Assert.AreEqual("", ((string)null).Truncate(3)); + Assert.AreEqual("", "Hi".Truncate(0)); + Assert.AreEqual("cba", "abc".Reverse()); + Assert.ThrowsExactly(() => ((string)null).Reverse()); + Assert.AreEqual("\"a\"\"b\"", "a\"b".ParseStringToCsv()); + Assert.AreEqual("\"\"", ((string)null).ParseStringToCsv()); + } + + [TestMethod] + public void PrefixAndSuffix() + { + Assert.AreEqual("bahaya", "berbahaya".RemovePrefix("ber", false)); + Assert.AreEqual("berbahaya", "berbahaya".RemovePrefix("xx")); + Assert.AreEqual("masak", "masakan".RemoveSuffix("an")); + Assert.AreEqual("masakan", "masakan".RemoveSuffix("zz")); + Assert.AreEqual("file.txt", "file".AppendSuffixIfMissing(".txt")); + Assert.AreEqual("file.txt", "file.txt".AppendSuffixIfMissing(".txt")); + Assert.AreEqual("/tmp", "tmp".AppendPrefixIfMissing("/")); + Assert.AreEqual("/tmp", "/tmp".AppendPrefixIfMissing("/")); + Assert.IsNull(((string)null).RemovePrefix("a")); + } + + [TestMethod] + public void LeftRightAndByteSize() + { + Assert.AreEqual("He", "Hello".Left(2)); + Assert.AreEqual("lo", "Hello".Right(2)); + Assert.ThrowsExactly(() => ((string)null).Left(1)); + Assert.ThrowsExactly(() => "ab".Left(5)); + Assert.ThrowsExactly(() => "ab".Right(-1)); + Assert.AreEqual(5, "Hello".GetByteSize(Encoding.ASCII)); + Assert.ThrowsExactly(() => "a".GetByteSize(null)); + } + + [TestMethod] + public void ReverseSlash_And_LineFeeds() + { + Assert.AreEqual(@"a\b", "a/b".ReverseSlash(0)); + Assert.AreEqual("a/b", @"a\b".ReverseSlash(1)); + Assert.AreEqual("keep", "keep".ReverseSlash(9)); + Assert.ThrowsExactly(() => ((string)null).ReverseSlash(0)); + Assert.AreEqual("ab.", "\nab.\n".ReplaceLineFeeds()); + Assert.AreEqual("ab.cd", "ab.\ncd".ReplaceLineFeeds()); + Assert.ThrowsExactly(() => ((string)null).ReplaceLineFeeds()); + } + + [TestMethod] + public void ToTextElements_EnumeratesCharacters() + { + string[] elements = "ab".ToTextElements().ToArray(); + CollectionAssert.AreEqual(new[] { "a", "b" }, elements); + Assert.ThrowsExactly(() => ((string)null).ToTextElements().ToArray()); + } + + [TestMethod] + public void CountOccurrences_TreatsNeedleAsLiteral() + { + const string sentence = "hey man! i went to the apple store, hey man! are you listening to me"; + Assert.AreEqual(2, sentence.CountOccurrences("HEY MAN!")); + Assert.AreEqual(2, "a.a.a".CountOccurrences(".")); + Assert.AreEqual(0, "".CountOccurrences("a")); + Assert.AreEqual(0, "abc".CountOccurrences(null)); + } + + [TestMethod] + public void Hashes_AreStableAndRejectEmpty() + { + string sha256 = "hi".CreateHashSha256(); + string sha512 = "hi".CreateHashSha512(); + Assert.AreEqual(64, sha256.Length); + Assert.AreEqual(128, sha512.Length); + Assert.AreEqual(sha256, "hi".CreateHashSha256()); + Assert.ThrowsExactly(() => "".CreateHashSha256()); + Assert.ThrowsExactly(() => ((string)null).CreateHashSha512()); + } + } +} diff --git a/StringExtensions/Properties/AssemblyInfo.cs b/StringExtensions/Properties/AssemblyInfo.cs deleted file mode 100644 index 5c5d24d..0000000 --- a/StringExtensions/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("StringExtensions")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("StringExtensions")] -[assembly: AssemblyCopyright("Copyright © 2015")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("f247ca55-e78f-42b9-8d9f-3b538c5d670e")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/StringExtensions/StringExtensionTest.cs b/StringExtensions/StringExtensionTest.cs deleted file mode 100644 index 2df2275..0000000 --- a/StringExtensions/StringExtensionTest.cs +++ /dev/null @@ -1,241 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Dynamic; -using System.Security.Cryptography; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using StringExtensionLibrary; - -namespace StringExtensions -{ - [TestClass] - public class StringExtensionTest - { - [TestMethod] - public void TestJsonToExpandoObject() - { - const string productString = "{'name':'Widget','expiryDate':'2010-12-20T18:01Z'," + - "'price':9.99,'sizes':['Small','Medium','Large']}"; - - dynamic product = productString.JsonToExpanderObject(); - Assert.IsInstanceOfType(product, typeof (ExpandoObject)); - - Assert.IsNotNull(product.name); - Assert.IsNotNull(product.expiryDate); - Assert.IsNotNull(product.price); - Assert.IsNotNull(product.sizes); - - var sizes = (List) product.sizes; - foreach (string item in sizes) - { - Assert.IsNotNull(item); - } - } - - [TestMethod] - public void TestLength() - { - string sample = "There is currently no easy way to update all packages within a solution"; - Assert.IsTrue(sample.IsMinLength(2)); - sample = "The running"; - Assert.IsFalse(sample.IsMinLength(50)); - sample = null; - Assert.IsFalse(sample.IsMinLength(1)); - sample = "One"; - Assert.IsTrue(sample.IsMaxLength(3)); - sample = "three"; - Assert.IsFalse(sample.IsMaxLength(3)); - } - - [TestMethod] - public void TestDoesNotStartWith() - { - Assert.IsTrue("test".DoesNotStartWith("a")); - Assert.IsFalse("test".DoesNotStartWith("t")); - Assert.IsTrue("".DoesNotStartWith("t")); - string NULL = null; - Assert.IsTrue(NULL.DoesNotStartWith("t")); - } - - [TestMethod] - public void ToTextElements() - { - string testing = "asdfasdf aasdflk asdfasdf"; - IEnumerable a = testing.ToTextElements(); - foreach (string k in a) - { - Console.WriteLine(k); - } - } - - [TestMethod] - public void TestIPv4Address() - { - Assert.IsFalse("64.233.161.1470".IsValidIPv4()); - Assert.IsTrue("64.233.161.147".IsValidIPv4()); - } - - [TestMethod] - public void TestQueryStringToDictionary() - { - const string url = "?name=ferret&field1=value1&field2=value2&field3=value3"; - IDictionary queryValues = url.QueryStringToDictionary(); - Assert.IsNotNull(queryValues); - foreach (var obj in queryValues) - { - Console.WriteLine("key={0},value={1}", obj.Key, obj.Value); - } - } - - [TestMethod] - public void TestIsAlphaOrNumeric() - { - Assert.IsTrue("Burning bridges as we go".IsAlpha()); - Assert.IsFalse("Burning bridges as we go!".IsAlpha()); - Assert.IsTrue("10 minutes left to code".IsAlphaNumeric()); - Assert.IsTrue("123456".IsAlphaNumeric()); - } - - [TestMethod] - public void TestRemovePrefixSufix() - { - Assert.AreEqual("berbahaya".RemovePrefix("ber", false), "bahaya"); - Assert.AreEqual("masakan".RemoveSuffix("an"), "masak"); - } - - [TestMethod] - public void TestJsonStringToObject() - { - const string productString = "{'name':'Widget','expiryDate':'2010-12-20T18:01Z'," + - "'price':9.99,'sizes':['Small','Medium','Large']}"; - var product = productString.JsonToObject(); - Assert.IsNotNull(product); - Console.WriteLine(product); - - const string productListString = - "[{'name':'Widget','expiryDate':'2010-12-20T18:01Z','price':9.99,'sizes':['Small','Medium','Large']}," + - "{'name':'Image','expiryDate':'2015-12-20T18:01Z','price':20.50,'sizes':['Small','Medium','Large','Extra Large']}]"; - var products = productListString.JsonToObject>(); - Assert.IsNotNull(products); - foreach (Product obj in products) - { - Console.WriteLine(obj); - } - } - - [TestMethod] - [ExpectedException(typeof (ArgumentException))] - public void TestToEnum() - { - Temperature guage = "VeryHigh".ToEnum(Temperature.High); - Assert.IsTrue(guage.Equals(Temperature.High)); - guage = "low".ToEnum(Temperature.Unknown); - Assert.IsTrue(guage.Equals(Temperature.Low)); - guage = "veryHigh".ToEnum(); - Assert.IsTrue(guage.Equals(Temperature.Unknown)); - guage = "Medium".ToEnum(); - Assert.IsTrue(guage.Equals(Temperature.Medium)); - Assert.IsInstanceOfType("high".ToEnum(), typeof (Temperature)); - } - - [TestMethod] - public void TestCountOccurrences() - { - const string sentence = "hey man! i went to the apple store, hey man! are you listening to me"; - Assert.IsTrue(sentence.CountOccurrences("HEY MAN!") == 2); - } - - [TestMethod] - [ExpectedException(typeof (CryptographicException))] - public void TestEncryptDecrypt() - { - const string key = "1234567890!@#$%^&*()_+"; - const string stringToEncrypt = "In my opinion best movie released 2014 is prometheus"; - string encryptedString = stringToEncrypt.Encrypt(key); - string decryptedString = encryptedString.Decrypt(key); - Assert.AreEqual(stringToEncrypt, decryptedString); - encryptedString.Decrypt("wrongkey"); - } - } - - /// - /// Temperature enum default values in c# are always 0 or first enum element - /// - internal enum Temperature - { - Unknown, - Low, - Medium, - High, - }; - - internal class Product - { - public string Name { set; get; } - public DateTime ExpiryDate { set; get; } - public decimal Price { set; get; } - public string[] Sizes { set; get; } - - public override string ToString() - { - return string.Format("Name: {0}, ExpiryDate: {1}, Price: {2}, Sizes: [{3}]", Name, ExpiryDate, Price, - string.Join(",", Sizes)); - } - } - - - internal class Address - { - public Address() - { - CreatedOn = DateTime.UtcNow; - Active = true; - } - - public string Street1 { set; get; } - public string Street2 { set; get; } - public string Street3 { set; get; } - public string City { set; get; } - public string Country { set; get; } - public DateTime CreatedOn { set; get; } - public bool Active { set; get; } - - public override string ToString() - { - return - string.Format( - "Active: {0}, City: {1}, Country: {2}, CreatedOn: {3}, Street1: {4}, Street2: {5}, Street3: {6}", - Active, City, Country, CreatedOn, Street1, Street2, Street3); - } - } - - internal class Person - { - public Person() - { - CreatedOn = DateTime.UtcNow; - Addresses = new HashSet
(); - } - - public string Name { set; get; } - public int Age { set; get; } - public DateTime Dob { set; get; } - public string Email { set; get; } - public string SocialSecurityNo { set; get; } - public string MobileNo { set; get; } - public string OfficeNo { set; get; } - public string PassportNo { set; get; } - public ICollection
Addresses { set; get; } - public string BirthPlace { set; get; } - public string Nationality { set; get; } - public DateTime CreatedOn { set; get; } - - public override string ToString() - { - return - string.Format( - "Name: {0}, Age: {1}, Dob: {2}, Email: {3}, SocialSecurityNo: {4}, MobileNo: {5}, OfficeNo: {6}, Address: {7}, PassportNo: {8}, BirthPlace: {9}, Nationality: {10}", - Name, Age, Dob, Email, SocialSecurityNo, MobileNo, OfficeNo, Addresses, PassportNo, BirthPlace, - Nationality); - } - } -} \ No newline at end of file diff --git a/StringExtensions/StringExtensions.csproj b/StringExtensions/StringExtensions.csproj index 4bdc2dd..1b16e3c 100644 --- a/StringExtensions/StringExtensions.csproj +++ b/StringExtensions/StringExtensions.csproj @@ -1,90 +1,39 @@ - - + + - Debug - AnyCPU - {44BA3FC1-8BFF-413C-B272-01193769DE0F} - Library - Properties - StringExtensions - StringExtensions - v4.5.1 - 512 - {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - 10.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - $(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages - False - UnitTest + net8.0 + StringExtensions.Tests + StringExtensions.Tests + disable + false + true + LatestMajor + disable + $(NoWarn);CS1591;CA1707;CA1861;CA1852 + true + cobertura + [StringExtensionLibrary]* + 90,80 + line,branch + minimum - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - - + - - + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + - - {91dc5c7a-7dd0-4e71-b6d9-5d3dd76d2c77} - StringExtensionLibrary - + - - - - - False - - - False - - - False - - - False - - - - - - - - \ No newline at end of file + + diff --git a/StringExtensions/ValidationTests.cs b/StringExtensions/ValidationTests.cs new file mode 100644 index 0000000..acd3924 --- /dev/null +++ b/StringExtensions/ValidationTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Linq; +using System.Net; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using StringExtensionLibrary; + +namespace StringExtensions.Tests +{ + [TestClass] + public class ValidationTests + { + [TestMethod] + public void IsDateTime_RespectsFormat() + { + Assert.IsTrue("26/08/2026 10:00:00".IsDateTime("dd/MM/yyyy HH:mm:ss")); + Assert.IsFalse("not-a-date".IsDateTime("dd/MM/yyyy")); + Assert.IsFalse(((string)null).IsDateTime("dd/MM/yyyy")); + Assert.IsFalse("".IsDateTime("dd/MM/yyyy")); + } + + [TestMethod] + public void IsInteger_And_IsNumeric() + { + Assert.IsTrue("42".IsInteger()); + Assert.IsFalse("4.2".IsInteger()); + Assert.IsFalse(((string)null).IsInteger()); + Assert.IsTrue("4.2".IsNumeric()); + Assert.IsFalse("x".IsNumeric()); + } + + [TestMethod] + public void IsAlpha_And_IsAlphaNumeric() + { + Assert.IsTrue("Burning bridges as we go".IsAlpha()); + Assert.IsFalse("Burning bridges as we go!".IsAlpha()); + Assert.IsFalse("".IsAlpha()); + Assert.IsFalse(((string)null).IsAlpha()); + Assert.IsTrue("10 minutes left to code".IsAlphaNumeric()); + Assert.IsTrue("123456".IsAlphaNumeric()); + Assert.IsFalse(((string)null).IsAlphaNumeric()); + } + + [TestMethod] + [DataRow("64.233.161.147", true)] + [DataRow("64.233.161.1470", false)] + [DataRow("256.1.1.1", false)] + [DataRow("1", false)] + [DataRow("http://127.0.0.1", false)] + [DataRow("127.00.0.1", false)] + [DataRow("", false)] + [DataRow(null, false)] + [DataRow(" 127.0.0.1 ", true)] + public void IsValidIPv4_UsesAddressParse(string value, bool expected) + { + Assert.AreEqual(expected, value.IsValidIPv4()); + } + + [TestMethod] + public void IsValidIPv4_RejectsIPv6() + { + Assert.IsFalse("::1".IsValidIPv4()); + Assert.AreEqual(AddressFamilyOf("127.0.0.1"), System.Net.Sockets.AddressFamily.InterNetwork); + } + + private static System.Net.Sockets.AddressFamily AddressFamilyOf(string ip) + { + return IPAddress.Parse(ip).AddressFamily; + } + + [TestMethod] + [DataRow("user@example.com", true)] + [DataRow("user+tag@example.com", true)] + [DataRow(" user@example.com ", true)] + [DataRow("not-an-email", false)] + [DataRow("", false)] + [DataRow(null, false)] + [DataRow(" ", false)] + public void IsEmailAddress_NullSafeAndPlusTags(string email, bool expected) + { + Assert.AreEqual(expected, email.IsEmailAddress()); + } + + [TestMethod] + public void LengthChecks_IncludeIsLengthMax() + { + const string sample = "There is currently no easy way to update all packages within a solution"; + Assert.IsTrue(sample.IsMinLength(2)); + Assert.IsFalse("The running".IsMinLength(50)); + Assert.IsFalse(((string)null).IsMinLength(1)); + Assert.IsTrue("One".IsMaxLength(3)); + Assert.IsFalse("three".IsMaxLength(3)); + Assert.IsTrue("abcd".IsLength(2, 6)); + Assert.IsFalse("abcd".IsLength(5, 6)); + Assert.IsFalse(((string)null).IsLength(0, 1)); + Assert.AreEqual(4, "abcd".GetLength()); + Assert.IsNull(((string)null).GetLength()); + } + + [TestMethod] + public void NullHelpers() + { + Assert.IsTrue(((string)null).IsNull()); + Assert.IsFalse("x".IsNull()); + Assert.IsTrue(((string)null).IsNullOrEmpty()); + Assert.IsTrue("".IsNullOrEmpty()); + Assert.IsFalse("x".IsNullOrEmpty()); + } + + [TestMethod] + public void StartsAndEndsWithIgnoreCase() + { + Assert.IsTrue("Hello".StartsWithIgnoreCase("he")); + Assert.IsFalse("Hello".StartsWithIgnoreCase("x")); + Assert.IsTrue("Hello".EndsWithIgnoreCase("LO")); + Assert.ThrowsExactly(() => ((string)null).StartsWithIgnoreCase("a")); + Assert.ThrowsExactly(() => "a".EndsWithIgnoreCase(null)); + } + + [TestMethod] + public void DoesNotStartOrEndWith_NullIsTrue() + { + Assert.IsTrue("test".DoesNotStartWith("a")); + Assert.IsFalse("test".DoesNotStartWith("t")); + Assert.IsTrue("".DoesNotStartWith("t")); + Assert.IsTrue(((string)null).DoesNotStartWith("t")); + Assert.IsTrue("test".DoesNotEndWith("a")); + Assert.IsFalse("test".DoesNotEndWith("t")); + Assert.IsTrue(((string)null).DoesNotEndWith("t")); + } + } +}