././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1699222962.1790128
bleachbit-4.6.0/ 0000775 0001750 0001750 00000000000 14522012662 011004 5 ustar 00z z ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/COPYING 0000664 0001750 0001750 00000104517 14522012661 012046 0 ustar 00z z 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
.
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/MANIFEST.in 0000664 0001750 0001750 00000001474 14522012661 012547 0 ustar 00z z include doc/CONTRIBUTING.md
include COPYING
include MANIFEST
include MANIFEST.in
include Makefile
include README.md
include bleachbit.png
include bleachbit.py
include bleachbit.spec
include cleaners/*xml
include cleaners/Makefile
include data/app-menu.ui
include debian/bleachbit.dsc
include debian/compat
include debian/copyright
include debian/debian.changelog
include debian/debian.control
include debian/debian.rules
include doc/cleaner_markup_language.xsd
include doc/example_cleaner.xml
include org.bleachbit.BleachBit.desktop
include org.bleachbit.BleachBit.metainfo.xml
include org.bleachbit.policy
include po/*po
include po/Makefile
include setup.py
include tests/*py
include windows/bleachbit.ico
include windows/bleachbit.nsi
include windows/gtk20.pot
include windows/setup_py2exe.bat
recursive-include bleachbit *py
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/Makefile 0000664 0001750 0001750 00000010067 14522012661 012447 0 ustar 00z z # Copyright (C) 2008-2023 Andrew Ziem. All rights reserved.
# License GPLv3+: GNU GPL version 3 or later .
# This is free software: You are free to change and redistribute it.
# There is NO WARRANTY, to the extent permitted by law.
#
# Makefile edited by https://github.com/Tobias-B-Besemer
# Done on 2019-03-13
# On some systems if not explicitly given, make uses /bin/sh
SHELL := /bin/bash
.PHONY: clean install tests build
prefix ?= /usr/local
bindir ?= $(prefix)/bin
datadir ?= $(prefix)/share
INSTALL = install
INSTALL_DATA = $(INSTALL) -m 644
# if not specified, do not check coverage
PYTHON ?= python3
COVERAGE ?= $(PYTHON)
build:
echo Nothing to build
clean:
@rm -vf {.,bleachbit,tests,windows,bleachbit/markovify}/*{pyc,pyo,~} # files
@rm -vrf {.,bleachbit,tests,windows,bleachbit/markovify}/__pycache__ # directories
@rm -vrf build dist # created by py2exe
@rm -rf BleachBit-Portable # created by windows/setup_py2exe.bat
@rm -rf BleachBit-*-portable.zip
@rm -vf MANIFEST # created by setup.py
make -C po clean
@rm -vrf locale
@rm -vrf {*/,./}*.{pychecker,pylint,pyflakes}.log
@rm -vrf windows/BleachBit-*-setup*.{exe,zip}
@rm -vrf htmlcov .coverage # code coverage reports
install:
# "binary"
mkdir -p $(DESTDIR)$(bindir)
$(INSTALL_DATA) bleachbit.py $(DESTDIR)$(bindir)/bleachbit
chmod 0755 $(DESTDIR)$(bindir)/bleachbit
# application launcher
mkdir -p $(DESTDIR)$(datadir)/applications
$(INSTALL_DATA) org.bleachbit.BleachBit.desktop $(DESTDIR)$(datadir)/applications/
# AppStream metadata
mkdir -p $(DESTDIR)$(datadir)/metainfo
$(INSTALL_DATA) org.bleachbit.BleachBit.metainfo.xml $(DESTDIR)$(datadir)/metainfo/
# Python code
mkdir -p $(DESTDIR)$(datadir)/bleachbit/markovify
$(INSTALL_DATA) bleachbit/*.py $(DESTDIR)$(datadir)/bleachbit
$(INSTALL_DATA) bleachbit/markovify/*.py $(DESTDIR)$(datadir)/bleachbit/markovify
#note: compileall is recursive
cd $(DESTDIR)$(datadir)/bleachbit && \
$(PYTHON) -O -c "import compileall; compileall.compile_dir('.')" && \
$(PYTHON) -c "import compileall; compileall.compile_dir('.')"
# cleaners
mkdir -p $(DESTDIR)$(datadir)/bleachbit/cleaners
$(INSTALL_DATA) cleaners/*.xml $(DESTDIR)$(datadir)/bleachbit/cleaners
# menu
$(INSTALL_DATA) data/app-menu.ui $(DESTDIR)$(datadir)/bleachbit
# icon
mkdir -p $(DESTDIR)$(datadir)/pixmaps
$(INSTALL_DATA) bleachbit.png $(DESTDIR)$(datadir)/pixmaps/
# translations
make -C po install DESTDIR=$(DESTDIR)
# PolicyKit
mkdir -p $(DESTDIR)$(datadir)/polkit-1/actions
$(INSTALL_DATA) org.bleachbit.policy $(DESTDIR)$(datadir)/polkit-1/actions/
lint:
[ -x "$$(command -v pychecker)" ] || echo "WARNING: pychecker not found"
[ -x "$$(command -v pyflakes)" ] || echo "WARNING: pyflakes not found"
[ -x "$$(command -v pylint)" ] || echo "WARNING: pylint not found"
for f in *py */*py; \
do \
echo "$$f"; \
( [ -x "$$(command -v pychecker)" ] && pyflakes "$$f" > "$$f".pychecker.log ); \
( [ -x "$$(command -v pyflakes)" ] && pyflakes "$$f" > "$$f".pyflakes.log ); \
( [ -x "$$(command -v pylint)" ] && pylint "$$f" > "$$f".pylint.log ); \
done; \
exit 0
delete_windows_files:
# This is used for building .deb and .rpm packages.
# Remove Windows-specific cleaners.
grep -l "cleaner id=\"\w*\" os=\"windows\"" cleaners/*xml | xargs rm -f
# Remove Windows-specific modules.
rm -f bleachbit/{Winapp,Windows*}.py
downgrade_desktop:
# This will downgrade the version of the .desktop file for older Linux distributions.
# See https://github.com/bleachbit/bleachbit/issues/750
desktop-file-validate org.bleachbit.BleachBit.desktop || \
sed --regexp-extended -i '/^(Keywords|Version)=/d' org.bleachbit.BleachBit.desktop
tests:
make -C cleaners tests; cleaners_status=$$?; \
$(COVERAGE) -m unittest discover -p Test*.py -v; py_status=$$?; \
exit $$(($$cleaners_status + $$py_status))
pretty:
autopep8 -i {.,bleachbit,tests}/*py
dos2unix {.,bleachbit,tests}/*py
make -C cleaners pretty
xmllint --format doc/cleaner_markup_language.xsd > doc/cleaner_markup_language.xsd.tmp
mv doc/cleaner_markup_language.xsd.tmp doc/cleaner_markup_language.xsd
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1699222962.1790128
bleachbit-4.6.0/PKG-INFO 0000664 0001750 0001750 00000001032 14522012662 012075 0 ustar 00z z Metadata-Version: 2.1
Name: bleachbit
Version: 4.6.0
Summary: BleachBit - Free space and maintain privacy
Home-page: https://www.bleachbit.org
Author: Andrew Ziem
Author-email: andrew@bleachbit.org
License: GPLv3
Download-URL: https://www.bleachbit.org/download
Platform: Linux and Windows; Python v2.6 and 2.7; GTK v3.12+
License-File: COPYING
BleachBit frees space and maintains privacy by quickly wiping files you don't need and didn't know you had. Supported applications include Edge, Firefox, Google Chrome, VLC, and many others.
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/README.md 0000664 0001750 0001750 00000003200 14522012661 012255 0 ustar 00z z # BleachBit
BleachBit cleans files to free disk space and to maintain privacy.
## Running from source
To run BleachBit without installation, unpack the tarball and then run these
commands:
make -C po local # build translations
python3 bleachbit.py
Then, review the preferences.
Then, select some options, and click Preview. Review the files, toggle options accordingly, and click Delete.
For information regarding the command line interface, run:
python3 bleachbit.py --help
## Links
* [BleachBit home
page](https://www.bleachbit.org)
* [Support](https://www.bleachbit.org/help)
* [Documentation](https://docs.bleachbit.org)
* [Translate in Weblate](https://hosted.weblate.org/projects/bleachbit/): under evaluation
## Licenses
BleachBit itself, including source code and cleaner definitions, is licensed under the [GNU General Public License version 3](COPYING), or at your option, any later version.
markovify is licensed under the [MIT License](https://github.com/jsvine/markovify/blob/master/LICENSE.txt).
### Development
* [BleachBit on AppVeyor](https://ci.appveyor.com/project/az0/bleachbit) 
* [BleachBit on Travis CI](https://travis-ci.com/github/bleachbit/bleachbit) 
* [LGTM](https://lgtm.com/projects/g/bleachbit/bleachbit/): code analysis
* [CleanerML Repository](https://github.com/bleachbit/cleanerml)
* [BleachBit Miscellaneous Repository](https://github.com/bleachbit/bleachbit-misc)
* [Winapp2.ini Repository](https://github.com/bleachbit/winapp2.ini)
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1699222962.159012
bleachbit-4.6.0/bleachbit/ 0000775 0001750 0001750 00000000000 14522012662 012721 5 ustar 00z z ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/Action.py 0000775 0001750 0001750 00000054402 14522012661 014517 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# -*- coding: UTF-8 -*-
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Actions that perform cleaning
"""
from bleachbit import Command, FileUtilities, General, Special, DeepScan
from bleachbit import _, fs_scan_re_flags
import glob
import logging
import os
import re
if 'posix' == os.name:
from bleachbit import Unix
logger = logging.getLogger(__name__)
def has_glob(s):
"""Checks whether the string contains any glob characters"""
return re.search('[?*\[\]]', s) is not None
def expand_multi_var(s, variables):
"""Expand strings with potentially-multiple values.
The placeholder is written in the format $$foo$$.
The function always returns a list of one or more strings.
"""
if not variables or s.find('$$') == -1:
# The input string is missing $$ or no variables are given.
return (s,)
var_keys_used = []
ret = []
for var_key in variables.keys():
sub = '$$%s$$' % var_key
if s.find(sub) > -1:
var_keys_used.append(var_key)
if not var_keys_used:
# No matching variables used, so return input string unmodified.
return (s,)
# filter the dictionary to the keys used
vars_used = {key: value for key,
value in variables.items() if key in var_keys_used}
# create a product of combinations
from itertools import product
vars_product = (dict(zip(vars_used, x))
for x in product(*vars_used.values()))
for var_set in vars_product:
ms = s # modified version of input string
for var_key, var_value in var_set.items():
sub = '$$%s$$' % var_key
ms = ms.replace(sub, var_value)
ret.append(ms)
if ret:
return ret
else:
# The string has $$, but it did not match anything
return (s,)
#
# Plugin framework
# http://martyalchin.com/2008/jan/10/simple-plugin-framework/
#
class PluginMount(type):
"""A simple plugin framework"""
def __init__(cls, name, bases, attrs):
if not hasattr(cls, 'plugins'):
cls.plugins = []
else:
cls.plugins.append(cls)
class ActionProvider(metaclass=PluginMount):
"""Abstract base class for performing individual cleaning actions"""
def __init__(self, action_node, path_vars=None):
"""Create ActionProvider from CleanerML """
pass
def get_deep_scan(self):
"""Return a dictionary used to construct a deep scan"""
raise StopIteration
def get_commands(self):
"""Yield each command (which can be previewed or executed)"""
pass
#
# base class
#
class FileActionProvider(ActionProvider):
"""Base class for providers which work on individual files"""
action_key = '_file'
CACHEABLE_SEARCHERS = ('walk.files',)
# global cache
cache = ('nothing', '', tuple())
def __init__(self, action_element, path_vars=None):
"""Initialize file search"""
ActionProvider.__init__(self, action_element, path_vars)
self.regex = action_element.getAttribute('regex')
assert(isinstance(self.regex, (str, type(None))))
self.nregex = action_element.getAttribute('nregex')
assert(isinstance(self.nregex, (str, type(None))))
self.wholeregex = action_element.getAttribute('wholeregex')
assert(isinstance(self.wholeregex, (str, type(None))))
self.nwholeregex = action_element.getAttribute('nwholeregex')
assert(isinstance(self.nwholeregex, (str, type(None))))
self.search = action_element.getAttribute('search')
self.object_type = action_element.getAttribute('type')
self._set_paths(action_element.getAttribute('path'), path_vars)
self.ds = None
if 'deep' == self.search:
self.ds = (self.paths[0], DeepScan.Search(
command=action_element.getAttribute('command'),
regex=self.regex, nregex=self.nregex,
wholeregex=self.wholeregex, nwholeregex=self.nwholeregex))
if not len(self.paths) == 1:
logger.warning(
# TRANSLATORS: Multi-value variables are explained in the online documentation.
# Basically, they are like an environment variable, but each multi-value variable
# can have multiple values. They're a way to make CleanerML files more concise.
_("Deep scan does not support multi-value variable."))
if not any([self.object_type, self.regex, self.nregex,
self.wholeregex, self.nwholeregex]):
# If the filter is not needed, bypass it for speed.
self.get_paths = self._get_paths
def _set_paths(self, raw_path, path_vars):
"""Set the list of paths to work on"""
self.paths = []
# expand special $$foo$$ which may give multiple values
for path2 in expand_multi_var(raw_path, path_vars):
path3 = os.path.expanduser(os.path.expandvars(path2))
if os.name == 'nt' and path3:
# convert forward slash to backslash for compatibility with getsize()
# and for display. Do not convert an empty path, or it will become
# the current directory (.).
path3 = os.path.normpath(path3)
self.paths.append(path3)
def get_deep_scan(self):
if self.ds is None:
return
yield self.ds
def get_paths(self):
"""Process the filters: regex, nregex, type
If a filter is defined and it fails to match, this function
returns False. Otherwise, this function returns True."""
# optimize tight loop, avoid slow python "."
regex = self.regex
nregex = self.nregex
wholeregex = self.wholeregex
nwholeregex = self.nwholeregex
basename = os.path.basename
object_type = self.object_type
if self.regex:
regex_c_search = re.compile(self.regex, fs_scan_re_flags).search
else:
regex_c_search = None
if self.nregex:
nregex_c_search = re.compile(self.nregex, fs_scan_re_flags).search
else:
nregex_c_search = None
if self.wholeregex:
wholeregex_c_search = re.compile(self.wholeregex, fs_scan_re_flags).search
else:
wholeregex_c_search = None
if self.nwholeregex:
nwholeregex_c_search = re.compile(
self.nwholeregex, fs_scan_re_flags).search
else:
nwholeregex_c_search = None
for path in self._get_paths():
if regex and not regex_c_search(basename(path)):
continue
if nregex and nregex_c_search(basename(path)):
continue
if wholeregex and not wholeregex_c_search(path):
continue
if nwholeregex and nwholeregex_c_search(path):
continue
if object_type:
if 'f' == object_type and not os.path.isfile(path):
continue
elif 'd' == object_type and not os.path.isdir(path):
continue
yield path
def _get_paths(self):
"""Return a filtered list of files"""
def get_file(path):
if os.path.lexists(path):
yield path
def get_walk_all(top):
"""Delete files and directories inside a directory but not the top directory"""
for expanded in glob.iglob(top):
path = None # sentinel value
yield from FileUtilities.children_in_directory(expanded, True)
# This condition executes when there are zero iterations
# in the loop above.
if path is None:
# This is a lint checker because this scenario may
# indicate the cleaner developer made a mistake.
if os.path.isfile(expanded):
logger.debug(
# TRANSLATORS: This is a lint-style warning that there seems to be a
# mild mistake in the CleanerML file because walk.all is expected to
# be used with directories instead of with files. Do not translate
# search="walk.all" and path="%s"
_('search="walk.all" used with regular file path="%s"'),
expanded,
)
def get_walk_files(top):
"""Delete files inside a directory but not any directories"""
for expanded in glob.iglob(top):
yield from FileUtilities.children_in_directory(expanded, False)
def get_top(top):
"""Delete directory contents and the directory itself"""
yield from get_walk_all(top)
if os.path.exists(top):
yield top
if 'deep' == self.search:
return
elif 'file' == self.search:
func = get_file
elif 'glob' == self.search:
func = glob.iglob
elif 'walk.all' == self.search:
func = get_walk_all
elif 'walk.files' == self.search:
func = get_walk_files
elif 'walk.top' == self.search:
func = get_top
else:
raise RuntimeError("invalid search='%s'" % self.search)
cache = self.__class__.cache
for input_path in self.paths:
if self.search == 'glob' and not has_glob(input_path):
# TRANSLATORS: This is a lint-style warning that the CleanerML file
# specified a search for glob, but the path specified didn't have any
# wildcard patterns. Therefore, maybe the developer either missed
# the wildcard or should search using path="file" which does not
# expect or support wildcards in the path.
logger.debug(_('path="%s" is not a glob pattern'), input_path)
# use cache
if self.search in self.CACHEABLE_SEARCHERS and cache[0] == self.search and cache[1] == input_path:
#logger.debug(_('using cached walk for path %s'), input_path)
for x in cache[2]:
yield x
return
else:
# if self.search in self.CACHEABLE_SEARCHERS:
# logger.debug('not using cache because it has (%s,%s) and we want (%s,%s)',
# cache[0], cache[1], self.search, input_path)
self.__class__.cache = ('cleared by', input_path, tuple())
# build new cache
#logger.debug('%s walking %s', id(self), input_path)
if self.search in self.CACHEABLE_SEARCHERS:
cache = self.__class__.cache = (self.search, input_path, [])
for path in func(input_path):
cache[2].append(path)
yield path
else:
for path in func(input_path):
yield path
def get_commands(self):
raise NotImplementedError('not implemented')
#
# Action providers
#
class AptAutoclean(ActionProvider):
"""Action to run 'apt-get autoclean'"""
action_key = 'apt.autoclean'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
def get_commands(self):
# Checking executable allows auto-hide to work for non-APT systems
if FileUtilities.exe_exists('apt-get'):
yield Command.Function(None,
Unix.apt_autoclean,
'apt-get autoclean')
class AptAutoremove(ActionProvider):
"""Action to run 'apt-get autoremove'"""
action_key = 'apt.autoremove'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
def get_commands(self):
# Checking executable allows auto-hide to work for non-APT systems
if FileUtilities.exe_exists('apt-get'):
yield Command.Function(None,
Unix.apt_autoremove,
'apt-get autoremove')
class AptClean(ActionProvider):
"""Action to run 'apt-get clean'"""
action_key = 'apt.clean'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
def get_commands(self):
# Checking executable allows auto-hide to work for non-APT systems
if FileUtilities.exe_exists('apt-get'):
yield Command.Function(None,
Unix.apt_clean,
'apt-get clean')
class ChromeAutofill(FileActionProvider):
"""Action to clean 'autofill' table in Google Chrome/Chromium"""
action_key = 'chrome.autofill'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(
path,
Special.delete_chrome_autofill,
_('Clean file'))
class ChromeDatabases(FileActionProvider):
"""Action to clean Databases.db in Google Chrome/Chromium"""
action_key = 'chrome.databases_db'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(
path,
Special.delete_chrome_databases_db,
_('Clean file'))
class ChromeFavicons(FileActionProvider):
"""Action to clean 'Favicons' file in Google Chrome/Chromium"""
action_key = 'chrome.favicons'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(
path,
Special.delete_chrome_favicons,
_('Clean file'))
class ChromeHistory(FileActionProvider):
"""Action to clean 'History' file in Google Chrome/Chromium"""
action_key = 'chrome.history'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(
path,
Special.delete_chrome_history,
_('Clean file'))
class ChromeKeywords(FileActionProvider):
"""Action to clean 'keywords' table in Google Chrome/Chromium"""
action_key = 'chrome.keywords'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(
path,
Special.delete_chrome_keywords,
_('Clean file'))
class Delete(FileActionProvider):
"""Action to delete files"""
action_key = 'delete'
def get_commands(self):
for path in self.get_paths():
yield Command.Delete(path)
class Ini(FileActionProvider):
"""Action to clean .ini configuration files"""
action_key = 'ini'
def __init__(self, action_element, path_vars=None):
FileActionProvider.__init__(self, action_element, path_vars)
self.section = action_element.getAttribute('section')
self.parameter = action_element.getAttribute('parameter')
if self.parameter == "":
self.parameter = None
def get_commands(self):
for path in self.get_paths():
yield Command.Ini(path, self.section, self.parameter)
class Journald(ActionProvider):
"""Action to run 'journalctl --vacuum-time=1'"""
action_key = 'journald.clean'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
def get_commands(self):
if FileUtilities.exe_exists('journalctl'):
yield Command.Function(None, Unix.journald_clean, 'journalctl --vacuum-time=1')
class Json(FileActionProvider):
"""Action to clean JSON configuration files"""
action_key = 'json'
def __init__(self, action_element, path_vars=None):
FileActionProvider.__init__(self, action_element, path_vars)
self.address = action_element.getAttribute('address')
def get_commands(self):
for path in self.get_paths():
yield Command.Json(path, self.address)
class MozillaUrlHistory(FileActionProvider):
"""Action to clean Mozilla (Firefox) URL history in places.sqlite"""
action_key = 'mozilla.url.history'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(path,
Special.delete_mozilla_url_history,
_('Clean file'))
class MozillaFavicons(FileActionProvider):
"""Action to clean Mozilla (Firefox) URL history in places.sqlite"""
action_key = 'mozilla.favicons'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(path,
Special.delete_mozilla_favicons,
_('Clean file'))
class OfficeRegistryModifications(FileActionProvider):
"""Action to delete LibreOffice history"""
action_key = 'office_registrymodifications'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(
path,
Special.delete_office_registrymodifications,
_('Clean'))
class Process(ActionProvider):
"""Action to run a process"""
action_key = 'process'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
self.cmd = os.path.expandvars(action_element.getAttribute('cmd'))
# by default, wait
self.wait = True
wait = action_element.getAttribute('wait')
if wait and wait.lower()[0] in ('f', 'n'):
# false or no
self.wait = False
def get_commands(self):
def run_process():
try:
if self.wait:
args = self.cmd.split(' ')
(rc, stdout, stderr) = General.run_external(args)
else:
rc = 0 # unknown because we don't wait
from subprocess import Popen
Popen(self.cmd)
except Exception as e:
raise RuntimeError(
'Exception in external command\nCommand: %s\nError: %s' % (self.cmd, str(e)))
else:
if not 0 == rc:
msg = 'Command: %s\nReturn code: %d\nStdout: %s\nStderr: %s\n'
logger.warning(msg, self.cmd, rc, stdout, stderr)
return 0
yield Command.Function(path=None, func=run_process, label=_("Run external command: %s") % self.cmd)
class Shred(FileActionProvider):
"""Action to shred files (override preference)"""
action_key = 'shred'
def get_commands(self):
for path in self.get_paths():
yield Command.Shred(path)
class SqliteVacuum(FileActionProvider):
"""Action to vacuum SQLite databases"""
action_key = 'sqlite.vacuum'
def get_commands(self):
for path in self.get_paths():
yield Command.Function(
path,
FileUtilities.vacuum_sqlite3,
# TRANSLATORS: Vacuum is a verb. The term is jargon
# from the SQLite database. Microsoft Access uses
# the term 'Compact Database' (which you may translate
# instead). Another synonym is 'defragment.'
_('Vacuum'))
class Truncate(FileActionProvider):
"""Action to truncate files"""
action_key = 'truncate'
def get_commands(self):
for path in self.get_paths():
yield Command.Truncate(path)
class WinShellChangeNotify(ActionProvider):
"""Action to clean the Windows Registry"""
action_key = 'win.shell.change.notify'
def get_commands(self):
from bleachbit import Windows
yield Command.Function(
None,
Windows.shell_change_notify,
None)
class Winreg(ActionProvider):
"""Action to clean the Windows Registry"""
action_key = 'winreg'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
self.keyname = action_element.getAttribute('path')
self.name = action_element.getAttribute('name')
def get_commands(self):
yield Command.Winreg(self.keyname, self.name)
class YumCleanAll(ActionProvider):
"""Action to run 'yum clean all'"""
action_key = 'yum.clean_all'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
def get_commands(self):
# Checking allows auto-hide to work for non-APT systems
if not FileUtilities.exe_exists('yum'):
return
yield Command.Function(
None,
Unix.yum_clean,
'yum clean all')
class DnfCleanAll(ActionProvider):
"""Action to run 'dnf clean all'"""
action_key = 'dnf.clean_all'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
def get_commands(self):
# Checking allows auto-hide to work for non-APT systems
if not FileUtilities.exe_exists('dnf'):
return
yield Command.Function(
None,
Unix.dnf_clean,
'dnf clean all')
class DnfAutoremove(ActionProvider):
"""Action to run 'dnf autoremove'"""
action_key = 'dnf.autoremove'
def __init__(self, action_element, path_vars=None):
ActionProvider.__init__(self, action_element, path_vars)
def get_commands(self):
# Checking allows auto-hide to work for non-APT systems
if not FileUtilities.exe_exists('dnf'):
return
yield Command.Function(
None,
Unix.dnf_autoremove,
'dnf autoremove')
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/CLI.py 0000775 0001750 0001750 00000026600 14522012661 013710 0 ustar 00z z #!/usr/bin/python3
# vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Command line interface
"""
from bleachbit.Cleaner import backends, create_simple_cleaner, register_cleaners
from bleachbit import _, APP_VERSION
from bleachbit import SystemInformation, Options, Worker
from bleachbit.Log import set_root_log_level
import logging
import optparse
import os
import sys
logger = logging.getLogger(__name__)
class CliCallback:
"""Command line's callback passed to Worker"""
def __init__(self, quiet=False):
self.quiet = quiet
def append_text(self, msg, tag=None):
"""Write text to the terminal"""
if not self.quiet:
print(msg.strip('\n'))
def update_progress_bar(self, status):
"""Not used"""
pass
def update_total_size(self, size):
"""Not used"""
pass
def update_item_size(self, op, opid, size):
"""Not used"""
pass
def worker_done(self, worker, really_delete):
"""Not used"""
pass
def cleaners_list():
"""Yield each cleaner-option pair"""
list(register_cleaners())
for key in sorted(backends):
c_id = backends[key].get_id()
for (o_id, _o_name) in backends[key].get_options():
yield "%s.%s" % (c_id, o_id)
def list_cleaners():
"""Display available cleaners"""
for cleaner in cleaners_list():
print(cleaner)
def preview_or_clean(operations, really_clean, quiet=False):
"""Preview deletes and other changes"""
cb = CliCallback(quiet)
worker = Worker.Worker(cb, really_clean, operations).run()
while next(worker):
pass
def args_to_operations_list(preset, all_but_warning):
"""For --preset and --all-but-warning return list of operations as list
Example return: ['google_chrome.cache', 'system.tmp']
"""
args = []
if not backends:
list(register_cleaners())
assert(len(backends) > 1)
for key in sorted(backends):
c_id = backends[key].get_id()
for (o_id, _o_name) in backends[key].get_options():
# restore presets from the GUI
if preset and Options.options.get_tree(c_id, o_id):
args.append('.'.join([c_id, o_id]))
elif all_but_warning and not backends[c_id].get_warning(o_id):
args.append('.'.join([c_id, o_id]))
return args
def args_to_operations(args, preset, all_but_warning):
"""Read arguments and return list of operations as dictionary"""
list(register_cleaners())
operations = {}
if not args:
args = []
args = set(args + args_to_operations_list(preset, all_but_warning))
for arg in args:
if 2 != len(arg.split('.')):
logger.warning(_("not a valid cleaner: %s"), arg)
continue
(cleaner_id, option_id) = arg.split('.')
# enable all options (for example, firefox.*)
if '*' == option_id:
if cleaner_id in operations:
del operations[cleaner_id]
operations[cleaner_id] = [
option_id2
for (option_id2, _o_name) in backends[cleaner_id].get_options()
]
continue
# add the specified option
if cleaner_id not in operations:
operations[cleaner_id] = []
if option_id not in operations[cleaner_id]:
operations[cleaner_id].append(option_id)
for (k, v) in operations.items():
operations[k] = sorted(v)
return operations
def process_cmd_line():
"""Parse the command line and execute given commands."""
# TRANSLATORS: This is the command line usage. Don't translate
# %prog, but do translate options, cleaner, and option.
# Don't translate and add "usage:" - it gets added by Python.
# More information about the command line is here
# https://www.bleachbit.org/documentation/command-line
usage = _("usage: %prog [options] cleaner.option1 cleaner.option2")
parser = optparse.OptionParser(usage)
parser.add_option("-l", "--list-cleaners", action="store_true",
help=_("list cleaners"))
parser.add_option("-p", "--preview", action="store_true",
help=_("preview files to be deleted and other changes"))
parser.add_option("-c", "--clean", action="store_true",
# TRANSLATORS: predefined cleaners are for applications, such as Firefox and Flash.
# This is different than cleaning an arbitrary file, such as a
# spreadsheet on the desktop.
help=_("run cleaners to delete files and make other permanent changes"))
parser.add_option("-s", "--shred", action="store_true",
help=_("shred specific files or folders"))
parser.add_option("-w", "--wipe-free-space", action="store_true",
help=_("wipe free space in the given paths"))
parser.add_option('-o', '--overwrite', action='store_true',
help=_('overwrite files to hide contents'))
parser.add_option("--gui", action="store_true",
help=_("launch the graphical interface"))
parser.add_option("--preset", action="store_true",
help=_("use options set in the graphical interface"))
parser.add_option("--all-but-warning", action="store_true",
help=_("enable all options that do not have a warning"))
parser.add_option(
'--debug', help=_("set log level to verbose"), action="store_true")
parser.add_option('--debug-log', help=_("log debug messages to file"))
parser.add_option("--sysinfo", action="store_true",
help=_("show system information"))
parser.add_option("-v", "--version", action="store_true",
help=_("output version information and exit"))
if 'nt' == os.name:
uac_help = _("do not prompt for administrator privileges")
else:
uac_help = optparse.SUPPRESS_HELP
parser.add_option("--no-uac", action="store_true", help=uac_help)
parser.add_option('--pot', action='store_true',
help=optparse.SUPPRESS_HELP)
if 'nt' == os.name:
parser.add_option("--update-winapp2", action="store_true",
help=_("update winapp2.ini, if a new version is available"))
# added for testing py2exe build
# https://github.com/bleachbit/bleachbit/commit/befe244efee9b2d4859c6b6c31f8bedfd4d85aad#diff-b578cd35e15095f69822ebe497bf8691da1b587d6cc5f5ec252ff4f186dbed56
parser.add_option('--exit', action='store_true',
help=optparse.SUPPRESS_HELP)
# some workaround for context menu added here
# https://github.com/bleachbit/bleachbit/commit/b09625925149c98a6c79e278c35d5995e7526993
def expand_context_menu_option(option, opt, value, parser):
setattr(parser.values, 'gui', True)
setattr(parser.values, 'exit', True)
parser.add_option("--context-menu", action="callback", callback=expand_context_menu_option,
help=optparse.SUPPRESS_HELP)
(options, args) = parser.parse_args()
cmd_list = (options.list_cleaners, options.wipe_free_space,
options.preview, options.clean)
cmd_count = sum(x is True for x in cmd_list)
if cmd_count > 1:
logger.error(
_('Specify only one of these commands: --list-cleaners, --wipe-free-space, --preview, --clean'))
sys.exit(1)
if not options.gui:
# The GUI has its own trigger for the same function.
from bleachbit.General import startup_check
startup_check()
did_something = False
if options.debug:
# set in __init__ so it takes effect earlier
pass
elif options.preset:
# but if --preset is given, check if GUI option sets debug
if Options.options.get('debug'):
set_root_log_level(Options.options.get('debug'))
logger.debug("Debugging is enabled in GUI settings.")
if options.debug_log:
logger.addHandler(logging.FileHandler(options.debug_log))
logger.info(SystemInformation.get_system_information())
if options.version:
print("""
BleachBit version %s
Copyright (C) 2008-2023 Andrew Ziem. All rights reserved.
License GPLv3+: GNU GPL version 3 or later .
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.""" % APP_VERSION)
sys.exit(0)
if 'nt' == os.name and options.update_winapp2:
from bleachbit import Update
logger.info(_("Checking online for updates to winapp2.ini"))
Update.check_updates(False, True,
lambda x: sys.stdout.write("%s\n" % x),
lambda: None)
# updates can be combined with --list-cleaners, --preview, --clean
did_something = True
if options.list_cleaners:
list_cleaners()
sys.exit(0)
if options.pot:
from bleachbit.CleanerML import create_pot
create_pot()
sys.exit(0)
if options.wipe_free_space:
if len(args) < 1:
logger.error(_("No directories given for --wipe-free-space"))
sys.exit(1)
logger.info(_("Wiping free space can take a long time."))
for wipe_path in args:
logger.info('Wiping free space in path: %s', wipe_path)
import bleachbit.FileUtilities
for _ret in bleachbit.FileUtilities.wipe_path(wipe_path):
pass
sys.exit(0)
if options.preview or options.clean:
operations = args_to_operations(
args, options.preset, options.all_but_warning)
if not operations:
logger.error(_("No work to do. Specify options."))
sys.exit(1)
if options.preview:
preview_or_clean(operations, False)
sys.exit(0)
if options.overwrite:
if not options.clean or options.shred:
logger.warning(
_("--overwrite is intended only for use with --clean"))
Options.options.set('shred', True, commit=False)
if options.clean:
preview_or_clean(operations, True)
sys.exit(0)
if options.gui:
import bleachbit.GUI
app = bleachbit.GUI.Bleachbit(
uac=not options.no_uac, shred_paths=args, auto_exit=options.exit)
sys.exit(app.run())
if options.shred:
# delete arbitrary files without GUI
# create a temporary cleaner object
backends['_gui'] = create_simple_cleaner(args)
operations = {'_gui': ['files']}
preview_or_clean(operations, True)
sys.exit(0)
if options.sysinfo:
print(SystemInformation.get_system_information())
sys.exit(0)
if not did_something:
parser.print_help()
if __name__ == '__main__':
process_cmd_line()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/Chaff.py 0000664 0001750 0001750 00000027761 14522012661 014316 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# -*- coding: UTF-8 -*-
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
import email.generator
from email.mime.text import MIMEText
import json
import logging
import os
import random
import tempfile
from datetime import datetime
import queue as _unused_module_Queue
from bleachbit import _, bleachbit_exe_path
from bleachbit import options_dir
from . import markovify
logger = logging.getLogger(__name__)
# These were typos in the original emails, not OCR errors:
# abdinh@state.gov
# mhcaleja@state.gov
RECIPIENTS = [
'abedinh@state.gov',
'adlerce@state.gov',
'baerdb@state.gov',
'baldersonkm@state.gov',
'balderstonkm@state.gov',
'bam@mikulski.senate.gov',
'bealeca@state.gov',
'benjamin_moncrief@lemieux.senate.gov',
'blaker2@state.gov',
'brimmere@state.gov',
'burnswj@state.gov',
'butzgych2@state.gov',
'campbellkm@state.gov',
'carsonj@state.gov',
'cholletdh@state.gov',
'cindy.buhl@mail.house.gov',
'colemancl@state.gov',
'crowleypj@state.gov',
'danieljj@state.gov',
'david_garten@lautenberg.senate.gov',
'dewanll@state.gov',
'feltmanjd@state.gov',
'fuchsmh@state.gov',
'goldbergps@state.gov',
'goldenjr@state.gov',
'gonzalezjs@state.gov',
'gordonph@state.gov',
'hanleymr@state.gov',
'hdr22@clintonemail.com',
'hillcr@state.gov',
'holbrookerc@state.gov',
'hormatsrd@state.gov',
'hr15@att.blackberry.net',
'hr15@mycingular.blackberry.net',
'hrod17@clintonemail.com',
'huma@clintonemail.com',
'hyded@state.gov',
'info@mailva.evite.com',
'jilotylc@state.gov',
'jonespw2@state.gov',
'kellyc@state.gov',
'klevorickcb@state.gov',
'kohhh@state.gov',
'laszczychj@state.gov',
'lewjj@state.gov',
'macmanusje@state.gov',
'marshallcp@state.gov',
'mchaleja@state.gov',
'millscd@state.gov',
'millscd@state.gov',
'muscatinel@state.gov',
'nidestr@state.gov',
'nulandvj@state.gov',
'oterom2@state.gov',
'posnermh@state.gov',
'reinesp@state.gov',
'reinespi@state.gov',
'ricese@state.gov',
'rodriguezme@state.gov',
'rooneym@state.gov',
's_specialassistants@state.gov',
'schwerindb@state.gov',
'shannonta@state.gov',
'shapiroa@state.gov',
'shermanwr@state.gov',
'slaughtera@state.gov',
'steinbergjb@state.gov',
'sterntd@state.gov',
'sullivanjj@state.gov',
'tauschereo@state.gov',
'tillemannts@state.gov',
'toivnf@state.gov',
'tommy_ross@reid.senate.gov',
'valenzuelaaa@state.gov',
'valmorolj@state.gov',
'valmorolj@state.gov',
'vermarr@state.gov',
'verveerms@state.gov',
'woodardew@state.gov']
DEFAULT_SUBJECT_LENGTH = 64
DEFAULT_NUMBER_OF_SENTENCES_CLINTON = 50
DEFAULT_NUMBER_OF_SENTENCES_2600 = 50
MODEL_BASENAMES = (
'2600_model.json.bz2',
'clinton_content_model.json.bz2',
'clinton_subject_model.json.bz2')
URL_TEMPLATES = (
'https://sourceforge.net/projects/bleachbit/files/chaff/%s/download',
'https://download.bleachbit.org/chaff/%s')
DEFAULT_MODELS_DIR = options_dir
def _load_model(model_path):
_open = open
if model_path.endswith('.bz2'):
import bz2
_open = bz2.open
with _open(model_path, 'rt', encoding='utf-8') as model_file:
return markovify.Text.from_dict(json.load(model_file))
def load_subject_model(model_path):
return _load_model(model_path)
def load_content_model(model_path):
return _load_model(model_path)
def load_2600_model(model_path):
return _load_model(model_path)
def _get_random_recipient():
return random.choice(RECIPIENTS)
def _get_random_datetime(min_year=2011, max_year=2012):
date = datetime.strptime('{} {}'.format(random.randint(
1, 365), random.randint(min_year, max_year)), '%j %Y')
# Saturday, September 15, 2012 2:20 PM
return date.strftime('%A, %B %d, %Y %I:%M %p')
def _get_random_content(content_model, number_of_sentences=DEFAULT_NUMBER_OF_SENTENCES_CLINTON):
content = []
for _ in range(number_of_sentences):
content.append(content_model.make_sentence())
content.append(random.choice([' ', ' ', '\n\n']))
try:
return MIMEText(''.join(content), _charset='iso-8859-1')
except UnicodeEncodeError:
return _get_random_content(content_model, number_of_sentences=number_of_sentences)
def _generate_email(subject_model, content_model, number_of_sentences=DEFAULT_NUMBER_OF_SENTENCES_CLINTON, subject_length=DEFAULT_SUBJECT_LENGTH):
message = _get_random_content(
content_model, number_of_sentences=number_of_sentences)
message['Subject'] = subject_model.make_short_sentence(subject_length)
message['To'] = _get_random_recipient()
message['From'] = _get_random_recipient()
message['Sent'] = _get_random_datetime()
return message
def download_url_to_fn(url, fn, on_error=None, max_retries=3, backoff_factor=0.5, timeout=60):
"""Download a URL to the given filename
fn: target filename
on_error: callback function in case of error
max_retries: retry count
backoff_factor: how long to wait before retries
timeout: number of seconds to wait to establish connection
return: True if succeeded, False if failed
"""
# FIXME: unify this with update_winapp2(), check_updates()
logger.info('Downloading %s to %s', url, fn)
import requests
import sys
if hasattr(sys, 'frozen'):
# when frozen by py2exe, certificates are in alternate location
CA_BUNDLE = os.path.join(bleachbit_exe_path, 'cacert.pem')
requests.utils.DEFAULT_CA_BUNDLE_PATH = CA_BUNDLE
requests.adapters.DEFAULT_CA_BUNDLE_PATH = CA_BUNDLE
from urllib3.util.retry import Retry
from requests.adapters import HTTPAdapter
# 408: request timeout
# 429: too many requests
# 500: internal server error
# 502: bad gateway
# 503: service unavailable
# 504: gateway_timeout
status_forcelist = (408, 429, 500, 502, 503, 504)
# sourceforge.net directories to download mirror
retries = Retry(total=max_retries, backoff_factor=backoff_factor,
status_forcelist=status_forcelist, redirect=5)
msg = _('Downloading URL failed: %s') % url
from bleachbit.Update import user_agent
headers = {'user_agent': user_agent()}
def do_error(msg2):
if on_error:
on_error(msg, msg2)
from bleachbit.FileUtilities import delete
delete(fn, ignore_missing=True) # delete any partial download
with requests.Session() as session:
session.mount('http://', HTTPAdapter(max_retries=retries))
try:
response = session.get(url, headers=headers, timeout=timeout)
content = response.content
except requests.exceptions.RequestException as exc:
msg2 = '{}: {}'.format(type(exc).__name__, exc)
logger.exception(msg)
do_error(msg2)
return False
else:
if not response.status_code == 200:
logger.error(msg)
msg2 = 'Status code: %s' % response.status_code
do_error(msg2)
return False
with open(fn, 'wb') as f:
f.write(content)
return True
def download_models(models_dir=DEFAULT_MODELS_DIR,
on_error=None):
"""Download models
Calls on_error(primary_message, secondary_message) in case of error
Returns success as boolean value
"""
for basename in (MODEL_BASENAMES):
fn = os.path.join(models_dir, basename)
if os.path.exists(fn):
logger.debug('File %s already exists', fn)
continue
this_file_success = False
for url_template in URL_TEMPLATES:
url = url_template % basename
if download_url_to_fn(url, fn, on_error):
this_file_success = True
break
if not this_file_success:
return False
return True
def generate_emails(number_of_emails,
email_output_dir,
models_dir=DEFAULT_MODELS_DIR,
number_of_sentences=DEFAULT_NUMBER_OF_SENTENCES_CLINTON,
on_progress=None,
*kwargs):
logger.debug('Loading two email models')
subject_model_path = os.path.join(
models_dir, 'clinton_subject_model.json.bz2')
content_model_path = os.path.join(
models_dir, 'clinton_content_model.json.bz2')
subject_model = load_subject_model(subject_model_path)
content_model = load_content_model(content_model_path)
logger.debug('Generating {:,} emails'.format(number_of_emails))
generated_file_names = []
for i in range(1, number_of_emails + 1):
with tempfile.NamedTemporaryFile(mode='w+', prefix='outlook-', suffix='.eml', dir=email_output_dir, delete=False) as email_output_file:
email_generator = email.generator.Generator(email_output_file)
msg = _generate_email(
subject_model, content_model, number_of_sentences=number_of_sentences)
email_generator.write(msg.as_string())
generated_file_names.append(email_output_file.name)
if on_progress:
on_progress(1.0*i/number_of_emails)
return generated_file_names
def _generate_2600_file(model, number_of_sentences=DEFAULT_NUMBER_OF_SENTENCES_2600):
content = []
for _ in range(number_of_sentences):
content.append(model.make_sentence())
# The space is repeated to make paragraphs longer.
content.append(random.choice([' ', ' ', '\n\n']))
return ''.join(content)
def generate_2600(file_count,
output_dir,
model_dir=DEFAULT_MODELS_DIR,
on_progress=None):
logger.debug('Loading 2600 model')
model_path = os.path.join(model_dir, '2600_model.json.bz2')
model = _load_model(model_path)
logger.debug('Generating {:,} files'.format(file_count))
generated_file_names = []
for i in range(1, file_count + 1):
with tempfile.NamedTemporaryFile(mode='w+', encoding='utf-8', prefix='2600-', suffix='.txt', dir=output_dir, delete=False) as output_file:
txt = _generate_2600_file(model)
output_file.write(txt)
generated_file_names.append(output_file.name)
if on_progress:
on_progress(1.0*i/file_count)
return generated_file_names
def have_models():
"""Check whether the models exist in the default location.
Used to check whether download is needed."""
for basename in (MODEL_BASENAMES):
fn = os.path.join(DEFAULT_MODELS_DIR, basename)
if not os.path.exists(fn):
return False
return True
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/Cleaner.py 0000775 0001750 0001750 00000074510 14522012661 014655 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Perform (or assist with) cleaning operations.
"""
import glob
import logging
import os.path
import re
import sys
from bleachbit import _
from bleachbit.FileUtilities import children_in_directory
from bleachbit.Options import options
from bleachbit import Command, FileUtilities, Memory, Special
# Suppress GTK warning messages while running in CLI #34
import warnings
warnings.simplefilter("ignore", Warning)
try:
from bleachbit.GuiBasic import Gtk, Gdk
HAVE_GTK = Gdk.get_default_root_window() is not None
except (ImportError, RuntimeError, ValueError) as e:
# ImportError happens when GTK is not installed.
# RuntimeError can happen when X is not available (e.g., cron, ssh).
# ValueError seen on BleachBit 3.0 with GTK 3 (GitHub issue 685)
HAVE_GTK = False
if 'posix' == os.name:
from bleachbit import Unix
elif 'nt' == os.name:
from bleachbit import Windows
# a module-level variable for holding cleaners
backends = {}
class Cleaner:
"""Base class for a cleaner"""
def __init__(self):
self.actions = []
self.id = None
self.description = None
self.name = None
self.options = {}
self.running = []
self.warnings = {}
self.regexes_compiled = []
def add_action(self, option_id, action):
"""Register 'action' (instance of class Action) to be executed
for ''option_id'. The actions must implement list_files and
other_cleanup()"""
self.actions.append((option_id, action))
def add_option(self, option_id, name, description):
"""Register option (such as 'cache')"""
self.options[option_id] = (name, description)
def add_running(self, detection_type, pathname):
"""Add a way to detect this program is currently running"""
self.running.append((detection_type, pathname))
def auto_hide(self):
"""Return boolean whether it is OK to automatically hide this
cleaner"""
for (option_id, __name) in self.get_options():
try:
for cmd in self.get_commands(option_id):
for _dummy in cmd.execute(False):
return False
for _ds in self.get_deep_scan(option_id):
return False
except Exception:
logger = logging.getLogger(__name__)
logger.exception('exception in auto_hide(), cleaner=%s, option=%s',
self.name, option_id)
return True
def get_commands(self, option_id):
"""Get list of Command instances for option 'option_id'"""
for action in self.actions:
if option_id == action[0]:
yield from action[1].get_commands()
if option_id not in self.options:
raise RuntimeError("Unknown option '%s'" % option_id)
def get_deep_scan(self, option_id):
"""Get dictionary used to build a deep scan"""
for action in self.actions:
if option_id == action[0]:
try:
yield from action[1].get_deep_scan()
except StopIteration:
return
if option_id not in self.options:
raise RuntimeError("Unknown option '%s'" % option_id)
def get_description(self):
"""Brief description of the cleaner"""
return self.description
def get_id(self):
"""Return the unique name of this cleaner"""
return self.id
def get_name(self):
"""Return the human name of this cleaner"""
return self.name
def get_option_descriptions(self):
"""Yield the names and descriptions of each option in a 2-tuple"""
if self.options:
for key in sorted(self.options.keys()):
yield (self.options[key][0], self.options[key][1])
def get_options(self):
"""Return user-configurable options in 2-tuple (id, name)"""
if self.options:
for key in sorted(self.options.keys()):
yield (key, self.options[key][0])
def get_warning(self, option_id):
"""Return a warning as string."""
if option_id in self.warnings:
return self.warnings[option_id]
else:
return None
def is_running(self):
"""Return whether the program is currently running"""
logger = logging.getLogger(__name__)
for running in self.running:
test = running[0]
pathname = running[1]
if 'exe' == test:
if ('posix' == os.name and Unix.is_running(pathname)) or \
('nt' == os.name and Windows.is_process_running(pathname)):
logger.debug("process '%s' is running", pathname)
return True
elif 'pathname' == test:
expanded = os.path.expanduser(os.path.expandvars(pathname))
for globbed in glob.iglob(expanded):
if os.path.exists(globbed):
logger.debug(
"file '%s' exists indicating '%s' is running", globbed, self.name)
return True
else:
raise RuntimeError(
"Unknown running-detection test '%s'" % test)
return False
def is_usable(self):
"""Return whether the cleaner is usable (has actions)"""
return len(self.actions) > 0
def set_warning(self, option_id, description):
"""Set a warning to be displayed when option is selected interactively"""
self.warnings[option_id] = description
class OpenOfficeOrg(Cleaner):
"""Delete OpenOffice.org cache"""
def __init__(self):
Cleaner.__init__(self)
self.options = {}
self.add_option('cache', _('Cache'), _('Delete the cache'))
self.add_option('recent_documents', _('Most recently used'), _(
"Delete the list of recently used documents"))
self.id = 'openofficeorg'
self.name = 'OpenOffice.org'
self.description = _("Office suite")
# reference: http://katana.oooninja.com/w/editions_of_openoffice.org
if 'posix' == os.name:
self.prefixes = ["~/.ooo-2.0", "~/.openoffice.org2",
"~/.openoffice.org2.0", "~/.openoffice.org/3",
"~/.ooo-dev3"]
if 'nt' == os.name:
self.prefixes = [
"$APPDATA\\OpenOffice.org\\3", "$APPDATA\\OpenOffice.org2"]
def get_commands(self, option_id):
# paths for which to run expand_glob_join
egj = []
if 'recent_documents' == option_id:
egj.append(
"user/registry/data/org/openoffice/Office/Histories.xcu")
egj.append(
"user/registry/cache/org.openoffice.Office.Histories.dat")
if 'recent_documents' == option_id and not 'cache' == option_id:
egj.append("user/registry/cache/org.openoffice.Office.Common.dat")
for egj_ in egj:
for prefix in self.prefixes:
for path in FileUtilities.expand_glob_join(prefix, egj_):
if 'nt' == os.name:
path = os.path.normpath(path)
if os.path.lexists(path):
yield Command.Delete(path)
if 'cache' == option_id:
dirs = []
for prefix in self.prefixes:
dirs += FileUtilities.expand_glob_join(
prefix, "user/registry/cache/")
for dirname in dirs:
if 'nt' == os.name:
dirname = os.path.normpath(dirname)
for filename in children_in_directory(dirname, False):
yield Command.Delete(filename)
if 'recent_documents' == option_id:
for prefix in self.prefixes:
for path in FileUtilities.expand_glob_join(prefix, "user/registry/data/org/openoffice/Office/Common.xcu"):
if os.path.lexists(path):
yield Command.Function(path,
Special.delete_ooo_history,
_('Delete the usage history'))
# ~/.openoffice.org/3/user/registrymodifications.xcu
# Apache OpenOffice.org 3.4.1 from openoffice.org on Ubuntu 13.04
# %AppData%\OpenOffice.org\3\user\registrymodifications.xcu
# Apache OpenOffice.org 3.4.1 from openoffice.org on Windows XP
for path in FileUtilities.expand_glob_join(prefix, "user/registrymodifications.xcu"):
if os.path.lexists(path):
yield Command.Function(path,
Special.delete_office_registrymodifications,
_('Delete the usage history'))
class System(Cleaner):
"""Clean the system in general"""
def __init__(self):
Cleaner.__init__(self)
#
# options for Linux and BSD
#
if 'posix' == os.name:
# TRANSLATORS: desktop entries are .desktop files in Linux that
# make up the application menu (the menu that shows BleachBit,
# Firefox, and others. The .desktop files also associate file
# types, so clicking on an .html file in Nautilus brings up
# Firefox.
# More information:
# http://standards.freedesktop.org/menu-spec/latest/index.html#introduction
self.add_option('desktop_entry', _('Broken desktop files'), _(
'Delete broken application menu entries and file associations'))
self.add_option('cache', _('Cache'), _('Delete the cache'))
# TRANSLATORS: Localizations are files supporting specific
# languages, so applications appear in Spanish, etc.
self.add_option('localizations', _('Localizations'), _(
'Delete files for unwanted languages'))
self.set_warning(
'localizations', _("Configure this option in the preferences."))
# TRANSLATORS: 'Rotated logs' refers to old system log files.
# Linux systems often have a scheduled job to rotate the logs
# which means compress all except the newest log and then delete
# the oldest log. You could translate this 'old logs.'
self.add_option(
'rotated_logs', _('Rotated logs'), _('Delete old system logs'))
self.add_option('recent_documents', _('Recent documents list'), _(
'Delete the list of recently used documents'))
self.add_option('trash', _('Trash'), _('Empty the trash'))
#
# options just for Linux
#
if sys.platform.startswith('linux'):
self.add_option('memory', _('Memory'),
# TRANSLATORS: 'free' means 'unallocated'
_('Wipe the swap and free memory'))
self.set_warning(
'memory', _('This option is experimental and may cause system problems.'))
#
# options just for Microsoft Windows
#
if 'nt' == os.name:
self.add_option('logs', _('Logs'), _('Delete the logs'))
self.add_option(
'memory_dump', _('Memory dump'), _('Delete the file'))
self.add_option('muicache', 'MUICache', _('Delete the cache'))
# TRANSLATORS: Prefetch is Microsoft Windows jargon.
self.add_option('prefetch', _('Prefetch'), _('Delete the cache'))
self.add_option(
'recycle_bin', _('Recycle bin'), _('Empty the recycle bin'))
# TRANSLATORS: 'Update' is a noun, and 'Update uninstallers' is an option to delete
# the uninstallers for software updates.
self.add_option('updates', _('Update uninstallers'), _(
'Delete uninstallers for Microsoft updates including hotfixes, service packs, and Internet Explorer updates'))
#
# options for GTK+
#
if HAVE_GTK:
self.add_option('clipboard', _('Clipboard'), _(
'The desktop environment\'s clipboard used for copy and paste operations'))
#
# options common to all platforms
#
# TRANSLATORS: "Custom" is an option allowing the user to specify which
# files and folders will be erased.
self.add_option('custom', _('Custom'), _(
'Delete user-specified files and folders'))
# TRANSLATORS: 'free' means 'unallocated'
self.add_option('free_disk_space', _('Free disk space'),
# TRANSLATORS: 'free' means 'unallocated'
_('Overwrite free disk space to hide deleted files'))
self.set_warning('free_disk_space', _('This option is very slow.'))
self.add_option(
'tmp', _('Temporary files'), _('Delete the temporary files'))
self.description = _("The system in general")
self.id = 'system'
self.name = _("System")
def get_commands(self, option_id):
# cache
if 'posix' == os.name and 'cache' == option_id:
dirname = os.path.expanduser("~/.cache/")
for filename in children_in_directory(dirname, True):
if not self.whitelisted(filename):
yield Command.Delete(filename)
# custom
if 'custom' == option_id:
for (c_type, c_path) in options.get_custom_paths():
if 'file' == c_type:
yield Command.Delete(c_path)
elif 'folder' == c_type:
for path in children_in_directory(c_path, True):
yield Command.Delete(path)
yield Command.Delete(c_path)
else:
raise RuntimeError(
'custom folder has invalid type %s' % c_type)
# menu
menu_dirs = ['~/.local/share/applications',
'~/.config/autostart',
'~/.gnome/apps/',
'~/.gnome2/panel2.d/default/launchers',
'~/.gnome2/vfolders/applications/',
'~/.kde/share/apps/RecentDocuments/',
'~/.kde/share/mimelnk',
'~/.kde/share/mimelnk/application/ram.desktop',
'~/.kde2/share/mimelnk/application/',
'~/.kde2/share/applnk']
if 'posix' == os.name and 'desktop_entry' == option_id:
for path in menu_dirs:
dirname = os.path.expanduser(path)
for filename in children_in_directory(dirname, False):
if filename.endswith('.desktop') and Unix.is_broken_xdg_desktop(filename):
yield Command.Delete(filename)
# unwanted locales
if 'posix' == os.name and 'localizations' == option_id:
for path in Unix.locales.localization_paths(locales_to_keep=options.get_languages()):
if os.path.isdir(path):
for f in FileUtilities.children_in_directory(path, True):
yield Command.Delete(f)
yield Command.Delete(path)
# Windows logs
if 'nt' == os.name and 'logs' == option_id:
paths = (
'$ALLUSERSPROFILE\\Application Data\\Microsoft\\Dr Watson\\*.log',
'$ALLUSERSPROFILE\\Application Data\\Microsoft\\Dr Watson\\user.dmp',
'$LocalAppData\\Microsoft\\Windows\\WER\\ReportArchive\\*\\*',
'$LocalAppData\\Microsoft\\Windows\WER\\ReportQueue\\*\\*',
'$programdata\\Microsoft\\Windows\\WER\\ReportArchive\\*\\*',
'$programdata\\Microsoft\\Windows\\WER\\ReportQueue\\*\\*',
'$localappdata\\Microsoft\\Internet Explorer\\brndlog.bak',
'$localappdata\\Microsoft\\Internet Explorer\\brndlog.txt',
'$windir\\*.log',
'$windir\\imsins.BAK',
'$windir\\OEWABLog.txt',
'$windir\\SchedLgU.txt',
'$windir\\ntbtlog.txt',
'$windir\\setuplog.txt',
'$windir\\REGLOCS.OLD',
'$windir\\Debug\\*.log',
'$windir\\Debug\\Setup\\UpdSh.log',
'$windir\\Debug\\UserMode\\*.log',
'$windir\\Debug\\UserMode\\ChkAcc.bak',
'$windir\\Debug\\UserMode\\userenv.bak',
'$windir\\Microsoft.NET\Framework\*\*.log',
'$windir\\pchealth\\helpctr\\Logs\\hcupdate.log',
'$windir\\security\\logs\\*.log',
'$windir\\security\\logs\\*.old',
'$windir\\SoftwareDistribution\\*.log',
'$windir\\SoftwareDistribution\\DataStore\\Logs\\*',
'$windir\\system32\\TZLog.log',
'$windir\\system32\\config\\systemprofile\\Application Data\\Microsoft\\Internet Explorer\\brndlog.bak',
'$windir\\system32\\config\\systemprofile\\Application Data\\Microsoft\\Internet Explorer\\brndlog.txt',
'$windir\\system32\\LogFiles\\AIT\\AitEventLog.etl.???',
'$windir\\system32\\LogFiles\\Firewall\\pfirewall.log*',
'$windir\\system32\\LogFiles\\Scm\\SCM.EVM*',
'$windir\\system32\\LogFiles\\WMI\\Terminal*.etl',
'$windir\\system32\\LogFiles\\WMI\\RTBackup\EtwRT.*etl',
'$windir\\system32\\wbem\\Logs\\*.lo_',
'$windir\\system32\\wbem\\Logs\\*.log', )
for path in paths:
expanded = os.path.expandvars(path)
for globbed in glob.iglob(expanded):
yield Command.Delete(globbed)
# memory
if sys.platform.startswith('linux') and 'memory' == option_id:
yield Command.Function(None, Memory.wipe_memory, _('Memory'))
# memory dump
# how to manually create this file
# http://www.pctools.com/guides/registry/detail/856/
if 'nt' == os.name and 'memory_dump' == option_id:
fname = os.path.expandvars('$windir\\memory.dmp')
if os.path.exists(fname):
yield Command.Delete(fname)
for fname in glob.iglob(os.path.expandvars('$windir\\Minidump\\*.dmp')):
yield Command.Delete(fname)
# most recently used documents list
if 'posix' == os.name and 'recent_documents' == option_id:
ru_fn = os.path.expanduser("~/.recently-used")
if os.path.lexists(ru_fn):
yield Command.Delete(ru_fn)
# GNOME 2.26 (as seen on Ubuntu 9.04) will retain the list
# in memory if it is simply deleted, so it must be shredded
# (or at least truncated).
#
# GNOME 2.28.1 (Ubuntu 9.10) and 2.30 (10.04) do not re-read
# the file after truncation, but do re-read it after
# shredding.
#
# https://bugzilla.gnome.org/show_bug.cgi?id=591404
def gtk_purge_items():
"""Purge GTK items"""
Gtk.RecentManager().get_default().purge_items()
yield 0
xbel_pathnames = [
'~/.recently-used.xbel',
'~/.local/share/recently-used.xbel*',
'~/snap/*/*/.local/share/recently-used.xbel']
for path1 in xbel_pathnames:
for path2 in glob.iglob(os.path.expanduser(path1)):
if os.path.lexists(path2):
yield Command.Shred(path2)
if HAVE_GTK:
# Use the Function to skip when in preview mode
yield Command.Function(None, gtk_purge_items, _('Recent documents list'))
if 'posix' == os.name and 'rotated_logs' == option_id:
for path in Unix.rotated_logs():
yield Command.Delete(path)
# temporary files
if 'posix' == os.name and 'tmp' == option_id:
dirnames = ['/tmp', '/var/tmp']
for dirname in dirnames:
for path in children_in_directory(dirname, True):
is_open = FileUtilities.openfiles.is_open(path)
ok = not is_open and os.path.isfile(path) and \
not os.path.islink(path) and \
FileUtilities.ego_owner(path) and \
not self.whitelisted(path)
if ok:
yield Command.Delete(path)
# temporary files
if 'nt' == os.name and 'tmp' == option_id:
dirnames = [os.path.expandvars(r'%temp%'), os.path.expandvars("%windir%\\temp\\")]
# whitelist the folder %TEMP%\Low but not its contents
# https://bugs.launchpad.net/bleachbit/+bug/1421726
for dirname in dirnames:
low = os.path.join(dirname, 'low').lower()
for filename in children_in_directory(dirname, True):
if not low == filename.lower():
yield Command.Delete(filename)
# trash
if 'posix' == os.name and 'trash' == option_id:
dirname = os.path.expanduser("~/.Trash")
for filename in children_in_directory(dirname, False):
yield Command.Delete(filename)
# fixme http://www.ramendik.ru/docs/trashspec.html
# http://standards.freedesktop.org/basedir-spec/basedir-spec-0.6.html
# ~/.local/share/Trash
# * GNOME 2.22, Fedora 9
# * KDE 4.1.3, Ubuntu 8.10
dirname = os.path.expanduser("~/.local/share/Trash/files")
for filename in children_in_directory(dirname, True):
yield Command.Delete(filename)
dirname = os.path.expanduser("~/.local/share/Trash/info")
for filename in children_in_directory(dirname, True):
yield Command.Delete(filename)
dirname = os.path.expanduser("~/.local/share/Trash/expunged")
# desrt@irc.gimpnet.org tells me that the trash
# backend puts files in here temporary, but in some situations
# the files are stuck.
for filename in children_in_directory(dirname, True):
yield Command.Delete(filename)
# clipboard
if HAVE_GTK and 'clipboard' == option_id:
def clear_clipboard():
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clipboard.set_text(' ', 1)
clipboard.clear()
return 0
yield Command.Function(None, clear_clipboard, _('Clipboard'))
# overwrite free space
shred_drives = options.get_list('shred_drives')
if 'free_disk_space' == option_id and shred_drives:
for pathname in shred_drives:
# TRANSLATORS: 'Free' means 'unallocated.'
# %s expands to a path such as C:\ or /tmp/
display = _("Overwrite free disk space %s") % pathname
def wipe_path_func():
# Yield control to GTK idle because this process
# is very slow. Also display progress.
yield from FileUtilities.wipe_path(pathname, idle=True)
yield 0
yield Command.Function(None, wipe_path_func, display)
# MUICache
if 'nt' == os.name and 'muicache' == option_id:
keys = (
'HKCU\\Software\\Microsoft\\Windows\\ShellNoRoam\\MUICache',
'HKCU\\Software\\Classes\\Local Settings\\Software\\Microsoft\\Windows\\Shell\\MuiCache')
for key in keys:
yield Command.Winreg(key, None)
# prefetch
if 'nt' == os.name and 'prefetch' == option_id:
for path in glob.iglob(os.path.expandvars('$windir\\Prefetch\\*.pf')):
yield Command.Delete(path)
# recycle bin
if 'nt' == os.name and 'recycle_bin' == option_id:
# This method allows shredding
recycled_any = False
for path in Windows.get_recycle_bin():
recycled_any = True
yield Command.Delete(path)
# Windows 10 refreshes the recycle bin icon when the user
# opens the recycle bin folder.
# This is a hack to refresh the icon.
def empty_recycle_bin_func():
import tempfile
tmpdir = tempfile.mkdtemp()
Windows.move_to_recycle_bin(tmpdir)
try:
Windows.empty_recycle_bin(None, True)
except:
logging.getLogger(__name__).info(
'error in empty_recycle_bin()', exc_info=True)
yield 0
# Using the Function Command prevents emptying the recycle bin
# when in preview mode.
if recycled_any:
yield Command.Function(None, empty_recycle_bin_func, _('Empty the recycle bin'))
# Windows Updates
if 'nt' == os.name and 'updates' == option_id:
for wu in Windows.delete_updates():
yield wu
def init_whitelist(self):
"""Initialize the whitelist only once for performance"""
regexes = [
'^/tmp/.X0-lock$',
'^/tmp/.truecrypt_aux_mnt.*/(control|volume)$',
'^/tmp/.vbox-[^/]+-ipc/lock$',
'^/tmp/.wine-[0-9]+/server-.*/lock$',
'^/tmp/fsa/', # fsarchiver
'^/tmp/gconfd-[^/]+/lock/ior$',
'^/tmp/kde-',
'^/tmp/kdesudo-',
'^/tmp/ksocket-',
'^/tmp/orbit-[^/]+/bonobo-activation-register[a-z0-9-]*.lock$',
'^/tmp/orbit-[^/]+/bonobo-activation-server-[a-z0-9-]*ior$',
'^/tmp/pulse-[^/]+/pid$',
'^/tmp/xauth',
'^/var/tmp/kdecache-',
'^' + os.path.expanduser('~/.cache/wallpaper/'),
# Flatpak mount point
'^' + os.path.expanduser('~/.cache/doc($|/)'),
# Clean Firefox cache from Firefox cleaner (LP#1295826)
'^' + os.path.expanduser('~/.cache/mozilla/'),
# Clean Google Chrome cache from Google Chrome cleaner (LP#656104)
'^' + os.path.expanduser('~/.cache/google-chrome/'),
'^' + os.path.expanduser('~/.cache/gnome-control-center/'),
# Clean Evolution cache from Evolution cleaner (GitHub #249)
'^' + os.path.expanduser('~/.cache/evolution/'),
# iBus Pinyin
# https://bugs.launchpad.net/bleachbit/+bug/1538919
'^' + os.path.expanduser('~/.cache/ibus/'),
# Linux Bluetooth daemon obexd directory is typically empty, so be careful
# not to delete the empty directory.
'^' + os.path.expanduser('~/.cache/obexd($|/)')]
for regex in regexes:
self.regexes_compiled.append(re.compile(regex))
def whitelisted(self, pathname):
"""Return boolean whether file is whitelisted"""
if os.name == 'nt':
# Whitelist is specific to POSIX
return False
if not self.regexes_compiled:
self.init_whitelist()
for regex in self.regexes_compiled:
if regex.match(pathname) is not None:
return True
return False
def register_cleaners(cb_progress=lambda x: None, cb_done=lambda: None):
"""Register all known cleaners: system, CleanerML, and Winapp2"""
global backends
# wipe out any registrations
# Because this is a global variable, cannot use backends = {}
backends.clear()
# initialize "hard coded" (non-CleanerML) backends
backends["openofficeorg"] = OpenOfficeOrg()
backends["system"] = System()
# register CleanerML cleaners
from bleachbit import CleanerML
cb_progress(_('Loading native cleaners.'))
yield from CleanerML.load_cleaners(cb_progress)
# register Winapp2.ini cleaners
if 'nt' == os.name:
cb_progress(_('Importing cleaners from Winapp2.ini.'))
from bleachbit import Winapp
yield from Winapp.load_cleaners(cb_progress)
cb_done()
yield False # end the iteration
def create_simple_cleaner(paths):
"""Shred arbitrary files (used in CLI and GUI)"""
cleaner = Cleaner()
cleaner.add_option(option_id='files', name='', description='')
cleaner.name = _("System") # shows up in progress bar
from bleachbit import Action
class CustomFileAction(Action.ActionProvider):
action_key = '__customfileaction'
def get_commands(self):
for path in paths:
if not isinstance(path, (str)):
raise RuntimeError(
'expected path as string but got %s' % str(path))
if not os.path.isabs(path):
path = os.path.abspath(path)
if os.path.isdir(path):
for child in children_in_directory(path, True):
yield Command.Shred(child)
yield Command.Shred(path)
provider = CustomFileAction(None)
cleaner.add_action('files', provider)
return cleaner
def create_wipe_cleaner(path):
"""Wipe free disk space of arbitrary paths (used in GUI)"""
cleaner = Cleaner()
cleaner.add_option(
option_id='free_disk_space', name='', description='')
cleaner.name = ''
# create a temporary cleaner object
display = _("Overwrite free disk space %s") % path
def wipe_path_func():
yield from FileUtilities.wipe_path(path, idle=True)
yield 0
from bleachbit import Action
class CustomWipeAction(Action.ActionProvider):
action_key = '__customwipeaction'
def get_commands(self):
yield Command.Function(None, wipe_path_func, display)
provider = CustomWipeAction(None)
cleaner.add_action('free_disk_space', provider)
return cleaner
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/CleanerML.py 0000775 0001750 0001750 00000031172 14522012661 015103 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Create cleaners from CleanerML (markup language)
"""
import bleachbit
from bleachbit.Action import ActionProvider
from bleachbit import _
from bleachbit.General import boolstr_to_bool, getText
from bleachbit.FileUtilities import expand_glob_join, listdir
from bleachbit import Cleaner
import logging
import os
import sys
import xml.dom.minidom
logger = logging.getLogger(__name__)
def default_vars():
"""Return default multi-value variables"""
ret = {}
if not os.name == 'nt':
return ret
# Expand ProgramFiles to also be ProgramW6432, etc.
wowvars = (('ProgramFiles', 'ProgramW6432'),
('CommonProgramFiles', 'CommonProgramW6432'))
for v1, v2 in wowvars:
# Remove None, if variable is not found.
# Make list unique.
mylist = list({x for x in (os.getenv(v1), os.getenv(v2)) if x})
ret[v1] = mylist
return ret
class CleanerML:
"""Create a cleaner from CleanerML"""
def __init__(self, pathname, xlate_cb=None):
"""Create cleaner from XML in pathname.
If xlate_cb is set, use it as a callback for each
translate-able string.
"""
self.action = None
self.cleaner = Cleaner.Cleaner()
self.option_id = None
self.option_name = None
self.option_description = None
self.option_warning = None
self.vars = default_vars()
self.xlate_cb = xlate_cb
if self.xlate_cb is None:
self.xlate_mode = False
self.xlate_cb = lambda x, y=None: None # do nothing
else:
self.xlate_mode = True
dom = xml.dom.minidom.parse(pathname)
self.handle_cleaner(dom.getElementsByTagName('cleaner')[0])
def get_cleaner(self):
"""Return the created cleaner"""
return self.cleaner
def os_match(self, os_str, platform=sys.platform):
"""Return boolean whether operating system matches
Keyword arguments:
os_str -- the required operating system as written in XML
platform -- used only for unit tests
"""
# If blank or if in .pot-creation-mode, return true.
if len(os_str) == 0 or self.xlate_mode:
return True
# Otherwise, check platform.
# Define the current operating system.
if platform == 'darwin':
current_os = ('darwin', 'bsd', 'unix')
elif platform.startswith('linux'):
current_os = ('linux', 'unix')
elif platform.startswith('openbsd'):
current_os = ('bsd', 'openbsd', 'unix')
elif platform.startswith('netbsd'):
current_os = ('bsd', 'netbsd', 'unix')
elif platform.startswith('freebsd'):
current_os = ('bsd', 'freebsd', 'unix')
elif platform == 'win32':
current_os = ('windows')
else:
raise RuntimeError('Unknown operating system: %s ' % sys.platform)
# Compare current OS against required OS.
return os_str in current_os
def handle_cleaner(self, cleaner):
""" element"""
if not self.os_match(cleaner.getAttribute('os')):
return
self.cleaner.id = cleaner.getAttribute('id')
self.handle_cleaner_label(cleaner.getElementsByTagName('label')[0])
description = cleaner.getElementsByTagName('description')
if description and description[0].parentNode == cleaner:
self.handle_cleaner_description(description[0])
for var in cleaner.getElementsByTagName('var'):
self.handle_cleaner_var(var)
for option in cleaner.getElementsByTagName('option'):
try:
self.handle_cleaner_option(option)
except:
exc_msg = _(
"Error in handle_cleaner_option() for cleaner id = {cleaner_id}, option XML={option_xml}")
logger.exception(exc_msg.format(
cleaner_id=exc_dict, option_xml=option.toxml()))
self.handle_cleaner_running(cleaner.getElementsByTagName('running'))
self.handle_localizations(
cleaner.getElementsByTagName('localizations'))
def handle_cleaner_label(self, label):
""" element under """
self.cleaner.name = _(getText(label.childNodes))
translate = label.getAttribute('translate')
if translate and boolstr_to_bool(translate):
self.xlate_cb(self.cleaner.name)
def handle_cleaner_description(self, description):
""" element under """
self.cleaner.description = _(getText(description.childNodes))
translators = description.getAttribute('translators')
self.xlate_cb(self.cleaner.description, translators)
def handle_cleaner_running(self, running_elements):
""" element under """
# example: opera
for running in running_elements:
if not self.os_match(running.getAttribute('os')):
continue
detection_type = running.getAttribute('type')
value = getText(running.childNodes)
self.cleaner.add_running(detection_type, value)
def handle_cleaner_option(self, option):
""" element"""
self.option_id = option.getAttribute('id')
self.option_description = None
self.option_name = None
self.handle_cleaner_option_label(
option.getElementsByTagName('label')[0])
description = option.getElementsByTagName('description')
self.handle_cleaner_option_description(description[0])
warning = option.getElementsByTagName('warning')
if warning:
self.handle_cleaner_option_warning(warning[0])
if self.option_warning:
self.cleaner.set_warning(self.option_id, self.option_warning)
for action in option.getElementsByTagName('action'):
self.handle_cleaner_option_action(action)
self.cleaner.add_option(
self.option_id, self.option_name, self.option_description)
def handle_cleaner_option_label(self, label):
""" element under """
self.option_name = _(getText(label.childNodes))
translate = label.getAttribute('translate')
translators = label.getAttribute('translators')
if not translate or boolstr_to_bool(translate):
self.xlate_cb(self.option_name, translators)
def handle_cleaner_option_description(self, description):
""" element under """
self.option_description = _(getText(description.childNodes))
translators = description.getAttribute('translators')
self.xlate_cb(self.option_description, translators)
def handle_cleaner_option_warning(self, warning):
""" element under """
self.option_warning = _(getText(warning.childNodes))
self.xlate_cb(self.option_warning)
def handle_cleaner_option_action(self, action_node):
""" element under """
if not self.os_match(action_node.getAttribute('os')):
return
command = action_node.getAttribute('command')
provider = None
for actionplugin in ActionProvider.plugins:
if actionplugin.action_key == command:
provider = actionplugin(action_node, self.vars)
if provider is None:
raise RuntimeError("Invalid command '%s'" % command)
self.cleaner.add_action(self.option_id, provider)
def handle_localizations(self, localization_nodes):
""" element under """
if not 'posix' == os.name:
return
from bleachbit import Unix
for localization_node in localization_nodes:
for child_node in localization_node.childNodes:
Unix.locales.add_xml(child_node)
# Add a dummy action so the file isn't reported as unusable
self.cleaner.add_action('localization', ActionProvider(None))
def handle_cleaner_var(self, var):
"""Handle one element under .
Example:
~/.config/f*
~/.config/foo
%AppData\foo
"""
var_name = var.getAttribute('name')
for value_element in var.getElementsByTagName('value'):
if not self.os_match(value_element.getAttribute('os')):
continue
value_str = getText(value_element.childNodes)
is_glob = value_element.getAttribute('search') == 'glob'
if is_glob:
value_list = expand_glob_join(value_str, '')
else:
value_list = [value_str, ]
if var_name in self.vars:
# append
self.vars[var_name] = value_list + self.vars[var_name]
else:
# initialize
self.vars[var_name] = value_list
def list_cleanerml_files(local_only=False):
"""List CleanerML files"""
cleanerdirs = (bleachbit.personal_cleaners_dir, )
if bleachbit.local_cleaners_dir:
# If the application is installed, locale_cleaners_dir is None
cleanerdirs = (bleachbit.local_cleaners_dir, )
if not local_only and bleachbit.system_cleaners_dir:
cleanerdirs += (bleachbit.system_cleaners_dir, )
for pathname in listdir(cleanerdirs):
if not pathname.lower().endswith('.xml'):
continue
import stat
st = os.stat(pathname)
if sys.platform != 'win32' and stat.S_IMODE(st[stat.ST_MODE]) & 2:
# TRANSLATORS: When BleachBit detects the file permissions are
# insecure, it will not load the cleaner as if it did not exist.
logger.warning(
_("Ignoring cleaner because it is world writable: %s"), pathname)
continue
yield pathname
def load_cleaners(cb_progress=lambda x: None):
"""Scan for CleanerML and load them"""
cleanerml_files = list(list_cleanerml_files())
cleanerml_files.sort()
if not cleanerml_files:
logger.debug('No CleanerML files to load.')
return
total_files = len(cleanerml_files)
cb_progress(0.0)
files_done = 0
for pathname in cleanerml_files:
try:
xmlcleaner = CleanerML(pathname)
except:
logger.exception(_("Error reading cleaner: %s"), pathname)
continue
cleaner = xmlcleaner.get_cleaner()
if cleaner.is_usable():
Cleaner.backends[cleaner.id] = cleaner
else:
logger.debug(
# TRANSLATORS: An action is something like cleaning a specific file.
# "Not usable" means the whole cleaner will be ignored.
# The substituted variable is a pathname.
_("Cleaner is not usable on this OS because it has no actions: %s"), pathname)
files_done += 1
cb_progress(1.0 * files_done / total_files)
yield True
def pot_fragment(msgid, pathname, translators=None):
"""Create a string fragment for generating .pot files"""
msgid = msgid.replace('"', '\\"') # escape quotation mark
if translators:
translators = "#. %s\n" % translators
else:
translators = ""
ret = '''%s#: %s
msgid "%s"
msgstr ""
''' % (translators, pathname, msgid)
return ret
def create_pot():
"""Create a .pot for translation using gettext"""
f = open('../po/cleanerml.pot', 'w')
for pathname in listdir('../cleaners'):
if not pathname.lower().endswith(".xml"):
continue
strings = []
try:
CleanerML(pathname,
lambda newstr, translators=None:
strings.append([newstr, translators]))
except:
logger.exception(_("Error reading cleaner: %s"), pathname)
continue
for (string, translators) in strings:
f.write(pot_fragment(string, pathname, translators))
f.close()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/Command.py 0000775 0001750 0001750 00000025145 14522012661 014662 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Command design pattern implementation for cleaning
Standard clean up commands are Delete, Truncate and Shred. Everything
else is counted as special commands: run any external process, edit
JSON or INI file, delete registry key, edit SQLite3 database, etc.
"""
from bleachbit import _
from bleachbit import FileUtilities
import logging
import os
import types
if 'nt' == os.name:
import bleachbit.Windows
else:
from bleachbit.General import WindowsError
logger = logging.getLogger(__name__)
def whitelist(path):
"""Return information that this file was whitelisted"""
ret = {
# TRANSLATORS: This is the label in the log indicating was
# skipped because it matches the whitelist
'label': _('Skip'),
'n_deleted': 0,
'n_special': 0,
'path': path,
'size': 0}
return ret
class Delete:
"""Delete a single file or directory. Obey the user
preference regarding shredding."""
def __init__(self, path):
"""Create a Delete instance to delete 'path'"""
self.path = path
self.shred = False
def __str__(self):
return 'Command to %s %s' % \
('shred' if self.shred else 'delete', self.path)
def execute(self, really_delete):
"""Make changes and return results"""
if FileUtilities.whitelisted(self.path):
yield whitelist(self.path)
return
ret = {
# TRANSLATORS: This is the label in the log indicating will be
# deleted (for previews) or was actually deleted
'label': _('Delete'),
'n_deleted': 1,
'n_special': 0,
'path': self.path,
'size': FileUtilities.getsize(self.path)}
if really_delete:
try:
FileUtilities.delete(self.path, self.shred)
except WindowsError as e:
# WindowsError: [Error 32] The process cannot access the file because it is being
# used by another process: 'C:\\Documents and
# Settings\\username\\Cookies\\index.dat'
if 32 != e.winerror and 5 != e.winerror:
raise
try:
bleachbit.Windows.delete_locked_file(self.path)
except:
raise
else:
if self.shred:
import warnings
warnings.warn(
_('At least one file was locked by another process, so its contents could not be overwritten. It will be marked for deletion upon system reboot.'))
# TRANSLATORS: The file will be deleted when the
# system reboots
ret['label'] = _('Mark for deletion')
yield ret
class Function:
"""Execute a simple Python function"""
def __init__(self, path, func, label):
"""Path is a pathname that exists or None. If
it exists, func takes the pathname. Otherwise,
function returns the size."""
self.path = path
self.func = func
self.label = label
try:
assert isinstance(func, types.FunctionType)
except AssertionError:
raise AssertionError('Expected MethodType but got %s' % type(func))
def __str__(self):
if self.path:
return 'Function: %s: %s' % (self.label, self.path)
else:
return 'Function: %s' % (self.label)
def execute(self, really_delete):
if self.path is not None and FileUtilities.whitelisted(self.path):
yield whitelist(self.path)
return
ret = {
'label': self.label,
'n_deleted': 0,
'n_special': 1,
'path': self.path,
'size': None}
if really_delete:
if self.path is None:
# Function takes no path. It returns the size.
func_ret = self.func()
if isinstance(func_ret, types.GeneratorType):
# function returned generator
for func_ret in self.func():
if True == func_ret or isinstance(func_ret, tuple):
# Return control to GTK idle loop.
# If tuple, then display progress.
yield func_ret
# either way, func_ret should be an integer
assert isinstance(func_ret, int)
ret['size'] = func_ret
else:
if os.path.isdir(self.path):
raise RuntimeError('Attempting to run file function %s on directory %s' %
(self.func.__name__, self.path))
# Function takes a path. We check the size.
oldsize = FileUtilities.getsize(self.path)
from sqlite3 import DatabaseError
try:
self.func(self.path)
except DatabaseError as e:
if -1 == e.message.find('file is encrypted or is not a database') and \
-1 == e.message.find('or missing database'):
raise
logger.exception(e.message)
return
try:
newsize = FileUtilities.getsize(self.path)
except OSError as e:
from errno import ENOENT
if e.errno == ENOENT:
# file does not exist
newsize = 0
else:
raise
ret['size'] = oldsize - newsize
yield ret
class Ini:
"""Remove sections or parameters from a .ini file"""
def __init__(self, path, section, parameter):
"""Create the instance"""
self.path = path
self.section = section
self.parameter = parameter
def __str__(self):
return 'Command to clean .ini path=%s, section=%s, parameter=%s ' % \
(self.path, self.section, self.parameter)
def execute(self, really_delete):
"""Make changes and return results"""
if FileUtilities.whitelisted(self.path):
yield whitelist(self.path)
return
ret = {
# TRANSLATORS: Parts of this file will be deleted
'label': _('Clean file'),
'n_deleted': 0,
'n_special': 1,
'path': self.path,
'size': None}
if really_delete:
oldsize = FileUtilities.getsize(self.path)
FileUtilities.clean_ini(self.path, self.section, self.parameter)
newsize = FileUtilities.getsize(self.path)
ret['size'] = oldsize - newsize
yield ret
class Json:
"""Remove a key from a JSON configuration file"""
def __init__(self, path, address):
"""Create the instance"""
self.path = path
self.address = address
def __str__(self):
return 'Command to clean JSON file, path=%s, address=%s ' % \
(self.path, self.address)
def execute(self, really_delete):
"""Make changes and return results"""
if FileUtilities.whitelisted(self.path):
yield whitelist(self.path)
return
ret = {
'label': _('Clean file'),
'n_deleted': 0,
'n_special': 1,
'path': self.path,
'size': None}
if really_delete:
oldsize = FileUtilities.getsize(self.path)
FileUtilities.clean_json(self.path, self.address)
newsize = FileUtilities.getsize(self.path)
ret['size'] = oldsize - newsize
yield ret
class Shred(Delete):
"""Shred a single file"""
def __init__(self, path):
"""Create an instance to shred 'path'"""
Delete.__init__(self, path)
self.shred = True
def __str__(self):
return 'Command to shred %s' % self.path
class Truncate(Delete):
"""Truncate a single file"""
def __str__(self):
return 'Command to truncate %s' % self.path
def execute(self, really_delete):
"""Make changes and return results"""
if FileUtilities.whitelisted(self.path):
yield whitelist(self.path)
return
ret = {
# TRANSLATORS: The file will be truncated to 0 bytes in length
'label': _('Truncate'),
'n_deleted': 1,
'n_special': 0,
'path': self.path,
'size': FileUtilities.getsize(self.path)}
if really_delete:
with open(self.path, 'w') as f:
f.truncate(0)
yield ret
class Winreg:
"""Clean Windows registry"""
def __init__(self, keyname, valuename):
"""Create the Windows registry cleaner"""
self.keyname = keyname
self.valuename = valuename
def __str__(self):
return 'Command to clean registry, key=%s, value=%s ' % (self.keyname, self.valuename)
def execute(self, really_delete):
"""Execute the Windows registry cleaner"""
if 'nt' != os.name:
return
_str = None # string representation
ret = None # return value meaning 'deleted' or 'delete-able'
if self.valuename:
_str = '%s<%s>' % (self.keyname, self.valuename)
ret = bleachbit.Windows.delete_registry_value(self.keyname,
self.valuename, really_delete)
else:
ret = bleachbit.Windows.delete_registry_key(
self.keyname, really_delete)
_str = self.keyname
if not ret:
# Nothing to delete or nothing was deleted. This return
# makes the auto-hide feature work nicely.
return
ret = {
'label': _('Delete registry key'),
'n_deleted': 0,
'n_special': 1,
'path': _str,
'size': 0}
yield ret
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/DeepScan.py 0000775 0001750 0001750 00000007671 14522012661 014772 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# -*- coding: UTF-8 -*-
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Scan directory tree for files to delete
"""
import logging
import os
import platform
import re
import unicodedata
from collections import namedtuple
from bleachbit import fs_scan_re_flags
from . import Command
def normalized_walk(top, **kwargs):
"""
macOS uses decomposed UTF-8 to store filenames. This functions
is like `os.walk` but recomposes those decomposed filenames on
macOS
"""
try:
from scandir import walk
except:
# there is a warning in FileUtilities, so don't warn again here
from os import walk
if 'Darwin' == platform.system():
for dirpath, dirnames, filenames in walk(top, **kwargs):
yield dirpath, dirnames, [
unicodedata.normalize('NFC', fn)
for fn in filenames
]
else:
yield from walk(top, **kwargs)
Search = namedtuple('Search', ['command', 'regex', 'nregex', 'wholeregex', 'nwholeregex'])
Search.__new__.__defaults__ = (None,) * len(Search._fields)
class CompiledSearch:
"""Compiled search condition"""
def __init__(self, search):
self.command = search.command
def re_compile(regex):
return re.compile(regex, fs_scan_re_flags) if regex else None
self.regex = re_compile(search.regex)
self.nregex = re_compile(search.nregex)
self.wholeregex = re_compile(search.wholeregex)
self.nwholeregex = re_compile(search.nwholeregex)
def match(self, dirpath, filename):
full_path = os.path.join(dirpath, filename)
if self.regex and not self.regex.search(filename):
return None
if self.nregex and self.nregex.search(filename):
return None
if self.wholeregex and not self.wholeregex.search(full_path):
return None
if self.nwholeregex and self.nwholeregex.search(full_path):
return None
return full_path
class DeepScan:
"""Advanced directory tree scan"""
def __init__(self, searches):
self.roots = []
self.searches = searches
def scan(self):
"""Perform requested searches and yield each match"""
logging.getLogger(__name__).debug(
'DeepScan.scan: searches=%s', str(self.searches))
import time
yield_time = time.time()
for (top, searches) in self.searches.items():
compiled_searches = [CompiledSearch(s) for s in searches]
for (dirpath, _dirnames, filenames) in normalized_walk(top):
for c in compiled_searches:
# fixme, don't match filename twice
for filename in filenames:
full_name = c.match(dirpath, filename)
if full_name is not None:
# fixme: support other commands
if c.command == 'delete':
yield Command.Delete(full_name)
elif c.command == 'shred':
yield Command.Shred(full_name)
if time.time() - yield_time > 0.25:
# allow GTK+ to process the idle loop
yield True
yield_time = time.time()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/DesktopMenuOptions.py 0000664 0001750 0001750 00000003005 14522012661 017102 0 ustar 00z z from bleachbit.Options import options
import os
from pathlib import Path
def install_kde_service_menu_file():
try:
# Honor the XDG Base Directory Specification first
# and check if $XDG_DATA_HOME has already been defined.
# The path default is $HOME/.local/share
data_home_path = Path(os.environ["XDG_DATA_HOME"])
except KeyError:
data_home_path = Path(os.environ["HOME"], ".local", "share")
service_file_path = data_home_path / "kio" / "servicemenus" / "shred_with_bleachbit.desktop"
if options.get("kde_shred_menu_option"):
dir_path = service_file_path.parent
if not dir_path.exists():
dir_path.mkdir(parents=True)
if not service_file_path.exists():
# Service file has dependency on `kdialog` which KDE installations may not provide by default.
with service_file_path.open('w') as service_file:
service_file_path.chmod(0o755)
service_file.write(r'''
[Desktop Entry]
Type=Service
Name=Shred With Bleachbit
X-KDE-ServiceTypes=KonqPopupMenu/Plugin
MimeType=all/all
Icon=bleachbit
Actions=BleachbitShred
Terminal=true
[Desktop Action BleachbitShred]
Name=Shred With Bleachbit
Icon=bleachbit
Exec=kdialog --yesno "This action will shred the following:\n\n$(echo %F | tr ' ' '\n')\n\nContinue?" && sh -c 'bleachbit --shred "$@"; echo Press enter/return to close; read' sh %F
''')
else:
try:
service_file_path.unlink()
except FileNotFoundError:
pass
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/FileUtilities.py 0000775 0001750 0001750 00000105531 14522012661 016055 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# -*- coding: UTF-8 -*-
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
File-related utilities
"""
import bleachbit
from bleachbit import _
import atexit
import errno
import glob
import locale
import logging
import os
import os.path
import random
import re
import stat
import string
import sys
import subprocess
import tempfile
import time
logger = logging.getLogger(__name__)
if 'nt' == os.name:
from pywintypes import error as pywinerror
import win32file
import bleachbit.Windows
os_path_islink = os.path.islink
os.path.islink = lambda path: os_path_islink(
path) or bleachbit.Windows.is_junction(path)
if 'posix' == os.name:
from bleachbit.General import WindowsError
pywinerror = WindowsError
try:
from scandir import walk
if 'nt' == os.name:
import scandir
import bleachbit.Windows
class _Win32DirEntryPython(scandir.Win32DirEntryPython):
def is_symlink(self):
return super(_Win32DirEntryPython, self).is_symlink() or bleachbit.Windows.is_junction(self.path)
scandir.scandir = scandir.scandir_python
scandir.DirEntry = scandir.Win32DirEntryPython = _Win32DirEntryPython
except ImportError:
if sys.version_info < (3, 5, 0):
# Python 3.5 incorporated scandir
logger.warning(
'scandir is not available, so falling back to slower os.walk()')
from os import walk
def open_files_linux():
return glob.iglob("/proc/*/fd/*")
def get_filesystem_type(path):
"""
* Get file system type from the given path
* return value: The tuple of (file_system_type, device_name)
* @ file_system_type: vfat, ntfs, etc
* @ device_name: C://, D://, etc
"""
try:
import psutil
except ImportError:
logger.warning('To get the file system type from the given path, you need to install psutil package')
return ("unknown", "none")
partitions = {
partition.mountpoint: (partition.fstype, partition.device)
for partition in psutil.disk_partitions()
}
if path in partitions:
return partitions[path]
splitpath = path.split(os.sep)
for i in range(0, len(splitpath)-1):
path = os.sep.join(splitpath[:i]) + os.sep
if path in partitions:
return partitions[path]
path = os.sep.join(splitpath[:i])
if path in partitions:
return partitions[path]
return ("unknown", "none")
def open_files_lsof(run_lsof=None):
if run_lsof is None:
def run_lsof():
return subprocess.check_output(["lsof", "-Fn", "-n"])
for f in run_lsof().split("\n"):
if f.startswith("n/"):
yield f[1:] # Drop lsof's "n"
def open_files():
if sys.platform.startswith('linux'):
files = open_files_linux()
elif 'darwin' == sys.platform or sys.platform.startswith('freebsd'):
files = open_files_lsof()
else:
raise RuntimeError('unsupported platform for open_files()')
for filename in files:
try:
target = os.path.realpath(filename)
except TypeError:
# happens, for example, when link points to
# '/etc/password\x00 (deleted)'
continue
except PermissionError:
# /proc/###/fd/0 with systemd
# https://github.com/bleachbit/bleachbit/issues/1515
continue
else:
yield target
class OpenFiles:
"""Cached way to determine whether a file is open by active process"""
def __init__(self):
self.last_scan_time = None
self.files = []
def file_qualifies(self, filename):
"""Return boolean whether filename qualifies to enter cache (check \
against blacklist)"""
return not filename.startswith("/dev") and \
not filename.startswith("/proc")
def scan(self):
"""Update cache"""
self.last_scan_time = time.time()
self.files = []
for filename in open_files():
if self.file_qualifies(filename):
self.files.append(filename)
def is_open(self, filename):
"""Return boolean whether filename is open by running process"""
if self.last_scan_time is None or (time.time() - self.last_scan_time) > 10:
self.scan()
return os.path.realpath(filename) in self.files
def __random_string(length):
"""Return random alphanumeric characters of given length"""
return ''.join(random.choice(string.ascii_letters + '0123456789_.-')
for i in range(length))
def bytes_to_human(bytes_i):
# type: (int) -> str
"""Display a file size in human terms (megabytes, etc.) using preferred standard (SI or IEC)"""
if bytes_i < 0:
return '-' + bytes_to_human(-bytes_i)
from bleachbit.Options import options
if options.get('units_iec'):
prefixes = ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi']
base = 1024.0
else:
prefixes = ['', 'k', 'M', 'G', 'T', 'P']
base = 1000.0
assert(isinstance(bytes_i, int))
if 0 == bytes_i:
return '0B'
if bytes_i >= base ** 3:
decimals = 2
elif bytes_i >= base:
decimals = 1
else:
decimals = 0
for exponent in range(0, len(prefixes)):
if bytes_i < base:
abbrev = round(bytes_i, decimals)
suf = prefixes[exponent]
return locale.str(abbrev) + suf + 'B'
else:
bytes_i /= base
return 'A lot.'
def children_in_directory(top, list_directories=False):
"""Iterate files and, optionally, subdirectories in directory"""
if type(top) is tuple:
for top_ in top:
yield from children_in_directory(top_, list_directories)
return
for (dirpath, dirnames, filenames) in walk(top, topdown=False):
if list_directories:
for dirname in dirnames:
yield os.path.join(dirpath, dirname)
for filename in filenames:
yield os.path.join(dirpath, filename)
def clean_ini(path, section, parameter):
"""Delete sections and parameters (aka option) in the file"""
def write(parser, ini_file):
"""
Reimplementation of the original RowConfigParser write function.
This function is 99% same as its origin. The only change is
removing a cast to str. This is needed to handle unicode chars.
"""
if parser._defaults:
ini_file.write("[%s]\n" % "DEFAULT")
for (key, value) in parser._defaults.items():
ini_file.write("%s = %s\n" %
(key, str(value).replace('\n', '\n\t')))
ini_file.write("\n")
for section in parser._sections:
ini_file.write("[%s]\n" % section)
for (key, value) in parser._sections[section].items():
if key == "__name__":
continue
if (value is not None) or (parser._optcre == parser.OPTCRE):
# The line below is the only changed line of the original function.
# This is the original line for reference:
# key = " = ".join((key, str(value).replace('\n', '\n\t')))
key = " = ".join((key, value.replace('\n', '\n\t')))
ini_file.write("%s\n" % (key))
ini_file.write("\n")
encoding = detect_encoding(path) or 'utf_8_sig'
# read file to parser
config = bleachbit.RawConfigParser()
config.optionxform = lambda option: option
config.write = write
with open(path, 'r', encoding=encoding) as fp:
config.read_file(fp)
# change file
changed = False
if config.has_section(section):
if parameter is None:
changed = True
config.remove_section(section)
elif config.has_option(section, parameter):
changed = True
config.remove_option(section, parameter)
# write file
if changed:
from bleachbit.Options import options
fp.close()
if options.get('shred'):
delete(path, True)
with open(path, 'w', encoding=encoding, newline='') as fp:
config.write(config, fp)
def clean_json(path, target):
"""Delete key in the JSON file"""
import json
changed = False
targets = target.split('/')
# read file to parser
with open(path, 'r', encoding='utf-8') as f:
js = json.load(f)
# change file
pos = js
while True:
new_target = targets.pop(0)
if not isinstance(pos, dict):
break
if new_target in pos and len(targets) > 0:
# descend
pos = pos[new_target]
elif new_target in pos:
# delete terminal target
changed = True
del(pos[new_target])
else:
# target not found
break
if 0 == len(targets):
# target not found
break
if changed:
from bleachbit.Options import options
if options.get('shred'):
delete(path, True)
# write file
with open(path, 'w', encoding='utf-8') as f:
json.dump(js, f)
def delete(path, shred=False, ignore_missing=False, allow_shred=True):
"""Delete path that is either file, directory, link or FIFO.
If shred is enabled as a function parameter or the BleachBit global
parameter, the path will be shredded unless allow_shred = False.
"""
from bleachbit.Options import options
is_special = False
path = extended_path(path)
do_shred = allow_shred and (shred or options.get('shred'))
if not os.path.lexists(path):
if ignore_missing:
return
raise OSError(2, 'No such file or directory', path)
if 'posix' == os.name:
# With certain (relatively rare) files on Windows os.lstat()
# may return Access Denied
mode = os.lstat(path)[stat.ST_MODE]
is_special = stat.S_ISFIFO(mode) or stat.S_ISLNK(mode)
if is_special:
os.remove(path)
elif os.path.isdir(path):
delpath = path
if do_shred:
if not is_dir_empty(path):
# Avoid renaming non-empty directory like https://github.com/bleachbit/bleachbit/issues/783
logger.info(_("Directory is not empty: %s"), path)
return
delpath = wipe_name(path)
try:
os.rmdir(delpath)
except OSError as e:
# [Errno 39] Directory not empty
# https://bugs.launchpad.net/bleachbit/+bug/1012930
if errno.ENOTEMPTY == e.errno:
logger.info(_("Directory is not empty: %s"), path)
elif errno.EBUSY == e.errno:
if os.name == 'posix' and os.path.ismount(path):
logger.info(_("Skipping mount point: %s"), path)
else:
logger.info(_("Device or resource is busy: %s"), path)
else:
raise
except WindowsError as e:
# WindowsError: [Error 145] The directory is not empty:
# 'C:\\Documents and Settings\\username\\Local Settings\\Temp\\NAILogs'
# Error 145 may happen if the files are scheduled for deletion
# during reboot.
if 145 == e.winerror:
logger.info(_("Directory is not empty: %s"), path)
else:
raise
elif os.path.isfile(path):
# wipe contents
if do_shred:
try:
wipe_contents(path)
except pywinerror as e:
# 2 = The system cannot find the file specified.
# This can happen with a broken symlink
# https://github.com/bleachbit/bleachbit/issues/195
if 2 != e.winerror:
raise
# If a broken symlink, try os.remove() below.
except IOError as e:
# permission denied (13) happens shredding MSIE 8 on Windows 7
logger.debug("IOError #%s shredding '%s'",
e.errno, path, exc_info=True)
# wipe name
os.remove(wipe_name(path))
else:
# unlink
os.remove(path)
elif os.path.islink(path):
os.remove(path)
else:
logger.info(_("Special file type cannot be deleted: %s"), path)
def detect_encoding(fn):
"""Detect the encoding of the file"""
try:
import chardet
except ImportError:
logger.warning(
'chardet module is not available to detect character encoding')
return None
with open(fn, 'rb') as f:
if not hasattr(chardet, 'universaldetector'):
# This method works on Ubuntu 16.04 with an older version of the module.
rawdata = f.read()
det = chardet.detect(rawdata)
if det['confidence'] > 0.5:
return det['encoding']
return None
# This method is faster, but it requires a newer version of the module.
detector = chardet.universaldetector.UniversalDetector()
for line in f.readlines():
detector.feed(line)
if detector.done:
break
detector.close()
return detector.result['encoding']
def ego_owner(filename):
"""Return whether current user owns the file"""
return os.lstat(filename).st_uid == os.getuid()
def exists_in_path(filename):
"""Returns boolean whether the filename exists in the path"""
delimiter = ':'
if 'nt' == os.name:
delimiter = ';'
for dirname in os.getenv('PATH').split(delimiter):
if os.path.exists(os.path.join(dirname, filename)):
return True
return False
def exe_exists(pathname):
"""Returns boolean whether executable exists"""
if os.path.isabs(pathname):
return os.path.exists(pathname)
else:
return exists_in_path(pathname)
def execute_sqlite3(path, cmds):
"""Execute 'cmds' on SQLite database 'path'"""
import sqlite3
import contextlib
with contextlib.closing(sqlite3.connect(path)) as conn:
cursor = conn.cursor()
# overwrites deleted content with zeros
# https://www.sqlite.org/pragma.html#pragma_secure_delete
from bleachbit.Options import options
if options.get('shred'):
cursor.execute('PRAGMA secure_delete=ON')
for cmd in cmds.split(';'):
try:
cursor.execute(cmd)
except sqlite3.OperationalError as exc:
if str(exc).find('no such function: ') >= 0:
# fixme: determine why randomblob and zeroblob are not
# available
logger.exception(exc.message)
else:
raise sqlite3.OperationalError(
'%s: %s' % (exc, path))
except sqlite3.DatabaseError as exc:
raise sqlite3.DatabaseError(
'%s: %s' % (exc, path))
cursor.close()
conn.commit()
def expand_glob_join(pathname1, pathname2):
"""Join pathname1 and pathname1, expand pathname, glob, and return as list"""
pathname3 = os.path.expanduser(os.path.expandvars(
os.path.join(pathname1, pathname2)))
ret = [pathname4 for pathname4 in glob.iglob(pathname3)]
return ret
def extended_path(path):
"""If applicable, return the extended Windows pathname"""
if 'nt' == os.name:
if path.startswith(r'\\?'):
return path
if path.startswith(r'\\'):
return '\\\\?\\unc\\' + path[2:]
return '\\\\?\\' + path
return path
def extended_path_undo(path):
""""""
if 'nt' == os.name:
if path.startswith(r'\\?\unc'):
return '\\' + path[7:]
if path.startswith(r'\\?'):
return path[4:]
return path
def free_space(pathname):
"""Return free space in bytes"""
if 'nt' == os.name:
import psutil
return psutil.disk_usage(pathname).free
mystat = os.statvfs(pathname)
return mystat.f_bfree * mystat.f_bsize
def getsize(path):
"""Return the actual file size considering spare files
and symlinks"""
if 'posix' == os.name:
try:
__stat = os.lstat(path)
except OSError as e:
# OSError: [Errno 13] Permission denied
# can happen when a regular user is trying to find the size of /var/log/hp/tmp
# where /var/log/hp is 0774 and /var/log/hp/tmp is 1774
if errno.EACCES == e.errno:
return 0
raise
return __stat.st_blocks * 512
if 'nt' == os.name:
# On rare files os.path.getsize() returns access denied, so first
# try FindFilesW.
# Also, apply prefix to use extended-length paths to support longer
# filenames.
finddata = win32file.FindFilesW(extended_path(path))
if not finddata:
# FindFilesW does not work for directories, so fall back to
# getsize()
return os.path.getsize(path)
else:
size = (finddata[0][4] * (0xffffffff + 1)) + finddata[0][5]
return size
return os.path.getsize(path)
def getsizedir(path):
"""Return the size of the contents of a directory"""
total_bytes = sum(
getsize(node)
for node in children_in_directory(path, list_directories=False)
)
return total_bytes
def globex(pathname, regex):
"""Yield a list of files with pathname and filter by regex"""
if type(pathname) is tuple:
for singleglob in pathname:
yield from globex(singleglob, regex)
else:
for path in glob.iglob(pathname):
if re.search(regex, path):
yield path
def guess_overwrite_paths():
"""Guess which partitions to overwrite (to hide deleted files)"""
# In case overwriting leaves large files, placing them in
# ~/.config makes it easy to find them and clean them.
ret = []
if 'posix' == os.name:
home = os.path.expanduser('~/.cache')
if not os.path.exists(home):
home = os.path.expanduser("~")
ret.append(home)
if not same_partition(home, '/tmp/'):
ret.append('/tmp')
elif 'nt' == os.name:
localtmp = os.path.expandvars('$TMP')
if not os.path.exists(localtmp):
logger.warning(_("The environment variable TMP refers to a directory that does not exist: %s"), localtmp)
localtmp = None
from bleachbit.Windows import get_fixed_drives
for drive in get_fixed_drives():
if localtmp and same_partition(localtmp, drive):
ret.append(localtmp)
else:
ret.append(drive)
else:
raise NotImplementedError('Unsupported OS in guess_overwrite_paths')
return ret
def human_to_bytes(human, hformat='si'):
"""Convert a string like 10.2GB into bytes. By
default use SI standard (base 10). The format of the
GNU command 'du' (base 2) also supported."""
if 'si' == hformat:
base = 1000
suffixes = 'kMGTE'
elif 'du' == hformat:
base = 1024
suffixes = 'KMGTE'
else:
raise ValueError("Invalid format: '%s'" % hformat)
matches = re.match(r'^(\d+(?:\.\d+)?) ?([' + suffixes + ']?)B?$', human)
if matches is None:
raise ValueError("Invalid input for '%s' (hformat='%s')" %
(human, hformat))
(amount, suffix) = matches.groups()
if '' == suffix:
exponent = 0
else:
exponent = suffixes.find(suffix) + 1
return int(float(amount) * base**exponent)
def is_dir_empty(dirname):
"""Returns boolean whether directory is empty.
It assumes the path exists and is a directory.
"""
if hasattr(os, 'scandir'):
if sys.version_info < (3, 6, 0):
# Python 3.5 added os.scandir() without context manager.
for _ in os.scandir(dirname):
return False
else:
# Python 3.6 added the context manager.
with os.scandir(dirname) as it:
for _entry in it:
return False
return True
# This method is slower, but it works with Python 3.4.
return len(os.listdir(dirname)) == 0
def listdir(directory):
"""Return full path of files in directory.
Path may be a tuple of directories."""
if type(directory) is tuple:
for dirname in directory:
yield from listdir(dirname)
return
dirname = os.path.expanduser(directory)
if not os.path.lexists(dirname):
return
for filename in os.listdir(dirname):
yield os.path.join(dirname, filename)
def same_partition(dir1, dir2):
"""Are both directories on the same partition?"""
if 'nt' == os.name:
try:
return free_space(dir1) == free_space(dir2)
except pywinerror as e:
if 5 == e.winerror:
# Microsoft Office 2010 Starter Edition has a virtual
# drive that gives access denied
# https://bugs.launchpad.net/bleachbit/+bug/1372179
# https://bugs.launchpad.net/bleachbit/+bug/1474848
# https://github.com/az0/bleachbit/issues/27
return dir1[0] == dir2[0]
raise
stat1 = os.statvfs(dir1)
stat2 = os.statvfs(dir2)
return stat1[stat.ST_DEV] == stat2[stat.ST_DEV]
def sync():
"""Flush file system buffers. sync() is different than fsync()"""
if 'posix' == os.name:
import ctypes
rc = ctypes.cdll.LoadLibrary('libc.so.6').sync()
if 0 != rc:
logger.error('sync() returned code %d', rc)
elif 'nt' == os.name:
import ctypes
ctypes.cdll.LoadLibrary('msvcrt.dll')._flushall()
def truncate_f(f):
"""Truncate the file object"""
try:
f.truncate(0)
f.flush()
os.fsync(f.fileno())
except OSError as e:
if e.errno != errno.ENOSPC:
raise
def uris_to_paths(file_uris):
"""Return a list of paths from text/uri-list"""
import urllib.parse
import urllib.request
assert isinstance(file_uris, (tuple, list))
file_paths = []
for file_uri in file_uris:
if not file_uri:
# ignore blank
continue
parsed_uri = urllib.parse.urlparse(file_uri)
if parsed_uri.scheme == 'file':
file_path = urllib.request.url2pathname(parsed_uri.path)
if file_path[2] == ':':
# remove front slash for Windows-style path
file_path = file_path[1:]
file_paths.append(file_path)
else:
logger.warning('Unsupported scheme: %s', file_uri)
return file_paths
def whitelisted_posix(path, check_realpath=True):
"""Check whether this POSIX path is whitelisted"""
from bleachbit.Options import options
if check_realpath and os.path.islink(path):
# also check the link name
if whitelisted_posix(path, False):
return True
# resolve symlink
path = os.path.realpath(path)
for pathname in options.get_whitelist_paths():
if pathname[0] == 'file' and path == pathname[1]:
return True
if pathname[0] == 'folder':
if path == pathname[1]:
return True
if path.startswith(pathname[1] + os.sep):
return True
return False
def whitelisted_windows(path):
"""Check whether this Windows path is whitelisted"""
from bleachbit.Options import options
for pathname in options.get_whitelist_paths():
# Windows is case insensitive
if pathname[0] == 'file' and path.lower() == pathname[1].lower():
return True
if pathname[0] == 'folder':
if path.lower() == pathname[1].lower():
return True
if path.lower().startswith(pathname[1].lower() + os.sep):
return True
# Simple drive letter like C:\ matches everything below
if len(pathname[1]) == 3 and path.lower().startswith(pathname[1].lower()):
return True
return False
if 'nt' == os.name:
whitelisted = whitelisted_windows
else:
whitelisted = whitelisted_posix
def wipe_contents(path, truncate=True):
"""Wipe files contents
http://en.wikipedia.org/wiki/Data_remanence
2006 NIST Special Publication 800-88 (p. 7): "Studies have
shown that most of today's media can be effectively cleared
by one overwrite"
"""
def wipe_write():
size = getsize(path)
try:
f = open(path, 'wb')
except IOError as e:
if e.errno == errno.EACCES: # permission denied
os.chmod(path, 0o200) # user write only
f = open(path, 'wb')
else:
raise
blanks = b'\0' * 4096
while size > 0:
f.write(blanks)
size -= 4096
f.flush() # flush to OS buffer
os.fsync(f.fileno()) # force write to disk
return f
if 'nt' == os.name:
from win32com.shell.shell import IsUserAnAdmin
if 'nt' == os.name and IsUserAnAdmin():
from bleachbit.WindowsWipe import file_wipe, UnsupportedFileSystemError
import warnings
from bleachbit import _
try:
file_wipe(path)
except pywinerror as e:
# 32=The process cannot access the file because it is being used by another process.
# 33=The process cannot access the file because another process has
# locked a portion of the file.
if not e.winerror in (32, 33):
# handle only locking errors
raise
# Try to truncate the file. This makes the behavior consistent
# with Linux and with Windows when IsUserAdmin=False.
try:
with open(path, 'w') as f:
truncate_f(f)
except IOError as e2:
if errno.EACCES == e2.errno:
# Common when the file is locked
# Errno 13 Permission Denied
pass
# translate exception to mark file to deletion in Command.py
raise WindowsError(e.winerror, e.strerror)
except UnsupportedFileSystemError as e:
warnings.warn(
_('There was at least one file on a file system that does not support advanced overwriting.'), UserWarning)
f = wipe_write()
else:
# The wipe succeed, so prepare to truncate.
f = open(path, 'w')
else:
f = wipe_write()
if truncate:
truncate_f(f)
f.close()
def wipe_name(pathname1):
"""Wipe the original filename and return the new pathname"""
(head, _tail) = os.path.split(pathname1)
# reference http://en.wikipedia.org/wiki/Comparison_of_file_systems#Limits
maxlen = 226
# first, rename to a long name
i = 0
while True:
try:
pathname2 = os.path.join(head, __random_string(maxlen))
os.rename(pathname1, pathname2)
break
except OSError:
if maxlen > 10:
maxlen -= 10
i += 1
if i > 100:
logger.info('exhausted long rename: %s', pathname1)
pathname2 = pathname1
break
# finally, rename to a short name
i = 0
while True:
try:
pathname3 = os.path.join(head, __random_string(i + 1))
os.rename(pathname2, pathname3)
break
except:
i += 1
if i > 100:
logger.info('exhausted short rename: %s', pathname2)
pathname3 = pathname2
break
return pathname3
def wipe_path(pathname, idle=False):
"""Wipe the free space in the path
This function uses an iterator to update the GUI."""
def temporaryfile():
# reference
# http://en.wikipedia.org/wiki/Comparison_of_file_systems#Limits
maxlen = 185
f = None
while True:
try:
f = tempfile.NamedTemporaryFile(
dir=pathname, suffix=__random_string(maxlen), delete=False)
# In case the application closes prematurely, make sure this
# file is deleted
atexit.register(
delete, f.name, allow_shred=False, ignore_missing=True)
break
except OSError as e:
if e.errno in (errno.ENAMETOOLONG, errno.ENOSPC, errno.ENOENT, errno.EINVAL):
# ext3 on Linux 3.5 returns ENOSPC if the full path is greater than 264.
# Shrinking the size helps.
# Microsoft Windows returns ENOENT "No such file or directory"
# or EINVAL "Invalid argument"
# when the path is too long such as %TEMP% but not in C:\
if maxlen > 5:
maxlen -= 5
continue
raise
return f
def estimate_completion():
"""Return (percent, seconds) to complete"""
remaining_bytes = free_space(pathname)
done_bytes = start_free_bytes - remaining_bytes
if done_bytes < 0:
# maybe user deleted large file after starting wipe
done_bytes = 0
if 0 == start_free_bytes:
done_percent = 0
else:
done_percent = 1.0 * done_bytes / (start_free_bytes + 1)
done_time = time.time() - start_time
rate = done_bytes / (done_time + 0.0001) # bytes per second
remaining_seconds = int(remaining_bytes / (rate + 0.0001))
return 1, done_percent, remaining_seconds
logger.debug(_("Wiping path: %s") % pathname)
if not os.path.isdir(pathname):
logger.error(_("Path to wipe must be an existing directory: %s"), pathname)
return
files = []
total_bytes = 0
start_free_bytes = free_space(pathname)
start_time = time.time()
# Because FAT32 has a maximum file size of 4,294,967,295 bytes,
# this loop is sometimes necessary to create multiple files.
while True:
try:
logger.debug(
_('Creating new, temporary file for wiping free space.'))
f = temporaryfile()
except OSError as e:
# Linux gives errno 24
# Windows gives errno 28 No space left on device
if e.errno in (errno.EMFILE, errno.ENOSPC):
break
else:
raise
# Get the file system type from the given path
fstype = get_filesystem_type(pathname)
fstype = fstype[0]
logging.debug('File System:' + fstype)
# print(f.name) # Added by Marvin for debugging #issue 1051
last_idle = time.time()
# Write large blocks to quickly fill the disk.
blanks = b'\0' * 65536
writtensize = 0
while True:
try:
if fstype != 'vfat':
f.write(blanks)
# In the ubuntu system, the size of file should be less then 4GB. If not, there should be EFBIG error.
# So the maximum file size should be less than or equal to "4GB - 65536byte".
elif writtensize < 4 * 1024 * 1024 * 1024 - 65536:
writtensize += f.write(blanks)
else:
break
except IOError as e:
if e.errno == errno.ENOSPC:
if len(blanks) > 1:
# Try writing smaller blocks
blanks = blanks[0:len(blanks) // 2]
else:
break
elif e.errno == errno.EFBIG:
break
else:
raise
if idle and (time.time() - last_idle) > 2:
# Keep the GUI responding, and allow the user to abort.
# Also display the ETA.
yield estimate_completion()
last_idle = time.time()
# Write to OS buffer
try:
f.flush()
except IOError as e:
# IOError: [Errno 28] No space left on device
# seen on Microsoft Windows XP SP3 with ~30GB free space but
# not on another XP SP3 with 64MB free space
if not e.errno == errno.ENOSPC:
logger.error(
_("Error #%d when flushing the file buffer." % e.errno))
os.fsync(f.fileno()) # write to disk
# Remember to delete
files.append(f)
# For statistics
total_bytes += f.tell()
# If no bytes were written, then quit.
# See https://github.com/bleachbit/bleachbit/issues/502
if start_free_bytes - total_bytes < 2: # Modified by Marvin to fix the issue #1051 [12/06/2020]
break
# sync to disk
sync()
# statistics
elapsed_sec = time.time() - start_time
rate_mbs = (total_bytes / (1000 * 1000)) / elapsed_sec
logger.info(_('Wrote {files:,} files and {bytes:,} bytes in {seconds:,} seconds at {rate:.2f} MB/s').format(
files=len(files), bytes=total_bytes, seconds=int(elapsed_sec), rate=rate_mbs))
# how much free space is left (should be near zero)
if 'posix' == os.name:
stats = os.statvfs(pathname)
logger.info(_("{bytes:,} bytes and {inodes:,} inodes available to non-super-user").format(
bytes=stats.f_bsize * stats.f_bavail, inodes=stats.f_favail))
logger.info(_("{bytes:,} bytes and {inodes:,} inodes available to super-user").format(
bytes=stats.f_bsize * stats.f_bfree, inodes=stats.f_ffree))
# truncate and close files
for f in files:
truncate_f(f)
while True:
try:
# Nikita: I noticed a bug that prevented file handles from
# being closed on FAT32. It sometimes takes two .close() calls
# to do actually close (and therefore delete) a temporary file
f.close()
break
except IOError as e:
if e.errno == 0:
logger.debug(
_("Handled unknown error #0 while truncating file."))
time.sleep(0.1)
# explicitly delete
delete(f.name, ignore_missing=True)
def vacuum_sqlite3(path):
"""Vacuum SQLite database"""
execute_sqlite3(path, 'vacuum')
openfiles = OpenFiles()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/GUI.py 0000775 0001750 0001750 00000142254 14522012661 013731 0 ustar 00z z #!/usr/bin/python3
# vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
GTK graphical user interface
"""
from bleachbit import GuiBasic
from bleachbit import Cleaner, FileUtilities
from bleachbit import _, APP_NAME, appicon_path, portable_mode, windows10_theme_path
from bleachbit.Options import options
# Now that the configuration is loaded, honor the debug preference there.
from bleachbit.Log import set_root_log_level
set_root_log_level(options.get('debug'))
from bleachbit.GuiPreferences import PreferencesDialog
from bleachbit.Cleaner import backends, register_cleaners
import bleachbit
from gi.repository import Gtk, Gdk, GObject, GLib, Gio
import glob
import logging
import os
import sys
import threading
import time
import gi
gi.require_version('Gtk', '3.0')
if os.name == 'nt':
from bleachbit import Windows
logger = logging.getLogger(__name__)
def threaded(func):
"""Decoration to create a threaded function"""
def wrapper(*args):
thread = threading.Thread(target=func, args=args)
thread.start()
return wrapper
def notify_gi(msg):
"""Show a pop-up notification.
The Windows pygy-aio installer does not include notify, so this is just for Linux.
"""
gi.require_version('Notify', '0.7')
from gi.repository import Notify
if Notify.init(APP_NAME):
notify = Notify.Notification.new('BleachBit', msg, 'bleachbit')
notify.set_hint("desktop-entry", GLib.Variant('s', 'bleachbit'))
notify.show()
notify.set_timeout(10000)
def notify_plyer(msg):
"""Show a pop-up notification.
Linux distributions do not include plyer, so this is just for Windows.
"""
from bleachbit import bleachbit_exe_path
# On Windows 10, PNG does not work.
__icon_fns = (
os.path.normpath(os.path.join(bleachbit_exe_path,
'share\\bleachbit.ico')),
os.path.normpath(os.path.join(bleachbit_exe_path,
'windows\\bleachbit.ico')))
icon_fn = None
for __icon_fn in __icon_fns:
if os.path.exists(__icon_fn):
icon_fn = __icon_fn
break
from plyer import notification
notification.notify(
title=APP_NAME,
message=msg,
app_name=APP_NAME, # not shown on Windows 10
app_icon=icon_fn,
)
def notify(msg):
"""Show a popup-notification"""
import importlib
if importlib.util.find_spec('plyer'):
# On Windows, use Plyer.
notify_plyer(msg)
return
# On Linux, use GTK Notify.
notify_gi(msg)
class Bleachbit(Gtk.Application):
_window = None
_shred_paths = None
_auto_exit = False
def __init__(self, uac=True, shred_paths=None, auto_exit=False):
application_id_suffix = self._init_windows_misc(auto_exit, shred_paths, uac)
application_id = '{}{}'.format('org.gnome.Bleachbit', application_id_suffix)
Gtk.Application.__init__(
self, application_id=application_id, flags=Gio.ApplicationFlags.FLAGS_NONE)
GObject.threads_init()
if auto_exit:
# This is used for automated testing of whether the GUI can start.
# It is called from assert_execute_console() in windows/setup_py2exe.py
self._auto_exit = True
if shred_paths:
self._shred_paths = shred_paths
if os.name == 'nt':
# clean up nonce files https://github.com/bleachbit/bleachbit/issues/858
import atexit
atexit.register(Windows.cleanup_nonce)
from bleachbit.General import startup_check
startup_check()
def _init_windows_misc(self, auto_exit, shred_paths, uac):
application_id_suffix = ''
is_context_menu_executed = auto_exit and shred_paths
if not os.name == 'nt':
return ''
if Windows.elevate_privileges(uac):
# privileges escalated in other process
sys.exit(0)
if is_context_menu_executed:
# When we have a running application and executing the Windows
# context menu command we start a new process with new application_id.
# That is because the command line arguments of the context menu command
# are not passed to the already running instance.
application_id_suffix = 'ContextMenuShred'
return application_id_suffix
def build_app_menu(self):
"""Build the application menu
On Linux with GTK 3.24, this code is necessary but not sufficient for
the menu to work. The headerbar code is also needed.
On Windows with GTK 3.18, this cde is sufficient for the menu to work.
"""
builder = Gtk.Builder()
builder.add_from_file(bleachbit.app_menu_filename)
menu = builder.get_object('app-menu')
self.set_app_menu(menu)
# set up mappings between in app-menu.ui and methods in this class
actions = {'shredFiles': self.cb_shred_file,
'shredFolders': self.cb_shred_folder,
'shredClipboard': self.cb_shred_clipboard,
'wipeFreeSpace': self.cb_wipe_free_space,
'makeChaff': self.cb_make_chaff,
'shredQuit': self.cb_shred_quit,
'preferences': self.cb_preferences_dialog,
'systemInformation': self.system_information_dialog,
'help': self.cb_help,
'about': self.about}
for action_name, callback in actions.items():
action = Gio.SimpleAction.new(action_name, None)
action.connect('activate', callback)
self.add_action(action)
def cb_help(self, action, param):
"""Callback for help"""
GuiBasic.open_url(bleachbit.help_contents_url, self._window)
def cb_make_chaff(self, action, param):
"""Callback to make chaff"""
from bleachbit.GuiChaff import ChaffDialog
cd = ChaffDialog(self._window)
cd.run()
def cb_shred_file(self, action, param):
"""Callback for shredding a file"""
# get list of files
paths = GuiBasic.browse_files(self._window, _("Choose files to shred"))
if not paths:
return
GUI.shred_paths(self._window, paths)
def cb_shred_folder(self, action, param):
"""Callback for shredding a folder"""
paths = GuiBasic.browse_folder(self._window,
_("Choose folder to shred"),
multiple=True,
stock_button=_('_Delete'))
if not paths:
return
GUI.shred_paths(self._window, paths)
def cb_shred_clipboard(self, action, param):
"""Callback for menu option: shred paths from clipboard"""
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clipboard.request_targets(self.cb_clipboard_uri_received)
def cb_clipboard_uri_received(self, clipboard, targets, data):
"""Callback for when URIs are received from clipboard"""
shred_paths = None
if Gdk.atom_intern_static_string('text/uri-list') in targets:
# Linux
shred_uris = clipboard.wait_for_contents(
Gdk.atom_intern_static_string('text/uri-list')).get_uris()
shred_paths = FileUtilities.uris_to_paths(shred_uris)
elif Gdk.atom_intern_static_string('FileNameW') in targets:
# Windows
# Use non-GTK+ functions because because GTK+ 2 does not work.
shred_paths = Windows.get_clipboard_paths()
if shred_paths:
GUI.shred_paths(self._window, shred_paths)
else:
logger.warning(_('No paths found in clipboard.'))
def cb_shred_quit(self, action, param):
"""Shred settings (for privacy reasons) and quit"""
# build a list of paths to delete
paths = []
if os.name == 'nt' and portable_mode:
# in portable mode on Windows, the options directory includes
# executables
paths.append(bleachbit.options_file)
if os.path.isdir(bleachbit.personal_cleaners_dir):
paths.append(bleachbit.personal_cleaners_dir)
for f in glob.glob(os.path.join(bleachbit.options_dir, "*.bz2")):
paths.append(f)
else:
paths.append(bleachbit.options_dir)
# prompt the user to confirm
if not GUI.shred_paths(self._window, paths, shred_settings=True):
logger.debug('user aborted shred')
# aborted
return
# Quit the application through the idle loop to allow the worker
# to delete the files. Use the lowest priority because the worker
# uses the standard priority. Otherwise, this will quit before
# the files are deleted.
#
# Rebuild a minimal bleachbit.ini when quitting
GLib.idle_add(self.quit, None, None, True,
priority=GObject.PRIORITY_LOW)
def cb_wipe_free_space(self, action, param):
"""callback to wipe free space in arbitrary folder"""
path = GuiBasic.browse_folder(self._window,
_("Choose a folder"),
multiple=False, stock_button=_('_OK'))
if not path:
# user cancelled
return
backends['_gui'] = Cleaner.create_wipe_cleaner(path)
# execute
operations = {'_gui': ['free_disk_space']}
self._window.preview_or_run_operations(True, operations)
def get_preferences_dialog(self):
return self._window.get_preferences_dialog()
def cb_preferences_dialog(self, action, param):
"""Callback for preferences dialog"""
pref = self.get_preferences_dialog()
pref.run()
# In case the user changed the log level...
GUI.update_log_level(self._window)
def get_about_dialog(self):
dialog = Gtk.AboutDialog(comments=_("Program to clean unnecessary files"),
copyright='Copyright (C) 2008-2023 Andrew Ziem',
program_name=APP_NAME,
version=bleachbit.APP_VERSION,
website=bleachbit.APP_URL,
transient_for=self._window)
try:
with open(bleachbit.license_filename) as f_license:
dialog.set_license(f_license.read())
except (IOError, TypeError):
dialog.set_license(
_("GNU General Public License version 3 or later.\nSee https://www.gnu.org/licenses/gpl-3.0.txt"))
# dialog.set_name(APP_NAME)
# TRANSLATORS: Maintain the names of translators here.
# Launchpad does this automatically for translations
# typed in Launchpad. This is a special string shown
# in the 'About' box.
dialog.set_translator_credits(_("translator-credits"))
if appicon_path and os.path.exists(appicon_path):
icon = Gtk.Image.new_from_file(appicon_path)
dialog.set_logo(icon.get_pixbuf())
return dialog
def about(self, _action, _param):
"""Create and show the about dialog"""
dialog = self.get_about_dialog()
dialog.run()
dialog.destroy()
def do_startup(self):
Gtk.Application.do_startup(self)
self.build_app_menu()
def quit(self, _action=None, _param=None, init_configuration=False):
if init_configuration:
bleachbit.Options.init_configuration()
self._window.destroy()
def get_system_information_dialog(self):
"""Show system information dialog"""
dialog = Gtk.Dialog(_("System information"), self._window)
dialog.set_default_size(600, 400)
txtbuffer = Gtk.TextBuffer()
from bleachbit import SystemInformation
txt = SystemInformation.get_system_information()
txtbuffer.set_text(txt)
textview = Gtk.TextView.new_with_buffer(txtbuffer)
textview.set_editable(False)
swindow = Gtk.ScrolledWindow()
swindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
swindow.add(textview)
dialog.vbox.pack_start(swindow, True, True, 0)
dialog.add_buttons(Gtk.STOCK_COPY, 100,
Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)
return (dialog, txt)
def system_information_dialog(self, _action, _param):
dialog, txt = self.get_system_information_dialog()
dialog.show_all()
while True:
rc = dialog.run()
if rc != 100:
break
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clipboard.set_text(txt, -1)
dialog.destroy()
def do_activate(self):
if not self._window:
self._window = GUI(
application=self, title=APP_NAME, auto_exit=self._auto_exit)
if 'nt' == os.name:
Windows.check_dll_hijacking(self._window)
self._window.present()
if self._shred_paths:
GLib.idle_add(GUI.shred_paths, self._window, self._shred_paths, priority=GObject.PRIORITY_LOW)
# When we shred paths and auto exit with the Windows Explorer context menu command we close the
# application in GUI.shred_paths, because if it is closed from here there are problems.
# Most probably this is something related with how GTK handles idle quit calls.
elif self._auto_exit:
GLib.idle_add(self.quit,
priority=GObject.PRIORITY_LOW)
print('Success')
class TreeInfoModel:
"""Model holds information to be displayed in the tree view"""
def __init__(self):
self.tree_store = Gtk.TreeStore(
GObject.TYPE_STRING, GObject.TYPE_BOOLEAN, GObject.TYPE_PYOBJECT, GObject.TYPE_STRING)
if not self.tree_store:
raise Exception("cannot create tree store")
self.row_changed_handler_id = None
self.refresh_rows()
self.tree_store.set_sort_func(3, self.sort_func)
self.tree_store.set_sort_column_id(3, Gtk.SortType.ASCENDING)
def get_model(self):
"""Return the tree store"""
return self.tree_store
def on_row_changed(self, __treemodel, path, __iter):
"""Event handler for when a row changes"""
parent = self.tree_store[path[0]][2]
child = None
if len(path) == 2:
child = self.tree_store[path][2]
value = self.tree_store[path][1]
options.set_tree(parent, child, value)
def refresh_rows(self):
"""Clear rows (cleaners) and add them fresh"""
if self.row_changed_handler_id:
self.tree_store.disconnect(self.row_changed_handler_id)
self.tree_store.clear()
for key in sorted(backends):
if not any(backends[key].get_options()):
# localizations has no options, so it should be hidden
# https://github.com/az0/bleachbit/issues/110
continue
c_name = backends[key].get_name()
c_id = backends[key].get_id()
c_value = options.get_tree(c_id, None)
if not c_value and options.get('auto_hide') and backends[key].auto_hide():
logger.debug("automatically hiding cleaner '%s'", c_id)
continue
parent = self.tree_store.append(None, (c_name, c_value, c_id, ""))
for (o_id, o_name) in backends[key].get_options():
o_value = options.get_tree(c_id, o_id)
self.tree_store.append(parent, (o_name, o_value, o_id, ""))
self.row_changed_handler_id = self.tree_store.connect("row-changed",
self.on_row_changed)
def sort_func(self, model, iter1, iter2, _user_data):
"""Sort the tree by the id
Index 0 is the display name
Index 2 is the ID (e.g., cookies, vacuum).
Sorting by ID is functionally important, so that vacuuming is done
last, even for other languages. See https://github.com/bleachbit/bleachbit/issues/441
"""
value1 = model[iter1][2].lower()
value2 = model[iter2][2].lower()
if value1 == value2:
return 0
if value1 > value2:
return 1
return -1
class TreeDisplayModel:
"""Displays the info model in a view"""
def make_view(self, model, parent, context_menu_event):
"""Create and return a TreeView object"""
self.view = Gtk.TreeView.new_with_model(model)
# hide headers
self.view.set_headers_visible(False)
# listen for right click (context menu)
self.view.connect("button_press_event", context_menu_event)
# first column
self.renderer0 = Gtk.CellRendererText()
self.column0 = Gtk.TreeViewColumn(_("Name"), self.renderer0, text=0)
self.view.append_column(self.column0)
self.view.set_search_column(0)
# second column
self.renderer1 = Gtk.CellRendererToggle()
self.renderer1.set_property('activatable', True)
self.renderer1.connect('toggled', self.col1_toggled_cb, model, parent)
self.column1 = Gtk.TreeViewColumn(_("Active"), self.renderer1)
self.column1.add_attribute(self.renderer1, "active", 1)
self.view.append_column(self.column1)
# third column
self.renderer2 = Gtk.CellRendererText()
self.renderer2.set_alignment(1.0, 0.0)
# TRANSLATORS: Size is the label for the column that shows how
# much space an option would clean or did clean
self.column2 = Gtk.TreeViewColumn(_("Size"), self.renderer2, text=3)
self.column2.set_alignment(1.0)
self.view.append_column(self.column2)
# finish
self.view.expand_all()
return self.view
def set_cleaner(self, path, model, parent_window, value):
"""Activate or deactivate option of cleaner."""
assert isinstance(value, bool)
assert isinstance(model, Gtk.TreeStore)
cleaner_id = None
i = path
if isinstance(i, str):
# type is either str or gtk.TreeIter
i = model.get_iter(path)
parent = model.iter_parent(i)
if parent:
# this is an option (child), not a cleaner (parent)
cleaner_id = model[parent][2]
option_id = model[path][2]
if cleaner_id and value:
# When enabling an option, present any warnings.
# (When disabling an option, there is no need to present warnings.)
warning = backends[cleaner_id].get_warning(option_id)
# TRANSLATORS: %(cleaner) may be Firefox, System, etc.
# %(option) may be cache, logs, cookies, etc.
# %(warning) may be 'This option is really slow'
msg = _("Warning regarding %(cleaner)s - %(option)s:\n\n%(warning)s") % \
{'cleaner': model[parent][0],
'option': model[path][0],
'warning': warning}
if warning:
resp = GuiBasic.message_dialog(parent_window,
msg,
Gtk.MessageType.WARNING,
Gtk.ButtonsType.OK_CANCEL,
_('Confirm'))
if Gtk.ResponseType.OK != resp:
# user cancelled, so don't toggle option
return
model[path][1] = value
def col1_toggled_cb(self, cell, path, model, parent_window):
"""Callback for toggling cleaners"""
is_toggled_on = not model[path][1] # Is the new state enabled?
self.set_cleaner(path, model, parent_window, is_toggled_on)
i = model.get_iter(path)
parent = model.iter_parent(i)
if parent and is_toggled_on:
# If child is enabled, then also enable the parent.
model[parent][1] = True
# If all siblings were toggled off, then also disable the parent.
if parent and not is_toggled_on:
sibling = model.iter_nth_child(parent, 0)
any_sibling_enabled = False
while sibling:
if model[sibling][1]:
any_sibling_enabled = True
sibling = model.iter_next(sibling)
if not any_sibling_enabled:
model[parent][1] = False
# If toggled and has children, then do the same for each child.
child = model.iter_children(i)
while child:
self.set_cleaner(child, model, parent_window, is_toggled_on)
child = model.iter_next(child)
return
class GUI(Gtk.ApplicationWindow):
"""The main application GUI"""
_style_provider = None
_style_provider_regular = None
_style_provider_dark = None
def __init__(self, auto_exit, *args, **kwargs):
super(GUI, self).__init__(*args, **kwargs)
self._show_splash_screen()
self._auto_exit = auto_exit
self.set_wmclass(APP_NAME, APP_NAME)
self.populate_window()
# Redirect logging to the GUI.
bb_logger = logging.getLogger('bleachbit')
from bleachbit.Log import GtkLoggerHandler
self.gtklog = GtkLoggerHandler(self.append_text)
bb_logger.addHandler(self.gtklog)
# process any delayed logs
from bleachbit.Log import DelayLog
if isinstance(sys.stderr, DelayLog):
for msg in sys.stderr.read():
self.append_text(msg)
# if stderr was redirected - keep redirecting it
sys.stderr = self.gtklog
self.set_windows10_theme()
Gtk.Settings.get_default().set_property(
'gtk-application-prefer-dark-theme', options.get('dark_mode'))
if options.is_corrupt():
logger.error(
_('Resetting the configuration file because it is corrupt: %s') % bleachbit.options_file)
bleachbit.Options.init_configuration()
GLib.idle_add(self.cb_refresh_operations)
def _show_splash_screen(self):
if os.name != 'nt':
return
font_conf_file = Windows.get_font_conf_file()
if not os.path.exists(font_conf_file):
logger.error('No fonts.conf file {}'.format(font_conf_file))
return
has_cache = Windows.has_fontconfig_cache(font_conf_file)
if not has_cache:
Windows.splash_thread.start()
def _confirm_delete(self, mention_preview, shred_settings=False):
if options.get("delete_confirmation"):
return GuiBasic.delete_confirmation_dialog(self, mention_preview, shred_settings=shred_settings)
return True
def destroy(self):
"""Prevent textbuffer usage during UI destruction"""
self.textbuffer = None
super(GUI, self).destroy()
def get_preferences_dialog(self):
return PreferencesDialog(
self,
self.cb_refresh_operations,
self.set_windows10_theme)
def shred_paths(self, paths, shred_settings=False):
"""Shred file or folders
When shredding_settings=True:
If user confirms to delete, then returns True. If user aborts, returns
False.
"""
# create a temporary cleaner object
backends['_gui'] = Cleaner.create_simple_cleaner(paths)
# preview and confirm
operations = {'_gui': ['files']}
self.preview_or_run_operations(False, operations)
if self._confirm_delete(False, shred_settings):
# delete
self.preview_or_run_operations(True, operations)
if shred_settings:
return True
if self._auto_exit:
GLib.idle_add(self.close,
priority=GObject.PRIORITY_LOW)
# user aborted
return False
def append_text(self, text, tag=None, __iter=None, scroll=True):
"""Add some text to the main log"""
if self.textbuffer is None:
# textbuffer was destroyed.
return
if not __iter:
__iter = self.textbuffer.get_end_iter()
if tag:
self.textbuffer.insert_with_tags_by_name(__iter, text, tag)
else:
self.textbuffer.insert(__iter, text)
# Scroll to end. If the command is run directly instead of
# through the idle loop, it may only scroll most of the way
# as seen on Ubuntu 9.04 with Italian and Spanish.
if scroll:
GLib.idle_add(lambda: self.textbuffer is not None and
self.textview.scroll_mark_onscreen(
self.textbuffer.get_insert()))
def update_log_level(self):
"""This gets called when the log level might have changed via the preferences."""
self.gtklog.update_log_level()
def on_selection_changed(self, selection):
"""When the tree view selection changed"""
model = self.view.get_model()
selected_rows = selection.get_selected_rows()
if not selected_rows[1]: # empty
# happens when searching in the tree view
return
paths = selected_rows[1][0]
row = paths[0]
name = model[row][0]
cleaner_id = model[row][2]
self.progressbar.hide()
description = backends[cleaner_id].get_description()
self.textbuffer.set_text("")
self.append_text(name + "\n", 'operation', scroll=False)
if not description:
description = ""
self.append_text(description + "\n\n\n", 'description', scroll=False)
for (label, description) in backends[cleaner_id].get_option_descriptions():
self.append_text(label, 'option_label', scroll=False)
if description:
self.append_text(': ', 'option_label', scroll=False)
self.append_text(description, scroll=False)
self.append_text("\n\n", scroll=False)
def get_selected_operations(self):
"""Return a list of the IDs of the selected operations in the tree view"""
ret = []
model = self.tree_store.get_model()
path = Gtk.TreePath(0)
__iter = model.get_iter(path)
while __iter:
if model[__iter][1]:
ret.append(model[__iter][2])
__iter = model.iter_next(__iter)
return ret
def get_operation_options(self, operation):
"""For the given operation ID, return a list of the selected option IDs."""
ret = []
model = self.tree_store.get_model()
path = Gtk.TreePath(0)
__iter = model.get_iter(path)
while __iter:
if operation == model[__iter][2]:
iterc = model.iter_children(__iter)
if not iterc:
return None
while iterc:
if model[iterc][1]:
# option is enabled
ret.append(model[iterc][2])
iterc = model.iter_next(iterc)
return ret
__iter = model.iter_next(__iter)
return None
def set_sensitive(self, is_sensitive):
"""Disable commands while an operation is running"""
self.view.set_sensitive(is_sensitive)
self.preview_button.set_sensitive(is_sensitive)
self.run_button.set_sensitive(is_sensitive)
self.stop_button.set_sensitive(not is_sensitive)
def run_operations(self, __widget):
"""Event when the 'delete' toolbar button is clicked."""
# fixme: should present this dialog after finding operations
# Disable delete confirmation message.
# if the option is selected under preference.
if self._confirm_delete(True):
self.preview_or_run_operations(True)
def preview_or_run_operations(self, really_delete, operations=None):
"""Preview operations or run operations (delete files)"""
assert isinstance(really_delete, bool)
from bleachbit import Worker
self.start_time = None
if not operations:
operations = {
operation: self.get_operation_options(operation)
for operation in self.get_selected_operations()
}
assert isinstance(operations, dict)
if not operations: # empty
GuiBasic.message_dialog(self,
_("You must select an operation"),
Gtk.MessageType.ERROR,
Gtk.ButtonsType.OK,
_('Error'))
return
try:
self.set_sensitive(False)
self.textbuffer.set_text("")
self.progressbar.show()
self.worker = Worker.Worker(self, really_delete, operations)
except Exception:
logger.exception('Error in Worker()')
else:
self.start_time = time.time()
worker = self.worker.run()
GLib.idle_add(worker.__next__)
def worker_done(self, worker, really_delete):
"""Callback for when Worker is done"""
self.progressbar.set_text("")
self.progressbar.set_fraction(1)
self.progressbar.set_text(_("Done."))
self.textview.scroll_mark_onscreen(self.textbuffer.get_insert())
self.set_sensitive(True)
# Close the program after cleaning is completed.
# if the option is selected under preference.
if really_delete:
if options.get("exit_done"):
sys.exit()
# notification for long-running process
elapsed = (time.time() - self.start_time)
logger.debug('elapsed time: %d seconds', elapsed)
if elapsed < 10 or self.is_active():
return
notify(_("Done."))
def create_operations_box(self):
"""Create and return the operations box (which holds a tree view)"""
scrolled_window = Gtk.ScrolledWindow()
scrolled_window.set_policy(
Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scrolled_window.set_overlay_scrolling(False)
self.tree_store = TreeInfoModel()
display = TreeDisplayModel()
mdl = self.tree_store.get_model()
self.view = display.make_view(
mdl, self, self.context_menu_event)
self.view.get_selection().connect("changed", self.on_selection_changed)
scrollbar_width = scrolled_window.get_vscrollbar().get_preferred_width()[1]
self.view.set_margin_right(scrollbar_width) # avoid conflict with scrollbar
scrolled_window.add(self.view)
return scrolled_window
def cb_refresh_operations(self):
"""Callback to refresh the list of cleaners"""
# Is this the first time in this session?
if not hasattr(self, 'recognized_cleanerml') and not self._auto_exit:
from bleachbit import RecognizeCleanerML
RecognizeCleanerML.RecognizeCleanerML()
self.recognized_cleanerml = True
# reload cleaners from disk
self.view.expand_all()
self.progressbar.show()
rc = register_cleaners(self.update_progress_bar,
self.cb_register_cleaners_done)
GLib.idle_add(rc.__next__)
return False
def cb_register_cleaners_done(self):
"""Called from register_cleaners()"""
self.progressbar.hide()
# update tree view
self.tree_store.refresh_rows()
# expand tree view
self.view.expand_all()
# Check for online updates.
if not self._auto_exit and \
bleachbit.online_update_notification_enabled and \
options.get("check_online_updates") and \
not hasattr(self, 'checked_for_updates'):
self.checked_for_updates = True
self.check_online_updates()
# Show information for first start.
# (The first start flag is set also for each new version.)
if options.get("first_start") and not self._auto_exit:
if os.name == 'posix':
self.append_text(
_('Access the application menu by clicking the hamburger icon on the title bar.'))
pref = self.get_preferences_dialog()
pref.run()
elif os.name == 'nt':
self.append_text(
_('Access the application menu by clicking the logo on the title bar.'))
options.set('first_start', False)
if os.name == 'nt':
# BitDefender false positive. BitDefender didn't mark BleachBit as infected or show
# anything in its log, but sqlite would fail to import unless BitDefender was in "game mode."
# http://bleachbit.sourceforge.net/forum/074-fails-errors
try:
import sqlite3
except ImportError as e:
self.append_text(
_("Error loading the SQLite module: the antivirus software may be blocking it."), 'error')
# Show notice about admin privileges.
if os.name == 'posix' and os.path.expanduser('~') == '/root':
self.append_text(
_('You are running BleachBit with administrative privileges for cleaning shared parts of the system, and references to the user profile folder will clean only the root account.')+'\n')
if os.name == 'nt' and options.get('shred'):
from win32com.shell.shell import IsUserAnAdmin
if not IsUserAnAdmin():
self.append_text(
_('Run BleachBit with administrator privileges to improve the accuracy of overwriting the contents of files.'))
self.append_text('\n')
if 'windowsapps' in sys.executable.lower():
self.append_text(
_('There is no official version of BleachBit on the Microsoft Store. Get the genuine version at https://www.bleachbit.org where it is always free of charge.') + '\n', 'error')
# remove from idle loop (see GObject.idle_add)
return False
def cb_run_option(self, widget, really_delete, cleaner_id, option_id):
"""Callback from context menu to delete/preview a single option"""
operations = {cleaner_id: [option_id]}
# preview
if not really_delete:
self.preview_or_run_operations(False, operations)
return
# delete
if self._confirm_delete(False):
self.preview_or_run_operations(True, operations)
return
def cb_stop_operations(self, __widget):
"""Callback to stop the preview/cleaning process"""
self.worker.abort()
def context_menu_event(self, treeview, event):
"""When user right clicks on the tree view"""
if event.button != 3:
return False
pathinfo = treeview.get_path_at_pos(int(event.x), int(event.y))
if not pathinfo:
return False
path, col, _cellx, _celly = pathinfo
treeview.grab_focus()
treeview.set_cursor(path, col, 0)
# context menu applies only to children, not parents
if len(path) != 2:
return False
# find the selected option
model = treeview.get_model()
option_id = model[path][2]
cleaner_id = model[path[0]][2]
# make a menu
menu = Gtk.Menu()
menu.connect('hide', lambda widget: widget.detach())
# TRANSLATORS: this is the context menu
preview_item = Gtk.MenuItem(label=_("Preview"))
preview_item.connect('activate', self.cb_run_option,
False, cleaner_id, option_id)
menu.append(preview_item)
# TRANSLATORS: this is the context menu
clean_item = Gtk.MenuItem(label=_("Clean"))
clean_item.connect('activate', self.cb_run_option,
True, cleaner_id, option_id)
menu.append(clean_item)
# show the context menu
menu.attach_to_widget(treeview)
menu.show_all()
menu.popup(None, None, None, None, event.button, event.time)
return True
def setup_drag_n_drop(self):
def cb_drag_data_received(widget, _context, _x, _y, data, info, _time):
if info == 80:
uris = data.get_uris()
paths = FileUtilities.uris_to_paths(uris)
self.shred_paths(paths)
def setup_widget(widget):
widget.drag_dest_set(Gtk.DestDefaults.MOTION | Gtk.DestDefaults.HIGHLIGHT | Gtk.DestDefaults.DROP,
[Gtk.TargetEntry.new("text/uri-list", 0, 80)], Gdk.DragAction.COPY)
widget.connect('drag_data_received', cb_drag_data_received)
setup_widget(self)
setup_widget(self.textview)
self.textview.connect('drag_motion', lambda widget,
context, x, y, time: True)
def update_progress_bar(self, status):
"""Callback to update the progress bar with number or text"""
if isinstance(status, float):
self.progressbar.set_fraction(status)
elif isinstance(status, str):
self.progressbar.set_show_text(True)
self.progressbar.set_text(status)
else:
raise RuntimeError('unexpected type: ' + str(type(status)))
def update_item_size(self, option, option_id, bytes_removed):
"""Update size in tree control"""
model = self.view.get_model()
text = FileUtilities.bytes_to_human(bytes_removed)
if bytes_removed == 0:
text = ""
treepath = Gtk.TreePath(0)
try:
__iter = model.get_iter(treepath)
except ValueError as e:
logger.warning(
'ValueError in get_iter() when updating file size for tree path=%s' % treepath)
return
while __iter:
if model[__iter][2] == option:
if option_id == -1:
model[__iter][3] = text
else:
child = model.iter_children(__iter)
while child:
if model[child][2] == option_id:
model[child][3] = text
child = model.iter_next(child)
__iter = model.iter_next(__iter)
def update_total_size(self, bytes_removed):
"""Callback to update the total size cleaned"""
context_id = self.status_bar.get_context_id('size')
text = FileUtilities.bytes_to_human(bytes_removed)
if bytes_removed == 0:
text = ""
self.status_bar.push(context_id, text)
def create_headerbar(self):
"""Create the headerbar"""
hbar = Gtk.HeaderBar()
hbar.props.show_close_button = True
hbar.props.title = APP_NAME
box = Gtk.Box()
Gtk.StyleContext.add_class(box.get_style_context(), "linked")
if os.name == 'nt':
icon_size = Gtk.IconSize.BUTTON
else:
icon_size = Gtk.IconSize.LARGE_TOOLBAR
# create the preview button
self.preview_button = Gtk.Button.new_from_icon_name(
'edit-find', icon_size)
self.preview_button.set_always_show_image(True)
self.preview_button.connect(
'clicked', lambda *dummy: self.preview_or_run_operations(False))
self.preview_button.set_tooltip_text(
_("Preview files in the selected operations (without deleting any files)"))
# TRANSLATORS: This is the preview button on the main window. It
# previews changes.
self.preview_button.set_label(_('Preview'))
box.add(self.preview_button)
# create the delete button
self.run_button = Gtk.Button.new_from_icon_name(
'edit-clear-all', icon_size)
self.run_button.set_always_show_image(True)
# TRANSLATORS: This is the clean button on the main window.
# It makes permanent changes: usually deleting files, sometimes
# altering them.
self.run_button.set_label(_('Clean'))
self.run_button.set_tooltip_text(
_("Clean files in the selected operations"))
self.run_button.connect("clicked", self.run_operations)
box.add(self.run_button)
# stop cleaning
self.stop_button = Gtk.Button.new_from_icon_name(
'process-stop', icon_size)
self.stop_button.set_always_show_image(True)
self.stop_button.set_label(_('Abort'))
self.stop_button.set_tooltip_text(
_('Abort the preview or cleaning process'))
self.stop_button.set_sensitive(False)
self.stop_button.connect('clicked', self.cb_stop_operations)
box.add(self.stop_button)
hbar.pack_start(box)
# Add hamburger menu on the right.
# This is not needed for Microsoft Windows because other code places its
# menu on the left side.
if os.name == 'nt':
return hbar
menu_button = Gtk.MenuButton()
icon = Gio.ThemedIcon(name="open-menu-symbolic")
image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON)
builder = Gtk.Builder()
builder.add_from_file(bleachbit.app_menu_filename)
menu_button.set_menu_model(builder.get_object('app-menu'))
menu_button.add(image)
hbar.pack_end(menu_button)
return hbar
def on_configure_event(self, widget, event):
(x, y) = self.get_position()
(width, height) = self.get_size()
# fixup maximized window position:
# on Windows if a window is maximized on a secondary monitor it is moved off the screen
if 'nt' == os.name:
window = self.get_window()
if window.get_state() & Gdk.WindowState.MAXIMIZED != 0:
screen = self.get_screen()
monitor_num = screen.get_monitor_at_window(window)
g = screen.get_monitor_geometry(monitor_num)
if x < g.x or x >= g.x + g.width or y < g.y or y >= g.y + g.height:
logger.debug("Maximized window {}+{}: monitor ({}) geometry = {}+{}".format(
(x, y), (width, height), monitor_num, (g.x, g.y), (g.width, g.height)))
self.move(g.x, g.y)
return True
# save window position and size
options.set("window_x", x, commit=False)
options.set("window_y", y, commit=False)
options.set("window_width", width, commit=False)
options.set("window_height", height, commit=False)
return False
def on_window_state_event(self, widget, event):
# save window state
maximized = event.new_window_state & Gdk.WindowState.MAXIMIZED != 0
options.set("window_maximized", maximized, commit=False)
return False
def on_delete_event(self, widget, event):
# commit options to disk
options.commit()
return False
def on_show(self, widget):
if 'nt' == os.name and Windows.splash_thread.is_alive():
Windows.splash_thread.join(0)
# restore window position, size and state
if not options.get('remember_geometry'):
return
if options.has_option("window_x") and options.has_option("window_y") and \
options.has_option("window_width") and options.has_option("window_height"):
r = Gdk.Rectangle()
(r.x, r.y) = (options.get("window_x"), options.get("window_y"))
(r.width, r.height) = (options.get(
"window_width"), options.get("window_height"))
screen = self.get_screen()
monitor_num = screen.get_monitor_at_point(r.x, r.y)
g = screen.get_monitor_geometry(monitor_num)
# only restore position and size if window left corner
# is within the closest monitor
if r.x >= g.x and r.x < g.x + g.width and \
r.y >= g.y and r.y < g.y + g.height:
logger.debug("closest monitor ({}) geometry = {}+{}, window geometry = {}+{}".format(
monitor_num, (g.x, g.y), (g.width, g.height), (r.x, r.y), (r.width, r.height)))
self.move(r.x, r.y)
self.resize(r.width, r.height)
if options.get("window_maximized"):
self.maximize()
def set_windows10_theme(self):
"""Toggle the Windows 10 theme"""
if not 'nt' == os.name:
return
if not self._style_provider_regular:
self._style_provider_regular = Gtk.CssProvider()
self._style_provider_regular.load_from_path(
os.path.join(windows10_theme_path, 'gtk.css'))
if not self._style_provider_dark:
self._style_provider_dark = Gtk.CssProvider()
self._style_provider_dark.load_from_path(
os.path.join(windows10_theme_path, 'gtk-dark.css'))
screen = Gdk.Display.get_default_screen(Gdk.Display.get_default())
if self._style_provider is not None:
Gtk.StyleContext.remove_provider_for_screen(
screen, self._style_provider)
if options.get("win10_theme"):
if options.get("dark_mode"):
self._style_provider = self._style_provider_dark
else:
self._style_provider = self._style_provider_regular
Gtk.StyleContext.add_provider_for_screen(
screen, self._style_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
else:
self._style_provider = None
def populate_window(self):
"""Create the main application window"""
screen = self.get_screen()
self.set_default_size(min(screen.width(), 800),
min(screen.height(), 600))
self.set_position(Gtk.WindowPosition.CENTER)
self.connect("configure-event", self.on_configure_event)
self.connect("window-state-event", self.on_window_state_event)
self.connect("delete-event", self.on_delete_event)
self.connect("show", self.on_show)
if appicon_path and os.path.exists(appicon_path):
self.set_icon_from_file(appicon_path)
# add headerbar
self.headerbar = self.create_headerbar()
self.set_titlebar(self.headerbar)
# split main window twice
hbox = Gtk.Box(homogeneous=False)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, homogeneous=False)
self.add(vbox)
vbox.add(hbox)
# add operations to left
operations = self.create_operations_box()
hbox.pack_start(operations, False, True, 0)
# create the right side of the window
right_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.progressbar = Gtk.ProgressBar()
right_box.pack_start(self.progressbar, False, True, 0)
# add output display on right
self.textbuffer = Gtk.TextBuffer()
swindow = Gtk.ScrolledWindow()
swindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
swindow.set_property('expand', True)
self.textview = Gtk.TextView.new_with_buffer(self.textbuffer)
self.textview.set_editable(False)
self.textview.set_wrap_mode(Gtk.WrapMode.WORD)
swindow.add(self.textview)
right_box.add(swindow)
hbox.add(right_box)
# add markup tags
tt = self.textbuffer.get_tag_table()
style_operation = Gtk.TextTag.new('operation')
style_operation.set_property('size-points', 14)
style_operation.set_property('weight', 700)
style_operation.set_property('pixels-above-lines', 10)
style_operation.set_property('justification', Gtk.Justification.CENTER)
tt.add(style_operation)
style_description = Gtk.TextTag.new('description')
style_description.set_property(
'justification', Gtk.Justification.CENTER)
tt.add(style_description)
style_option_label = Gtk.TextTag.new('option_label')
style_option_label.set_property('weight', 700)
style_option_label.set_property('left-margin', 20)
tt.add(style_option_label)
style_operation = Gtk.TextTag.new('error')
style_operation.set_property('foreground', '#b00000')
tt.add(style_operation)
self.status_bar = Gtk.Statusbar()
vbox.add(self.status_bar)
# setup drag&drop
self.setup_drag_n_drop()
# done
self.show_all()
self.progressbar.hide()
@threaded
def check_online_updates(self):
"""Check for software updates in background"""
from bleachbit import Update
try:
updates = Update.check_updates(options.get('check_beta'),
options.get('update_winapp2'),
self.append_text,
lambda: GLib.idle_add(self.cb_refresh_operations))
if updates:
GLib.idle_add(
lambda: Update.update_dialog(self, updates))
except Exception:
logger.exception(_("Error when checking for updates: "))
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/General.py 0000775 0001750 0001750 00000013442 14522012661 014656 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
General code
"""
import logging
import os
import sys
import bleachbit
logger = logging.getLogger(__name__)
#
# XML
#
def boolstr_to_bool(value):
"""Convert a string boolean to a Python boolean"""
if 'true' == value.lower():
return True
if 'false' == value.lower():
return False
raise RuntimeError("Invalid boolean: '%s'" % value)
def getText(nodelist):
"""Return the text data in an XML node
http://docs.python.org/library/xml.dom.minidom.html"""
rc = "".join(
node.data for node in nodelist if node.nodeType == node.TEXT_NODE
)
return rc
#
# General
#
class WindowsError(Exception):
"""Dummy class for non-Windows systems"""
def __str__(self):
return 'this is a dummy class for non-Windows systems'
def chownself(path):
"""Set path owner to real self when running in sudo.
If sudo creates a path and the owner isn't changed, the
owner may not be able to access the path."""
if 'posix' != os.name:
return
uid = getrealuid()
logger.debug('chown(%s, uid=%s)', path, uid)
if 0 == path.find('/root'):
logger.info('chown for path /root aborted')
return
try:
os.chown(path, uid, -1)
except:
logger.exception('Error in chown() under chownself()')
def getrealuid():
"""Get the real user ID when running in sudo mode"""
if 'posix' != os.name:
raise RuntimeError('getrealuid() requires POSIX')
if os.getenv('SUDO_UID'):
return int(os.getenv('SUDO_UID'))
try:
login = os.getlogin()
# On Ubuntu 9.04, getlogin() under sudo returns non-root user.
# On Fedora 11, getlogin() under sudo returns 'root'.
# On Fedora 11, getlogin() under su returns non-root user.
except:
login = os.getenv('LOGNAME')
if login and 'root' != login:
import pwd
return pwd.getpwnam(login)[3]
return os.getuid()
def makedirs(path):
"""Make directory recursively considering sudo permissions.
'Path' should not end in a delimiter."""
logger.debug('makedirs(%s)', path)
if os.path.lexists(path):
return
parentdir = os.path.split(path)[0]
if not os.path.lexists(parentdir):
makedirs(parentdir)
os.mkdir(path, 0o700)
if sudo_mode():
chownself(path)
def run_external(args, stdout=None, env=None, clean_env=True):
"""Run external command and return (return code, stdout, stderr)"""
logger.debug('running cmd ' + ' '.join(args))
import subprocess
if stdout is None:
stdout = subprocess.PIPE
kwargs = {}
encoding = bleachbit.stdout_encoding
if sys.platform == 'win32':
# hide the 'DOS box' window
import win32process
import win32con
stui = subprocess.STARTUPINFO()
stui.dwFlags = win32process.STARTF_USESHOWWINDOW
stui.wShowWindow = win32con.SW_HIDE
kwargs['startupinfo'] = stui
encoding='mbcs'
if not env and clean_env and 'posix' == os.name:
# Clean environment variables so that that subprocesses use English
# instead of translated text. This helps when checking for certain
# strings in the output.
# https://github.com/bleachbit/bleachbit/issues/167
# https://github.com/bleachbit/bleachbit/issues/168
keep_env = ('PATH', 'HOME', 'LD_LIBRARY_PATH', 'TMPDIR', 'BLEACHBIT_TEST_OPTIONS_DIR')
env = {key: value for key, value in os.environ.items() if key in keep_env}
env['LANG'] = 'C'
env['LC_ALL'] = 'C'
p = subprocess.Popen(args, stdout=stdout,
stderr=subprocess.PIPE, env=env, **kwargs)
try:
out = p.communicate()
except KeyboardInterrupt:
out = p.communicate()
print(out[0])
print(out[1])
raise
return (p.returncode,
str(out[0], encoding=encoding) if out[0] else '',
str(out[1], encoding=encoding) if out[1] else '')
def startup_check():
"""At application startup, check some things are okay."""
if 'nt' == os.name:
from bleachbit.Windows import check_dll_hijacking
check_dll_hijacking()
# BitDefender false positive. BitDefender didn't mark BleachBit as infected or show
# anything in its log, but sqlite would fail to import unless BitDefender was in "game mode."
# https://www.bleachbit.org/forum/074-fails-errors
from bleachbit import ModuleNotFoundError
try:
from sqlite3 import SQLITE_OK
except (ModuleNotFoundError, ImportError):
logger.exception(
'The sqlite3 module could not be loaded. On Windows, the antivirus may be blocking it. On FreeBSD, install the package databases/py-sqlite3.')
def sudo_mode():
"""Return whether running in sudo mode"""
if not sys.platform.startswith('linux'):
return False
# if 'root' == os.getenv('USER'):
# gksu in Ubuntu 9.10 changes the username. If the username is root,
# we're practically not in sudo mode.
# Fedora 13: os.getenv('USER') = 'root' under sudo
# return False
return os.getenv('SUDO_UID') is not None
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/GuiBasic.py 0000775 0001750 0001750 00000016712 14522012661 014772 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Basic GUI code
"""
from bleachbit import _, ModuleNotFoundError
import os
try:
import gi
except ModuleNotFoundError as e:
print('*'*60)
print('Please install PyGObject')
print('https://pygobject.readthedocs.io/en/latest/getting_started.html')
print('*'*60)
raise e
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, Gdk # keep after gi.require_version()
if os.name == 'nt':
from bleachbit import Windows
def browse_folder(parent, title, multiple, stock_button):
"""Ask the user to select a folder. Return the full path or None."""
if os.name == 'nt' and not os.getenv('BB_NATIVE'):
ret = Windows.browse_folder(parent, title)
return [ret] if multiple and not ret is None else ret
# fall back to GTK+
chooser = Gtk.FileChooserDialog(transient_for=parent,
title=title,
action=Gtk.FileChooserAction.SELECT_FOLDER)
chooser.add_buttons(_("_Cancel"), Gtk.ResponseType.CANCEL,
stock_button, Gtk.ResponseType.OK)
chooser.set_default_response(Gtk.ResponseType.OK)
chooser.set_select_multiple(multiple)
chooser.set_current_folder(os.path.expanduser('~'))
resp = chooser.run()
if multiple:
ret = chooser.get_filenames()
else:
ret = chooser.get_filename()
chooser.hide()
chooser.destroy()
if Gtk.ResponseType.OK != resp:
# user cancelled
return None
return ret
def browse_file(parent, title):
"""Prompt user to select a single file"""
if os.name == 'nt' and not os.getenv('BB_NATIVE'):
return Windows.browse_file(parent, title)
chooser = Gtk.FileChooserDialog(title=title,
transient_for=parent,
action=Gtk.FileChooserAction.OPEN)
chooser.add_buttons(_("_Cancel"), Gtk.ResponseType.CANCEL,
_("_Open"), Gtk.ResponseType.OK)
chooser.set_default_response(Gtk.ResponseType.OK)
chooser.set_current_folder(os.path.expanduser('~'))
resp = chooser.run()
path = chooser.get_filename()
chooser.destroy()
if Gtk.ResponseType.OK != resp:
# user cancelled
return None
return path
def browse_files(parent, title):
"""Prompt user to select multiple files to delete"""
if os.name == 'nt' and not os.getenv('BB_NATIVE'):
return Windows.browse_files(parent, title)
chooser = Gtk.FileChooserDialog(title=title,
transient_for=parent,
action=Gtk.FileChooserAction.OPEN)
chooser.add_buttons(_("_Cancel"), Gtk.ResponseType.CANCEL,
_("_Delete"), Gtk.ResponseType.OK)
chooser.set_default_response(Gtk.ResponseType.OK)
chooser.set_select_multiple(True)
chooser.set_current_folder(os.path.expanduser('~'))
resp = chooser.run()
paths = chooser.get_filenames()
chooser.destroy()
if Gtk.ResponseType.OK != resp:
# user cancelled
return None
return paths
def delete_confirmation_dialog(parent, mention_preview, shred_settings=False):
"""Return boolean whether OK to delete files."""
dialog = Gtk.Dialog(title=_("Delete confirmation"), transient_for=parent,
modal=True,
destroy_with_parent=True)
dialog.set_default_size(300, -1)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL,
homogeneous=False, spacing=10)
if shred_settings:
notice_text = _("This function deletes all BleachBit settings and then quits the application. Use this to hide your use of BleachBit or to reset its settings. The next time you start BleachBit, the settings will initialize to default values.")
notice = Gtk.Label(label=notice_text)
notice.set_line_wrap(True)
vbox.pack_start(notice, False, True, 0)
if mention_preview:
question_text = _(
"Are you sure you want to permanently delete files according to the selected operations? The actual files that will be deleted may have changed since you ran the preview.")
else:
question_text = _(
"Are you sure you want to permanently delete these files?")
question = Gtk.Label(label=question_text)
question.set_line_wrap(True)
vbox.pack_start(question, False, True, 0)
dialog.get_content_area().pack_start(vbox, False, True, 0)
dialog.get_content_area().set_spacing(10)
dialog.add_button(_('_Delete'), Gtk.ResponseType.ACCEPT)
dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
dialog.set_default_response(Gtk.ResponseType.CANCEL)
dialog.show_all()
ret = dialog.run()
dialog.destroy()
return ret == Gtk.ResponseType.ACCEPT
def message_dialog(parent, msg, mtype=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, title=None):
"""Convenience wrapper for Gtk.MessageDialog"""
dialog = Gtk.MessageDialog(transient_for=parent,
modal=True,
destroy_with_parent=True,
message_type=mtype,
buttons=buttons,
text=msg)
if title:
dialog.set_title(title)
resp = dialog.run()
dialog.destroy()
return resp
def open_url(url, parent_window=None, prompt=True):
"""Open an HTTP URL. Try to run as non-root."""
# drop privileges so the web browser is running as a normal process
if os.name == 'posix' and os.getuid() == 0:
msg = _(
"Because you are running as root, please manually open this link in a web browser:\n%s") % url
message_dialog(None, msg, Gtk.MessageType.INFO)
return
if prompt:
# find hostname
import re
ret = re.search('^http(s)?://([a-z.]+)', url)
if not ret:
host = url
else:
host = ret.group(2)
# TRANSLATORS: %s expands to www.bleachbit.org or similar
msg = _("Open web browser to %s?") % host
resp = message_dialog(parent_window,
msg,
Gtk.MessageType.QUESTION,
Gtk.ButtonsType.OK_CANCEL,
_('Confirm'))
if Gtk.ResponseType.OK != resp:
return
# open web browser
if os.name == 'nt':
# in Gtk.show_uri() avoid 'glib.GError: No application is registered as
# handling this file'
import webbrowser
webbrowser.open(url)
elif (Gtk.get_major_version(), Gtk.get_minor_version()) < (3, 22):
# Ubuntu 16.04 LTS ships with GTK 3.18
Gtk.show_uri(None, url, Gdk.CURRENT_TIME)
else:
Gtk.show_uri_on_window(parent_window, url, Gdk.CURRENT_TIME)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/GuiChaff.py 0000775 0001750 0001750 00000016411 14522012661 014754 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# -*- coding: UTF-8 -*-
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
GUI for making chaff
"""
from bleachbit import _
from bleachbit.Chaff import download_models, generate_emails, generate_2600, have_models
from gi.repository import Gtk, GLib
import logging
import os
logger = logging.getLogger(__name__)
def make_files_thread(file_count, inspiration, output_folder, delete_when_finished, on_progress):
if inspiration == 0:
generated_file_names = generate_2600(
file_count, output_folder, on_progress=on_progress)
elif inspiration == 1:
generated_file_names = generate_emails(
file_count, output_folder, on_progress=on_progress)
if delete_when_finished:
on_progress(0, msg=_('Deleting files'))
for i in range(0, file_count):
os.unlink(generated_file_names[i])
on_progress(1.0 * (i+1)/file_count)
on_progress(1.0, is_done=True)
class ChaffDialog(Gtk.Dialog):
"""Present the dialog to make chaff"""
def __init__(self, parent):
self._make_dialog(parent)
def _make_dialog(self, parent):
"""Make the main dialog"""
# TRANSLATORS: BleachBit creates digital chaff like that is like the
# physical chaff airplanes use to protect themselves from radar-guided
# missiles. For more explanation, see the online documentation.
Gtk.Dialog.__init__(self, _("Make chaff"), parent)
Gtk.Dialog.set_modal(self,True)
self.set_border_width(5)
box = self.get_content_area()
label = Gtk.Label(
_("Make randomly-generated messages inspired by documents."))
box.add(label)
inspiration_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
# TRANSLATORS: Inspiration is a choice of documents from which random
# text will be generated.
inspiration_box.add(Gtk.Label(_("Inspiration")))
self.inspiration_combo = Gtk.ComboBoxText()
self.inspiration_combo_options = (
_('2600 Magazine'), _("Hillary Clinton's emails"))
for combo_option in self.inspiration_combo_options:
self.inspiration_combo.append_text(combo_option)
self.inspiration_combo.set_active(0) # Set default
inspiration_box.add(self.inspiration_combo)
box.add(inspiration_box)
spin_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
spin_box.add(Gtk.Label(_("Number of files")))
adjustment = Gtk.Adjustment(100, 1, 99999, 1, 1000, 0)
self.file_count = Gtk.SpinButton(adjustment=adjustment)
spin_box.add(self.file_count)
box.add(spin_box)
folder_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
folder_box.add(Gtk.Label(_("Select destination folder")))
self.choose_folder_button = Gtk.FileChooserButton()
self.choose_folder_button.set_action(
Gtk.FileChooserAction.SELECT_FOLDER)
import tempfile
self.choose_folder_button.set_filename(tempfile.gettempdir())
folder_box.add(self.choose_folder_button)
box.add(folder_box)
delete_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
delete_box.add(Gtk.Label(_("When finished")))
self.when_finished_combo = Gtk.ComboBoxText()
self.combo_options = (
_('Delete without shredding'), _('Do not delete'))
for combo_option in self.combo_options:
self.when_finished_combo.append_text(combo_option)
self.when_finished_combo.set_active(0) # Set default
delete_box.add(self.when_finished_combo)
box.add(delete_box)
self.progressbar = Gtk.ProgressBar()
box.add(self.progressbar)
self.progressbar.hide()
self.make_button = Gtk.Button(_("Make files"))
self.make_button.connect('clicked', self.on_make_files)
box.add(self.make_button)
def download_models_gui(self):
"""Download models and return whether successful as boolean"""
def on_download_error(msg, msg2):
dialog = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR,
Gtk.ButtonsType.CANCEL, msg)
dialog.format_secondary_text(msg2)
dialog.run()
dialog.destroy()
return download_models(on_error=on_download_error)
def download_models_dialog(self):
"""Download models"""
dialog = Gtk.MessageDialog(self, 0, Gtk.MessageType.QUESTION,
Gtk.ButtonsType.OK_CANCEL, _("Download data needed for chaff generator?"))
response = dialog.run()
ret = None
if response == Gtk.ResponseType.OK:
# User wants to download
ret = self.download_models_gui() # True if successful
elif response == Gtk.ResponseType.CANCEL:
ret = False
dialog.destroy()
return ret
def on_make_files(self, widget):
"""Callback for make files button"""
file_count = self.file_count.get_value_as_int()
output_dir = self.choose_folder_button.get_filename()
delete_when_finished = self.when_finished_combo.get_active() == 0
inspiration = self.inspiration_combo.get_active()
if not output_dir:
dialog = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR,
Gtk.ButtonsType.CANCEL, _("Select destination folder"))
dialog.run()
dialog.destroy()
return
if not have_models():
if not self.download_models_dialog():
return
def _on_progress(fraction, msg, is_done):
"""Update progress bar from GLib main loop"""
if msg:
self.progressbar.set_text(msg)
self.progressbar.set_fraction(fraction)
if is_done:
self.progressbar.hide()
self.make_button.set_sensitive(True)
def on_progress(fraction, msg=None, is_done=False):
"""Callback for progress bar"""
# Use idle_add() because threads cannot make GDK calls.
GLib.idle_add(_on_progress, fraction, msg, is_done)
msg = _('Generating files')
logger.info(msg)
self.progressbar.show()
self.progressbar.set_text(msg)
self.progressbar.set_show_text(True)
self.progressbar.set_fraction(0.0)
self.make_button.set_sensitive(False)
import threading
args = (file_count, inspiration, output_dir,
delete_when_finished, on_progress)
t = threading.Thread(target=make_files_thread, args=args)
t.start()
def run(self):
"""Run the dialog"""
self.show_all()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/GuiPreferences.py 0000664 0001750 0001750 00000053051 14522012661 016204 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# -*- coding: UTF-8 -*-
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Preferences dialog
"""
from bleachbit import _, _p, online_update_notification_enabled
from bleachbit.Options import options
from bleachbit import GuiBasic
from gi.repository import Gtk
import logging
import os
if 'nt' == os.name:
from bleachbit import Windows
if 'posix' == os.name:
from bleachbit import Unix
logger = logging.getLogger(__name__)
LOCATIONS_WHITELIST = 1
LOCATIONS_CUSTOM = 2
class PreferencesDialog:
"""Present the preferences dialog and save changes"""
def __init__(self, parent, cb_refresh_operations, cb_set_windows10_theme):
self.cb_refresh_operations = cb_refresh_operations
self.cb_set_windows10_theme = cb_set_windows10_theme
self.parent = parent
self.dialog = Gtk.Dialog(title=_("Preferences"),
transient_for=parent,
modal=True,
destroy_with_parent=True)
self.dialog.set_default_size(300, 200)
notebook = Gtk.Notebook()
notebook.append_page(self.__general_page(),
Gtk.Label(label=_("General")))
notebook.append_page(self.__locations_page(
LOCATIONS_CUSTOM), Gtk.Label(label=_("Custom")))
notebook.append_page(self.__drives_page(),
Gtk.Label(label=_("Drives")))
if 'posix' == os.name:
notebook.append_page(self.__languages_page(),
Gtk.Label(label=_("Languages")))
notebook.append_page(self.__locations_page(
LOCATIONS_WHITELIST), Gtk.Label(label=_("Whitelist")))
# pack_start parameters: child, expand (reserve space), fill (actually fill it), padding
self.dialog.get_content_area().pack_start(notebook, True, True, 0)
self.dialog.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)
self.refresh_operations = False
def __del__(self):
"""Destructor called when the dialog is closing"""
if self.refresh_operations:
# refresh the list of cleaners
self.cb_refresh_operations()
def __toggle_callback(self, cell, path):
"""Callback function to toggle option"""
options.toggle(path)
if online_update_notification_enabled:
self.cb_beta.set_sensitive(options.get('check_online_updates'))
if 'nt' == os.name:
self.cb_winapp2.set_sensitive(
options.get('check_online_updates'))
if 'auto_hide' == path:
self.refresh_operations = True
if 'dark_mode' == path:
if 'nt' == os.name and options.get('win10_theme'):
self.cb_set_windows10_theme()
Gtk.Settings.get_default().set_property(
'gtk-application-prefer-dark-theme', options.get('dark_mode'))
if 'win10_theme' == path:
self.cb_set_windows10_theme()
if 'debug' == path:
from bleachbit.Log import set_root_log_level
set_root_log_level(options.get('debug'))
if 'kde_shred_menu_option' == path:
from bleachbit.DesktopMenuOptions import install_kde_service_menu_file
install_kde_service_menu_file()
def __general_page(self):
"""Return a widget containing the general page"""
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
if online_update_notification_enabled:
cb_updates = Gtk.CheckButton.new_with_label(
_("Check periodically for software updates via the Internet"))
cb_updates.set_active(options.get('check_online_updates'))
cb_updates.connect(
'toggled', self.__toggle_callback, 'check_online_updates')
cb_updates.set_tooltip_text(
_("If an update is found, you will be given the option to view information about it. Then, you may manually download and install the update."))
vbox.pack_start(cb_updates, False, True, 0)
updates_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
updates_box.set_border_width(10)
self.cb_beta = Gtk.CheckButton.new_with_label(
label=_("Check for new beta releases"))
self.cb_beta.set_active(options.get('check_beta'))
self.cb_beta.set_sensitive(options.get('check_online_updates'))
self.cb_beta.connect(
'toggled', self.__toggle_callback, 'check_beta')
updates_box.pack_start(self.cb_beta, False, True, 0)
if 'nt' == os.name:
self.cb_winapp2 = Gtk.CheckButton.new_with_label(
_("Download and update cleaners from community (winapp2.ini)"))
self.cb_winapp2.set_active(options.get('update_winapp2'))
self.cb_winapp2.set_sensitive(
options.get('check_online_updates'))
self.cb_winapp2.connect(
'toggled', self.__toggle_callback, 'update_winapp2')
updates_box.pack_start(self.cb_winapp2, False, True, 0)
vbox.pack_start(updates_box, False, True, 0)
# TRANSLATORS: This means to hide cleaners which would do
# nothing. For example, if Firefox were never used on
# this system, this option would hide Firefox to simplify
# the list of cleaners.
cb_auto_hide = Gtk.CheckButton.new_with_label(
label=_("Hide irrelevant cleaners"))
cb_auto_hide.set_active(options.get('auto_hide'))
cb_auto_hide.connect('toggled', self.__toggle_callback, 'auto_hide')
vbox.pack_start(cb_auto_hide, False, True, 0)
# TRANSLATORS: Overwriting is the same as shredding. It is a way
# to prevent recovery of the data. You could also translate
# 'Shred files to prevent recovery.'
cb_shred = Gtk.CheckButton(
label=_("Overwrite contents of files to prevent recovery"))
cb_shred.set_active(options.get('shred'))
cb_shred.connect('toggled', self.__toggle_callback, 'shred')
cb_shred.set_tooltip_text(
_("Overwriting is ineffective on some file systems and with certain BleachBit operations. Overwriting is significantly slower."))
vbox.pack_start(cb_shred, False, True, 0)
# Close the application after cleaning is complete.
cb_exit = Gtk.CheckButton.new_with_label(
label=_("Exit after cleaning"))
cb_exit.set_active(options.get('exit_done'))
cb_exit.connect('toggled', self.__toggle_callback, 'exit_done')
vbox.pack_start(cb_exit, False, True, 0)
# Disable delete confirmation message.
cb_popup = Gtk.CheckButton(label=_("Confirm before delete"))
cb_popup.set_active(options.get('delete_confirmation'))
cb_popup.connect(
'toggled', self.__toggle_callback, 'delete_confirmation')
vbox.pack_start(cb_popup, False, True, 0)
# Use base 1000 over 1024?
cb_units_iec = Gtk.CheckButton(
label=_("Use IEC sizes (1 KiB = 1024 bytes) instead of SI (1 kB = 1000 bytes)"))
cb_units_iec.set_active(options.get("units_iec"))
cb_units_iec.connect('toggled', self.__toggle_callback, 'units_iec')
vbox.pack_start(cb_units_iec, False, True, 0)
if 'nt' == os.name:
# Dark theme
cb_win10_theme = Gtk.CheckButton(_("Windows 10 theme"))
cb_win10_theme.set_active(options.get("win10_theme"))
cb_win10_theme.connect(
'toggled', self.__toggle_callback, 'win10_theme')
vbox.pack_start(cb_win10_theme, False, True, 0)
# Dark theme
self.cb_dark_mode = Gtk.CheckButton(label=_("Dark mode"))
self.cb_dark_mode.set_active(options.get("dark_mode"))
self.cb_dark_mode.connect('toggled', self.__toggle_callback, 'dark_mode')
vbox.pack_start(self.cb_dark_mode, False, True, 0)
# Remember window geometry (position and size)
self.cb_geom = Gtk.CheckButton(label=_("Remember window geometry"))
self.cb_geom.set_active(options.get("remember_geometry"))
self.cb_geom.connect('toggled', self.__toggle_callback, 'remember_geometry')
vbox.pack_start(self.cb_geom, False, True, 0)
# Debug logging
cb_debug = Gtk.CheckButton(label=_("Show debug messages"))
cb_debug.set_active(options.get("debug"))
cb_debug.connect('toggled', self.__toggle_callback, 'debug')
vbox.pack_start(cb_debug, False, True, 0)
# KDE context menu shred option
cb_kde_shred_menu_option = Gtk.CheckButton(label=_("Add shred context menu option (KDE Plasma specific)"))
cb_kde_shred_menu_option.set_active(options.get("kde_shred_menu_option"))
cb_kde_shred_menu_option.connect('toggled', self.__toggle_callback, 'kde_shred_menu_option')
vbox.pack_start(cb_kde_shred_menu_option, False, True, 0)
return vbox
def __drives_page(self):
"""Return widget containing the drives page"""
def add_drive_cb(button):
"""Callback for adding a drive"""
title = _("Choose a folder")
pathname = GuiBasic.browse_folder(
self.parent, title, multiple=False, stock_button=Gtk.STOCK_ADD)
if pathname:
liststore.append([pathname])
pathnames.append(pathname)
options.set_list('shred_drives', pathnames)
def remove_drive_cb(button):
"""Callback for removing a drive"""
treeselection = treeview.get_selection()
(model, _iter) = treeselection.get_selected()
if None == _iter:
# nothing selected
return
pathname = model[_iter][0]
liststore.remove(_iter)
pathnames.remove(pathname)
options.set_list('shred_drives', pathnames)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# TRANSLATORS: 'free' means 'unallocated'
notice = Gtk.Label(label=_(
"Choose a writable folder for each drive for which to overwrite free space."))
notice.set_line_wrap(True)
vbox.pack_start(notice, False, True, 0)
liststore = Gtk.ListStore(str)
pathnames = options.get_list('shred_drives')
if pathnames:
pathnames = sorted(pathnames)
if not pathnames:
pathnames = []
for pathname in pathnames:
liststore.append([pathname])
treeview = Gtk.TreeView.new_with_model(liststore)
crt = Gtk.CellRendererText()
tvc = Gtk.TreeViewColumn(None, crt, text=0)
treeview.append_column(tvc)
vbox.pack_start(treeview, True, True, 0)
# TRANSLATORS: In the preferences dialog, this button adds a path to
# the list of paths
button_add = Gtk.Button.new_with_label(label=_p('button', 'Add'))
button_add.connect("clicked", add_drive_cb)
# TRANSLATORS: In the preferences dialog, this button removes a path
# from the list of paths
button_remove = Gtk.Button.new_with_label(label=_p('button', 'Remove'))
button_remove.connect("clicked", remove_drive_cb)
button_box = Gtk.ButtonBox(orientation=Gtk.Orientation.HORIZONTAL)
button_box.set_layout(Gtk.ButtonBoxStyle.START)
button_box.pack_start(button_add, True, True, 0)
button_box.pack_start(button_remove, True, True, 0)
vbox.pack_start(button_box, False, True, 0)
return vbox
def __languages_page(self):
"""Return widget containing the languages page"""
def preserve_toggled_cb(cell, path, liststore):
"""Callback for toggling the 'preserve' column"""
__iter = liststore.get_iter_from_string(path)
value = not liststore.get_value(__iter, 0)
liststore.set(__iter, 0, value)
langid = liststore[path][1]
options.set_language(langid, value)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
notice = Gtk.Label(
label=_("All languages will be deleted except those checked."))
vbox.pack_start(notice, False, False, 0)
# populate data
liststore = Gtk.ListStore('gboolean', str, str)
for lang, native in sorted(Unix.Locales.native_locale_names.items()):
liststore.append([(options.get_language(lang)), lang, native])
# create treeview
treeview = Gtk.TreeView.new_with_model(liststore)
# create column views
self.renderer0 = Gtk.CellRendererToggle()
self.renderer0.set_property('activatable', True)
self.renderer0.connect('toggled', preserve_toggled_cb, liststore)
self.column0 = Gtk.TreeViewColumn(
_("Preserve"), self.renderer0, active=0)
treeview.append_column(self.column0)
self.renderer1 = Gtk.CellRendererText()
self.column1 = Gtk.TreeViewColumn(_("Code"), self.renderer1, text=1)
treeview.append_column(self.column1)
self.renderer2 = Gtk.CellRendererText()
self.column2 = Gtk.TreeViewColumn(_("Name"), self.renderer2, text=2)
treeview.append_column(self.column2)
treeview.set_search_column(2)
# finish
swindow = Gtk.ScrolledWindow()
swindow.set_overlay_scrolling(False)
swindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
swindow.set_size_request(300, 200)
swindow.add(treeview)
vbox.pack_start(swindow, False, True, 0)
return vbox
def __locations_page(self, page_type):
"""Return a widget containing a list of files and folders"""
def add_whitelist_file_cb(button):
"""Callback for adding a file"""
title = _("Choose a file")
pathname = GuiBasic.browse_file(self.parent, title)
if pathname:
for this_pathname in pathnames:
if pathname == this_pathname[1]:
logger.warning(
"'%s' already exists in whitelist", pathname)
return
liststore.append([_('File'), pathname])
pathnames.append(['file', pathname])
options.set_whitelist_paths(pathnames)
def add_whitelist_folder_cb(button):
"""Callback for adding a folder"""
title = _("Choose a folder")
pathname = GuiBasic.browse_folder(self.parent, title,
multiple=False, stock_button=Gtk.STOCK_ADD)
if pathname:
for this_pathname in pathnames:
if pathname == this_pathname[1]:
logger.warning(
"'%s' already exists in whitelist", pathname)
return
liststore.append([_('Folder'), pathname])
pathnames.append(['folder', pathname])
options.set_whitelist_paths(pathnames)
def remove_whitelist_path_cb(button):
"""Callback for removing a path"""
treeselection = treeview.get_selection()
(model, _iter) = treeselection.get_selected()
if None == _iter:
# nothing selected
return
pathname = model[_iter][1]
liststore.remove(_iter)
for this_pathname in pathnames:
if this_pathname[1] == pathname:
pathnames.remove(this_pathname)
options.set_whitelist_paths(pathnames)
def add_custom_file_cb(button):
"""Callback for adding a file"""
title = _("Choose a file")
pathname = GuiBasic.browse_file(self.parent, title)
if pathname:
for this_pathname in pathnames:
if pathname == this_pathname[1]:
logger.warning(
"'%s' already exists in whitelist", pathname)
return
liststore.append([_('File'), pathname])
pathnames.append(['file', pathname])
options.set_custom_paths(pathnames)
def add_custom_folder_cb(button):
"""Callback for adding a folder"""
title = _("Choose a folder")
pathname = GuiBasic.browse_folder(self.parent, title,
multiple=False, stock_button=Gtk.STOCK_ADD)
if pathname:
for this_pathname in pathnames:
if pathname == this_pathname[1]:
logger.warning(
"'%s' already exists in whitelist", pathname)
return
liststore.append([_('Folder'), pathname])
pathnames.append(['folder', pathname])
options.set_custom_paths(pathnames)
def remove_custom_path_cb(button):
"""Callback for removing a path"""
treeselection = treeview.get_selection()
(model, _iter) = treeselection.get_selected()
if None == _iter:
# nothing selected
return
pathname = model[_iter][1]
liststore.remove(_iter)
for this_pathname in pathnames:
if this_pathname[1] == pathname:
pathnames.remove(this_pathname)
options.set_custom_paths(pathnames)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# load data
if LOCATIONS_WHITELIST == page_type:
pathnames = options.get_whitelist_paths()
elif LOCATIONS_CUSTOM == page_type:
pathnames = options.get_custom_paths()
liststore = Gtk.ListStore(str, str)
for paths in pathnames:
type_code = paths[0]
type_str = None
if type_code == 'file':
type_str = _('File')
elif type_code == 'folder':
type_str = _('Folder')
else:
raise RuntimeError("Invalid type code: '%s'" % type_code)
path = paths[1]
liststore.append([type_str, path])
if LOCATIONS_WHITELIST == page_type:
# TRANSLATORS: "Paths" is used generically to refer to both files
# and folders
notice = Gtk.Label(
label=_("These paths will not be deleted or modified."))
elif LOCATIONS_CUSTOM == page_type:
notice = Gtk.Label(
label=_("These locations can be selected for deletion."))
vbox.pack_start(notice, False, False, 0)
# create treeview
treeview = Gtk.TreeView.new_with_model(liststore)
# create column views
self.renderer0 = Gtk.CellRendererText()
self.column0 = Gtk.TreeViewColumn(_("Type"), self.renderer0, text=0)
treeview.append_column(self.column0)
self.renderer1 = Gtk.CellRendererText()
# TRANSLATORS: In the tree view "Path" is used generically to refer to a
# file, a folder, or a pattern describing either
self.column1 = Gtk.TreeViewColumn(_("Path"), self.renderer1, text=1)
treeview.append_column(self.column1)
treeview.set_search_column(1)
# finish tree view
swindow = Gtk.ScrolledWindow()
swindow.set_overlay_scrolling(False)
swindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
swindow.set_size_request(300, 200)
swindow.add(treeview)
vbox.pack_start(swindow, False, True, 0)
# buttons that modify the list
button_add_file = Gtk.Button.new_with_label(
label=_p('button', 'Add file'))
if LOCATIONS_WHITELIST == page_type:
button_add_file.connect("clicked", add_whitelist_file_cb)
elif LOCATIONS_CUSTOM == page_type:
button_add_file.connect("clicked", add_custom_file_cb)
button_add_folder = Gtk.Button.new_with_label(
label=_p('button', 'Add folder'))
if LOCATIONS_WHITELIST == page_type:
button_add_folder.connect("clicked", add_whitelist_folder_cb)
elif LOCATIONS_CUSTOM == page_type:
button_add_folder.connect("clicked", add_custom_folder_cb)
button_remove = Gtk.Button.new_with_label(label=_p('button', 'Remove'))
if LOCATIONS_WHITELIST == page_type:
button_remove.connect("clicked", remove_whitelist_path_cb)
elif LOCATIONS_CUSTOM == page_type:
button_remove.connect("clicked", remove_custom_path_cb)
button_box = Gtk.ButtonBox(orientation=Gtk.Orientation.HORIZONTAL)
button_box.set_layout(Gtk.ButtonBoxStyle.START)
button_box.pack_start(button_add_file, True, True, 0)
button_box.pack_start(button_add_folder, True, True, 0)
button_box.pack_start(button_remove, True, True, 0)
vbox.pack_start(button_box, False, True, 0)
# return page
return vbox
def run(self):
"""Run the dialog"""
self.dialog.show_all()
self.dialog.run()
self.dialog.destroy()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/Log.py 0000664 0001750 0001750 00000006536 14522012661 014025 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Logging
"""
import logging
def is_debugging_enabled_via_cli():
"""Return boolean whether user required debugging on the command line"""
import sys
return any(arg.startswith('--debug') for arg in sys.argv)
class DelayLog(object):
def __init__(self):
self.queue = []
self.msg = ''
def read(self):
yield from self.queue
self.queue = []
def write(self, msg):
self.msg += msg
if self.msg[-1] == '\n':
self.queue.append(self.msg)
self.msg = ''
def init_log():
"""Set up the root logger
This is one of the first steps in __init___
"""
logger = logging.getLogger('bleachbit')
import sys
# On Microsoft Windows when running frozen without the console,
# avoid py2exe redirecting stderr to bleachbit.exe.log by not
# writing to stderr because py2exe redirects stderr to a file.
#
# sys.frozen = 'console_exe' means the console is shown, which
# does not require special handling.
if hasattr(sys, 'frozen') and sys.frozen == 'windows_exe':
sys.stderr = DelayLog()
# debug if command line asks for it or if this a non-final release
if is_debugging_enabled_via_cli():
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
logger_sh = logging.StreamHandler()
logger.addHandler(logger_sh)
return logger
def set_root_log_level(is_debug=False):
"""Adjust the root log level
This runs later in the application's startup process when the
configuration is loaded or after a change via the GUI.
"""
root_logger = logging.getLogger('bleachbit')
root_logger.setLevel(logging.DEBUG if is_debug else logging.INFO)
class GtkLoggerHandler(logging.Handler):
def __init__(self, append_text):
logging.Handler.__init__(self)
self.append_text = append_text
self.msg = ''
self.update_log_level()
def update_log_level(self):
"""Set the log level"""
from bleachbit.Options import options
if options.get('debug'):
self.min_level = logging.DEBUG
else:
self.min_level = logging.WARNING
def emit(self, record):
if record.levelno < self.min_level:
return
tag = 'error' if record.levelno >= logging.WARNING else None
msg = record.getMessage()
if record.exc_text:
msg = msg + '\n' + record.exc_text
self.append_text(msg + '\n', tag)
def write(self, msg):
self.msg += msg
if self.msg[-1] == '\n':
tag = None
self.append_text(msg, tag)
self.msg = ''
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/Memory.py 0000775 0001750 0001750 00000025473 14522012661 014560 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# -*- coding: UTF-8 -*-
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Wipe memory
"""
from bleachbit import FileUtilities
from bleachbit import General
from bleachbit import _
import logging
import os
import re
import subprocess
import sys
logger = logging.getLogger(__name__)
def count_swap_linux():
"""Count the number of swap devices in use"""
count = 0
with open("/proc/swaps") as f:
for line in f:
if line[0] == '/':
count += 1
return count
def get_proc_swaps():
"""Return the output of 'swapon -s'"""
# Usually 'swapon -s' is identical to '/proc/swaps'
# Here is one exception:
# https://bugs.launchpad.net/ubuntu/+source/bleachbit/+bug/1092792
(rc, stdout, _stderr) = General.run_external(['swapon', '-s'])
if 0 == rc:
return stdout
logger.debug(
_("The command 'swapoff -s' failed, so falling back to /proc/swaps for swap information."))
return open("/proc/swaps").read()
def parse_swapoff(swapoff):
"""Parse the output of swapoff and return the device name"""
# English is 'swapoff on /dev/sda5' but German is 'swapoff für ...'
# Example output in English with LVM and hyphen: 'swapoff on /dev/mapper/lubuntu-swap_1'
# This matches swap devices and swap files
ret = re.search('^swapoff (\w* )?(/[\w/.-]+)$', swapoff)
if not ret:
# no matches
return None
return ret.group(2)
def disable_swap_linux():
"""Disable Linux swap and return list of devices"""
if 0 == count_swap_linux():
return
logger.debug(_("Disabling swap."))
args = ["swapoff", "-a", "-v"]
(rc, stdout, stderr) = General.run_external(args)
if 0 != rc:
raise RuntimeError(stderr.replace("\n", ""))
devices = []
for line in stdout.split('\n'):
line = line.replace('\n', '')
if '' == line:
continue
ret = parse_swapoff(line)
if ret is None:
raise RuntimeError("Unexpected output:\nargs='%(args)s'\nstdout='%(stdout)s'\nstderr='%(stderr)s'"
% {'args': str(args), 'stdout': stdout, 'stderr': stderr})
devices.append(ret)
return devices
def enable_swap_linux():
"""Enable Linux swap"""
logger.debug(_("Re-enabling swap."))
args = ["swapon", "-a"]
p = subprocess.Popen(args, stderr=subprocess.PIPE)
p.wait()
outputs = p.communicate()
if 0 != p.returncode:
raise RuntimeError(outputs[1].replace("\n", ""))
def make_self_oom_target_linux():
"""Make the current process the primary target for Linux out-of-memory killer"""
# In Linux 2.6.36 the system changed from oom_adj to oom_score_adj
path = '/proc/%d/oom_score_adj' % os.getpid()
if os.path.exists(path):
with open(path, 'w') as f:
f.write('1000')
else:
path = '/proc/%d/oomadj' % os.getpid()
if os.path.exists(path):
with open(path, 'w') as f:
f.write('15')
# OOM likes nice processes
logger.debug(_("Setting nice value %d for this process."), os.nice(19))
# OOM prefers non-privileged processes
try:
uid = General.getrealuid()
if uid > 0:
logger.debug(
_("Dropping privileges of process ID {pid} to user ID {uid}.").format(pid=os.getpid(), uid=uid))
os.seteuid(uid)
except:
logger.exception('Error when dropping privileges')
def fill_memory_linux():
"""Fill unallocated memory"""
report_free()
allocbytes = int(physical_free() * 0.4)
if allocbytes < 1024:
return
bytes_str = FileUtilities.bytes_to_human(allocbytes)
# TRANSLATORS: The variable is a quantity like 5kB
logger.info(_("Allocating and wiping %s of memory."),
bytes_str)
try:
buf = '\x00' * allocbytes
except MemoryError:
pass
else:
fill_memory_linux()
# TRANSLATORS: The variable is a quantity like 5kB
logger.debug(_("Freeing %s of memory."), bytes_str)
del buf
report_free()
def get_swap_size_linux(device, proc_swaps=None):
"""Return the size of the partition in bytes"""
if proc_swaps is None:
proc_swaps = get_proc_swaps()
line = proc_swaps.split('\n')[0]
if not re.search('Filename\s+Type\s+Size', line):
raise RuntimeError("Unexpected first line in swap summary '%s'" % line)
for line in proc_swaps.split('\n')[1:]:
ret = re.search("%s\s+\w+\s+([0-9]+)\s" % device, line)
if ret:
return int(ret.group(1)) * 1024
raise RuntimeError("error: cannot find size of swap device '%s'\n%s" %
(device, proc_swaps))
def get_swap_uuid(device):
"""Find the UUID for the swap device"""
uuid = None
args = ['blkid', device, '-s', 'UUID']
(_rc, stdout, _stderr) = General.run_external(args)
for line in stdout.split('\n'):
# example: /dev/sda5: UUID="ee0e85f6-6e5c-42b9-902f-776531938bbf"
ret = re.search("^%s: UUID=\"([a-z0-9-]+)\"" % device, line)
if ret is not None:
uuid = ret.group(1)
logger.debug(_("Found UUID for swap file {device} is {uuid}.").format(
device=device, uuid=uuid))
return uuid
def physical_free_darwin(run_vmstat=None):
def parse_line(k, v):
return k, int(v.strip(" ."))
def get_page_size(line):
m = re.match(
r"Mach Virtual Memory Statistics: \(page size of (\d+) bytes\)",
line)
if m is None:
raise RuntimeError("Can't parse vm_stat output")
return int(m.groups()[0])
if run_vmstat is None:
def run_vmstat():
return subprocess.check_output(["vm_stat"])
output = iter(run_vmstat().split("\n"))
page_size = get_page_size(next(output))
vm_stat = dict(parse_line(*l.split(":")) for l in output if l != "")
return vm_stat["Pages free"] * page_size
def physical_free_linux():
"""Return the physical free memory on Linux"""
free_bytes = 0
with open("/proc/meminfo") as f:
for line in f:
line = line.replace("\n", "")
ret = re.search('(MemFree|Cached):[ ]*([0-9]*) kB', line)
if ret is not None:
kb = int(ret.group(2))
free_bytes += kb * 1024
if free_bytes > 0:
return free_bytes
else:
raise Exception("unknown")
def physical_free_windows():
"""Return physical free memory on Windows"""
from ctypes import c_long, c_ulonglong
from ctypes.wintypes import Structure, sizeof, windll, byref
class MEMORYSTATUSEX(Structure):
_fields_ = [
('dwLength', c_long),
('dwMemoryLoad', c_long),
('ullTotalPhys', c_ulonglong),
('ullAvailPhys', c_ulonglong),
('ullTotalPageFile', c_ulonglong),
('ullAvailPageFile', c_ulonglong),
('ullTotalVirtual', c_ulonglong),
('ullAvailVirtual', c_ulonglong),
('ullExtendedVirtual', c_ulonglong),
]
def GlobalMemoryStatusEx():
x = MEMORYSTATUSEX()
x.dwLength = sizeof(x)
windll.kernel32.GlobalMemoryStatusEx(byref(x))
return x
z = GlobalMemoryStatusEx()
return z.ullAvailPhys
def physical_free():
if sys.platform.startswith('linux'):
return physical_free_linux()
elif 'win32' == sys.platform:
return physical_free_windows()
elif 'darwin' == sys.platform:
return physical_free_darwin()
else:
raise RuntimeError('unsupported platform for physical_free()')
def report_free():
"""Report free memory"""
bytes_free = physical_free()
bytes_str = FileUtilities.bytes_to_human(bytes_free)
# TRANSLATORS: The variable is a quantity like 5kB
logger.debug(_("Physical free memory is %s."),
bytes_str)
def wipe_swap_linux(devices, proc_swaps):
"""Shred the Linux swap file and then reinitialize it"""
if devices is None:
return
if 0 < count_swap_linux():
raise RuntimeError('Cannot wipe swap while it is in use')
for device in devices:
# if '/cryptswap' in device:
# logger.info('Skipping encrypted swap device %s.', device)
# continue
# TRANSLATORS: The variable is a device like /dev/sda2
logger.info(_("Wiping the swap device %s."), device)
safety_limit_bytes = 29 * 1024 ** 3 # 29 gibibytes
actual_size_bytes = get_swap_size_linux(device, proc_swaps)
if actual_size_bytes > safety_limit_bytes:
raise RuntimeError(
'swap device %s is larger (%d) than expected (%d)' %
(device, actual_size_bytes, safety_limit_bytes))
uuid = get_swap_uuid(device)
# wipe
FileUtilities.wipe_contents(device, truncate=False)
# reinitialize
# TRANSLATORS: The variable is a device like /dev/sda2
logger.debug(_("Reinitializing the swap device %s."), device)
args = ['mkswap', device]
if uuid:
args.append("-U")
args.append(uuid)
(rc, _stdout, stderr) = General.run_external(args)
if 0 != rc:
raise RuntimeError(stderr.replace("\n", ""))
def wipe_memory():
"""Wipe unallocated memory"""
# cache the file because 'swapoff' changes it
proc_swaps = get_proc_swaps()
devices = disable_swap_linux()
yield True # process GTK+ idle loop
# TRANSLATORS: The variable is a device like /dev/sda2
logger.debug(_("Detected these swap devices: %s"), str(devices))
wipe_swap_linux(devices, proc_swaps)
yield True
child_pid = os.fork()
if 0 == child_pid:
make_self_oom_target_linux()
fill_memory_linux()
os._exit(0)
else:
# TRANSLATORS: This is a debugging message that the parent process is waiting for the child process.
logger.debug(_("The function wipe_memory() with process ID {pid} is waiting for child process ID {cid}.").format(
pid=os.getpid(), cid=child_pid))
rc = os.waitpid(child_pid, 0)[1]
if rc not in [0, 9]:
logger.warning(
_("The child memory-wiping process returned code %d."), rc)
enable_swap_linux()
yield 0 # how much disk space was recovered
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/Options.py 0000664 0001750 0001750 00000033530 14522012661 014731 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Store and retrieve user preferences
"""
import bleachbit
from bleachbit import General
from bleachbit import _
import logging
import os
import re
logger = logging.getLogger(__name__)
if 'nt' == os.name:
from win32file import GetLongPathName
boolean_keys = ['auto_hide',
'check_beta',
'check_online_updates',
'dark_mode',
'debug',
'delete_confirmation',
'exit_done',
'first_start',
'kde_shred_menu_option',
'remember_geometry',
'shred',
'units_iec',
'window_maximized']
if 'nt' == os.name:
boolean_keys.append('update_winapp2')
boolean_keys.append('win10_theme')
int_keys = ['window_x', 'window_y', 'window_width', 'window_height', ]
def path_to_option(pathname):
"""Change a pathname to a .ini option name (a key)"""
# On Windows change to lowercase and use backwards slashes.
pathname = os.path.normcase(pathname)
# On Windows expand DOS-8.3-style pathnames.
if 'nt' == os.name and os.path.exists(pathname):
pathname = GetLongPathName(pathname)
if ':' == pathname[1]:
# ConfigParser treats colons in a special way
pathname = pathname[0] + pathname[2:]
return pathname
def init_configuration():
"""Initialize an empty configuration, if necessary"""
if not os.path.exists(bleachbit.options_dir):
General.makedirs(bleachbit.options_dir)
if os.path.lexists(bleachbit.options_file):
logger.debug('Deleting configuration: %s ' % bleachbit.options_file)
os.remove(bleachbit.options_file)
with open(bleachbit.options_file, 'w', encoding='utf-8-sig') as f_ini:
f_ini.write('[bleachbit]\n')
if os.name == 'nt' and bleachbit.portable_mode:
f_ini.write('[Portable]\n')
for section in options.config.sections():
options.config.remove_section(section)
options.restore()
class Options:
"""Store and retrieve user preferences"""
def __init__(self):
self.purged = False
self.config = bleachbit.RawConfigParser()
self.config.optionxform = str # make keys case sensitive for hashpath purging
self.config.BOOLEAN_STATES['t'] = True
self.config.BOOLEAN_STATES['f'] = False
self.restore()
def __flush(self):
"""Write information to disk"""
if not self.purged:
self.__purge()
if not os.path.exists(bleachbit.options_dir):
General.makedirs(bleachbit.options_dir)
mkfile = not os.path.exists(bleachbit.options_file)
with open(bleachbit.options_file, 'w', encoding='utf-8-sig') as _file:
try:
self.config.write(_file)
except IOError as e:
from errno import ENOSPC
if e.errno == ENOSPC:
logger.error(
_("Disk was full when writing configuration to file %s"), bleachbit.options_file)
else:
raise
if mkfile and General.sudo_mode():
General.chownself(bleachbit.options_file)
def __purge(self):
"""Clear out obsolete data"""
self.purged = True
if not self.config.has_section('hashpath'):
return
for option in self.config.options('hashpath'):
pathname = option
if 'nt' == os.name and re.search('^[a-z]\\\\', option):
# restore colon lost because ConfigParser treats colon special
# in keys
pathname = pathname[0] + ':' + pathname[1:]
exists = False
try:
exists = os.path.lexists(pathname)
except:
# this deals with corrupt keys
# https://www.bleachbit.org/forum/bleachbit-wont-launch-error-startup
logger.error(
_("Error checking whether path exists: %s"), pathname)
if not exists:
# the file does not on exist, so forget it
self.config.remove_option('hashpath', option)
def __set_default(self, key, value):
"""Set the default value"""
if not self.config.has_option('bleachbit', key):
self.set(key, value)
def has_option(self, option, section='bleachbit'):
"""Check if option is set"""
return self.config.has_option(section, option)
def get(self, option, section='bleachbit'):
"""Retrieve a general option"""
if not 'nt' == os.name and 'update_winapp2' == option:
return False
if section == 'bleachit' and option == 'debug':
from bleachbit.Log import is_debugging_enabled_via_cli
if is_debugging_enabled_via_cli():
# command line overrides stored configuration
return True
if section == 'hashpath' and option[1] == ':':
option = option[0] + option[2:]
if option in boolean_keys:
return self.config.getboolean(section, option)
elif option in int_keys:
return self.config.getint(section, option)
return self.config.get(section, option)
def get_hashpath(self, pathname):
"""Recall the hash for a file"""
return self.get(path_to_option(pathname), 'hashpath')
def get_language(self, langid):
"""Retrieve value for whether to preserve the language"""
if not self.config.has_option('preserve_languages', langid):
return False
return self.config.getboolean('preserve_languages', langid)
def get_languages(self):
"""Return a list of all selected languages"""
if not self.config.has_section('preserve_languages'):
return None
return self.config.options('preserve_languages')
def get_list(self, option):
"""Return an option which is a list data type"""
section = "list/%s" % option
if not self.config.has_section(section):
return None
values = [
self.config.get(section, option)
for option in sorted(self.config.options(section))
]
return values
def get_paths(self, section):
"""Abstracts get_whitelist_paths and get_custom_paths"""
if not self.config.has_section(section):
return []
myoptions = []
for option in sorted(self.config.options(section)):
pos = option.find('_')
if -1 == pos:
continue
myoptions.append(option[0:pos])
values = []
for option in set(myoptions):
p_type = self.config.get(section, option + '_type')
p_path = self.config.get(section, option + '_path')
values.append((p_type, p_path))
return values
def get_whitelist_paths(self):
"""Return the whitelist of paths"""
return self.get_paths("whitelist/paths")
def get_custom_paths(self):
"""Return list of custom paths"""
return self.get_paths("custom/paths")
def get_tree(self, parent, child):
"""Retrieve an option for the tree view. The child may be None."""
option = parent
if child is not None:
option += "." + child
if not self.config.has_option('tree', option):
return False
try:
return self.config.getboolean('tree', option)
except:
# in case of corrupt configuration (Launchpad #799130)
logger.exception('Error in get_tree()')
return False
def is_corrupt(self):
"""Perform a self-check for corruption of the configuration"""
# no boolean key must raise an exception
for boolean_key in boolean_keys:
try:
if self.config.has_option('bleachbit', boolean_key):
self.config.getboolean('bleachbit', boolean_key)
except ValueError:
return True
# no int key must raise an exception
for int_key in int_keys:
try:
if self.config.has_option('bleachbit', int_key):
self.config.getint('bleachbit', int_key)
except ValueError:
return True
return False
def restore(self):
"""Restore saved options from disk"""
try:
self.config.read(bleachbit.options_file, encoding='utf-8-sig')
except:
logger.exception("Error reading application's configuration")
if not self.config.has_section("bleachbit"):
self.config.add_section("bleachbit")
if not self.config.has_section("hashpath"):
self.config.add_section("hashpath")
if not self.config.has_section("list/shred_drives"):
from bleachbit.FileUtilities import guess_overwrite_paths
try:
self.set_list('shred_drives', guess_overwrite_paths())
except:
logger.exception(
_("Error when setting the default drives to shred."))
# set defaults
self.__set_default("auto_hide", True)
self.__set_default("check_beta", False)
self.__set_default("check_online_updates", True)
self.__set_default("dark_mode", True)
self.__set_default("debug", False)
self.__set_default("delete_confirmation", True)
self.__set_default("exit_done", False)
self.__set_default("kde_shred_menu_option", False)
self.__set_default("remember_geometry", True)
self.__set_default("shred", False)
self.__set_default("units_iec", False)
self.__set_default("window_maximized", False)
if 'nt' == os.name:
self.__set_default("update_winapp2", False)
self.__set_default("win10_theme", False)
if not self.config.has_section('preserve_languages'):
lang = bleachbit.user_locale
pos = lang.find('_')
if -1 != pos:
lang = lang[0: pos]
for _lang in set([lang, 'en']):
logger.info(_("Automatically preserving language %s."), _lang)
self.set_language(_lang, True)
# BleachBit upgrade or first start ever
if not self.config.has_option('bleachbit', 'version') or \
self.get('version') != bleachbit.APP_VERSION:
self.set('first_start', True)
# set version
self.set("version", bleachbit.APP_VERSION)
def set(self, key, value, section='bleachbit', commit=True):
"""Set a general option"""
self.config.set(section, key, str(value))
if commit:
self.__flush()
def commit(self):
self.__flush()
def set_hashpath(self, pathname, hashvalue):
"""Remember the hash of a path"""
self.set(path_to_option(pathname), hashvalue, 'hashpath')
def set_list(self, key, values):
"""Set a value which is a list data type"""
section = "list/%s" % key
if self.config.has_section(section):
self.config.remove_section(section)
self.config.add_section(section)
for counter, value in enumerate(values):
self.config.set(section, str(counter), value)
self.__flush()
def set_whitelist_paths(self, values):
"""Save the whitelist"""
section = "whitelist/paths"
if self.config.has_section(section):
self.config.remove_section(section)
self.config.add_section(section)
for counter, value in enumerate(values):
self.config.set(section, str(counter) + '_type', value[0])
self.config.set(section, str(counter) + '_path', value[1])
self.__flush()
def set_custom_paths(self, values):
"""Save the customlist"""
section = "custom/paths"
if self.config.has_section(section):
self.config.remove_section(section)
self.config.add_section(section)
for counter, value in enumerate(values):
self.config.set(section, str(counter) + '_type', value[0])
self.config.set(section, str(counter) + '_path', value[1])
self.__flush()
def set_language(self, langid, value):
"""Set the value for a locale (whether to preserve it)"""
if not self.config.has_section('preserve_languages'):
self.config.add_section('preserve_languages')
if self.config.has_option('preserve_languages', langid) and not value:
self.config.remove_option('preserve_languages', langid)
else:
self.config.set('preserve_languages', langid, str(value))
self.__flush()
def set_tree(self, parent, child, value):
"""Set an option for the tree view. The child may be None."""
if not self.config.has_section("tree"):
self.config.add_section("tree")
option = parent
if child is not None:
option = option + "." + child
if self.config.has_option('tree', option) and not value:
self.config.remove_option('tree', option)
else:
self.config.set('tree', option, str(value))
self.__flush()
def toggle(self, key):
"""Toggle a boolean key"""
self.set(key, not self.get(key))
options = Options()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/RecognizeCleanerML.py 0000775 0001750 0001750 00000013545 14522012661 016755 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Check local CleanerML files as a security measure
"""
from bleachbit import _, _p
import bleachbit
from bleachbit.CleanerML import list_cleanerml_files
from bleachbit.Options import options
import hashlib
import logging
import os
import sys
logger = logging.getLogger(__name__)
KNOWN = 1
CHANGED = 2
NEW = 3
def cleaner_change_dialog(changes, parent):
"""Present a dialog regarding the change of cleaner definitions"""
def toggled(cell, path, model):
"""Callback for clicking the checkbox"""
__iter = model.get_iter_from_string(path)
value = not model.get_value(__iter, 0)
model.set(__iter, 0, value)
# TODO: move to GuiBasic
from bleachbit.GuiBasic import Gtk
from gi.repository import GObject
dialog = Gtk.Dialog(title=_("Security warning"),
transient_for=parent,
modal=True, destroy_with_parent=True)
dialog.set_default_size(600, 500)
# create warning
warnbox = Gtk.Box()
image = Gtk.Image()
image.set_from_icon_name("dialog-warning", Gtk.IconSize.DIALOG)
warnbox.pack_start(image, False, True, 0)
# TRANSLATORS: Cleaner definitions are XML data files that define
# which files will be cleaned.
label = Gtk.Label(
label=_("These cleaner definitions are new or have changed. Malicious definitions can damage your system. If you do not trust these changes, delete the files or quit."))
label.set_line_wrap(True)
warnbox.pack_start(label, True, True, 0)
dialog.vbox.pack_start(warnbox, False, True, 0)
# create tree view
liststore = Gtk.ListStore(GObject.TYPE_BOOLEAN, GObject.TYPE_STRING)
treeview = Gtk.TreeView(model=liststore)
renderer0 = Gtk.CellRendererToggle()
renderer0.set_property('activatable', True)
renderer0.connect('toggled', toggled, liststore)
# TRANSLATORS: This is the column label (header) in the tree view for the
# security dialog
treeview.append_column(
Gtk.TreeViewColumn(_p('column_label', 'Delete'), renderer0, active=0))
renderer1 = Gtk.CellRendererText()
# TRANSLATORS: This is the column label (header) in the tree view for the
# security dialog
treeview.append_column(
Gtk.TreeViewColumn(_p('column_label', 'Filename'), renderer1, text=1))
# populate tree view
for change in changes:
liststore.append([False, change[0]])
# populate dialog with widgets
scrolled_window = Gtk.ScrolledWindow()
scrolled_window.add(treeview)
dialog.vbox.pack_start(scrolled_window, True, True, 0)
dialog.add_button(Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT)
dialog.add_button(Gtk.STOCK_QUIT, Gtk.ResponseType.CLOSE)
# run dialog
dialog.show_all()
while True:
if Gtk.ResponseType.ACCEPT != dialog.run():
sys.exit(0)
delete = []
for row in liststore:
b = row[0]
path = row[1]
if b:
delete.append(path)
if 0 == len(delete):
# no files selected to delete
break
from . import GuiBasic
if not GuiBasic.delete_confirmation_dialog(parent, mention_preview=False):
# confirmation not accepted, so do not delete files
continue
for path in delete:
logger.info("deleting unrecognized CleanerML '%s'", path)
os.remove(path)
break
dialog.destroy()
def hashdigest(string):
"""Return hex digest of hash for a string"""
# hashlib requires Python 2.5
if isinstance(string, str):
string = string.encode()
return hashlib.sha512(string).hexdigest()
class RecognizeCleanerML:
"""Check local CleanerML files as a security measure"""
def __init__(self, parent_window=None):
self.parent_window = parent_window
try:
self.salt = options.get('hashsalt')
except bleachbit.NoOptionError:
self.salt = hashdigest(os.urandom(512))
options.set('hashsalt', self.salt)
self.__scan()
def __recognized(self, pathname):
"""Is pathname recognized?"""
with open(pathname) as f:
body = f.read()
new_hash = hashdigest(self.salt + body)
try:
known_hash = options.get_hashpath(pathname)
except bleachbit.NoOptionError:
return NEW, new_hash
if new_hash == known_hash:
return KNOWN, new_hash
return CHANGED, new_hash
def __scan(self):
"""Look for files and act accordingly"""
changes = []
for pathname in sorted(list_cleanerml_files(local_only=True)):
pathname = os.path.abspath(pathname)
(status, myhash) = self.__recognized(pathname)
if NEW == status or CHANGED == status:
changes.append([pathname, status, myhash])
if changes:
cleaner_change_dialog(changes, self.parent_window)
for change in changes:
pathname = change[0]
myhash = change[2]
logger.info("remembering CleanerML file '%s'", pathname)
if os.path.exists(pathname):
options.set_hashpath(pathname, myhash)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/Revision.py 0000664 0001750 0001750 00000000025 14522012661 015065 0 ustar 00z z revision = "ae1605f"
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/Special.py 0000775 0001750 0001750 00000046172 14522012661 014667 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Cross-platform, special cleaning operations
"""
from bleachbit.Options import options
from bleachbit import FileUtilities
import logging
import os.path
from urllib.parse import urlparse, urlunparse
logger = logging.getLogger(__name__)
def __get_chrome_history(path, fn='History'):
"""Get Google Chrome or Chromium history version. 'path' is name of any file in same directory"""
path_history = os.path.join(os.path.dirname(path), fn)
ver = get_sqlite_int(
path_history, 'select value from meta where key="version"')[0]
assert ver > 1
return ver
def __sqlite_table_exists(pathname, table):
"""Check whether a table exists in the SQLite database"""
cmd = "select name from sqlite_master where type='table' and name=?;"
import sqlite3
conn = sqlite3.connect(pathname)
cursor = conn.cursor()
ret = False
cursor.execute(cmd, (table,))
if cursor.fetchone():
ret = True
cursor.close()
conn.commit()
conn.close()
return ret
def __shred_sqlite_char_columns(table, cols=None, where="", path=None):
"""Create an SQL command to shred character columns"""
if path and not __sqlite_table_exists(path, table):
return ""
cmd = ""
if not where:
# If None, set to empty string.
where = ""
if cols and options.get('shred'):
cmd += "update or ignore %s set %s %s;" % \
(table, ",".join(["%s = randomblob(length(%s))" % (col, col)
for col in cols]), where)
cmd += "update or ignore %s set %s %s;" % \
(table, ",".join(["%s = zeroblob(length(%s))" % (col, col)
for col in cols]), where)
cmd += "delete from %s %s;" % (table, where)
return cmd
def get_sqlite_int(path, sql, parameters=None):
"""Run SQL on database in 'path' and return the integers"""
row_factory = lambda cursor, row: int(row[0])
return _get_sqlite_values(path, sql, row_factory, parameters)
def _get_sqlite_values(path, sql, row_factory=None, parameters=None):
"""Run SQL on database in 'path' and return the integers"""
import sqlite3
conn = sqlite3.connect(path)
if row_factory is not None:
conn.row_factory = row_factory
cursor = conn.cursor()
if parameters:
cursor.execute(sql, parameters)
else:
cursor.execute(sql)
values = cursor.fetchall()
cursor.close()
conn.close()
return values
def delete_chrome_autofill(path):
"""Delete autofill table in Chromium/Google Chrome 'Web Data' database"""
cols = ('name', 'value', 'value_lower')
cmds = __shred_sqlite_char_columns('autofill', cols, path=path)
# autofill_profile_* existed for years until Google Chrome stable released August 2023
cols = ('first_name', 'middle_name', 'last_name', 'full_name')
cmds += __shred_sqlite_char_columns('autofill_profile_names', cols, path=path)
cmds += __shred_sqlite_char_columns('autofill_profile_emails', ('email',), path=path)
cmds += __shred_sqlite_char_columns('autofill_profile_phones', ('number',), path=path)
cols = ('company_name', 'street_address', 'dependent_locality',
'city', 'state', 'zipcode', 'country_code')
cmds += __shred_sqlite_char_columns('autofill_profiles', cols, path=path)
# local_addresses* appeared in Google Chrome stable versions released August 2023
cols = ('guid', 'use_count', 'use_date', 'date_modified',
'language_code', 'label', 'initial_creator_id', 'last_modifier_id')
cmds += __shred_sqlite_char_columns('local_addresses', cols, path=path)
cols = ('guid', 'type', 'value', 'verification_status')
cmds += __shred_sqlite_char_columns(
'local_addresses_type_tokens', cols, path=path)
cols = (
'company_name', 'street_address', 'address_1', 'address_2', 'address_3', 'address_4',
'postal_code', 'country_code', 'language_code', 'recipient_name', 'phone_number')
cmds += __shred_sqlite_char_columns('server_addresses', cols, path=path)
FileUtilities.execute_sqlite3(path, cmds)
def delete_chrome_databases_db(path):
"""Delete remote HTML5 cookies (avoiding extension data) from the Databases.db file"""
cols = ('origin', 'name', 'description')
where = "where origin not like 'chrome-%'"
cmds = __shred_sqlite_char_columns('Databases', cols, where, path)
FileUtilities.execute_sqlite3(path, cmds)
def delete_chrome_favicons(path):
"""Delete Google Chrome and Chromium favicons not use in in history for bookmarks"""
path_history = os.path.join(os.path.dirname(path), 'History')
if os.path.exists(path_history):
ver = __get_chrome_history(path)
else:
# assume it's the newer version
ver = 38
cmds = ""
if ver >= 4:
# Version 4 includes Chromium 12
# Version 20 includes Chromium 14, Google Chrome 15, Google Chrome 19
# Version 22 includes Google Chrome 20
# Version 25 is Google Chrome 26
# Version 26 is Google Chrome 29
# Version 28 is Google Chrome 30
# Version 29 is Google Chrome 37
# Version 32 is Google Chrome 51
# Version 36 is Google Chrome 60
# Version 38 is Google Chrome 64
# Version 42 is Google Chrome 79
# icon_mapping
cols = ('page_url',)
where = None
if os.path.exists(path_history):
cmds += "attach database \"%s\" as History;" % path_history
where = "where page_url not in (select distinct url from History.urls)"
cmds += __shred_sqlite_char_columns('icon_mapping', cols, where, path)
# favicon images
cols = ('image_data', )
where = "where icon_id not in (select distinct icon_id from icon_mapping)"
cmds += __shred_sqlite_char_columns('favicon_bitmaps',
cols, where, path)
# favicons
# Google Chrome 30 (database version 28): image_data moved to table
# favicon_bitmaps
if ver < 28:
cols = ('url', 'image_data')
else:
cols = ('url', )
where = "where id not in (select distinct icon_id from icon_mapping)"
cmds += __shred_sqlite_char_columns('favicons', cols, where, path)
elif 3 == ver:
# Version 3 includes Google Chrome 11
cols = ('url', 'image_data')
where = None
if os.path.exists(path_history):
cmds += "attach database \"%s\" as History;" % path_history
where = "where id not in(select distinct favicon_id from History.urls)"
cmds += __shred_sqlite_char_columns('favicons', cols, where, path)
else:
raise RuntimeError('%s is version %d' % (path, ver))
FileUtilities.execute_sqlite3(path, cmds)
def delete_chrome_history(path):
"""Clean history from History and Favicon files without affecting bookmarks"""
if not os.path.exists(path):
logger.debug(
'aborting delete_chrome_history() because history does not exist: %s' % path)
return
cols = ('url', 'title')
where = ""
ids_int = get_chrome_bookmark_ids(path)
if ids_int:
ids_str = ",".join([str(id0) for id0 in ids_int])
where = "where id not in (%s) " % ids_str
cmds = __shred_sqlite_char_columns('urls', cols, where, path)
cmds += __shred_sqlite_char_columns('visits', path=path)
# Google Chrome 79 no longer has lower_term in keyword_search_terms
cols = ('term',)
cmds += __shred_sqlite_char_columns('keyword_search_terms',
cols, path=path)
ver = __get_chrome_history(path)
if ver >= 20:
# downloads, segments, segment_usage first seen in Chrome 14,
# Google Chrome 15 (database version = 20).
# Google Chrome 30 (database version 28) doesn't have full_path, but it
# does have current_path and target_path
if ver >= 28:
cmds += __shred_sqlite_char_columns(
'downloads', ('current_path', 'target_path'), path=path)
cmds += __shred_sqlite_char_columns(
'downloads_url_chains', ('url', ), path=path)
else:
cmds += __shred_sqlite_char_columns(
'downloads', ('full_path', 'url'), path=path)
cmds += __shred_sqlite_char_columns('segments', ('name',), path=path)
cmds += __shred_sqlite_char_columns('segment_usage', path=path)
FileUtilities.execute_sqlite3(path, cmds)
def delete_chrome_keywords(path):
"""Delete keywords table in Chromium/Google Chrome 'Web Data' database"""
cols = ('short_name', 'keyword', 'favicon_url',
'originating_url', 'suggest_url')
where = "where not date_created = 0"
cmds = __shred_sqlite_char_columns('keywords', cols, where, path)
cmds += "update keywords set usage_count = 0;"
ver = __get_chrome_history(path, 'Web Data')
if 43 <= ver < 49:
# keywords_backup table first seen in Google Chrome 17 / Chromium 17 which is Web Data version 43
# In Google Chrome 25, the table is gone.
cmds += __shred_sqlite_char_columns('keywords_backup',
cols, where, path)
cmds += "update keywords_backup set usage_count = 0;"
FileUtilities.execute_sqlite3(path, cmds)
def delete_office_registrymodifications(path):
"""Erase LibreOffice 3.4 and Apache OpenOffice.org 3.4 MRU in registrymodifications.xcu"""
import xml.dom.minidom
dom1 = xml.dom.minidom.parse(path)
modified = False
for node in dom1.getElementsByTagName("item"):
if not node.hasAttribute("oor:path"):
continue
if not node.getAttribute("oor:path").startswith('/org.openoffice.Office.Histories/Histories/'):
continue
node.parentNode.removeChild(node)
node.unlink()
modified = True
if modified:
with open(path, 'w', encoding='utf-8') as xml_file:
dom1.writexml(xml_file)
def delete_mozilla_url_history(path):
"""Delete URL history in Mozilla places.sqlite (Firefox 3 and family)"""
cmds = ""
have_places = __sqlite_table_exists(path, 'moz_places')
if have_places:
# delete the URLs in moz_places
places_suffix = "where id in (select " \
"moz_places.id from moz_places " \
"left join moz_bookmarks on moz_bookmarks.fk = moz_places.id " \
"where moz_bookmarks.id is null); "
cols = ('url', 'rev_host', 'title')
cmds += __shred_sqlite_char_columns('moz_places',
cols, places_suffix, path)
# For any bookmarks that remain in moz_places, reset the non-character values.
cmds += "update moz_places set visit_count=0, frecency=-1, last_visit_date=null;"
# delete any orphaned annotations in moz_annos
annos_suffix = "where id in (select moz_annos.id " \
"from moz_annos " \
"left join moz_places " \
"on moz_annos.place_id = moz_places.id " \
"where moz_places.id is null); "
cmds += __shred_sqlite_char_columns(
'moz_annos', ('content', ), annos_suffix, path)
# Delete any orphaned favicons.
# Firefox 78 no longer has a table named moz_favicons, and it no longer has
# a column favicon_id in the table moz_places. (This change probably happened before version 78.)
if have_places and __sqlite_table_exists(path, 'moz_favicons'):
fav_suffix = "where id not in (select favicon_id " \
"from moz_places where favicon_id is not null ); "
cols = ('url', 'data')
cmds += __shred_sqlite_char_columns('moz_favicons',
cols, fav_suffix, path)
# Delete orphaned origins.
if have_places and __sqlite_table_exists(path, 'moz_origins'):
origins_where = 'where id not in (select distinct origin_id from moz_places)'
cmds += __shred_sqlite_char_columns('moz_origins',
('host',), origins_where, path)
# For any remaining origins, reset the statistic.
cmds += "update moz_origins set frecency=-1;"
if __sqlite_table_exists(path, 'moz_meta'):
cmds += "delete from moz_meta where key like 'origin_frecency_%';"
# Delete all history visits.
cmds += "delete from moz_historyvisits;"
# delete any orphaned input history
if have_places:
input_suffix = "where place_id not in (select distinct id from moz_places)"
cols = ('input',)
cmds += __shred_sqlite_char_columns('moz_inputhistory',
cols, input_suffix, path)
# delete the whole moz_hosts table
# Reference: https://bugzilla.mozilla.org/show_bug.cgi?id=932036
# Reference:
# https://support.mozilla.org/en-US/questions/937290#answer-400987
if __sqlite_table_exists(path, 'moz_hosts'):
cmds += __shred_sqlite_char_columns('moz_hosts', ('host',), path=path)
cmds += "delete from moz_hosts;"
# execute the commands
FileUtilities.execute_sqlite3(path, cmds)
def delete_mozilla_favicons(path):
"""Delete favorites icon in Mozilla places.favicons only if they are not bookmarks (Firefox 3 and family)"""
def remove_path_from_url(url):
url = urlparse(url.lstrip('fake-favicon-uri:'))
return urlunparse((url.scheme, url.netloc, '', '', '', ''))
cmds = ""
places_path = os.path.join(os.path.dirname(path), 'places.sqlite')
cmds += 'attach database "{}" as places;'.format(places_path)
bookmarked_urls_query = ("select url from {db}moz_places where id in "
"(select distinct fk from {db}moz_bookmarks where fk is not null){filter}")
# delete all not bookmarked pages with icons
urls_where = "where page_url not in ({})".format(
bookmarked_urls_query.format(db='places.', filter=''))
cmds += __shred_sqlite_char_columns('moz_pages_w_icons',
('page_url',), urls_where, path)
# delete all not bookmarked icons to pages mapping
mapping_where = "where page_id not in (select id from moz_pages_w_icons)"
cmds += __shred_sqlite_char_columns('moz_icons_to_pages',
where=mapping_where, path=path)
# this intermediate cleaning is needed for the next query to favicons db which collects
# icon ids that don't have a bookmark or have domain level bookmark
FileUtilities.execute_sqlite3(path, cmds)
# collect favicons that are not bookmarked with their full url which collects also domain level bookmarks
id_and_url_pairs = _get_sqlite_values(path,
"select id, icon_url from moz_icons where "
"(id not in (select icon_id from moz_icons_to_pages))")
# We query twice the bookmarked urls and this is a kind of duplication. This is because the first usage of
# bookmarks is for refining further queries to favicons db and if we first extract the bookmarks as a Python list
# and give them to the query we could cause an error in execute_sqlite3 since it splits the cmds string by ';' and
# bookmarked url could contain a ';'. Also if we have a Python list with urls we need to pay attention to escaping
# JavaScript strings in some bookmarks and probably other things. So the safer way for now is to not compose a query
# with Python list of extracted urls.
row_factory = lambda cursor, row: row[0]
# with the row_factory bookmarked_urls is a list of urls, instead of list of tuples with first element a url
bookmarked_urls = _get_sqlite_values(places_path,
bookmarked_urls_query.format(db='', filter=" and url NOT LIKE 'javascript:%'"),
row_factory)
bookmarked_urls_domains = list(map(remove_path_from_url, bookmarked_urls))
ids_to_delete = [id for id, url in id_and_url_pairs
if (
# collect only favicons with not bookmarked urls with same domain or
# their domain is a part of a bookmarked url but the favicons are not domain level
# in other words collect all that are not bookmarked
remove_path_from_url(url) not in bookmarked_urls_domains or
urlparse(url).path.count('/') > 1
)
]
# delete all not bookmarked icons
icons_where = "where (id in ({}))".format(str(ids_to_delete).replace('[', '').replace(']', ''))
cols = ('icon_url', 'data')
cmds += __shred_sqlite_char_columns('moz_icons', cols, icons_where, path)
FileUtilities.execute_sqlite3(path, cmds)
def delete_ooo_history(path):
"""Erase the OpenOffice.org MRU in Common.xcu. No longer valid in Apache OpenOffice.org 3.4."""
import xml.dom.minidom
dom1 = xml.dom.minidom.parse(path)
changed = False
for node in dom1.getElementsByTagName("node"):
if node.hasAttribute("oor:name"):
if "History" == node.getAttribute("oor:name"):
node.parentNode.removeChild(node)
node.unlink()
changed = True
break
if changed:
dom1.writexml(open(path, "w", encoding='utf-8'))
def get_chrome_bookmark_ids(history_path):
"""Given the path of a history file, return the ids in the
urls table that are bookmarks"""
bookmark_path = os.path.join(os.path.dirname(history_path), 'Bookmarks')
if not os.path.exists(bookmark_path):
return []
urls = get_chrome_bookmark_urls(bookmark_path)
ids = []
for url in urls:
ids += get_sqlite_int(
history_path, 'select id from urls where url=?', (url,))
return ids
def get_chrome_bookmark_urls(path):
"""Return a list of bookmarked URLs in Google Chrome/Chromium"""
import json
# read file to parser
with open(path, 'r', encoding='utf-8') as f:
js = json.load(f)
# empty list
urls = []
# local recursive function
def get_chrome_bookmark_urls_helper(node):
if not isinstance(node, dict):
return
if 'type' not in node:
return
if node['type'] == "folder":
# folders have children
for child in node['children']:
get_chrome_bookmark_urls_helper(child)
if node['type'] == "url" and 'url' in node:
urls.append(node['url'])
# find bookmarks
for node in js['roots']:
get_chrome_bookmark_urls_helper(js['roots'][node])
return list(set(urls)) # unique
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/SystemInformation.py 0000775 0001750 0001750 00000010373 14522012661 016773 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# -*- coding: UTF-8 -*-
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Show system information
"""
import bleachbit
import locale
import os
import platform
import sys
if 'nt' == os.name:
from win32com.shell import shell
def get_system_information():
"""Return system information as a string"""
# this section is for application and library versions
s = "BleachBit version %s" % bleachbit.APP_VERSION
try:
# Linux tarball will have a revision but not build_number
from bleachbit.Revision import revision
s += '\nGit revision %s' % revision
except:
pass
try:
from bleachbit.Revision import build_number
s += '\nBuild number %s' % build_number
except:
pass
try:
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
s += '\nGTK version {0}.{1}.{2}'.format(
Gtk.get_major_version(), Gtk.get_minor_version(), Gtk.get_micro_version())
s += '\nGTK theme = %s' % Gtk.Settings.get_default().get_property('gtk-theme-name')
s += '\nGTK icon theme = %s' % Gtk.Settings.get_default().get_property('gtk-icon-theme-name')
s += '\nGTK prefer dark theme = %s' % Gtk.Settings.get_default().get_property('gtk-application-prefer-dark-theme')
except:
pass
import sqlite3
s += "\nSQLite version %s" % sqlite3.sqlite_version
# this section is for variables defined in __init__.py
s += "\nlocal_cleaners_dir = %s" % bleachbit.local_cleaners_dir
s += "\nlocale_dir = %s" % bleachbit.locale_dir
s += "\noptions_dir = %s" % bleachbit.options_dir
s += "\npersonal_cleaners_dir = %s" % bleachbit.personal_cleaners_dir
s += "\nsystem_cleaners_dir = %s" % bleachbit.system_cleaners_dir
# this section is for information about the system environment
s += "\nlocale.getdefaultlocale = %s" % str(locale.getdefaultlocale())
if 'posix' == os.name:
envs = ('DESKTOP_SESSION', 'LOGNAME', 'USER', 'SUDO_UID')
elif 'nt' == os.name:
envs = ('APPDATA', 'cd', 'LocalAppData', 'LocalAppDataLow', 'Music',
'USERPROFILE', 'ProgramFiles', 'ProgramW6432', 'TMP')
for env in envs:
s += "\nos.getenv('%s') = %s" % (env, os.getenv(env))
s += "\nos.path.expanduser('~') = %s" % os.path.expanduser('~')
if sys.platform.startswith('linux'):
s += "\nplatform.linux_distribution() = %s" % str(platform.linux_distribution())
# Mac Version Name - Dictionary
macosx_dict = {'5': 'Leopard', '6': 'Snow Leopard', '7': 'Lion', '8': 'Mountain Lion',
'9': 'Mavericks', '10': 'Yosemite', '11': 'El Capitan', '12': 'Sierra'}
if sys.platform.startswith('darwin'):
if hasattr(platform, 'mac_ver'):
for key in macosx_dict:
if (platform.mac_ver()[0].split('.')[1] == key):
s += "\nplatform.mac_ver() = %s" % str(
platform.mac_ver()[0] + " (" + macosx_dict[key] + ")")
else:
s += "\nplatform.dist() = %s" % str(platform.linux_distribution(full_distribution_name=0))
if 'nt' == os.name:
s += "\nplatform.win32_ver[1]() = %s" % platform.win32_ver()[1]
s += "\nplatform.platform = %s" % platform.platform()
s += "\nplatform.version = %s" % platform.version()
s += "\nsys.argv = %s" % sys.argv
s += "\nsys.executable = %s" % sys.executable
s += "\nsys.version = %s" % sys.version
if 'nt' == os.name:
s += "\nwin32com.shell.shell.IsUserAnAdmin() = %s" % shell.IsUserAnAdmin(
)
s += "\n__file__ = %s" % __file__
return s
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/Unix.py 0000775 0001750 0001750 00000065527 14522012661 014237 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# -*- coding: UTF-8 -*-
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Integration specific to Unix-like operating systems
"""
import bleachbit
from bleachbit import FileUtilities, General
from bleachbit import _
import glob
import logging
import os
import re
import shlex
import subprocess
import sys
logger = logging.getLogger(__name__)
try:
Pattern = re.Pattern
except AttributeError:
Pattern = re._pattern_type
JOURNALD_REGEX = r'^Vacuuming done, freed ([\d.]+[BKMGT]?) of archived journals (on disk|from [\w/]+).$'
class LocaleCleanerPath:
"""This represents a path with either a specific folder name or a folder name pattern.
It also may contain several compiled regex patterns for localization items (folders or files)
and additional LocaleCleanerPaths that get traversed when asked to supply a list of localization
items"""
def __init__(self, location):
if location is None:
raise RuntimeError("location is none")
self.pattern = location
self.children = []
def add_child(self, child):
"""Adds a child LocaleCleanerPath"""
self.children.append(child)
return child
def add_path_filter(self, pre, post):
"""Adds a filter consisting of a prefix and a postfix
(e.g. 'foobar_' and '\.qm' to match 'foobar_en_US.utf-8.qm)"""
try:
regex = re.compile('^' + pre + Locales.localepattern + post + '$')
except Exception as errormsg:
raise RuntimeError(
"Malformed regex '%s' or '%s': %s" % (pre, post, errormsg))
self.add_child(regex)
def get_subpaths(self, basepath):
"""Returns direct subpaths for this object, i.e. either the named subfolder or all
subfolders matching the pattern"""
if isinstance(self.pattern, Pattern):
return (os.path.join(basepath, p) for p in os.listdir(basepath)
if self.pattern.match(p) and os.path.isdir(os.path.join(basepath, p)))
path = os.path.join(basepath, self.pattern)
return [path] if os.path.isdir(path) else []
def get_localizations(self, basepath):
"""Returns all localization items for this object and all descendant objects"""
for path in self.get_subpaths(basepath):
for child in self.children:
if isinstance(child, LocaleCleanerPath):
yield from child.get_localizations(path)
elif isinstance(child, Pattern):
for element in os.listdir(path):
match = child.match(element)
if match is not None:
yield (match.group('locale'),
match.group('specifier'),
os.path.join(path, element))
class Locales:
"""Find languages and localization files"""
# The regular expression to match locale strings and extract the langcode.
# See test_locale_regex() in tests/TestUnix.py for examples
# This doesn't match all possible valid locale strings to avoid
# matching filenames you might want to keep, e.g. the regex
# to match jp.eucJP might also match jp.importantfileextension
localepattern =\
r'(?P[a-z]{2,3})' \
r'(?P[_-][A-Z]{2,4})?(?:\.[\w]+[\d-]+|@\w+)?' \
r'(?P[.-_](?:(?:ISO|iso|UTF|utf|us-ascii)[\d-]+|(?:euc|EUC)[A-Z]+))?'
native_locale_names = \
{'aa': 'Afaraf',
'ab': 'аҧсуа бызшәа',
'ace': 'بهسا اچيه',
'ach': 'Acoli',
'ae': 'avesta',
'af': 'Afrikaans',
'ak': 'Akan',
'am': 'አማርኛ',
'an': 'aragonés',
'ang': 'Old English',
'anp': 'Angika',
'ar': 'العربية',
'as': 'অসমীয়া',
'ast': 'Asturianu',
'av': 'авар мацӀ',
'ay': 'aymar aru',
'az': 'azərbaycan dili',
'ba': 'башҡорт теле',
'bal': 'Baluchi',
'be': 'Беларуская мова',
'bg': 'български език',
'bh': 'भोजपुरी',
'bi': 'Bislama',
'bm': 'bamanankan',
'bn': 'বাংলা',
'bo': 'བོད་ཡིག',
'br': 'brezhoneg',
'brx': 'Bodo (India)',
'bs': 'босански',
'byn': 'Bilin',
'ca': 'català',
'ce': 'нохчийн мотт',
'cgg': 'Chiga',
'ch': 'Chamoru',
'ckb': 'Central Kurdish',
'co': 'corsu',
'cr': 'ᓀᐦᐃᔭᐍᐏᐣ',
'crh': 'Crimean Tatar',
'cs': 'česky',
'csb': 'Cashubian',
'cu': 'ѩзыкъ словѣньскъ',
'cv': 'чӑваш чӗлхи',
'cy': 'Cymraeg',
'da': 'dansk',
'de': 'Deutsch',
'doi': 'डोगरी; ڈوگرى',
'dv': 'ދިވެހި',
'dz': 'རྫོང་ཁ',
'ee': 'Eʋegbe',
'el': 'Ελληνικά',
'en': 'English',
'en_AU': 'Australian English',
'en_CA': 'Canadian English',
'en_GB': 'British English',
'eo': 'Esperanto',
'es': 'Español',
'es_419': 'Latin American Spanish',
'et': 'eesti',
'eu': 'euskara',
'fa': 'فارسی',
'ff': 'Fulfulde',
'fi': 'suomen kieli',
'fil': 'Wikang Filipino',
'fin': 'suomen kieli',
'fj': 'vosa Vakaviti',
'fo': 'føroyskt',
'fr': 'Français',
'frp': 'Arpitan',
'fur': 'Frilian',
'fy': 'Frysk',
'ga': 'Gaeilge',
'gd': 'Gàidhlig',
'gez': 'Geez',
'gl': 'galego',
'gn': 'Avañeẽ',
'gu': 'Gujarati',
'gv': 'Gaelg',
'ha': 'هَوُسَ',
'haw': 'Hawaiian',
'he': 'עברית',
'hi': 'हिन्दी',
'hne': 'Chhattisgarhi',
'ho': 'Hiri Motu',
'hr': 'Hrvatski',
'hsb': 'Upper Sorbian',
'ht': 'Kreyòl ayisyen',
'hu': 'Magyar',
'hy': 'Հայերեն',
'hz': 'Otjiherero',
'ia': 'Interlingua',
'id': 'Indonesian',
'ie': 'Interlingue',
'ig': 'Asụsụ Igbo',
'ii': 'ꆈꌠ꒿',
'ik': 'Iñupiaq',
'ilo': 'Ilokano',
'ina': 'Interlingua',
'io': 'Ido',
'is': 'Íslenska',
'it': 'Italiano',
'iu': 'ᐃᓄᒃᑎᑐᑦ',
'iw': 'עברית',
'ja': '日本語',
'jv': 'basa Jawa',
'ka': 'ქართული',
'kab': 'Tazwawt',
'kac': 'Jingpho',
'kg': 'Kikongo',
'ki': 'Gĩkũyũ',
'kj': 'Kuanyama',
'kk': 'қазақ тілі',
'kl': 'kalaallisut',
'km': 'ខ្មែរ',
'kn': 'ಕನ್ನಡ',
'ko': '한국어',
'kok': 'Konkani',
'kr': 'Kanuri',
'ks': 'कश्मीरी',
'ku': 'Kurdî',
'kv': 'коми кыв',
'kw': 'Kernewek',
'ky': 'Кыргызча',
'la': 'latine',
'lb': 'Lëtzebuergesch',
'lg': 'Luganda',
'li': 'Limburgs',
'ln': 'Lingála',
'lo': 'ພາສາລາວ',
'lt': 'lietuvių kalba',
'lu': 'Tshiluba',
'lv': 'latviešu valoda',
'mai': 'Maithili',
'mg': 'fiteny malagasy',
'mh': 'Kajin M̧ajeļ',
'mhr': 'Eastern Mari',
'mi': 'te reo Māori',
'mk': 'македонски јазик',
'ml': 'മലയാളം',
'mn': 'монгол',
'mni': 'Manipuri',
'mr': 'मराठी',
'ms': 'بهاس ملايو',
'mt': 'Malti',
'my': 'ဗမာစာ',
'na': 'Ekakairũ Naoero',
'nb': 'Bokmål',
'nd': 'isiNdebele',
'nds': 'Plattdüütsch',
'ne': 'नेपाली',
'ng': 'Owambo',
'nl': 'Nederlands',
'nn': 'Norsk nynorsk',
'no': 'Norsk',
'nr': 'isiNdebele',
'nso': 'Pedi',
'nv': 'Diné bizaad',
'ny': 'chiCheŵa',
'oc': 'occitan',
'oj': 'ᐊᓂᔑᓈᐯᒧᐎᓐ',
'om': 'Afaan Oromoo',
'or': 'ଓଡ଼ିଆ',
'os': 'ирон æвзаг',
'pa': 'ਪੰਜਾਬੀ',
'pap': 'Papiamentu',
'pau': 'a tekoi er a Belau',
'pi': 'पाऴि',
'pl': 'polski',
'ps': 'پښتو',
'pt': 'Português',
'pt_BR': 'Português do Brasil',
'qu': 'Runa Simi',
'rm': 'rumantsch grischun',
'rn': 'Ikirundi',
'ro': 'română',
'ru': 'Pусский',
'rw': 'Ikinyarwanda',
'sa': 'संस्कृतम्',
'sat': 'ᱥᱟᱱᱛᱟᱲᱤ',
'sc': 'sardu',
'sd': 'सिन्धी',
'se': 'Davvisámegiella',
'sg': 'yângâ tî sängö',
'shn': 'Shan',
'si': 'සිංහල',
'sk': 'slovenčina',
'sl': 'slovenščina',
'sm': 'gagana faa Samoa',
'sn': 'chiShona',
'so': 'Soomaaliga',
'sq': 'Shqip',
'sr': 'Српски',
'ss': 'SiSwati',
'st': 'Sesotho',
'su': 'Basa Sunda',
'sv': 'svenska',
'sw': 'Kiswahili',
'ta': 'தமிழ்',
'te': 'తెలుగు',
'tet': 'Tetum',
'tg': 'тоҷикӣ',
'th': 'ไทย',
'ti': 'ትግርኛ',
'tig': 'Tigre',
'tk': 'Türkmen',
'tl': 'ᜏᜒᜃᜅ᜔ ᜆᜄᜎᜓᜄ᜔',
'tn': 'Setswana',
'to': 'faka Tonga',
'tr': 'Türkçe',
'ts': 'Xitsonga',
'tt': 'татар теле',
'tw': 'Twi',
'ty': 'Reo Tahiti',
'ug': 'Uyghur',
'uk': 'Українська',
'ur': 'اردو',
'uz': 'Ўзбек',
've': 'Tshivenḓa',
'vi': 'Tiếng Việt',
'vo': 'Volapük',
'wa': 'walon',
'wae': 'Walser',
'wal': 'Wolaytta',
'wo': 'Wollof',
'xh': 'isiXhosa',
'yi': 'ייִדיש',
'yo': 'Yorùbá',
'za': 'Saɯ cueŋƅ',
'zh': '中文',
'zh_CN': '中文',
'zh_TW': '中文',
'zu': 'isiZulu'}
def __init__(self):
self._paths = LocaleCleanerPath(location='/')
def add_xml(self, xml_node, parent=None):
"""Parses the xml data and adds nodes to the LocaleCleanerPath-tree"""
if parent is None:
parent = self._paths
if xml_node.ELEMENT_NODE != xml_node.nodeType:
return
# if a pattern is supplied, we recurse into all matching subdirectories
if 'regexfilter' == xml_node.nodeName:
pre = xml_node.getAttribute('prefix') or ''
post = xml_node.getAttribute('postfix') or ''
parent.add_path_filter(pre, post)
elif 'path' == xml_node.nodeName:
if xml_node.hasAttribute('directoryregex'):
pattern = xml_node.getAttribute('directoryregex')
if '/' in pattern:
raise RuntimeError(
'directoryregex may not contain slashes.')
pattern = re.compile(pattern)
parent = parent.add_child(LocaleCleanerPath(pattern))
# a combination of directoryregex and filter could be too much
else:
if xml_node.hasAttribute("location"):
# if there's a filter attribute, it should apply to this path
parent = parent.add_child(LocaleCleanerPath(
xml_node.getAttribute('location')))
if xml_node.hasAttribute('filter'):
userfilter = xml_node.getAttribute('filter')
if 1 != userfilter.count('*'):
raise RuntimeError(
"Filter string '%s' must contain the placeholder * exactly once" % userfilter)
# we can't use re.escape, because it escapes too much
(pre, post) = (re.sub(r'([\[\]()^$.])', r'\\\1', p)
for p in userfilter.split('*'))
parent.add_path_filter(pre, post)
else:
raise RuntimeError(
"Invalid node '%s', expected '' or ''" % xml_node.nodeName)
# handle child nodes
for child_xml in xml_node.childNodes:
self.add_xml(child_xml, parent)
def localization_paths(self, locales_to_keep):
"""Returns all localization items matching the previously added xml configuration"""
if not locales_to_keep:
raise RuntimeError('Found no locales to keep')
purgeable_locales = frozenset((locale for locale in Locales.native_locale_names.keys()
if locale not in locales_to_keep))
for (locale, specifier, path) in self._paths.get_localizations('/'):
specific = locale + (specifier or '')
if specific in purgeable_locales or \
(locale in purgeable_locales and specific not in locales_to_keep):
yield path
def __is_broken_xdg_desktop_application(config, desktop_pathname):
"""Returns boolean whether application desktop entry file is broken"""
if not config.has_option('Desktop Entry', 'Exec'):
logger.info(
"is_broken_xdg_menu: missing required option 'Exec': '%s'", desktop_pathname)
return True
exe = config.get('Desktop Entry', 'Exec').split(" ")[0]
if not FileUtilities.exe_exists(exe):
logger.info(
"is_broken_xdg_menu: executable '%s' does not exist '%s'", exe, desktop_pathname)
return True
if 'env' == exe:
# Wine v1.0 creates .desktop files like this
# Exec=env WINEPREFIX="/home/z/.wine" wine "C:\\Program
# Files\\foo\\foo.exe"
execs = shlex.split(config.get('Desktop Entry', 'Exec'))
wineprefix = None
del execs[0]
while True:
if execs[0].find("=") < 0:
break
(name, value) = execs[0].split("=")
if name == 'WINEPREFIX':
wineprefix = value
del execs[0]
if not FileUtilities.exe_exists(execs[0]):
logger.info(
"is_broken_xdg_menu: executable '%s' does not exist '%s'", execs[0], desktop_pathname)
return True
# check the Windows executable exists
if wineprefix:
windows_exe = wine_to_linux_path(wineprefix, execs[1])
if not os.path.exists(windows_exe):
logger.info("is_broken_xdg_menu: Windows executable '%s' does not exist '%s'",
windows_exe, desktop_pathname)
return True
return False
def is_unregistered_mime(mimetype):
"""Returns True if the MIME type is known to be unregistered. If
registered or unknown, conservatively returns False."""
try:
from gi.repository import Gio
if 0 == len(Gio.app_info_get_all_for_type(mimetype)):
return True
except ImportError:
logger.warning(
'error calling gio.app_info_get_all_for_type(%s)', mimetype)
return False
def is_broken_xdg_desktop(pathname):
"""Returns boolean whether the given XDG desktop entry file is broken.
Reference: http://standards.freedesktop.org/desktop-entry-spec/latest/"""
config = bleachbit.RawConfigParser()
config.read(pathname)
if not config.has_section('Desktop Entry'):
logger.info(
"is_broken_xdg_menu: missing required section 'Desktop Entry': '%s'", pathname)
return True
if not config.has_option('Desktop Entry', 'Type'):
logger.info(
"is_broken_xdg_menu: missing required option 'Type': '%s'", pathname)
return True
file_type = config.get('Desktop Entry', 'Type').strip().lower()
if 'link' == file_type:
if not config.has_option('Desktop Entry', 'URL') and \
not config.has_option('Desktop Entry', 'URL[$e]'):
logger.info(
"is_broken_xdg_menu: missing required option 'URL': '%s'", pathname)
return True
return False
if 'mimetype' == file_type:
if not config.has_option('Desktop Entry', 'MimeType'):
logger.info(
"is_broken_xdg_menu: missing required option 'MimeType': '%s'", pathname)
return True
mimetype = config.get('Desktop Entry', 'MimeType').strip().lower()
if is_unregistered_mime(mimetype):
logger.info(
"is_broken_xdg_menu: MimeType '%s' not registered '%s'", mimetype, pathname)
return True
return False
if 'application' != file_type:
logger.warning("unhandled type '%s': file '%s'", file_type, pathname)
return False
if __is_broken_xdg_desktop_application(config, pathname):
return True
return False
def is_running_darwin(exename):
try:
ps_out = subprocess.check_output(["ps", "aux", "-c"],
universal_newlines=True)
processes = (re.split(r"\s+", p, 10)[10]
for p in ps_out.split("\n") if p != "")
next(processes) # drop the header
return exename in processes
except IndexError:
raise RuntimeError("Unexpected output from ps")
def is_running_linux(exename):
"""Check whether exename is running"""
for filename in glob.iglob("/proc/*/exe"):
try:
target = os.path.realpath(filename)
except TypeError:
# happens, for example, when link points to
# '/etc/password\x00 (deleted)'
continue
except OSError:
# 13 = permission denied
continue
# Google Chrome shows 74 on Ubuntu 19.04 shows up as
# /opt/google/chrome/chrome (deleted)
found_exename = os.path.basename(target).replace(' (deleted)', '')
if exename == found_exename:
return True
return False
def is_running(exename):
"""Check whether exename is running"""
if sys.platform.startswith('linux'):
return is_running_linux(exename)
elif ('darwin' == sys.platform or
sys.platform.startswith('openbsd') or
sys.platform.startswith('freebsd')):
return is_running_darwin(exename)
else:
raise RuntimeError('unsupported platform for physical_free()')
def rotated_logs():
"""Yield a list of rotated (i.e., old) logs in /var/log/"""
# Ubuntu 9.04
# /var/log/dmesg.0
# /var/log/dmesg.1.gz
# Fedora 10
# /var/log/messages-20090118
globpaths = ('/var/log/*.[0-9]',
'/var/log/*/*.[0-9]',
'/var/log/*.gz',
'/var/log/*/*gz',
'/var/log/*/*.old',
'/var/log/*.old')
for globpath in globpaths:
yield from glob.iglob(globpath)
regex = '-[0-9]{8}$'
globpaths = ('/var/log/*-*', '/var/log/*/*-*')
for path in FileUtilities.globex(globpaths, regex):
whitelist_re = '^/var/log/(removed_)?(packages|scripts)'
if re.match(whitelist_re, path) is None: # for Slackware, Launchpad #367575
yield path
def wine_to_linux_path(wineprefix, windows_pathname):
"""Return a Linux pathname from an absolute Windows pathname and Wine prefix"""
drive_letter = windows_pathname[0]
windows_pathname = windows_pathname.replace(drive_letter + ":",
"drive_" + drive_letter.lower())
windows_pathname = windows_pathname.replace("\\", "/")
return os.path.join(wineprefix, windows_pathname)
def run_cleaner_cmd(cmd, args, freed_space_regex=r'[\d.]+[kMGTE]?B?', error_line_regexes=None):
"""Runs a specified command and returns how much space was (reportedly) freed.
The subprocess shouldn't need any user input and the user should have the
necessary rights.
freed_space_regex gets applied to every output line, if the re matches,
add values captured by the single group in the regex"""
if not FileUtilities.exe_exists(cmd):
raise RuntimeError(_('Executable not found: %s') % cmd)
freed_space_regex = re.compile(freed_space_regex)
error_line_regexes = [re.compile(regex)
for regex in error_line_regexes or []]
env = {'LC_ALL': 'C', 'PATH': os.getenv('PATH')}
output = subprocess.check_output([cmd] + args, stderr=subprocess.STDOUT,
universal_newlines=True, env=env)
freed_space = 0
for line in output.split('\n'):
m = freed_space_regex.match(line)
if m is not None:
freed_space += FileUtilities.human_to_bytes(m.group(1))
for error_re in error_line_regexes:
if error_re.search(line):
raise RuntimeError('Invalid output from %s: %s' % (cmd, line))
return freed_space
def journald_clean():
"""Clean the system journals"""
try:
return run_cleaner_cmd('journalctl', ['--vacuum-size=1'], JOURNALD_REGEX)
except subprocess.CalledProcessError as e:
raise RuntimeError("Error calling '%s':\n%s" %
(' '.join(e.cmd), e.output))
def apt_autoremove():
"""Run 'apt-get autoremove' and return the size (un-rounded, in bytes) of freed space"""
args = ['--yes', 'autoremove']
# After this operation, 74.7MB disk space will be freed.
# After this operation, 44.0 kB disk space will be freed.
freed_space_regex = r'.*, ([\d.]+ ?[a-zA-Z]{2}) disk space will be freed.'
try:
return run_cleaner_cmd('apt-get', args, freed_space_regex, ['^E: '])
except subprocess.CalledProcessError as e:
raise RuntimeError("Error calling '%s':\n%s" %
(' '.join(e.cmd), e.output))
def apt_autoclean():
"""Run 'apt-get autoclean' and return the size (un-rounded, in bytes) of freed space"""
try:
return run_cleaner_cmd('apt-get', ['autoclean'], r'^Del .*\[([\d.]+[a-zA-Z]{2})}]', ['^E: '])
except subprocess.CalledProcessError as e:
raise RuntimeError("Error calling '%s':\n%s" %
(' '.join(e.cmd), e.output))
def apt_clean():
"""Run 'apt-get clean' and return the size in bytes of freed space"""
old_size = get_apt_size()
try:
run_cleaner_cmd('apt-get', ['clean'], '^unused regex$', ['^E: '])
except subprocess.CalledProcessError as e:
raise RuntimeError("Error calling '%s':\n%s" %
(' '.join(e.cmd), e.output))
new_size = get_apt_size()
return old_size - new_size
def get_apt_size():
"""Return the size of the apt cache (in bytes)"""
(rc, stdout, stderr) = General.run_external(['apt-get', '-s', 'clean'])
paths = re.findall('/[/a-z\.\*]+', stdout)
return get_globs_size(paths)
def get_globs_size(paths):
"""Get the cumulative size (in bytes) of a list of globs"""
total_size = 0
for path in paths:
for p in glob.iglob(path):
total_size += FileUtilities.getsize(p)
return total_size
def yum_clean():
"""Run 'yum clean all' and return size in bytes recovered"""
if os.path.exists('/var/run/yum.pid'):
msg = _(
"%s cannot be cleaned because it is currently running. Close it, and try again.") % "Yum"
raise RuntimeError(msg)
old_size = FileUtilities.getsizedir('/var/cache/yum')
args = ['--enablerepo=*', 'clean', 'all']
invalid = ['You need to be root', 'Cannot remove rpmdb file']
run_cleaner_cmd('yum', args, '^unused regex$', invalid)
new_size = FileUtilities.getsizedir('/var/cache/yum')
return old_size - new_size
def dnf_clean():
"""Run 'dnf clean all' and return size in bytes recovered"""
if os.path.exists('/var/run/dnf.pid'):
msg = _(
"%s cannot be cleaned because it is currently running. Close it, and try again.") % "Dnf"
raise RuntimeError(msg)
old_size = FileUtilities.getsizedir('/var/cache/dnf')
args = ['--enablerepo=*', 'clean', 'all']
invalid = ['You need to be root', 'Cannot remove rpmdb file']
run_cleaner_cmd('dnf', args, '^unused regex$', invalid)
new_size = FileUtilities.getsizedir('/var/cache/dnf')
return old_size - new_size
units = {"B": 1, "k": 10**3, "M": 10**6, "G": 10**9}
def parseSize(size):
"""Parse the size returned by dnf"""
number, unit = [string.strip() for string in size.split()]
return int(float(number)*units[unit])
def dnf_autoremove():
"""Run 'dnf autoremove' and return size in bytes recovered."""
if os.path.exists('/var/run/dnf.pid'):
msg = _(
"%s cannot be cleaned because it is currently running. Close it, and try again.") % "Dnf"
raise RuntimeError(msg)
cmd = ['dnf', '-y', 'autoremove']
(rc, stdout, stderr) = General.run_external(cmd)
freed_bytes = 0
allout = stdout + stderr
if 'Error: This command has to be run under the root user.' in allout:
raise RuntimeError('dnf autoremove >> requires root permissions')
if rc > 0:
raise RuntimeError('dnf raised error %s: %s' % (rc, stderr))
cregex = re.compile("Freed space: ([\d.]+[\s]+[BkMG])")
match = cregex.search(allout)
if match:
freed_bytes = parseSize(match.group(1))
logger.debug(
'dnf_autoremove >> total freed bytes: %s', freed_bytes)
return freed_bytes
def is_linux_display_protocol_wayland():
assert(sys.platform.startswith('linux'))
result = General.run_external(['loginctl'])
session = result[1].split('\n')[1].strip().split(' ')[0]
result = General.run_external(['loginctl', 'show-session', session, '-p', 'Type'])
return 'wayland' in result[1].lower()
def root_is_not_allowed_to_X_session():
assert (sys.platform.startswith('linux'))
result = General.run_external(['xhost'], clean_env=False)
xhost_returned_error = result[0] == 1
return xhost_returned_error
def is_display_protocol_wayland_and_root_not_allowed():
return (
bleachbit.Unix.is_linux_display_protocol_wayland() and
os.environ['USER'] == 'root' and
bleachbit.Unix.root_is_not_allowed_to_X_session()
)
locales = Locales()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/Update.py 0000775 0001750 0001750 00000016336 14522012661 014530 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Check for updates via the Internet
"""
import bleachbit
from bleachbit import _
import hashlib
import logging
import os
import os.path
import platform
import socket
import sys
import xml.dom.minidom
from urllib.request import build_opener
from urllib.error import URLError
logger = logging.getLogger(__name__)
def update_winapp2(url, hash_expected, append_text, cb_success):
"""Download latest winapp2.ini file. Hash is sha512 or None to disable checks"""
# first, determine whether an update is necessary
from bleachbit import personal_cleaners_dir
fn = os.path.join(personal_cleaners_dir, 'winapp2.ini')
delete_current = False
if os.path.exists(fn):
with open(fn, 'rb') as f:
hash_current = hashlib.sha512(f.read()).hexdigest()
if not hash_expected or hash_current == hash_expected:
# update is same as current
return
delete_current = True
# download update
# FIXME: refactor this to share code with bleachbit.Chaff.download_url_to_fn()
opener = build_opener()
opener.addheaders = [('User-Agent', user_agent())]
doc = opener.open(fullurl=url, timeout=20).read()
# verify hash
hash_actual = hashlib.sha512(doc).hexdigest()
if hash_expected and not hash_actual == hash_expected:
raise RuntimeError("hash for %s actually %s instead of %s" %
(url, hash_actual, hash_expected))
# delete current
if delete_current:
from bleachbit.FileUtilities import delete
delete(fn, True)
# write file
if not os.path.exists(personal_cleaners_dir):
os.mkdir(personal_cleaners_dir)
with open(fn, 'wb') as f:
f.write(doc)
append_text(_('New winapp2.ini was downloaded.'))
cb_success()
def user_agent():
"""Return the user agent string"""
__platform = platform.system() # Linux or Windows
__os = platform.uname()[2] # e.g., 2.6.28-12-generic or XP
if sys.platform == "win32":
# misleading: Python 2.5.4 shows uname()[2] as Vista on Windows 7
__os = platform.uname()[3][
0:3] # 5.1 = Windows XP, 6.0 = Vista, 6.1 = 7
elif sys.platform.startswith('linux'):
dist = platform.linux_distribution()
# example: ('fedora', '11', 'Leonidas')
# example: ('', '', '') for Arch Linux
if 0 < len(dist[0]):
__os = dist[0] + '/' + dist[1] + '-' + dist[2]
elif sys.platform[:6] == 'netbsd':
__sys = platform.system()
mach = platform.machine()
rel = platform.release()
__os = __sys + '/' + mach + ' ' + rel
__locale = ""
try:
import locale
__locale = locale.getdefaultlocale()[0] # e.g., en_US
except:
logger.exception('Exception when getting default locale')
try:
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
gtkver = '; GTK %s' % '.'.join([str(x) for x in Gtk.gtk_version])
except:
gtkver = ""
agent = "BleachBit/%s (%s; %s; %s%s)" % (bleachbit.APP_VERSION,
__platform, __os, __locale, gtkver)
return agent
def update_dialog(parent, updates):
"""Updates contains the version numbers and URLs"""
from gi.repository import Gtk
from bleachbit.GuiBasic import open_url
dlg = Gtk.Dialog(title=_("Update BleachBit"),
transient_for=parent,
modal=True,
destroy_with_parent=True)
dlg.set_default_size(250, 125)
label = Gtk.Label(label=_("A new version is available."))
dlg.vbox.pack_start(label, True, True, 0)
for update in updates:
ver = update[0]
url = update[1]
box_update = Gtk.Box()
# TRANSLATORS: %s expands to version such as '0.8.4' or '0.8.5beta' or
# similar
button_stable = Gtk.Button(_("Update to version %s") % ver)
button_stable.connect(
'clicked', lambda dummy: open_url(url, parent, False))
button_stable.connect('clicked', lambda dummy: dlg.response(0))
box_update.pack_start(button_stable, False, True, 10)
dlg.vbox.pack_start(box_update, False, True, 0)
dlg.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)
dlg.show_all()
dlg.run()
dlg.destroy()
return False
def get_ip_for_url(url):
"""Given an https URL, return the IP address"""
if not url:
return '(no URL)'
url_split = url.split('/')
if len(url_split) < 3:
return '(bad URL)'
hostname = url.split('/')[2]
import socket
try:
ip_address = socket.gethostbyname(hostname)
except socket.gaierror:
return '(socket.gaierror)'
return ip_address
def check_updates(check_beta, check_winapp2, append_text, cb_success):
"""Check for updates via the Internet"""
opener = build_opener()
socket.setdefaulttimeout(bleachbit.socket_timeout)
opener.addheaders = [('User-Agent', user_agent())]
import encodings.idna # https://github.com/bleachbit/bleachbit/issues/760
url = bleachbit.update_check_url
if 'windowsapp' in sys.executable.lower():
url += '?windowsapp=1'
try:
handle = opener.open(url)
except URLError as e:
logger.error(
_('Error when opening a network connection to check for updates. Please verify the network is working and that a firewall is not blocking this application. Error message: {}').format(e))
logger.debug('URL {} has IP address {}'.format(
url, get_ip_for_url(url)))
if hasattr(e, 'headers'):
logger.debug(e.headers)
return ()
doc = handle.read()
try:
dom = xml.dom.minidom.parseString(doc)
except:
logger.exception('The update information does not parse: %s', doc)
return ()
def parse_updates(element):
if element:
ver = element[0].getAttribute('ver')
url = element[0].firstChild.data
return ver, url
return ()
stable = parse_updates(dom.getElementsByTagName("stable"))
beta = parse_updates(dom.getElementsByTagName("beta"))
wa_element = dom.getElementsByTagName('winapp2')
if check_winapp2 and wa_element:
wa_sha512 = wa_element[0].getAttribute('sha512')
wa_url = wa_element[0].getAttribute('url')
update_winapp2(wa_url, wa_sha512, append_text, cb_success)
dom.unlink()
if stable and beta and check_beta:
return stable, beta
if stable:
return stable,
if beta and check_beta:
return beta,
return ()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/Winapp.py 0000775 0001750 0001750 00000042671 14522012661 014545 0 ustar 00z z
# vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Import Winapp2.ini files
"""
import logging
import os
import glob
import re
from xml.dom.minidom import parseString
import bleachbit
from bleachbit import Cleaner, Windows
from bleachbit.Action import Delete, Winreg
from bleachbit import _
logger = logging.getLogger(__name__)
# TRANSLATORS: This is cleaner name for cleaners imported from winapp2.ini
langsecref_map = {
'3001': ('winapp2_internet_explorer', 'Internet Explorer'),
'3005': ('winapp2_edge_classic', 'Microsoft Edge'),
'3006': ('winapp2_edge_chromium', 'Microsoft Edge'),
# TRANSLATORS: This is cleaner name for cleaners imported from winapp2.ini
'3021': ('winapp2_applications', _('Applications')),
# TRANSLATORS: This is cleaner name for cleaners imported from winapp2.ini
'3022': ('winapp2_internet', _('Internet')),
# TRANSLATORS: This is cleaner name for cleaners imported from winapp2.ini
'3023': ('winapp2_multimedia', _('Multimedia')),
# TRANSLATORS: This is cleaner name for cleaners imported from winapp2.ini
'3024': ('winapp2_utilities', _('Utilities')),
'3025': ('winapp2_windows', 'Microsoft Windows'),
'3026': ('winapp2_mozilla', 'Firefox/Mozilla'),
'3027': ('winapp2_opera', 'Opera'),
'3028': ('winapp2_safari', 'Safari'),
'3029': ('winapp2_google_chrome', 'Google Chrome'),
'3030': ('winapp2_thunderbird', 'Thunderbird'),
'3031': ('winapp2_windows_store', 'Windows Store'),
'3033': ('winapp2_vivaldi', 'Vivaldi'),
'3034': ('winapp2_brave', 'Brave'),
# Section=Games (technically not langsecref)
'Games': ('winapp2_games', _('Games'))}
def xml_escape(s):
"""Lightweight way to escape XML entities"""
return s.replace('&', '&').replace('"', '"').replace('<', '<').replace('>', '>')
def section2option(s):
"""Normalize section name to appropriate option name"""
ret = re.sub(r'[^a-z0-9]', '_', s.lower())
ret = re.sub(r'_+', '_', ret)
ret = re.sub(r'(^_|_$)', '', ret)
return ret
def detectos(required_ver, mock=False):
"""Returns boolean whether the detectos is compatible with the
current operating system, or the mock version, if given."""
# Do not compare as string because Windows 10 (build 10.0) comes after
# Windows 8.1 (build 6.3).
assert isinstance(required_ver, str)
current_os = mock or Windows.parse_windows_build()
required_ver = required_ver.strip()
if '|' not in required_ver:
# Exact version
return Windows.parse_windows_build(required_ver) == current_os
# Format of min|max
req_min = required_ver.split('|')[0]
req_max = required_ver.split('|')[1]
if req_min and current_os < Windows.parse_windows_build(req_min):
return False
if req_max and current_os > Windows.parse_windows_build(req_max):
return False
return True
def winapp_expand_vars(pathname):
"""Expand environment variables using special Winapp2.ini rules"""
# This is the regular expansion
expand1 = os.path.expandvars(pathname)
# Winapp2.ini expands %ProgramFiles% to %ProgramW6432%, etc.
subs = (('ProgramFiles', 'ProgramW6432'),
('CommonProgramFiles', 'CommonProgramW6432'))
for (sub_orig, sub_repl) in subs:
pattern = re.compile('%{}%'.format(sub_orig), flags=re.IGNORECASE)
if pattern.match(pathname):
expand2 = pattern.sub('%{}%'.format(sub_repl), pathname)
return expand1, os.path.expandvars(expand2)
return expand1,
def detect_file(pathname):
"""Check whether a path exists for DetectFile#="""
for expanded in winapp_expand_vars(pathname):
for _ in glob.iglob(expanded):
return True
return False
def special_detect(code):
"""Check whether the SpecialDetect== software exists"""
# The last two are used only for testing
sd_keys = {'DET_CHROME': r'HKCU\Software\Google\Chrome',
'DET_MOZILLA': r'HKCU\Software\Mozilla\Firefox',
'DET_OPERA': r'HKCU\Software\Opera Software',
'DET_THUNDERBIRD': r'HKLM\SOFTWARE\Clients\Mail\Mozilla Thunderbird',
'DET_WINDOWS': r'HKCU\Software\Microsoft',
'DET_SPACE_QUEST': r'HKCU\Software\Sierra Games\Space Quest'}
if code in sd_keys:
return Windows.detect_registry_key(sd_keys[code])
else:
logger.error('Unknown SpecialDetect=%s', code)
return False
def fnmatch_translate(pattern):
"""Same as the original without the end"""
import fnmatch
ret = fnmatch.translate(pattern)
if ret.endswith('$'):
return ret[:-1]
return re.sub(r'\\Z(\(\?ms\))?$', '', ret)
class Winapp:
"""Create cleaners from a Winapp2.ini-style file"""
def __init__(self, pathname, cb_progress=lambda x: None):
"""Create cleaners from a Winapp2.ini-style file"""
self.cleaners = {}
self.cleaner_ids = []
for langsecref in set(langsecref_map.values()):
self.add_section(langsecref[0], langsecref[1])
self.errors = 0
self.parser = bleachbit.RawConfigParser()
self.parser.read(pathname)
self.re_detect = re.compile(r'^detect(\d+)?$')
self.re_detectfile = re.compile(r'^detectfile(\d+)?$')
self.re_excludekey = re.compile(r'^excludekey\d+$')
section_total_count = len(self.parser.sections())
section_done_count = 0
for section in self.parser.sections():
try:
self.handle_section(section)
except Exception:
self.errors += 1
logger.exception('parsing error in section %s', section)
else:
section_done_count += 1
cb_progress(1.0*section_done_count/section_total_count)
def add_section(self, cleaner_id, name):
"""Add a section (cleaners)"""
self.cleaner_ids.append(cleaner_id)
self.cleaners[cleaner_id] = Cleaner.Cleaner()
self.cleaners[cleaner_id].id = cleaner_id
self.cleaners[cleaner_id].name = name
self.cleaners[cleaner_id].description = _('Imported from winapp2.ini')
# The detect() function in this module effectively does what
# auto_hide() does, so this avoids redundant, slow processing.
self.cleaners[cleaner_id].auto_hide = lambda: False
def section_to_cleanerid(self, langsecref):
"""Given a langsecref (or section name), find the internal
BleachBit cleaner ID."""
# pre-defined, such as 3021
if langsecref in langsecref_map.keys():
return langsecref_map[langsecref][0]
# custom, such as games
cleanerid = 'winapp2_' + section2option(langsecref)
if cleanerid not in self.cleaners:
# never seen before
self.add_section(cleanerid, langsecref)
return cleanerid
def excludekey_to_nwholeregex(self, excludekey):
r"""Translate one ExcludeKey to CleanerML nwholeregex
Supported examples
FILE=%LocalAppData%\BleachBit\BleachBit.ini
FILE=%LocalAppData%\BleachBit\|BleachBit.ini
FILE=%LocalAppData%\BleachBit\|*.ini
FILE=%LocalAppData%\BleachBit\|*.ini;*.bak
PATH=%LocalAppData%\BleachBit\
PATH=%LocalAppData%\BleachBit\|*.*
"""
parts = excludekey.split('|')
parts[0] = parts[0].upper()
if parts[0] == 'REG':
raise NotImplementedError('REG not supported in ExcludeKey')
# the last part contains the filename(s)
files = None
files_regex = ''
if len(parts) == 3:
files = parts[2].split(';')
if len(files) == 1:
# one file pattern like *.* or *.log
files_regex = fnmatch_translate(files[0])
if files_regex == '*.*':
files = None
elif len(files) > 1:
# multiple file patterns like *.log;*.bak
files_regex = '(%s)' % '|'.join(
[fnmatch_translate(f) for f in files])
# the middle part contains the file
regexes = []
for expanded in winapp_expand_vars(parts[1]):
regex = None
if not files:
# There is no third part, so this is either just a folder,
# or sometimes the file is specified directly.
regex = fnmatch_translate(expanded)
if files:
# match one or more file types, directly in this tree or in any
# sub folder
regex = r'%s\\%s' % (
re.sub(r'\\\\((?:\))?)$', r'\1', fnmatch_translate(expanded)), files_regex)
regexes.append(regex)
if len(regexes) == 1:
return regexes[0]
else:
return '(%s)' % '|'.join(regexes)
def detect(self, section):
"""Check whether to show the section
The logic:
If the DetectOS does not match, the section is inactive.
If any Detect or DetectFile matches, the section is active.
If neither Detect or DetectFile was given, the section is active.
Otherwise, the section is inactive.
"""
if self.parser.has_option(section, 'detectos'):
required_ver = self.parser.get(section, 'detectos')
if not detectos(required_ver):
return False
any_detect_option = False
if self.parser.has_option(section, 'specialdetect'):
any_detect_option = True
sd_code = self.parser.get(section, 'specialdetect')
if special_detect(sd_code):
return True
for option in self.parser.options(section):
if re.match(self.re_detect, option):
# Detect= checks for a registry key
any_detect_option = True
key = self.parser.get(section, option)
if Windows.detect_registry_key(key):
return True
elif re.match(self.re_detectfile, option):
# DetectFile= checks for a file
any_detect_option = True
key = self.parser.get(section, option)
if detect_file(key):
return True
return not any_detect_option
def handle_section(self, section):
"""Parse a section"""
# check whether the section is active (i.e., whether it will be shown)
if not self.detect(section):
return
# excludekeys ignores a file, path, or registry key
excludekeys = [
self.excludekey_to_nwholeregex(self.parser.get(section, option))
for option in self.parser.options(section)
if re.match(self.re_excludekey, option)
]
# there are two ways to specify sections: langsecref= and section=
if self.parser.has_option(section, 'langsecref'):
# verify the langsecref number is known
# langsecref_num is 3021, games, etc.
langsecref_num = self.parser.get(section, 'langsecref')
elif self.parser.has_option(section, 'section'):
langsecref_num = self.parser.get(section, 'section')
else:
logger.error(
'neither option LangSecRef nor Section found in section %s', section)
return
# find the BleachBit internal cleaner ID
lid = self.section_to_cleanerid(langsecref_num)
self.cleaners[lid].add_option(
section2option(section), section.replace('*', ''), '')
for option in self.parser.options(section):
if (
option
in {
"default",
"langsecref",
"section",
"detectos",
"specialdetect",
}
or re.match(self.re_detect, option)
or re.match(self.re_detectfile, option)
or re.match(self.re_excludekey, option)
):
continue
if option.startswith('filekey'):
self.handle_filekey(lid, section, option, excludekeys)
elif option.startswith('regkey'):
self.handle_regkey(lid, section, option)
elif option == 'warning':
self.cleaners[lid].set_warning(
section2option(section), self.parser.get(section, 'warning'))
else:
logger.warning(
'unknown option %s in section %s', option, section)
return
def __make_file_provider(self, dirname, filename, recurse, removeself, excludekeys):
"""Change parsed FileKey to action provider"""
regex = ''
if recurse:
search = 'walk.files'
path = dirname
if filename == '*.*':
if removeself:
search = 'walk.all'
else:
import fnmatch
regex = ' regex="^%s$" ' % xml_escape(fnmatch.translate(filename))
else:
search = 'glob'
path = os.path.join(dirname, filename)
if path.find('*') == -1:
search = 'file'
excludekeysxml = ''
if excludekeys:
if len(excludekeys) > 1:
# multiple
exclude_str = '(%s)' % '|'.join(excludekeys)
else:
# just one
exclude_str = excludekeys[0]
excludekeysxml = 'nwholeregex="%s"' % xml_escape(exclude_str)
action_str = ' ' % \
(search, xml_escape(path), regex, excludekeysxml)
yield Delete(parseString(action_str).childNodes[0])
if removeself:
search = 'file'
if dirname.find('*') > -1:
search = 'glob'
action_str = ' ' % \
(search, xml_escape(dirname))
yield Delete(parseString(action_str).childNodes[0])
def handle_filekey(self, lid, ini_section, ini_option, excludekeys):
"""Parse a FileKey# option.
Section is [Application Name] and option is the FileKey#"""
elements = self.parser.get(
ini_section, ini_option).strip().split('|')
dirnames = winapp_expand_vars(elements.pop(0))
filenames = ""
if elements:
filenames = elements.pop(0)
recurse = False
removeself = False
for element in elements:
element = element.upper()
if element == 'RECURSE':
recurse = True
elif element == 'REMOVESELF':
recurse = True
removeself = True
else:
logger.warning(
'unknown file option %s in section %s', element, ini_section)
for filename in filenames.split(';'):
for dirname in dirnames:
# If dirname is a drive letter it needs a special treatment on Windows:
# https://www.reddit.com/r/learnpython/comments/gawqne/why_cant_i_ospathjoin_on_a_drive_letterc/
dirname = '{}{}'.format(dirname, os.path.sep) if os.path.splitdrive(dirname)[0] == dirname else dirname
for provider in self.__make_file_provider(dirname, filename, recurse, removeself, excludekeys):
self.cleaners[lid].add_action(
section2option(ini_section), provider)
def handle_regkey(self, lid, ini_section, ini_option):
"""Parse a RegKey# option"""
elements = self.parser.get(
ini_section, ini_option).strip().split('|')
path = xml_escape(elements[0])
name = ""
if len(elements) == 2:
name = 'name="%s"' % xml_escape(elements[1])
action_str = ' ' % (path, name)
provider = Winreg(parseString(action_str).childNodes[0])
self.cleaners[lid].add_action(section2option(ini_section), provider)
def get_cleaners(self):
"""Return the created cleaners"""
for cleaner_id in self.cleaner_ids:
if self.cleaners[cleaner_id].is_usable():
yield self.cleaners[cleaner_id]
def list_winapp_files():
"""List winapp2.ini files"""
for dirname in (bleachbit.personal_cleaners_dir, bleachbit.system_cleaners_dir):
fname = os.path.join(dirname, 'winapp2.ini')
if os.path.exists(fname):
yield fname
def load_cleaners(cb_progress=lambda x: None):
"""Scan for winapp2.ini files and load them"""
cb_progress(0.0)
for pathname in list_winapp_files():
try:
inicleaner = Winapp(pathname, cb_progress)
except Exception as e:
logger.exception(
"Error reading winapp2.ini cleaner '%s'", pathname)
else:
for cleaner in inicleaner.get_cleaners():
Cleaner.backends[cleaner.id] = cleaner
yield True
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/Windows.py 0000775 0001750 0001750 00000075345 14522012661 014745 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Functionality specific to Microsoft Windows
The Windows Registry terminology can be confusing. Take for example
the reference
* HKCU\\Software\\BleachBit
* CurrentVersion
These are the terms:
* 'HKCU' is an abbreviation for the hive HKEY_CURRENT_USER.
* 'HKCU\Software\BleachBit' is the key name.
* 'Software' is a sub-key of HCKU.
* 'BleachBit' is a sub-key of 'Software.'
* 'CurrentVersion' is the value name.
* '0.5.1' is the value data.
"""
import bleachbit
from bleachbit import _, Command, FileUtilities, General
import glob
import logging
import os
import sys
import shutil
from threading import Thread, Event
import xml.dom.minidom
from decimal import Decimal
if 'win32' == sys.platform:
import winreg
import pywintypes
import win32api
import win32con
import win32file
import win32gui
import win32process
import win32security
from ctypes import windll, c_ulong, c_buffer, byref, sizeof
from win32com.shell import shell, shellcon
psapi = windll.psapi
kernel = windll.kernel32
logger = logging.getLogger(__name__)
def browse_file(_, title):
"""Ask the user to select a single file. Return full path"""
try:
ret = win32gui.GetOpenFileNameW(None,
Flags=win32con.OFN_EXPLORER
| win32con.OFN_FILEMUSTEXIST
| win32con.OFN_HIDEREADONLY,
Title=title)
except pywintypes.error as e:
logger = logging.getLogger(__name__)
if 0 == e.winerror:
logger.debug('browse_file(): user cancelled')
else:
logger.exception('exception in browse_file()')
return None
return ret[0]
def browse_files(_, title):
"""Ask the user to select files. Return full paths"""
try:
# The File parameter is a hack to increase the buffer length.
ret = win32gui.GetOpenFileNameW(None,
File='\x00' * 10240,
Flags=win32con.OFN_ALLOWMULTISELECT
| win32con.OFN_EXPLORER
| win32con.OFN_FILEMUSTEXIST
| win32con.OFN_HIDEREADONLY,
Title=title)
except pywintypes.error as e:
if 0 == e.winerror:
logger.debug('browse_files(): user cancelled')
else:
logger.exception('exception in browse_files()')
return None
_split = ret[0].split('\x00')
if 1 == len(_split):
# only one filename
return _split
dirname = _split[0]
pathnames = [os.path.join(dirname, fname) for fname in _split[1:]]
return pathnames
def browse_folder(_, title):
"""Ask the user to select a folder. Return full path."""
flags = 0x0010 #SHBrowseForFolder path input
pidl = shell.SHBrowseForFolder(None, None, title, flags)[0]
if pidl is None:
# user cancelled
return None
fullpath = shell.SHGetPathFromIDListW(pidl)
return fullpath
def check_dll_hijacking(window=None):
"""Check for possible DLL search-order hijacking
https://bugs.python.org/issue27410
"""
major = sys.version_info[0]
minor = sys.version_info[1]
# BleachBit 4.4.2 uses Python 3.4.4.
# The branch with Python 3.10 was tested as not vulnerable.
if major > 3 or (major == 3 and minor >= 10):
return False
if not (os.path.exists(r'c:\python3.dll') or os.path.exists(r'c:\dlls\python3.dll')):
return False
# This workaround will be removed when the Python 3.10 branch is ready.
msg = _('The file python3.dll was found in c:\ or c:\dlls, which indicates a possible attempt at DLL search-order hijacking.')
logger.error(msg)
if window:
from bleachbit.GuiBasic import message_dialog
from gi.repository import Gtk
message_dialog(
window,
msg,
Gtk.MessageType.WARNING,
Gtk.ButtonsType.OK,
title=_('Warning'))
sys.exit(1)
def cleanup_nonce():
"""On exit, clean up GTK junk files"""
for fn in glob.glob(os.path.expandvars('%TEMP%\gdbus-nonce-file-*')):
logger.debug('cleaning GTK nonce file: %s', fn)
FileUtilities.delete(fn)
def csidl_to_environ(varname, csidl):
"""Define an environment variable from a CSIDL for use in CleanerML and Winapp2.ini"""
try:
sppath = shell.SHGetSpecialFolderPath(None, csidl)
except:
logger.info(
'exception when getting special folder path for %s', varname)
return
# there is exception handling in set_environ()
set_environ(varname, sppath)
def delete_locked_file(pathname):
"""Delete a file that is currently in use"""
if os.path.exists(pathname):
MOVEFILE_DELAY_UNTIL_REBOOT = 4
if 0 == windll.kernel32.MoveFileExW(pathname, None, MOVEFILE_DELAY_UNTIL_REBOOT):
from ctypes import WinError
raise WinError()
def delete_registry_value(key, value_name, really_delete):
"""Delete named value under the registry key.
Return boolean indicating whether reference found and
successful. If really_delete is False (meaning preview),
just check whether the value exists."""
(hive, sub_key) = split_registry_key(key)
if really_delete:
try:
hkey = winreg.OpenKey(hive, sub_key, 0, winreg.KEY_SET_VALUE)
winreg.DeleteValue(hkey, value_name)
except WindowsError as e:
if e.winerror == 2:
# 2 = 'file not found' means value does not exist
return False
raise
else:
return True
try:
hkey = winreg.OpenKey(hive, sub_key)
winreg.QueryValueEx(hkey, value_name)
except WindowsError as e:
if e.winerror == 2:
return False
raise
else:
return True
def delete_registry_key(parent_key, really_delete):
"""Delete registry key including any values and sub-keys.
Return boolean whether found and success. If really
delete is False (meaning preview), just check whether
the key exists."""
parent_key = str(parent_key) # Unicode to byte string
(hive, parent_sub_key) = split_registry_key(parent_key)
hkey = None
try:
hkey = winreg.OpenKey(hive, parent_sub_key)
except WindowsError as e:
if e.winerror == 2:
# 2 = 'file not found' happens when key does not exist
return False
if not really_delete:
return True
if not hkey:
# key not found
return False
keys_size = winreg.QueryInfoKey(hkey)[0]
child_keys = [
parent_key + '\\' + winreg.EnumKey(hkey, i) for i in range(keys_size)
]
for child_key in child_keys:
delete_registry_key(child_key, True)
winreg.DeleteKey(hive, parent_sub_key)
return True
def delete_updates():
"""Returns commands for deleting Windows Updates files"""
windir = os.path.expandvars('%windir%')
dirs = glob.glob(os.path.join(windir, '$NtUninstallKB*'))
dirs += [os.path.expandvars(r'%windir%\SoftwareDistribution')]
dirs += [os.path.expandvars(r'%windir%\SoftwareDistribution.old')]
dirs += [os.path.expandvars(r'%windir%\SoftwareDistribution.bak')]
dirs += [os.path.expandvars(r'%windir%\ie7updates')]
dirs += [os.path.expandvars(r'%windir%\ie8updates')]
dirs += [os.path.expandvars(r'%windir%\system32\catroot2')]
dirs += [os.path.expandvars(r'%systemdrive%\windows.old')]
dirs += [os.path.expandvars(r'%systemdrive%\$windows.~bt')]
dirs += [os.path.expandvars(r'%systemdrive%\$windows.~ws')]
if not dirs:
# if nothing to delete, then also do not restart service
return
args = []
def run_wu_service():
General.run_external(args)
return 0
services = {}
all_services = ('wuauserv', 'cryptsvc', 'bits', 'msiserver')
for service in all_services:
import win32serviceutil
services[service] = win32serviceutil.QueryServiceStatus(service)[
1] == 4
logger.debug('Windows service {} has current state: {}'.format(
service, services[service]))
if services[service]:
args = ['net', 'stop', service]
yield Command.Function(None, run_wu_service, " ".join(args))
for path1 in dirs:
for path2 in FileUtilities.children_in_directory(path1, True):
yield Command.Delete(path2)
if os.path.exists(path1):
yield Command.Delete(path1)
for this_service in all_services:
if services[this_service]:
args = ['net', 'start', this_service]
yield Command.Function(None, run_wu_service, " ".join(args))
def detect_registry_key(parent_key):
"""Detect whether registry key exists"""
try:
parent_key = str(parent_key) # Unicode to byte string
except UnicodeEncodeError:
return False
(hive, parent_sub_key) = split_registry_key(parent_key)
hkey = None
try:
hkey = winreg.OpenKey(hive, parent_sub_key)
except WindowsError as e:
if e.winerror == 2:
# 2 = 'file not found' happens when key does not exist
return False
if not hkey:
# key not found
return False
return True
def elevate_privileges(uac):
"""On Windows Vista and later, try to get administrator
privileges. If successful, return True (so original process
can exit). If failed or not applicable, return False."""
if shell.IsUserAnAdmin():
logger.debug('already an admin (UAC not required)')
htoken = win32security.OpenProcessToken(
win32api.GetCurrentProcess(), win32security.TOKEN_ADJUST_PRIVILEGES | win32security.TOKEN_QUERY)
newPrivileges = [
(win32security.LookupPrivilegeValue(None, "SeBackupPrivilege"), win32security.SE_PRIVILEGE_ENABLED),
(win32security.LookupPrivilegeValue(None, "SeRestorePrivilege"), win32security.SE_PRIVILEGE_ENABLED),
]
win32security.AdjustTokenPrivileges(htoken, 0, newPrivileges)
win32file.CloseHandle(htoken)
return False
elif not uac:
return False
if hasattr(sys, 'frozen'):
# running frozen in py2exe
exe = sys.executable
parameters = "--gui --no-uac"
else:
pyfile = os.path.join(bleachbit.bleachbit_exe_path, 'bleachbit.py')
# If the Python file is on a network drive, do not offer the UAC because
# the administrator may not have privileges and user will not be
# prompted.
if len(pyfile) > 0 and path_on_network(pyfile):
logger.debug(
"debug: skipping UAC because '%s' is on network", pyfile)
return False
parameters = '"%s" --gui --no-uac' % pyfile
exe = sys.executable
parameters = _add_command_line_parameters(parameters)
logger.debug('elevate_privileges() exe=%s, parameters=%s', exe, parameters)
rc = None
try:
rc = shell.ShellExecuteEx(lpVerb='runas',
lpFile=exe,
lpParameters=parameters,
nShow=win32con.SW_SHOW)
except pywintypes.error as e:
if 1223 == e.winerror:
logger.debug('user denied the UAC dialog')
return False
raise
logger.debug('ShellExecuteEx=%s', rc)
if isinstance(rc, dict):
return True
return False
def _add_command_line_parameters(parameters):
"""
Add any command line parameters such as --debug-log.
"""
if '--context-menu' in sys.argv:
return '{} {} "{}"'.format(parameters, ' '.join(sys.argv[1:-1]), sys.argv[-1])
return '{} {}'.format(parameters, ' '.join(sys.argv[1:]))
def empty_recycle_bin(path, really_delete):
"""Empty the recycle bin or preview its size.
If the recycle bin is empty, it is not emptied again to avoid an error.
Keyword arguments:
path -- A drive, folder or None. None refers to all recycle bins.
really_delete -- If True, then delete. If False, then just preview.
"""
(bytes_used, num_files) = shell.SHQueryRecycleBin(path)
if really_delete and num_files > 0:
# Trying to delete an empty Recycle Bin on Vista/7 causes a
# 'catastrophic failure'
flags = shellcon.SHERB_NOSOUND | shellcon.SHERB_NOCONFIRMATION | shellcon.SHERB_NOPROGRESSUI
shell.SHEmptyRecycleBin(None, path, flags)
return bytes_used
def get_clipboard_paths():
"""Return a tuple of Unicode pathnames from the clipboard"""
import win32clipboard
win32clipboard.OpenClipboard()
path_list = ()
try:
path_list = win32clipboard.GetClipboardData(win32clipboard.CF_HDROP)
except TypeError:
pass
finally:
win32clipboard.CloseClipboard()
return path_list
def get_fixed_drives():
"""Yield each fixed drive"""
for drive in win32api.GetLogicalDriveStrings().split('\x00'):
if win32file.GetDriveType(drive) == win32file.DRIVE_FIXED:
# Microsoft Office 2010 Starter creates a virtual drive that
# looks much like a fixed disk but isdir() returns false
# and free_space() returns access denied.
# https://bugs.launchpad.net/bleachbit/+bug/1474848
if os.path.isdir(drive):
yield drive
def get_known_folder_path(folder_name):
"""Return the path of a folder by its Folder ID
Requires Windows Vista, Server 2008, or later
Based on the code Michael Kropat (mkropat) from
licensed under the GNU GPL"""
import ctypes
from ctypes import wintypes
from uuid import UUID
class GUID(ctypes.Structure):
_fields_ = [
("Data1", wintypes.DWORD),
("Data2", wintypes.WORD),
("Data3", wintypes.WORD),
("Data4", wintypes.BYTE * 8)
]
def __init__(self, uuid_):
ctypes.Structure.__init__(self)
self.Data1, self.Data2, self.Data3, self.Data4[
0], self.Data4[1], rest = uuid_.fields
for i in range(2, 8):
self.Data4[i] = rest >> (8 - i - 1) * 8 & 0xff
class FOLDERID:
LocalAppDataLow = UUID(
'{A520A1A4-1780-4FF6-BD18-167343C5AF16}')
Fonts = UUID('{FD228CB7-AE11-4AE3-864C-16F3910AB8FE}')
class UserHandle:
current = wintypes.HANDLE(0)
_CoTaskMemFree = windll.ole32.CoTaskMemFree
_CoTaskMemFree.restype = None
_CoTaskMemFree.argtypes = [ctypes.c_void_p]
try:
_SHGetKnownFolderPath = windll.shell32.SHGetKnownFolderPath
except AttributeError:
# Not supported on Windows XP
return None
_SHGetKnownFolderPath.argtypes = [
ctypes.POINTER(GUID), wintypes.DWORD, wintypes.HANDLE, ctypes.POINTER(
ctypes.c_wchar_p)
]
class PathNotFoundException(Exception):
pass
folderid = getattr(FOLDERID, folder_name)
fid = GUID(folderid)
pPath = ctypes.c_wchar_p()
S_OK = 0
if _SHGetKnownFolderPath(ctypes.byref(fid), 0, UserHandle.current, ctypes.byref(pPath)) != S_OK:
raise PathNotFoundException(folder_name)
path = pPath.value
_CoTaskMemFree(pPath)
return path
def get_recycle_bin():
"""Yield a list of files in the recycle bin"""
pidl = shell.SHGetSpecialFolderLocation(0, shellcon.CSIDL_BITBUCKET)
desktop = shell.SHGetDesktopFolder()
h = desktop.BindToObject(pidl, None, shell.IID_IShellFolder)
for item in h:
path = h.GetDisplayNameOf(item, shellcon.SHGDN_FORPARSING)
if os.path.isdir(path):
# Return the contents of a normal directory, but do
# not recurse Windows symlinks in the Recycle Bin.
yield from FileUtilities.children_in_directory(path, True)
yield path
def get_windows_version():
"""Get the Windows major and minor version in a decimal like 10.0"""
v = win32api.GetVersionEx(0)
vstr = '%d.%d' % (v[0], v[1])
return Decimal(vstr)
def is_junction(path):
"""Check whether the path is a link
On Python 2.7 the function os.path.islink() always returns False,
so this is needed
"""
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
attr = windll.kernel32.GetFileAttributesW(path)
return bool(attr & FILE_ATTRIBUTE_REPARSE_POINT)
def is_process_running(name):
"""Return boolean whether process (like firefox.exe) is running
Works on Windows Vista or later, but on Windows XP gives an ImportError
"""
import psutil
name = name.lower()
for proc in psutil.process_iter():
try:
if proc.name().lower() == name:
return True
except psutil.NoSuchProcess:
pass
return False
def move_to_recycle_bin(path):
"""Move 'path' into recycle bin"""
shell.SHFileOperation(
(0, shellcon.FO_DELETE, path, None, shellcon.FOF_ALLOWUNDO | shellcon.FOF_NOCONFIRMATION))
def parse_windows_build(build=None):
"""
Parse build string like 1.2.3 or 1.2 to numeric,
ignoring the third part, if present.
"""
if not build:
# If not given, default to current system's version
return get_windows_version()
return Decimal('.'.join(build.split('.')[0:2]))
def path_on_network(path):
"""Check whether 'path' is on a network drive"""
drive = os.path.splitdrive(path)[0]
if drive.startswith(r'\\'):
return True
return win32file.GetDriveType(drive) == win32file.DRIVE_REMOTE
def shell_change_notify():
"""Notify the Windows shell of update.
Used in windows_explorer.xml."""
shell.SHChangeNotify(shellcon.SHCNE_ASSOCCHANGED, shellcon.SHCNF_IDLIST,
None, None)
return 0
def set_environ(varname, path):
"""Define an environment variable for use in CleanerML and Winapp2.ini"""
if not path:
return
if varname in os.environ:
#logger.debug('set_environ(%s, %s): skipping because environment variable is already defined', varname, path)
if 'nt' == os.name:
os.environ[varname] = os.path.expandvars('%%%s%%' % varname)
# Do not redefine the environment variable when it already exists
# But re-encode them with utf-8 instead of mbcs
return
try:
if not os.path.exists(path):
raise RuntimeError(
'Variable %s points to a non-existent path %s' % (varname, path))
os.environ[varname] = path
except:
logger.exception(
'set_environ(%s, %s): exception when setting environment variable', varname, path)
def setup_environment():
"""Define any extra environment variables for use in CleanerML and Winapp2.ini"""
csidl_to_environ('commonappdata', shellcon.CSIDL_COMMON_APPDATA)
csidl_to_environ('documents', shellcon.CSIDL_PERSONAL)
# Windows XP does not define localappdata, but Windows Vista and 7 do
csidl_to_environ('localappdata', shellcon.CSIDL_LOCAL_APPDATA)
csidl_to_environ('music', shellcon.CSIDL_MYMUSIC)
csidl_to_environ('pictures', shellcon.CSIDL_MYPICTURES)
csidl_to_environ('video', shellcon.CSIDL_MYVIDEO)
# LocalLowAppData does not have a CSIDL for use with
# SHGetSpecialFolderPath. Instead, it is identified using
# SHGetKnownFolderPath in Windows Vista and later
try:
path = get_known_folder_path('LocalAppDataLow')
except:
logger.exception('exception identifying LocalAppDataLow')
else:
set_environ('LocalAppDataLow', path)
# %cd% can be helpful for cleaning portable applications when
# BleachBit is portable. It is the same variable name as defined by
# cmd.exe .
set_environ('cd', os.getcwd())
def split_registry_key(full_key):
r"""Given a key like HKLM\Software split into tuple (hive, key).
Used internally."""
assert len(full_key) >= 6
[k1, k2] = full_key.split("\\", 1)
hive_map = {
'HKCR': winreg.HKEY_CLASSES_ROOT,
'HKCU': winreg.HKEY_CURRENT_USER,
'HKLM': winreg.HKEY_LOCAL_MACHINE,
'HKU': winreg.HKEY_USERS}
if k1 not in hive_map:
raise RuntimeError("Invalid Windows registry hive '%s'" % k1)
return hive_map[k1], k2
def symlink_or_copy(src, dst):
"""Symlink with fallback to copy
Symlink is faster and uses virtually no storage, but it it requires administrator
privileges or Windows developer mode.
If symlink is not available, just copy the file.
"""
try:
os.symlink(src, dst)
logger.debug('linked %s to %s', src, dst)
except (PermissionError, OSError) as e:
shutil.copy(src, dst)
logger.debug('copied %s to %s', src, dst)
def has_fontconfig_cache(font_conf_file):
dom = xml.dom.minidom.parse(font_conf_file)
fc_element = dom.getElementsByTagName('fontconfig')[0]
cachefile = 'd031bbba323fd9e5b47e0ee5a0353f11-le32d8.cache-6'
expanded_localdata = os.path.expandvars('%LOCALAPPDATA%')
expanded_homepath = os.path.join(os.path.expandvars('%HOMEDRIVE%'), os.path.expandvars('%HOMEPATH%'))
for dir_element in fc_element.getElementsByTagName('cachedir'):
if dir_element.firstChild.nodeValue == 'LOCAL_APPDATA_FONTCONFIG_CACHE':
dirpath = os.path.join(expanded_localdata, 'fontconfig', 'cache')
elif dir_element.firstChild.nodeValue == 'fontconfig' and dir_element.getAttribute('prefix') == 'xdg':
dirpath = os.path.join(expanded_homepath, '.cache', 'fontconfig')
elif dir_element.firstChild.nodeValue == '~/.fontconfig':
dirpath = os.path.join(expanded_homepath, '.fontconfig')
else:
# user has entered a custom directory
dirpath = dir_element.firstChild.nodeValue
if dirpath and os.path.exists(os.path.join(dirpath, cachefile)):
return True
return False
def get_font_conf_file():
"""Return the full path to fonts.conf"""
if hasattr(sys, 'frozen'):
# running inside py2exe
return os.path.join(bleachbit.bleachbit_exe_path, 'etc', 'fonts', 'fonts.conf')
import gi
gnome_dir = os.path.join(os.path.dirname(os.path.dirname(gi.__file__)), 'gnome')
if not os.path.isdir(gnome_dir):
# BleachBit is running from a stand-alone Python installation.
gnome_dir = os.path.join(sys.exec_prefix, '..', '..')
return os.path.join(gnome_dir, 'etc', 'fonts', 'fonts.conf')
class SplashThread(Thread):
def __init__(self, group=None, target=None, name=None,
args=(), kwargs={}, Verbose=None):
super().__init__(group, self._show_splash_screen, name, args, kwargs)
self._splash_screen_started = Event()
self._splash_screen_handle = None
self._splash_screen_height = None
self._splash_screen_width = None
def start(self):
Thread.start(self)
self._splash_screen_started.wait()
logger.debug('SplashThread started')
def run(self):
self._splash_screen_handle = self._target()
self._splash_screen_started.set()
# Dispatch messages
win32gui.PumpMessages()
def join(self, *args):
import win32con, win32gui
win32gui.PostMessage(self._splash_screen_handle, win32con.WM_CLOSE, 0, 0)
Thread.join(self, *args)
def _show_splash_screen(self):
#get instance handle
hInstance = win32api.GetModuleHandle()
# the class name
className = 'SimpleWin32'
# create and initialize window class
wndClass = win32gui.WNDCLASS()
wndClass.style = win32con.CS_HREDRAW | win32con.CS_VREDRAW
wndClass.lpfnWndProc = self.wndProc
wndClass.hInstance = hInstance
wndClass.hIcon = win32gui.LoadIcon(0, win32con.IDI_APPLICATION)
wndClass.hCursor = win32gui.LoadCursor(0, win32con.IDC_ARROW)
wndClass.hbrBackground = win32gui.GetStockObject(win32con.WHITE_BRUSH)
wndClass.lpszClassName = className
# register window class
wndClassAtom = None
try:
wndClassAtom = win32gui.RegisterClass(wndClass)
except Exception as e:
raise e
displayWidth = win32api.GetSystemMetrics(0)
displayHeigh = win32api.GetSystemMetrics(1)
self._splash_screen_height = 100
self._splash_screen_width = displayWidth // 4
windowPosX = (displayWidth - self._splash_screen_width) // 2
windowPosY = (displayHeigh - self._splash_screen_height) // 2
hWindow = win32gui.CreateWindow(
wndClassAtom, #it seems message dispatching only works with the atom, not the class name
'Bleachbit splash screen',
win32con.WS_POPUPWINDOW |
win32con.WS_VISIBLE,
windowPosX,
windowPosY,
self._splash_screen_width,
self._splash_screen_height,
0,
0,
hInstance,
None)
is_splash_screen_on_top = self._force_set_foreground_window(hWindow)
logger.debug(
'Is splash screen on top: {}'.format(is_splash_screen_on_top)
)
return hWindow
def _force_set_foreground_window(self, hWindow):
# As there are some restrictions about which processes can call SetForegroundWindow as described here:
# https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow
# we try consecutively three different ways to show the splash screen on top of all other windows.
# Solution 1: Pressing alt key unlocks SetForegroundWindow
# https://stackoverflow.com/questions/14295337/win32gui-setactivewindow-error-the-specified-procedure-could-not-be-found
# Not using win32com.client.Dispatch like in the link because there are problems when building with py2exe.
ALT_KEY = win32con.VK_MENU
RIGHT_ALT = 0xb8
win32api.keybd_event(ALT_KEY, RIGHT_ALT, 0, 0)
win32api.keybd_event(ALT_KEY, RIGHT_ALT, win32con.KEYEVENTF_KEYUP, 0)
win32gui.ShowWindow(hWindow, win32con.SW_SHOW)
try:
win32gui.SetForegroundWindow(hWindow)
except Exception as e:
exc_message = str(e)
logger.debug(
'Failed attempt to show splash screen with keybd_event: {}'.format(exc_message)
)
if win32gui.GetForegroundWindow() == hWindow:
return True
# Solution 2: Attaching current thread to the foreground thread in order to use BringWindowToTop
# https://shlomio.wordpress.com/2012/09/04/solved-setforegroundwindow-win32-api-not-always-works/
foreground_thread_id, foreground_process_id = win32process.GetWindowThreadProcessId(win32gui.GetForegroundWindow())
appThread = win32api.GetCurrentThreadId()
if foreground_thread_id != appThread:
try:
win32process.AttachThreadInput(foreground_thread_id, appThread, True)
win32gui.BringWindowToTop(hWindow)
win32gui.ShowWindow(hWindow, win32con.SW_SHOW)
win32process.AttachThreadInput(foreground_thread_id, appThread, False)
except Exception as e:
exc_message = str(e)
logger.debug(
'Failed attempt to show splash screen with AttachThreadInput: {}'.format(exc_message)
)
else:
win32gui.BringWindowToTop(hWindow)
win32gui.ShowWindow(hWindow, win32con.SW_SHOW)
if win32gui.GetForegroundWindow() == hWindow:
return True
# Solution 3: Working with timers that lock/unlock SetForegroundWindow
#https://gist.github.com/EBNull/1419093
try:
timeout = win32gui.SystemParametersInfo(win32con.SPI_GETFOREGROUNDLOCKTIMEOUT)
win32gui.SystemParametersInfo(win32con.SPI_SETFOREGROUNDLOCKTIMEOUT, 0, win32con.SPIF_SENDCHANGE)
win32gui.BringWindowToTop(hWindow)
win32gui.SetForegroundWindow(hWindow)
win32gui.SystemParametersInfo(win32con.SPI_SETFOREGROUNDLOCKTIMEOUT, timeout, win32con.SPIF_SENDCHANGE)
except Exception as e:
exc_message = str(e)
logger.debug(
'Failed attempt to show splash screen with SystemParametersInfo: {}'.format(exc_message)
)
if win32gui.GetForegroundWindow() == hWindow:
return True
# Solution 4: If on some machines the splash screen still doesn't come on top, we can try
# the following solution that combines attaching to a thread and timers:
# https://www.codeproject.com/Tips/76427/How-to-Bring-Window-to-Top-with-SetForegroundWindo
return False
def wndProc(self, hWnd, message, wParam, lParam):
if message == win32con.WM_PAINT:
hDC, paintStruct = win32gui.BeginPaint(hWnd)
folder_with_ico_file = 'share' if hasattr(sys, 'frozen') else 'windows'
filename = os.path.join(os.path.dirname(sys.argv[0]), folder_with_ico_file, 'bleachbit.ico')
flags = win32con.LR_LOADFROMFILE
hIcon = win32gui.LoadImage(
0, filename, win32con.IMAGE_ICON,
0, 0, flags)
# Default icon size seems to be 32 pixels so we center the icon vertically.
default_icon_size = 32
icon_top_margin = self._splash_screen_height - 2 * (default_icon_size + 2)
win32gui.DrawIcon(hDC, 0, icon_top_margin, hIcon)
# win32gui.DrawIconEx(hDC, 0, 0, hIcon, 64, 64, 0, 0, win32con.DI_NORMAL)
rect = win32gui.GetClientRect(hWnd)
textmetrics = win32gui.GetTextMetrics(hDC)
text_left_margin = 2 * default_icon_size
text_rect = (text_left_margin, (rect[3]-textmetrics['Height'])//2, rect[2], rect[3])
win32gui.DrawText(
hDC,
_("BleachBit is starting...\n"),
-1,
text_rect,
win32con.DT_WORDBREAK)
win32gui.EndPaint(hWnd, paintStruct)
return 0
elif message == win32con.WM_DESTROY:
win32gui.PostQuitMessage(0)
return 0
else:
return win32gui.DefWindowProc(hWnd, message, wParam, lParam)
splash_thread = SplashThread()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/WindowsWipe.py 0000664 0001750 0001750 00000120771 14522012661 015561 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
from bleachbit.FileUtilities import extended_path, extended_path_undo
"""
***
*** Owner: Andrew Ziem
*** Author: Peter Marshall
***
*** References:
*** Windows Internals (Russinovich, Solomon, Ionescu), 6th edition
*** http://windowsitpro.com/systems-management/inside-windows-nt-disk-defragmenting
*** https://technet.microsoft.com/en-us/sysinternals/sdelete.aspx
*** https://blogs.msdn.microsoft.com/jeffrey_wall/2004/09/13/defrag-api-c-wrappers/
*** https://msdn.microsoft.com/en-us/library/windows/desktop/aa364572(v=vs.85).aspx
***
***
*** Algorithm
*** --Phase 1
*** - Check if the file has special characteristics (sparse, encrypted,
*** compressed), determine file system (NTFS or FAT), Windows version.
*** - Read the on-disk locations of the file using defrag API.
*** - If file characteristics don't rule it out, just do a direct write
*** of zero-fill on entire file size and flush to disk.
*** - Read back the on-disk locations of the file using defrag API.
*** - If locations are exactly the same, we are done.
*** - Otherwise, enumerate clusters that did not get overwritten in place
*** ("missed clusters").
*** They are probably still untouched, we need to wipe them.
*** - If it was a special file that wouldn't be wiped by a direct write,
*** we will truncate the file and treat it all as missed clusters.
***
*** --Phase 2
*** - (*) Get volume bitmap of free/allocated clusters using defrag API.
*** Figure out if checkpoint has made our missed clusters available
*** for use again (this is potentially delayed by a few seconds in NTFS).
*** - If they have not yet been made available, wait 0.1s then repeat
*** previous check (*), up to a limit of 7s in polling.
*** - Figure out if it is better to bridge the extents, wiping more clusters
*** but gaining a performance boost from reduced total cycles and overhead.
*** - Recurse over the extents we need to wipe, breaking them down into
*** smaller extents if necessary.
*** - Write a zero-fill file that will provide enough clusters to
*** completely overwrite each extent in turn.
*** - Iterate over the zero-fill file, moving clusters from our zero file
*** to the missed clusters using defrag API.
*** - If the defrag move operation did not succeed, it was probably because
*** another process has grabbed a cluster on disk that we wanted to
*** write to. This can also happen when, by chance, the move's source and
*** target ranges overlap.
*** - In response, we can break the extent down into sub-sections and
*** attempt to wipe each subsection (eventually down to a granularity
*** of one cluster). We also inspect allocated/free sectors to look ahead
*** and avoid making move calls that we know will fail.
*** - If a cluster was allocated by some other Windows process before we could
*** explicitly wipe it, it is assumed to be wiped. Even if Windows writes a
*** small amount of explicit data to a cluster, it seems to write zero-fill
*** out to the end of the cluster to round it out.
***
***
*** TO DO
*** - Test working correctly if per-user disk quotas are in place
***
"""
# Imports.
import sys
import os
import struct
import logging
from operator import itemgetter
from random import randint
from collections import namedtuple
from win32api import (GetVolumeInformation, GetDiskFreeSpace,
GetVersionEx, Sleep)
from win32file import (CreateFile, CreateFileW,
CloseHandle, GetDriveType,
GetFileSize, GetFileAttributesW,
SetFileAttributesW,
DeviceIoControl, SetFilePointer,
WriteFile,
LockFile, DeleteFile,
SetEndOfFile, FlushFileBuffers)
from winioctlcon import (FSCTL_GET_RETRIEVAL_POINTERS,
FSCTL_GET_VOLUME_BITMAP,
FSCTL_GET_NTFS_VOLUME_DATA,
FSCTL_MOVE_FILE,
FSCTL_SET_COMPRESSION,
FSCTL_SET_SPARSE,
FSCTL_SET_ZERO_DATA)
from win32file import (GENERIC_READ, GENERIC_WRITE, FILE_BEGIN,
FILE_SHARE_READ, FILE_SHARE_WRITE,
OPEN_EXISTING, CREATE_ALWAYS, FILE_FLAG_BACKUP_SEMANTICS,
DRIVE_REMOTE, DRIVE_CDROM, DRIVE_UNKNOWN)
from win32con import (FILE_ATTRIBUTE_ENCRYPTED,
FILE_ATTRIBUTE_COMPRESSED,
FILE_ATTRIBUTE_SPARSE_FILE,
FILE_ATTRIBUTE_HIDDEN,
FILE_ATTRIBUTE_READONLY,
FILE_FLAG_RANDOM_ACCESS,
FILE_FLAG_NO_BUFFERING,
FILE_FLAG_WRITE_THROUGH,
COMPRESSION_FORMAT_DEFAULT)
VER_SUITE_PERSONAL = 0x200 # doesn't seem to be present in win32con.
# Constants.
simulate_concurrency = False # remove this test function when QA complete
# drive_letter_safety = "E" # protection to only use removable drives
# don't use C: or D:, but E: and beyond OK.
tmp_file_name = "bbtemp.dat"
spike_file_name = "bbspike" # cluster number will be appended
write_buf_size = 512 * 1024 # 512 kilobytes
# Set up logging
logger = logging.getLogger(__name__)
# Unpacks the next element in a structure, using format requested.
# Returns the element and the remaining content of the structure.
def unpack_element(fmt, structure):
chunk_size = struct.calcsize(fmt)
element = struct.unpack(fmt, structure[:chunk_size])
if element and len(element) > 0:
element = element[0] # convert from tuple to single element
structure = structure[chunk_size:]
return element, structure
# GET_RETRIEVAL_POINTERS gives us a list of VCN, LCN tuples.
# Convert from that format into a list of cluster start/end tuples.
# The flag for writing bridged extents is a way of handling
# the structure of compressed files. If a part of the file is close
# to contiguous on disk, bridge its extents to combine them, even
# though there are some unrelated clusters in between.
# Generator function, will return results one tuple at a time.
def logical_ranges_to_extents(ranges, bridge_compressed=False):
if not bridge_compressed:
vcn_count = 0
for vcn, lcn in ranges:
# If we encounter an LCN of -1, we have reached a
# "space-saved" part of a compressed file. These clusters
# don't map to clusters on disk, just advance beyond them.
if lcn < 0:
vcn_count = vcn
continue
# Figure out length for this cluster range.
# Keep track of VCN inside this file.
this_vcn_span = vcn - vcn_count
vcn_count = vcn
assert this_vcn_span >= 0
yield (lcn, lcn + this_vcn_span - 1)
else:
vcn_count = 0
last_record = len(ranges)
index = 0
while index < last_record:
vcn, lcn = ranges[index]
# If we encounter an LCN of -1, we have reached a
# "space-saved" part of a compressed file. These clusters
# don't map to clusters on disk, just advance beyond them.
if lcn < 0:
vcn_count = vcn
index += 1
continue
# Figure out if we have a block of clusters that can
# be merged together. The pattern is regular disk
# clusters interspersed with -1 space-saver sections
# that are arranged with gaps of 16 clusters or less.
merge_index = index
while (lcn >= 0 and
merge_index + 2 < last_record and
ranges[merge_index + 1][1] < 0 and
ranges[merge_index + 2][1] >= 0 and
ranges[merge_index + 2][1] - ranges[merge_index][1] <= 16 and
ranges[merge_index + 2][1] - ranges[merge_index][1] > 0):
merge_index += 2
# Figure out length for this cluster range.
# Keep track of VCN inside this file.
if merge_index == index:
index += 1
this_vcn_span = vcn - vcn_count
vcn_count = vcn
assert this_vcn_span >= 0
yield (lcn, lcn + this_vcn_span - 1)
else:
index = merge_index + 1
last_vcn_span = (ranges[merge_index][0] -
ranges[merge_index - 1][0])
vcn = ranges[merge_index][0]
vcn_count = vcn
assert last_vcn_span >= 0
yield (lcn, ranges[merge_index][1] + last_vcn_span - 1)
# Determine clusters that are in extents list A but not in B.
# Generator function, will return results one tuple at a time.
def extents_a_minus_b(a, b):
# Sort the lists of start/end points.
a_sorted = sorted(a, key=itemgetter(0))
b_sorted = sorted(b, key=itemgetter(0))
b_is_empty = not b
for a_begin, a_end in a_sorted:
# If B is an empty list, each item of A will be unchanged.
if b_is_empty:
yield (a_begin, a_end)
for b_begin, b_end in b_sorted:
if b_begin > a_end:
# Already gone beyond current A range and no matches.
# Return this range of A unbroken.
yield (a_begin, a_end)
break
elif b_end < a_begin:
# Too early in list, keep searching.
continue
elif b_begin <= a_begin:
if b_end >= a_end:
# This range of A is completely covered by B.
# Do nothing and pass on to next range of A.
break
else:
# This range of A is partially covered by B.
# Remove the covered range from A and loop
a_begin = b_end + 1
else:
# This range of A is partially covered by B.
# Return the first part of A not covered.
# Either process remainder of A range or move to next A.
yield (a_begin, b_begin - 1)
if b_end >= a_end:
break
else:
a_begin = b_end + 1
# Decide if it will be more efficient to bridge the extents and wipe
# some additional clusters that weren't strictly part of the file.
# By grouping write/move cycles into larger portions, we can reduce
# overhead and complete the wipe quicker - even though it involves
# a higher number of total clusters written.
def choose_if_bridged(volume_handle, total_clusters,
orig_extents, bridged_extents):
logger.debug('bridged extents: {}'.format(bridged_extents))
allocated_extents = []
volume_bitmap, bitmap_size = get_volume_bitmap(volume_handle,
total_clusters)
count_ofree, count_oallocated = check_extents(
orig_extents, volume_bitmap)
count_bfree, count_ballocated = check_extents(
bridged_extents,
volume_bitmap,
allocated_extents)
bridged_extents = [x for x in extents_a_minus_b(bridged_extents,
allocated_extents)]
extra_allocated_clusters = count_ballocated - count_oallocated
saving_in_extents = len(orig_extents) - len(bridged_extents)
logger.debug(("Bridged extents would require us to work around %d " +
"more allocated clusters.") % extra_allocated_clusters)
logger.debug("It would reduce extent count from %d to %d." % (
len(orig_extents), len(bridged_extents)))
# Use a penalty of 10 extents for each extra allocated cluster.
# Why 10? Assuming our next granularity above 1 cluster is a 10 cluster
# extent, a single allocated cluster would cause us to perform 8
# additional write/move cycles due to splitting that extent into single
# clusters.
# If we had a notion of distribution of extra allocated clusters,
# we could make this calc more exact. But it's just a rule of thumb.
tradeoff = saving_in_extents - extra_allocated_clusters * 10
if tradeoff > 0:
logger.debug("Quickest method should be bridged extents")
return bridged_extents
else:
logger.debug("Quickest method should be original extents")
return orig_extents
# Break an extent into smaller portions (numbers are tuned to give something
# in the range of 8 to 15 portions).
# Generator function, will return results one tuple at a time.
def split_extent(lcn_start, lcn_end):
split_factor = 10
exponent = 0
count = lcn_end - lcn_start + 1
while count > split_factor**(exponent + 1.3):
exponent += 1
extent_size = split_factor**exponent
for x in range(lcn_start, lcn_end + 1, extent_size):
yield (x, min(x + extent_size - 1, lcn_end))
# Check extents to see if they are marked as free.
def check_extents(extents, volume_bitmap, allocated_extents=None):
count_free, count_allocated = (0, 0)
for lcn_start, lcn_end in extents:
for cluster in range(lcn_start, lcn_end + 1):
if check_mapped_bit(volume_bitmap, cluster):
count_allocated += 1
if allocated_extents is not None:
allocated_extents.append((cluster, cluster)) # Modified by Marvin [12/05/2020] The extents should have (start, end) format
else:
count_free += 1
logger.debug("Extents checked: clusters free %d; allocated %d",
count_free, count_allocated)
return (count_free, count_allocated)
# Check extents to see if they are marked as free.
# Copy of the above that simulates concurrency for testing purposes.
# Once every x clusters at random it will allocate a cluster on disk
# to prove that the algorithm can handle it.
def check_extents_concurrency(extents, volume_bitmap,
tmp_file_path, volume_handle,
total_clusters,
allocated_extents=None):
odds_to_allocate = 1200 # 1 in 1200
count_free, count_allocated = (0, 0)
for lcn_start, lcn_end in extents:
for cluster in range(lcn_start, lcn_end + 1):
# Every once in a while, occupy a particular cluster on disk.
if randint(1, odds_to_allocate) == odds_to_allocate:
spike_cluster(volume_handle, cluster, tmp_file_path)
if bool(randint(0, 1)):
# Simulate allocated before the check, by refetching
# the volume bitmap.
logger.debug("Simulate known allocated")
volume_bitmap, _ = get_volume_bitmap(
volume_handle, total_clusters)
else:
# Simulate allocated after the check.
logger.debug("Simulate unknown allocated")
if check_mapped_bit(volume_bitmap, cluster):
count_allocated += 1
if allocated_extents is not None:
allocated_extents.append(cluster)
else:
count_free += 1
logger.debug("Extents checked: clusters free %d; allocated %d",
count_free, count_allocated)
return (count_free, count_allocated)
# Allocate a cluster on disk by pinning it with a file.
# This simulates another process having grabbed it while our
# algorithm is working.
# This is only used for testing, especially testing concurrency issues.
def spike_cluster(volume_handle, cluster, tmp_file_path):
spike_file_path = os.path.dirname(tmp_file_path)
if spike_file_path[-1] != os.sep:
spike_file_path += os.sep
spike_file_path += spike_file_name + str(cluster)
file_handle = CreateFile(spike_file_path,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
None, CREATE_ALWAYS, 0, None)
# 2000 bytes is enough to direct the file to its own cluster and not
# land entirely in the MFT.
write_zero_fill(file_handle, 2000)
move_file(volume_handle, file_handle, 0, cluster, 1)
CloseHandle(file_handle)
logger.debug("Spiked cluster %d with %s" % (cluster, spike_file_path))
# Check if an LCN is allocated (True) or free (False).
# The LCN determines at what index into the bytes/bits structure we
# should look.
def check_mapped_bit(volume_bitmap, lcn):
assert isinstance(lcn, int)
mapped_bit = volume_bitmap[lcn // 8]
bit_location = lcn % 8 # zero-based
if bit_location > 0:
mapped_bit = mapped_bit >> bit_location
mapped_bit = mapped_bit & 1
return mapped_bit > 0
# Check the operating system. Go no further unless we are on
# Windows and it's Win NT or later.
def check_os():
if os.name.lower() != "nt":
raise RuntimeError("This function requires Windows NT or later")
# Determine which version of Windows we are running.
# Not currently used, except to control encryption test case
# depending on whether it's Windows Home Edition or something higher end.
def determine_win_version():
ver_info = GetVersionEx(1)
is_home = bool(ver_info[7] & VER_SUITE_PERSONAL)
if ver_info[:2] == (6, 0):
return "Vista", is_home
elif ver_info[0] >= 6:
return "Later than Vista", is_home
else:
return "Something else", is_home
# Open the file to get a Windows file handle, ensuring it exists.
# CreateFileW gives us Unicode support.
def open_file(file_name, mode=GENERIC_READ):
file_handle = CreateFileW(file_name, mode, 0, None,
OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, None)
return file_handle
# Close file
def close_file(file_handle):
CloseHandle(file_handle)
# Get some basic information about a file.
def get_file_basic_info(file_name, file_handle):
file_attributes = GetFileAttributesW(file_name)
file_size = GetFileSize(file_handle)
is_compressed = bool(file_attributes & FILE_ATTRIBUTE_COMPRESSED)
is_encrypted = bool(file_attributes & FILE_ATTRIBUTE_ENCRYPTED)
is_sparse = bool(file_attributes & FILE_ATTRIBUTE_SPARSE_FILE)
is_special = is_compressed | is_encrypted | is_sparse
if is_special:
logger.debug('{}: {} {} {}'.format(file_name,
'compressed' if is_compressed else '',
'encrypted' if is_encrypted else '',
'sparse' if is_sparse else ''))
return file_size, is_special
# Truncate a file. Do this when we want to release its clusters.
def truncate_file(file_handle):
SetFilePointer(file_handle, 0, FILE_BEGIN)
SetEndOfFile(file_handle)
FlushFileBuffers(file_handle)
# Given a Windows file path, determine the volume that contains it.
# Append the separator \ to it (more useful for subsequent calls).
def volume_from_file(file_name):
# strip \\?\
split_path = os.path.splitdrive(extended_path_undo(file_name))
volume = split_path[0]
if volume and volume[-1] != os.sep:
volume += os.sep
return volume
class UnsupportedFileSystemError(Exception):
"""An exception for an unsupported file system"""
# Given a volume, get the relevant volume information.
# We are interested in:
# First call: Drive Name; Max Path; File System.
# Second call: Sectors per Cluster; Bytes per Sector; Total # of Clusters.
# Third call: Drive Type.
def get_volume_information(volume):
# If it's a UNC path, raise an error.
if not volume:
raise UnsupportedFileSystemError(
"Only files with a Local File System path can be wiped.")
result1 = GetVolumeInformation(volume)
result2 = GetDiskFreeSpace(volume)
result3 = GetDriveType(volume)
for drive_enum, error_reason in [
(DRIVE_REMOTE, "a network drive"),
(DRIVE_CDROM, "a CD-ROM"),
(DRIVE_UNKNOWN, "an unknown drive type")]:
if result3 == drive_enum:
raise UnsupportedFileSystemError(
"This file is on %s and can't be wiped." % error_reason)
# Only NTFS and FAT variations are supported.
# UDF (file system for CD-RW etc) is not supported.
if result1[4].upper() == "UDF":
raise UnsupportedFileSystemError(
"This file system (UDF) is not supported.")
volume_info = namedtuple('VolumeInfo', [
'drive_name', 'max_path', 'file_system',
'sectors_per_cluster', 'bytes_per_sector', 'total_clusters'])
return volume_info(result1[0], result1[2], result1[4],
result2[0], result2[1], result2[3])
# Get read/write access to a volume.
def obtain_readwrite(volume):
# Optional protection that we are running on removable media only.
assert volume
# if drive_letter_safety:
# drive_containing_file = volume[0].upper()
# assert drive_containing_file >= drive_letter_safety.upper()
volume = '\\\\.\\' + volume
if volume[-1] == os.sep:
volume = volume.rstrip(os.sep)
# We need the FILE_SHARE flags so that this open call can succeed
# despite something on the volume being in use by another process.
volume_handle = CreateFile(volume, GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
None, OPEN_EXISTING,
FILE_FLAG_RANDOM_ACCESS |
FILE_FLAG_NO_BUFFERING |
FILE_FLAG_WRITE_THROUGH,
None)
#logger.debug("Opened volume %s", volume)
return volume_handle
# Retrieve a list of pointers to the file location on disk.
# If translate_to_extents is False, return the Windows VCN/LCN format.
# If True, do an extra conversion to get a list of extents on disk.
def get_extents(file_handle, translate_to_extents=True):
# Assemble input structure and query Windows for retrieval pointers.
# The input structure is the number 0 as a signed 64 bit integer.
input_struct = struct.pack('q', 0)
# 4K, 32K, 256K, 2M step ups in buffer size, until call succeeds.
# Compressed/encrypted/sparse files tend to have more chopped up extents.
buf_retry_sizes = [4 * 1024, 32 * 1024, 256 * 1024, 2 * 1024**2]
for retrieval_pointers_buf_size in buf_retry_sizes:
try:
rp_struct = DeviceIoControl(file_handle,
FSCTL_GET_RETRIEVAL_POINTERS,
input_struct,
retrieval_pointers_buf_size)
except:
err_info = sys.exc_info()[1]
err_code = err_info.winerror
if err_code == 38: # when file size is 0.
# (38, 'DeviceIoControl', 'Reached the end of the file.')
return []
elif err_code in [122, 234]: # when buffer not large enough.
# (122, 'DeviceIoControl',
# 'The data area passed to a system call is too small.')
# (234, 'DeviceIoControl', 'More data is available.')
pass
else:
raise
else:
# Call succeeded, break out from for loop.
break
# At this point we have a FSCTL_GET_RETRIEVAL_POINTERS (rp) structure.
# Process content of the first part of structure.
# Separate the retrieval pointers list up front, so we are not making
# too many string copies of it.
chunk_size = struct.calcsize('IIq')
rp_list = rp_struct[chunk_size:]
rp_struct = rp_struct[:chunk_size]
record_count, rp_struct = unpack_element('I', rp_struct) # 4 bytes
_, rp_struct = unpack_element('I', rp_struct) # 4 bytes
starting_vcn, rp_struct = unpack_element('q', rp_struct) # 8 bytes
# 4 empty bytes were consumed above.
# This is for reasons of 64-bit alignment inside structure.
# If we make the GET_RETRIEVAL_POINTERS request with 0,
# this should always come back 0.
assert starting_vcn == 0
# Populate the extents array with the ranges from rp structure.
ranges = []
c = record_count
i = 0
chunk_size = struct.calcsize('q')
buf_size = len(rp_list)
while c > 0 and i < buf_size:
next_vcn = struct.unpack_from('q', rp_list, offset=i)
lcn = struct.unpack_from('q', rp_list, offset=i + chunk_size)
ranges.append((next_vcn[0], lcn[0]))
i += chunk_size * 2
c -= 1
if not translate_to_extents:
return ranges
else:
return [x for x in logical_ranges_to_extents(ranges)]
# Tell Windows to make this file compressed on disk.
# Only used for the test suite.
def file_make_compressed(file_handle):
# Assemble input structure.
# Just tell Windows to use standard compression.
input_struct = struct.pack('H', COMPRESSION_FORMAT_DEFAULT)
buf_size = struct.calcsize('H')
_ = DeviceIoControl(file_handle, FSCTL_SET_COMPRESSION,
input_struct, buf_size)
# Tell Windows to make this file sparse on disk.
# Only used for the test suite.
def file_make_sparse(file_handle):
_ = DeviceIoControl(file_handle, FSCTL_SET_SPARSE, None, None)
# Tell Windows to add a zero region to a sparse file.
# Only used for the test suite.
def file_add_sparse_region(file_handle, byte_start, byte_end):
# Assemble input structure.
input_struct = struct.pack('qq', byte_start, byte_end)
buf_size = struct.calcsize('qq')
_ = DeviceIoControl(file_handle, FSCTL_SET_ZERO_DATA,
input_struct, buf_size)
# Retrieve a bitmap of whether clusters on disk are free/allocated.
def get_volume_bitmap(volume_handle, total_clusters):
# Assemble input structure and query Windows for volume bitmap.
# The input structure is the number 0 as a signed 64 bit integer.
input_struct = struct.pack('q', 0)
# Figure out the buffer size. Add small fudge factor to ensure success.
buf_size = int(total_clusters / 8) + 16 + 64
vb_struct = DeviceIoControl(volume_handle, FSCTL_GET_VOLUME_BITMAP,
input_struct, buf_size)
# At this point we have a FSCTL_GET_VOLUME_BITMAP (vb) structure.
# Process content of the first part of structure.
# Separate the volume bitmap up front, so we are not making too
# many string copies of it.
chunk_size = struct.calcsize('2q')
volume_bitmap = vb_struct[chunk_size:]
vb_struct = vb_struct[:chunk_size]
starting_lcn, vb_struct = unpack_element('q', vb_struct) # 8 bytes
bitmap_size, vb_struct = unpack_element('q', vb_struct) # 8 bytes
# If we make the GET_VOLUME_BITMAP request with 0,
# this should always come back 0.
assert starting_lcn == 0
# The remaining part of the structure is the actual bitmap.
return volume_bitmap, bitmap_size
# Retrieve info about an NTFS volume.
# We are mainly interested in the locations of the Master File Table.
# This call is currently not necessary, but has been left in to address any
# future need.
def get_ntfs_volume_data(volume_handle):
# 512 bytes will be comfortably enough to store return object.
vd_struct = DeviceIoControl(volume_handle, FSCTL_GET_NTFS_VOLUME_DATA,
None, 512)
# At this point we have a FSCTL_GET_NTFS_VOLUME_DATA (vd) structure.
# Pick out the elements from structure that are useful to us.
_, vd_struct = unpack_element('q', vd_struct) # 8 bytes
number_sectors, vd_struct = unpack_element('q', vd_struct) # 8 bytes
total_clusters, vd_struct = unpack_element('q', vd_struct) # 8 bytes
free_clusters, vd_struct = unpack_element('q', vd_struct) # 8 bytes
total_reserved, vd_struct = unpack_element('q', vd_struct) # 8 bytes
_, vd_struct = unpack_element('4I', vd_struct) # 4*4 bytes
_, vd_struct = unpack_element('3q', vd_struct) # 3*8 bytes
mft_zone_start, vd_struct = unpack_element('q', vd_struct) # 8 bytes
mft_zone_end, vd_struct = unpack_element('q', vd_struct) # 8 bytes
# Quick sanity check that we got something reasonable for MFT zone.
assert (mft_zone_start < mft_zone_end and
mft_zone_start > 0 and mft_zone_end > 0)
logger.debug("MFT from %d to %d", mft_zone_start, mft_zone_end)
return mft_zone_start, mft_zone_end
# Poll to confirm that our clusters were freed.
# Check ten times per second for a duration of seven seconds.
# According to Windows Internals book, it may take several seconds
# until NTFS does a checkpoint and releases the clusters.
# In later versions of Windows, this seems to be instantaneous.
def poll_clusters_freed(volume_handle, total_clusters, orig_extents):
polling_duration_seconds = 7
attempts_per_second = 10
if not orig_extents:
return True
for _ in range(polling_duration_seconds * attempts_per_second):
volume_bitmap, bitmap_size = get_volume_bitmap(volume_handle,
total_clusters)
count_free, count_allocated = check_extents(
orig_extents, volume_bitmap)
# Some inexact measure to determine if our clusters were freed
# by the OS, knowing that another process may grab some clusters
# in between our polling attempts.
if count_free > count_allocated:
return True
Sleep(1000 / attempts_per_second)
return False
# Move a file (or portion of) to a new location on the disk using
# the Defrag API.
# This will raise an exception if a cluster was not free,
# or if the call failed for whatever other reason.
def move_file(volume_handle, file_handle, starting_vcn,
starting_lcn, cluster_count):
# Assemble input structure for our request.
# We include a couple of zero ints for 64-bit alignment.
input_struct = struct.pack('IIqqII', int(file_handle), 0, starting_vcn,
starting_lcn, cluster_count, 0)
vb_struct = DeviceIoControl(volume_handle, FSCTL_MOVE_FILE,
input_struct, None)
# Write zero-fill to a file.
# Write_length is the number of bytes to be written.
def write_zero_fill(file_handle, write_length):
# Bytearray will be initialized with null bytes as part of constructor.
fill_string = bytearray(write_buf_size)
assert len(fill_string) == write_buf_size
# Loop and perform writes of write_buf_size bytes or less.
# Continue until write_length bytes have been written.
# There is no need to explicitly move the file pointer while
# writing. We are writing contiguously.
while write_length > 0:
if write_length >= write_buf_size:
write_string = fill_string
write_length -= write_buf_size
else:
write_string = fill_string[:write_length]
write_length = 0
# Write buffer to file.
#logger.debug("Write %d bytes", len(write_string))
_, bytes_written = WriteFile(file_handle, write_string)
assert bytes_written == len(write_string)
FlushFileBuffers(file_handle)
# Wipe the file using the extents list we have built.
# We just rewrite the file with enough zeros to cover all clusters.
def wipe_file_direct(file_handle, extents, cluster_size, file_size):
assert cluster_size > 0
# Remember that file_size measures full expanded content of the file,
# which may not always match with size on disk (eg. if file compressed).
LockFile(file_handle, 0, 0, file_size & 0xFFFF, file_size >> 16)
if extents:
# Use size on disk to determine how many clusters of zeros we write.
for lcn_start, lcn_end in extents:
# logger.debug("Wiping extent from %d to %d...",
# lcn_start, lcn_end)
write_length = (lcn_end - lcn_start + 1) * cluster_size
write_zero_fill(file_handle, write_length)
else:
# Special case - file so small it can be contained within the
# directory entry in the MFT part of the disk.
#logger.debug("Wiping tiny file that fits entirely on MFT")
write_length = file_size
write_zero_fill(file_handle, write_length)
# Wipe an extent by making calls to the defrag API.
# We create a new zero-filled file, then move its clusters to the
# position on disk that we want to wipe.
# Use a look-ahead with the volume bitmap to figure out if we can expect
# our call to succeed.
# If not, break the extent into smaller pieces efficiently.
# Windows concepts:
# LCN (Logical Cluster Number) = a cluster location on disk; an absolute
# position on the volume we are writing
# VCN (Virtual Cluster Number) = relative position within a file, measured
# in clusters
def wipe_extent_by_defrag(volume_handle, lcn_start, lcn_end, cluster_size,
total_clusters, tmp_file_path):
assert cluster_size > 0
logger.debug("Examining extent from %d to %d for wipe...",
lcn_start, lcn_end)
write_length = (lcn_end - lcn_start + 1) * cluster_size
# Check the state of the volume bitmap for the extent we want to
# overwrite. If any sectors are allocated, reduce the task
# into smaller parts.
# We also reduce to smaller pieces if the extent is larger than
# 2 megabytes. For no particular reason except to avoid the entire
# request failing because one cluster became allocated.
volume_bitmap, bitmap_size = get_volume_bitmap(volume_handle,
total_clusters)
# This option simulates another process that grabs clusters on disk
# from time to time.
# It should be moved away after QA is complete.
if not simulate_concurrency:
count_free, count_allocated = check_extents(
[(lcn_start, lcn_end)], volume_bitmap)
else:
count_free, count_allocated = check_extents_concurrency(
[(lcn_start, lcn_end)], volume_bitmap,
tmp_file_path, volume_handle, total_clusters)
if count_allocated > 0 and count_free == 0:
return False
if count_allocated > 0 or write_length > write_buf_size * 4:
if lcn_start >= lcn_end:
return False
for split_s, split_e in split_extent(lcn_start, lcn_end):
wipe_extent_by_defrag(volume_handle, split_s, split_e,
cluster_size, total_clusters,
tmp_file_path)
return True
# Put the zero-fill file in place.
file_handle = CreateFile(tmp_file_path, GENERIC_READ | GENERIC_WRITE,
0, None, CREATE_ALWAYS,
FILE_ATTRIBUTE_HIDDEN, None)
write_zero_fill(file_handle, write_length)
new_extents = get_extents(file_handle)
# We know the original extent was contiguous.
# The new zero-fill file may not be contiguous, so it requires a
# loop to be sure of reaching the end of the new file's clusters.
new_vcn = 0
for new_lcn_start, new_lcn_end in new_extents:
# logger.debug("Zero-fill wrote from %d to %d",
# new_lcn_start, new_lcn_end)
cluster_count = new_lcn_end - new_lcn_start + 1
cluster_dest = lcn_start + new_vcn
if new_lcn_start != cluster_dest:
logger.debug("Move %d clusters to %d",
cluster_count, cluster_dest)
try:
move_file(volume_handle, file_handle, new_vcn,
cluster_dest, cluster_count)
except:
# Move file failed, probably because another process
# has allocated a cluster on disk.
# Break into smaller pieces and do what we can.
logger.debug("!! Move encountered an error !!")
CloseHandle(file_handle)
if lcn_start >= lcn_end:
return False
for split_s, split_e in split_extent(lcn_start, lcn_end):
wipe_extent_by_defrag(volume_handle, split_s, split_e,
cluster_size, total_clusters,
tmp_file_path)
return True
else:
# If Windows put the zero-fill extent on the exact clusters we
# intended to place it, no need to attempt a move.
logging.debug("No need to move extent from %d",
new_lcn_start)
new_vcn += cluster_count
CloseHandle(file_handle)
DeleteFile(tmp_file_path)
return True
# Clean up open handles etc.
def clean_up(file_handle, volume_handle, tmp_file_path):
try:
if file_handle:
CloseHandle(file_handle)
if volume_handle:
CloseHandle(volume_handle)
if tmp_file_path:
DeleteFile(tmp_file_path)
except:
pass
# Main flow of control.
def file_wipe(file_name):
# add \\?\ if it does not exist to support Unicode and long paths
file_name = extended_path(file_name)
check_os()
win_version, _ = determine_win_version()
volume = volume_from_file(file_name)
volume_info = get_volume_information(volume)
cluster_size = (volume_info.sectors_per_cluster *
volume_info.bytes_per_sector)
file_handle = open_file(file_name)
file_size, is_special = get_file_basic_info(file_name, file_handle)
orig_extents = get_extents(file_handle)
if is_special:
bridged_extents = [x for x in logical_ranges_to_extents(
get_extents(file_handle, False), True)]
CloseHandle(file_handle)
#logger.debug('Original extents: {}'.format(orig_extents))
volume_handle = obtain_readwrite(volume)
attrs = GetFileAttributesW(file_name)
if attrs & FILE_ATTRIBUTE_READONLY:
# Remove read-only attribute to avoid "access denied" in CreateFileW().
SetFileAttributesW(file_name, attrs & ~FILE_ATTRIBUTE_READONLY)
file_handle = open_file(file_name, GENERIC_READ | GENERIC_WRITE)
if not is_special:
# Direct overwrite when it's a regular file.
#logger.info("Attempting direct file wipe.")
wipe_file_direct(file_handle, orig_extents, cluster_size, file_size)
new_extents = get_extents(file_handle)
CloseHandle(file_handle)
#logger.debug('New extents: {}'.format(new_extents))
if orig_extents == new_extents:
clean_up(None, volume_handle, None)
return
# Expectation was that extents should be identical and file is wiped.
# If OS didn't give that to us, continue below and use defrag wipe.
# Any extent within new_extents has now been wiped by above.
# It can be subtracted from the orig_extents list, and now we will
# just clean up anything not yet overwritten.
orig_extents = extents_a_minus_b(orig_extents, new_extents)
else:
# File needs special treatment. We can't just do a basic overwrite.
# First we will truncate it. Then chase down the freed clusters to
# wipe them, now that they are no longer part of the file.
truncate_file(file_handle)
CloseHandle(file_handle)
# Poll to confirm that our clusters were freed.
poll_clusters_freed(volume_handle, volume_info.total_clusters,
orig_extents)
# Chase down all the freed clusters we can, and wipe them.
#logger.debug("Attempting defrag file wipe.")
# Put the temp file in the same folder as the target wipe file.
# Should be able to write this path if user can write the wipe file.
tmp_file_path = os.path.dirname(file_name) + os.sep + tmp_file_name
if is_special:
orig_extents = choose_if_bridged(volume_handle,
volume_info.total_clusters,
orig_extents, bridged_extents)
for lcn_start, lcn_end in orig_extents:
result = wipe_extent_by_defrag(volume_handle, lcn_start, lcn_end,
cluster_size, volume_info.total_clusters,
tmp_file_path)
# Clean up.
clean_up(None, volume_handle, tmp_file_path)
return
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/Worker.py 0000775 0001750 0001750 00000033444 14522012661 014556 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Perform the preview or delete operations
"""
from bleachbit import DeepScan, FileUtilities
from bleachbit.Cleaner import backends
from bleachbit import _, ngettext
import logging
import math
import sys
import os
logger = logging.getLogger(__name__)
class Worker:
"""Perform the preview or delete operations"""
def __init__(self, ui, really_delete, operations):
"""Create a Worker
ui: an instance with methods
append_text()
update_progress_bar()
update_total_size()
update_item_size()
worker_done()
really_delete: (boolean) preview or make real changes?
operations: dictionary where operation-id is the key and
operation-id are values
"""
self.ui = ui
self.really_delete = really_delete
assert(isinstance(operations, dict))
self.operations = operations
self.size = 0
self.total_bytes = 0
self.total_deleted = 0
self.total_errors = 0
self.total_special = 0 # special operations
self.yield_time = None
self.is_aborted = False
if 0 == len(self.operations):
raise RuntimeError("No work to do")
def abort(self):
"""Stop the preview/cleaning operation"""
self.is_aborted = True
def print_exception(self, operation):
"""Display exception"""
# TRANSLATORS: This indicates an error. The special keyword
# %(operation)s will be replaced by 'firefox' or 'opera' or
# some other cleaner ID. The special keyword %(msg)s will be
# replaced by a message such as 'Permission denied.'
err = _("Exception while running operation '%(operation)s': '%(msg)s'") \
% {'operation': operation, 'msg': str(sys.exc_info()[1])}
logger.error(err, exc_info=True)
self.total_errors += 1
def execute(self, cmd, operation_option):
"""Execute or preview the command"""
ret = None
try:
for ret in cmd.execute(self.really_delete):
if True == ret or isinstance(ret, tuple):
# Temporarily pass control to the GTK idle loop,
# allow user to abort, and
# display progress (if applicable).
yield ret
if self.is_aborted:
return
except SystemExit:
pass
except Exception as e:
# 2 = does not exist
# 13 = permission denied
from errno import ENOENT, EACCES
if isinstance(e, OSError) and e.errno in (ENOENT, EACCES):
# For access denied, do not show traceback
exc_message = str(e)
logger.error('%s: %s', exc_message, cmd)
else:
# For other errors, show the traceback.
msg = _('Error: {operation_option}: {command}')
data = {'command': cmd, 'operation_option': operation_option}
logger.error(msg.format(**data), exc_info=True)
self.total_errors += 1
else:
if ret is None:
return
if isinstance(ret['size'], int):
size = FileUtilities.bytes_to_human(ret['size'])
self.size += ret['size']
self.total_bytes += ret['size']
else:
size = "?B"
if ret['path']:
path = ret['path']
else:
path = ''
line = "%s %s %s\n" % (ret['label'], size, path)
self.total_deleted += ret['n_deleted']
self.total_special += ret['n_special']
if ret['label']:
# the label may be a hidden operation
# (e.g., win.shell.change.notify)
self.ui.append_text(line)
def clean_operation(self, operation):
"""Perform a single cleaning operation"""
operation_options = self.operations[operation]
assert(isinstance(operation_options, list))
logger.debug("clean_operation('%s'), options = '%s'",
operation, operation_options)
if not operation_options:
return
if self.really_delete and backends[operation].is_running():
# TRANSLATORS: %s expands to a name such as 'Firefox' or 'System'.
err = _("%s cannot be cleaned because it is currently running. Close it, and try again.") \
% backends[operation].get_name()
self.ui.append_text(err + "\n", 'error')
self.total_errors += 1
return
import time
self.yield_time = time.time()
total_size = 0
for option_id in operation_options:
self.size = 0
assert(isinstance(option_id, str))
# normal scan
for cmd in backends[operation].get_commands(option_id):
for ret in self.execute(cmd, '%s.%s' % (operation, option_id)):
if True == ret:
# Return control to PyGTK idle loop to keep
# it responding allow the user to abort
self.yield_time = time.time()
yield True
if self.is_aborted:
break
if time.time() - self.yield_time > 0.25:
if self.really_delete:
self.ui.update_total_size(self.total_bytes)
yield True
self.yield_time = time.time()
self.ui.update_item_size(operation, option_id, self.size)
total_size += self.size
# deep scan
for (path, search) in backends[operation].get_deep_scan(option_id):
if '' == path:
path = os.path.expanduser('~')
if search.command not in ('delete', 'shred'):
raise NotImplementedError(
'Deep scan only supports deleting or shredding now')
if path not in self.deepscans:
self.deepscans[path] = []
self.deepscans[path].append(search)
self.ui.update_item_size(operation, -1, total_size)
def run_delayed_op(self, operation, option_id):
"""Run one delayed operation"""
self.ui.update_progress_bar(0.0)
if 'free_disk_space' == option_id:
# TRANSLATORS: 'free' means 'unallocated'
msg = _("Please wait. Wiping free disk space.")
self.ui.append_text(
_('Wiping free disk space erases remnants of files that were deleted without shredding. It does not free up space.'))
self.ui.append_text('\n')
elif 'memory' == option_id:
msg = _("Please wait. Cleaning %s.") % _("Memory")
else:
raise RuntimeError("Unexpected option_id in delayed ops")
self.ui.update_progress_bar(msg)
for cmd in backends[operation].get_commands(option_id):
for ret in self.execute(cmd, '%s.%s' % (operation, option_id)):
if isinstance(ret, tuple):
# Display progress (for free disk space)
phase = ret[0]
# A while ago there were other phase numbers. Currently it's just 1
if phase != 1:
raise RuntimeError(
'While wiping free space, unexpected phase %d' % phase)
percent_done = ret[1]
eta_seconds = ret[2]
self.ui.update_progress_bar(percent_done)
if isinstance(eta_seconds, int):
eta_mins = math.ceil(eta_seconds / 60)
msg2 = ngettext("About %d minute remaining.",
"About %d minutes remaining.", eta_mins) \
% eta_mins
self.ui.update_progress_bar(msg + ' ' + msg2)
else:
self.ui.update_progress_bar(msg)
if self.is_aborted:
break
if True == ret or isinstance(ret, tuple):
# Return control to PyGTK idle loop to keep
# it responding and allow the user to abort.
yield True
def run(self):
"""Perform the main cleaning process which has these phases
1. General cleaning
2. Deep scan
3. Memory
4. Free disk space"""
self.deepscans = {}
# prioritize
self.delayed_ops = []
for operation in self.operations:
delayables = ['free_disk_space', 'memory']
for delayable in delayables:
if operation not in ('system', '_gui'):
continue
if delayable in self.operations[operation]:
i = self.operations[operation].index(delayable)
del self.operations[operation][i]
priority = 99
if 'free_disk_space' == delayable:
priority = 100
new_op = (priority, {operation: [delayable]})
self.delayed_ops.append(new_op)
# standard operations
import warnings
with warnings.catch_warnings(record=True) as ws:
# This warning system allows general warnings. Duplicate will
# be removed, and the warnings will show near the end of
# the log.
warnings.simplefilter('once')
for _dummy in self.run_operations(self.operations):
# yield to GTK+ idle loop
yield True
for w in ws:
logger.warning(w.message)
# run deep scan
if self.deepscans:
yield from self.run_deep_scan()
# delayed operations
for op in sorted(self.delayed_ops):
operation = list(op[1].keys())[0]
for option_id in list(op[1].values())[0]:
for _ret in self.run_delayed_op(operation, option_id):
# yield to GTK+ idle loop
yield True
# print final stats
bytes_delete = FileUtilities.bytes_to_human(self.total_bytes)
if self.really_delete:
# TRANSLATORS: This refers to disk space that was
# really recovered (in other words, not a preview)
line = _("Disk space recovered: %s") % bytes_delete
else:
# TRANSLATORS: This refers to a preview (no real
# changes were made yet)
line = _("Disk space to be recovered: %s") % bytes_delete
self.ui.append_text("\n%s" % line)
if self.really_delete:
# TRANSLATORS: This refers to the number of files really
# deleted (in other words, not a preview).
line = _("Files deleted: %d") % self.total_deleted
else:
# TRANSLATORS: This refers to the number of files that
# would be deleted (in other words, simply a preview).
line = _("Files to be deleted: %d") % self.total_deleted
self.ui.append_text("\n%s" % line)
if self.total_special > 0:
line = _("Special operations: %d") % self.total_special
self.ui.append_text("\n%s" % line)
if self.total_errors > 0:
line = _("Errors: %d") % self.total_errors
self.ui.append_text("\n%s" % line, 'error')
self.ui.append_text('\n')
if self.really_delete:
self.ui.update_total_size(self.total_bytes)
self.ui.worker_done(self, self.really_delete)
yield False
def run_deep_scan(self):
"""Run deep scans"""
logger.debug(' deepscans=%s' % self.deepscans)
# TRANSLATORS: The "deep scan" feature searches over broad
# areas of the file system such as the user's whole home directory
# or all the system executables.
self.ui.update_progress_bar(_("Please wait. Running deep scan."))
yield True # allow GTK to update the screen
ds = DeepScan.DeepScan(self.deepscans)
for cmd in ds.scan():
if True == cmd:
yield True
continue
for _ret in self.execute(cmd, 'deepscan'):
yield True
def run_operations(self, my_operations):
"""Run a set of operations (general, memory, free disk space)"""
for count, operation in enumerate(my_operations):
self.ui.update_progress_bar(1.0 * count / len(my_operations))
name = backends[operation].get_name()
if self.really_delete:
# TRANSLATORS: %s is replaced with Firefox, System, etc.
msg = _("Please wait. Cleaning %s.") % name
else:
# TRANSLATORS: %s is replaced with Firefox, System, etc.
msg = _("Please wait. Previewing %s.") % name
self.ui.update_progress_bar(msg)
yield True # show the progress bar message now
try:
for _dummy in self.clean_operation(operation):
yield True
except:
self.print_exception(operation)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/__init__.py 0000664 0001750 0001750 00000024430 14522012661 015034 0 ustar 00z z # vim: ts=4:sw=4:expandtab
# -*- coding: UTF-8 -*-
# BleachBit
# Copyright (C) 2008-2023 Andrew Ziem
# https://www.bleachbit.org
#
# 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 .
"""
Code that is commonly shared throughout BleachBit
"""
import gettext
import locale
import os
import re
import sys
import platform
from bleachbit import Log
from configparser import RawConfigParser, NoOptionError
APP_VERSION = "4.6.0"
APP_NAME = "BleachBit"
APP_URL = "https://www.bleachbit.org"
socket_timeout = 10
if sys.version_info < (3,0,0):
print('BleachBit no longer supports Python 2.x.')
sys.exit(1)
if hasattr(sys, 'frozen') and sys.frozen == 'windows_exe':
stdout_encoding = 'utf-8'
else:
stdout_encoding = sys.stdout.encoding
if 'win32' == sys.platform:
import win_unicode_console
win_unicode_console.enable()
if not hasattr(platform, 'linux_distribution'):
from ._platform import _linux_distribution
platform.linux_distribution = _linux_distribution
logger = Log.init_log()
# Setting below value to false disables update notification (useful
# for packages in repositories).
online_update_notification_enabled = True
#
# Paths
#
# Windows
bleachbit_exe_path = None
if hasattr(sys, 'frozen'):
# running frozen in py2exe
bleachbit_exe_path = os.path.dirname(sys.executable)
else:
# __file__ is absolute path to __init__.py
bleachbit_exe_path = os.path.dirname(os.path.dirname(__file__))
# license
license_filename = None
license_filenames = ('/usr/share/common-licenses/GPL-3', # Debian, Ubuntu
# Microsoft Windows
os.path.join(bleachbit_exe_path, 'COPYING'),
'/usr/share/doc/bleachbit-' + APP_VERSION + '/COPYING', # CentOS, Fedora, RHEL
'/usr/share/licenses/bleachbit/COPYING', # Fedora 21+, RHEL 7+
'/usr/share/doc/packages/bleachbit/COPYING', # OpenSUSE 11.1
'/usr/pkg/share/doc/bleachbit/COPYING', # NetBSD 5
'/usr/share/licenses/common/GPL3/license.txt') # Arch Linux
for lf in license_filenames:
if os.path.exists(lf):
license_filename = lf
break
# configuration
portable_mode = False
options_dir = None
if 'posix' == os.name:
options_dir = os.path.expanduser("~/.config/bleachbit")
elif 'nt' == os.name:
os.environ.pop('FONTCONFIG_FILE', None)
if os.path.exists(os.path.join(bleachbit_exe_path, 'bleachbit.ini')):
# portable mode
portable_mode = True
options_dir = bleachbit_exe_path
else:
# installed mode
options_dir = os.path.expandvars(r"${APPDATA}\BleachBit")
try:
options_dir = os.environ['BLEACHBIT_TEST_OPTIONS_DIR']
except KeyError:
pass
options_file = os.path.join(options_dir, "bleachbit.ini")
# check whether the application is running from the source tree
if not portable_mode:
paths = (
'../cleaners',
'../Makefile',
'../COPYING')
existing = (
os.path.exists(os.path.join(bleachbit_exe_path, path))
for path in paths)
portable_mode = all(existing)
# personal cleaners
personal_cleaners_dir = os.path.join(options_dir, "cleaners")
# system cleaners
# On Windows in portable mode, the bleachbit_exe_path is equal to
# options_dir, so be careful that system_cleaner_dir is not set to
# personal_cleaners_dir.
if os.path.isdir(os.path.join(bleachbit_exe_path, 'cleaners')) and not portable_mode:
system_cleaners_dir = os.path.join(bleachbit_exe_path, 'cleaners')
elif sys.platform.startswith('linux') or sys.platform == 'darwin':
system_cleaners_dir = '/usr/share/bleachbit/cleaners'
elif sys.platform == 'win32':
system_cleaners_dir = os.path.join(bleachbit_exe_path, 'share\\cleaners\\')
elif sys.platform[:6] == 'netbsd':
system_cleaners_dir = '/usr/pkg/share/bleachbit/cleaners'
elif sys.platform.startswith('openbsd') or sys.platform.startswith('freebsd'):
system_cleaners_dir = '/usr/local/share/bleachbit/cleaners'
else:
system_cleaners_dir = None
logger.warning(
'unknown system cleaners directory for platform %s ', sys.platform)
# local cleaners directory for running without installation (Windows or Linux)
local_cleaners_dir = None
if portable_mode:
local_cleaners_dir = os.path.join(bleachbit_exe_path, 'cleaners')
# windows10 theme
windows10_theme_path = os.path.normpath(os.path.join(bleachbit_exe_path, 'themes/windows10'))
# application icon
__icons = (
'/usr/share/pixmaps/bleachbit.png', # Linux
'/usr/pkg/share/pixmaps/bleachbit.png', # NetBSD
'/usr/local/share/pixmaps/bleachbit.png', # FreeBSD and OpenBSD
os.path.normpath(os.path.join(bleachbit_exe_path,
'share\\bleachbit.png')), # Windows
# When running from source (i.e., not installed).
os.path.normpath(os.path.join(bleachbit_exe_path, 'bleachbit.png')),
)
appicon_path = None
for __icon in __icons:
if os.path.exists(__icon):
appicon_path = __icon
# menu
# This path works when running from source (cross platform) or when
# installed on Windows.
app_menu_filename = os.path.join(bleachbit_exe_path, 'data', 'app-menu.ui')
if not os.path.exists(app_menu_filename) and system_cleaners_dir:
# This path works when installed on Linux.
app_menu_filename = os.path.abspath(
os.path.join(system_cleaners_dir, '../app-menu.ui'))
if not os.path.exists(app_menu_filename):
logger.error('unknown location for app-menu.ui')
# locale directory
if os.path.exists("./locale/"):
# local locale (personal)
locale_dir = os.path.abspath("./locale/")
# system-wide installed locale
elif sys.platform.startswith("linux") or sys.platform == "darwin":
locale_dir = "/usr/share/locale/"
elif sys.platform == "win32":
locale_dir = os.path.join(bleachbit_exe_path, "share\\locale\\")
elif sys.platform[:6] == "netbsd":
locale_dir = "/usr/pkg/share/locale/"
elif sys.platform.startswith("openbsd") or sys.platform.startswith("freebsd"):
locale_dir = "/usr/local/share/locale/"
#
# gettext
#
try:
(user_locale, encoding) = locale.getdefaultlocale()
except:
logger.exception('error getting locale')
user_locale = None
encoding = None
if user_locale is None:
user_locale = 'C'
logger.warning("no default locale found. Assuming '%s'", user_locale)
if 'win32' == sys.platform:
os.environ['LANG'] = user_locale
try:
if not os.path.exists(locale_dir):
raise RuntimeError('translations not installed')
t = gettext.translation('bleachbit', locale_dir)
_ = t.gettext
except:
def _(msg):
"""Dummy replacement for gettext"""
return msg
try:
locale.bindtextdomain('bleachbit', locale_dir)
except AttributeError:
if sys.platform.startswith('win'):
try:
# We're on Windows; try and use libintl-8.dll instead
import ctypes
libintl = ctypes.cdll.LoadLibrary('libintl-8.dll')
except OSError:
# libintl-8.dll isn't available; give up
pass
else:
# bindtextdomain can not handle Unicode
libintl.bindtextdomain(b'bleachbit', locale_dir.encode('utf-8'))
libintl.bind_textdomain_codeset(b'bleachbit', b'UTF-8')
except:
logger.exception('error binding text domain')
try:
ngettext = t.ngettext
except:
def ngettext(singular, plural, n):
"""Dummy replacement for plural gettext"""
if 1 == n:
return singular
return plural
#
# pgettext
#
# Code released in the Public Domain. You can do whatever you want with this package.
# Originally written by Pierre Métras for the OLPC XO laptop.
#
# Original source: http://dev.laptop.org/git/activities/clock/plain/pgettext.py
# pgettext(msgctxt, msgid) from gettext is not supported in Python as of January 2017
# http://bugs.python.org/issue2504
# This issue was fixed in Python 3.8, but we need to support older versions.
# Meanwhile we get official support, we have to simulate it.
# See http://www.gnu.org/software/gettext/manual/gettext.html#Ambiguities for
# more information about pgettext.
# The separator between message context and message id.This value is the same as
# the one used in gettext.h, so PO files should be still valid when Python gettext
# module will include pgettext() function.
GETTEXT_CONTEXT_GLUE = "\004"
def pgettext(msgctxt, msgid):
"""A custom implementation of GNU pgettext().
"""
if msgctxt is None or msgctxt == "":
return _(msgid)
translation = _(msgctxt + GETTEXT_CONTEXT_GLUE + msgid)
if translation.startswith(msgctxt + GETTEXT_CONTEXT_GLUE):
return msgid
else:
return translation
# Map our pgettext() custom function to _p()
_p = pgettext
#
# URLs
#
base_url = "https://update.bleachbit.org"
help_contents_url = "%s/help/%s" \
% (base_url, APP_VERSION)
release_notes_url = "%s/release-notes/%s" \
% (base_url, APP_VERSION)
update_check_url = "%s/update/%s" % (base_url, APP_VERSION)
# set up environment variables
if 'nt' == os.name:
from bleachbit import Windows
Windows.setup_environment()
if 'posix' == os.name:
# XDG base directory specification
envs = {
'XDG_DATA_HOME': os.path.expanduser('~/.local/share'),
'XDG_CONFIG_HOME': os.path.expanduser('~/.config'),
'XDG_CACHE_HOME': os.path.expanduser('~/.cache')
}
for varname, value in envs.items():
if not os.getenv(varname):
os.environ[varname] = value
# should be re.IGNORECASE on macOS
fs_scan_re_flags = 0 if os.name == 'posix' else re.IGNORECASE
#
# Exceptions
#
# Python 3.6 is the first with this exception
if hasattr(sys.modules['builtins'], 'ModuleNotFoundError'):
ModuleNotFoundError = sys.modules['builtins'].ModuleNotFoundError
else:
class ModuleNotFoundError(Exception):
pass
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/_platform.py 0000775 0001750 0001750 00000027300 14522012661 015262 0 ustar 00z z #! /usr/bin/python3.7
""" This module tries to retrieve as much platform-identifying data as
possible. It makes this information available via function APIs.
If called from the command line, it prints the platform
information concatenated as single string to stdout. The output
format is usable as part of a filename.
"""
# This module is maintained by Marc-Andre Lemburg .
# If you find problems, please submit bug reports/patches via the
# Python bug tracker (http://bugs.python.org) and assign them to "lemburg".
#
# Still needed:
# * support for MS-DOS (PythonDX ?)
# * support for Amiga and other still unsupported platforms running Python
# * support for additional Linux distributions
#
# Many thanks to all those who helped adding platform-specific
# checks (in no particular order):
#
# Charles G Waldman, David Arnold, Gordon McMillan, Ben Darnell,
# Jeff Bauer, Cliff Crawford, Ivan Van Laningham, Josef
# Betancourt, Randall Hopper, Karl Putland, John Farrell, Greg
# Andruk, Just van Rossum, Thomas Heller, Mark R. Levinson, Mark
# Hammond, Bill Tutt, Hans Nowak, Uwe Zessin (OpenVMS support),
# Colin Kong, Trent Mick, Guido van Rossum, Anthony Baxter, Steve
# Dower
#
# History:
#
#
#
# 1.0.8 - changed Windows support to read version from kernel32.dll
# 1.0.7 - added DEV_NULL
# 1.0.6 - added linux_distribution()
# 1.0.5 - fixed Java support to allow running the module on Jython
# 1.0.4 - added IronPython support
# 1.0.3 - added normalization of Windows system name
# 1.0.2 - added more Windows support
# 1.0.1 - reformatted to make doc.py happy
# 1.0.0 - reformatted a bit and checked into Python CVS
# 0.8.0 - added sys.version parser and various new access
# APIs (python_version(), python_compiler(), etc.)
# 0.7.2 - fixed architecture() to use sizeof(pointer) where available
# 0.7.1 - added support for Caldera OpenLinux
# 0.7.0 - some fixes for WinCE; untabified the source file
# 0.6.2 - support for OpenVMS - requires version 1.5.2-V006 or higher and
# vms_lib.getsyi() configured
# 0.6.1 - added code to prevent 'uname -p' on platforms which are
# known not to support it
# 0.6.0 - fixed win32_ver() to hopefully work on Win95,98,NT and Win2k;
# did some cleanup of the interfaces - some APIs have changed
# 0.5.5 - fixed another type in the MacOS code... should have
# used more coffee today ;-)
# 0.5.4 - fixed a few typos in the MacOS code
# 0.5.3 - added experimental MacOS support; added better popen()
# workarounds in _syscmd_ver() -- still not 100% elegant
# though
# 0.5.2 - fixed uname() to return '' instead of 'unknown' in all
# return values (the system uname command tends to return
# 'unknown' instead of just leaving the field empty)
# 0.5.1 - included code for slackware dist; added exception handlers
# to cover up situations where platforms don't have os.popen
# (e.g. Mac) or fail on socket.gethostname(); fixed libc
# detection RE
# 0.5.0 - changed the API names referring to system commands to *syscmd*;
# added java_ver(); made syscmd_ver() a private
# API (was system_ver() in previous versions) -- use uname()
# instead; extended the win32_ver() to also return processor
# type information
# 0.4.0 - added win32_ver() and modified the platform() output for WinXX
# 0.3.4 - fixed a bug in _follow_symlinks()
# 0.3.3 - fixed popen() and "file" command invocation bugs
# 0.3.2 - added architecture() API and support for it in platform()
# 0.3.1 - fixed syscmd_ver() RE to support Windows NT
# 0.3.0 - added system alias support
# 0.2.3 - removed 'wince' again... oh well.
# 0.2.2 - added 'wince' to syscmd_ver() supported platforms
# 0.2.1 - added cache logic and changed the platform string format
# 0.2.0 - changed the API to use functions instead of module globals
# since some action take too long to be run on module import
# 0.1.0 - first release
#
# You can always get the latest version of this module at:
#
# http://www.egenix.com/files/python/platform.py
#
# If that URL should fail, try contacting the author.
__copyright__ = """
Copyright (c) 1999-2000, Marc-Andre Lemburg; mailto:mal@lemburg.com
Copyright (c) 2000-2010, eGenix.com Software GmbH; mailto:info@egenix.com
Permission to use, copy, modify, and distribute this software and its
documentation for any purpose and without fee or royalty is hereby granted,
provided that the above copyright notice appear in all copies and that
both that copyright notice and this permission notice appear in
supporting documentation or portions thereof, including modifications,
that you make.
EGENIX.COM SOFTWARE GMBH DISCLAIMS ALL WARRANTIES WITH REGARD TO
THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS, IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL,
INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING
FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION
WITH THE USE OR PERFORMANCE OF THIS SOFTWARE !
"""
import os, re
### Globals & Constants
# Directory to search for configuration information on Unix.
# Constant used by test_platform to test linux_distribution().
_UNIXCONFDIR = '/etc'
def _dist_try_harder(distname, version, id):
""" Tries some special tricks to get the distribution
information in case the default method fails.
Currently supports older SuSE Linux, Caldera OpenLinux and
Slackware Linux distributions.
"""
if os.path.exists('/var/adm/inst-log/info'):
# SuSE Linux stores distribution information in that file
distname = 'SuSE'
with open('/var/adm/inst-log/info') as f:
for line in f:
tv = line.split()
if len(tv) == 2:
tag, value = tv
else:
continue
if tag == 'MIN_DIST_VERSION':
version = value.strip()
elif tag == 'DIST_IDENT':
values = value.split('-')
id = values[2]
return distname, version, id
if os.path.exists('/etc/.installed'):
# Caldera OpenLinux has some infos in that file (thanks to Colin Kong)
with open('/etc/.installed') as f:
for line in f:
pkg = line.split('-')
if len(pkg) >= 2 and pkg[0] == 'OpenLinux':
# XXX does Caldera support non Intel platforms ? If yes,
# where can we find the needed id ?
return 'OpenLinux', pkg[1], id
if os.path.isdir('/usr/lib/setup'):
# Check for slackware version tag file (thanks to Greg Andruk)
verfiles = os.listdir('/usr/lib/setup')
for n in range(len(verfiles)-1, -1, -1):
if verfiles[n][:14] != 'slack-version-':
del verfiles[n]
if verfiles:
verfiles.sort()
distname = 'slackware'
version = verfiles[-1][14:]
return distname, version, id
return distname, version, id
_release_filename = re.compile(r'(\w+)[-_](release|version)', re.ASCII)
_lsb_release_version = re.compile(r'(.+)'
r' release '
r'([\d.]+)'
r'[^(]*(?:\((.+)\))?', re.ASCII)
_release_version = re.compile(r'([^0-9]+)'
r'(?: release )?'
r'([\d.]+)'
r'[^(]*(?:\((.+)\))?', re.ASCII)
# See also http://www.novell.com/coolsolutions/feature/11251.html
# and http://linuxmafia.com/faq/Admin/release-files.html
# and http://data.linux-ntfs.org/rpm/whichrpm
# and http://www.die.net/doc/linux/man/man1/lsb_release.1.html
_supported_dists = (
'SuSE', 'debian', 'fedora', 'redhat', 'centos',
'mandrake', 'mandriva', 'rocks', 'slackware', 'yellowdog', 'gentoo',
'UnitedLinux', 'turbolinux', 'arch', 'mageia', 'Ubuntu')
def _parse_release_file(firstline):
# Default to empty 'version' and 'id' strings. Both defaults are used
# when 'firstline' is empty. 'id' defaults to empty when an id can not
# be deduced.
version = ''
id = ''
# Parse the first line
m = _lsb_release_version.match(firstline)
if m is not None:
# LSB format: "distro release x.x (codename)"
return tuple(m.groups())
# Pre-LSB format: "distro x.x (codename)"
m = _release_version.match(firstline)
if m is not None:
return tuple(m.groups())
# Unknown format... take the first two words
l = firstline.strip().split()
if l:
version = l[0]
if len(l) > 1:
id = l[1]
return '', version, id
_distributor_id_file_re = re.compile(r"(?:DISTRIB_ID\s*=)\s*(.*)", re.I)
_release_file_re = re.compile(r"(?:DISTRIB_RELEASE\s*=)\s*(.*)", re.I)
_codename_file_re = re.compile(r"(?:DISTRIB_CODENAME\s*=)\s*(.*)", re.I)
def _linux_distribution(distname='', version='', id='', supported_dists=_supported_dists,
full_distribution_name=1):
""" Tries to determine the name of the Linux OS distribution name.
The function first looks for a distribution release file in
/etc and then reverts to _dist_try_harder() in case no
suitable files are found.
supported_dists may be given to define the set of Linux
distributions to look for. It defaults to a list of currently
supported Linux distributions identified by their release file
name.
If full_distribution_name is true (default), the full
distribution read from the OS is returned. Otherwise the short
name taken from supported_dists is used.
Returns a tuple (distname, version, id) which default to the
args given as parameters.
"""
# check for the Debian/Ubuntu /etc/lsb-release file first, needed so
# that the distribution doesn't get identified as Debian.
try:
with open("/etc/lsb-release", "r") as etclsbrel:
for line in etclsbrel:
m = _distributor_id_file_re.search(line)
if m:
_u_distname = m.group(1).strip()
m = _release_file_re.search(line)
if m:
_u_version = m.group(1).strip()
m = _codename_file_re.search(line)
if m:
_u_id = m.group(1).strip()
if _u_distname and _u_version:
return (_u_distname, _u_version, _u_id)
except (EnvironmentError, UnboundLocalError):
pass
try:
etc = os.listdir(_UNIXCONFDIR)
except OSError:
# Probably not a Unix system
return distname, version, id
etc.sort()
for file in etc:
m = _release_filename.match(file)
if m is not None:
_distname, dummy = m.groups()
if _distname in supported_dists:
distname = _distname
break
else:
return _dist_try_harder(distname, version, id)
# Read the first line
with open(os.path.join(_UNIXCONFDIR, file), 'r',
encoding='utf-8', errors='surrogateescape') as f:
firstline = f.readline()
_distname, _version, _id = _parse_release_file(firstline)
if _distname and full_distribution_name:
distname = _distname
if _version:
version = _version
if _id:
id = _id
return distname, version, id
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1699222962.159012
bleachbit-4.6.0/bleachbit/markovify/ 0000775 0001750 0001750 00000000000 14522012662 014730 5 ustar 00z z ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/markovify/__init__.py 0000664 0001750 0001750 00000000302 14522012661 017033 0 ustar 00z z # version is not needed
#from .__version__ import __version__
from .chain import Chain
from .text import Text, NewlineText
from .splitters import split_into_sentences
from .utils import combine
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/markovify/chain.py 0000664 0001750 0001750 00000012047 14522012661 016367 0 ustar 00z z # markovify
# Copyright (c) 2015, Jeremy Singer-Vine
# Origin: https://github.com/jsvine/markovify
# MIT License: https://github.com/jsvine/markovify/blob/master/LICENSE.txt
import random
import operator
import bisect
import json
# Python3 compatibility
try: # pragma: no cover
basestring
except NameError: # pragma: no cover
basestring = str
BEGIN = "___BEGIN__"
END = "___END__"
def accumulate(iterable, func=operator.add):
"""
Cumulative calculations. (Summation, by default.)
Via: https://docs.python.org/3/library/itertools.html#itertools.accumulate
"""
it = iter(iterable)
total = next(it)
yield total
for element in it:
total = func(total, element)
yield total
class Chain(object):
"""
A Markov chain representing processes that have both beginnings and ends.
For example: Sentences.
"""
def __init__(self, corpus, state_size, model=None):
"""
`corpus`: A list of lists, where each outer list is a "run"
of the process (e.g., a single sentence), and each inner list
contains the steps (e.g., words) in the run. If you want to simulate
an infinite process, you can come very close by passing just one, very
long run.
`state_size`: An integer indicating the number of items the model
uses to represent its state. For text generation, 2 or 3 are typical.
"""
self.state_size = state_size
self.model = model or self.build(corpus, self.state_size)
self.precompute_begin_state()
def build(self, corpus, state_size):
"""
Build a Python representation of the Markov model. Returns a dict
of dicts where the keys of the outer dict represent all possible states,
and point to the inner dicts. The inner dicts represent all possibilities
for the "next" item in the chain, along with the count of times it
appears.
"""
# Using a DefaultDict here would be a lot more convenient, however the memory
# usage is far higher.
model = {}
for run in corpus:
items = ([ BEGIN ] * state_size) + run + [ END ]
for i in range(len(run) + 1):
state = tuple(items[i:i+state_size])
follow = items[i+state_size]
if state not in model:
model[state] = {}
if follow not in model[state]:
model[state][follow] = 0
model[state][follow] += 1
return model
def precompute_begin_state(self):
"""
Caches the summation calculation and available choices for BEGIN * state_size.
Significantly speeds up chain generation on large corpuses. Thanks, @schollz!
"""
begin_state = tuple([ BEGIN ] * self.state_size)
choices, weights = zip(*self.model[begin_state].items())
cumdist = list(accumulate(weights))
self.begin_cumdist = cumdist
self.begin_choices = choices
def move(self, state):
"""
Given a state, choose the next item at random.
"""
if state == tuple([ BEGIN ] * self.state_size):
choices = self.begin_choices
cumdist = self.begin_cumdist
else:
choices, weights = zip(*self.model[state].items())
cumdist = list(accumulate(weights))
r = random.random() * cumdist[-1]
selection = choices[bisect.bisect(cumdist, r)]
return selection
def gen(self, init_state=None):
"""
Starting either with a naive BEGIN state, or the provided `init_state`
(as a tuple), return a generator that will yield successive items
until the chain reaches the END state.
"""
state = init_state or (BEGIN,) * self.state_size
while True:
next_word = self.move(state)
if next_word == END: break
yield next_word
state = tuple(state[1:]) + (next_word,)
def walk(self, init_state=None):
"""
Return a list representing a single run of the Markov model, either
starting with a naive BEGIN state, or the provided `init_state`
(as a tuple).
"""
return list(self.gen(init_state))
def to_json(self):
"""
Dump the model as a JSON object, for loading later.
"""
return json.dumps(list(self.model.items()))
@classmethod
def from_json(cls, json_thing):
"""
Given a JSON object or JSON string that was created by `self.to_json`,
return the corresponding markovify.Chain.
"""
if isinstance(json_thing, basestring):
obj = json.loads(json_thing)
else:
obj = json_thing
if isinstance(obj, list):
rehydrated = {tuple(item[0]): item[1] for item in obj}
elif isinstance(obj, dict):
rehydrated = obj
else:
raise ValueError("Object should be dict or list")
state_size = len(list(rehydrated.keys())[0])
inst = cls(None, state_size, rehydrated)
return inst
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/markovify/splitters.py 0000664 0001750 0001750 00000004256 14522012661 017341 0 ustar 00z z # -*- coding: utf-8 -*-
# markovify
# Copyright (c) 2015, Jeremy Singer-Vine
# Origin: https://github.com/jsvine/markovify
# MIT License: https://github.com/jsvine/markovify/blob/master/LICENSE.txt
import re
ascii_lowercase = "abcdefghijklmnopqrstuvwxyz"
ascii_uppercase = ascii_lowercase.upper()
# States w/ with thanks to https://github.com/unitedstates/python-us
# Titles w/ thanks to https://github.com/nytimes/emphasis and @donohoe
abbr_capped = "|".join([
"ala|ariz|ark|calif|colo|conn|del|fla|ga|ill|ind|kan|ky|la|md|mass|mich|minn|miss|mo|mont|neb|nev|okla|ore|pa|tenn|vt|va|wash|wis|wyo", # States
"u.s",
"mr|ms|mrs|msr|dr|gov|pres|sen|sens|rep|reps|prof|gen|messrs|col|sr|jf|sgt|mgr|fr|rev|jr|snr|atty|supt", # Titles
"ave|blvd|st|rd|hwy", # Streets
"jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec", # Months
"|".join(ascii_lowercase) # Initials
]).split("|")
abbr_lowercase = "etc|v|vs|viz|al|pct"
exceptions = "U.S.|U.N.|E.U.|F.B.I.|C.I.A.".split("|")
def is_abbreviation(dotted_word):
clipped = dotted_word[:-1]
if clipped[0] in ascii_uppercase:
if clipped.lower() in abbr_capped: return True
else: return False
else:
if clipped in abbr_lowercase: return True
else: return False
def is_sentence_ender(word):
if word in exceptions: return False
if word[-1] in [ "?", "!" ]:
return True
if len(re.sub(r"[^A-Z]", "", word)) > 1:
return True
if word[-1] == "." and (not is_abbreviation(word)):
return True
return False
def split_into_sentences(text):
potential_end_pat = re.compile(r"".join([
r"([\w\.'’&\]\)]+[\.\?!])", # A word that ends with punctuation
r"([‘’“”'\"\)\]]*)", # Followed by optional quote/parens/etc
r"(\s+(?![a-z\-–—]))", # Followed by whitespace + non-(lowercase or dash)
]), re.U)
dot_iter = re.finditer(potential_end_pat, text)
end_indices = [ (x.start() + len(x.group(1)) + len(x.group(2)))
for x in dot_iter
if is_sentence_ender(x.group(1)) ]
spans = zip([None] + end_indices, end_indices + [None])
sentences = [ text[start:end].strip() for start, end in spans ]
return sentences
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/markovify/text.py 0000664 0001750 0001750 00000021014 14522012661 016263 0 ustar 00z z # markovify
# Copyright (c) 2015, Jeremy Singer-Vine
# Origin: https://github.com/jsvine/markovify
# MIT License: https://github.com/jsvine/markovify/blob/master/LICENSE.txt
import re
import random
from .splitters import split_into_sentences
from .chain import Chain, BEGIN
# BleachBit does not use unidecode
#from unidecode import unidecode
DEFAULT_MAX_OVERLAP_RATIO = 0.7
DEFAULT_MAX_OVERLAP_TOTAL = 15
DEFAULT_TRIES = 10
class ParamError(Exception):
pass
class Text(object):
def __init__(self, input_text, state_size=2, chain=None, parsed_sentences=None, retain_original=True):
"""
input_text: A string.
state_size: An integer, indicating the number of words in the model's state.
chain: A trained markovify.Chain instance for this text, if pre-processed.
parsed_sentences: A list of lists, where each outer list is a "run"
of the process (e.g. a single sentence), and each inner list
contains the steps (e.g. words) in the run. If you want to simulate
an infinite process, you can come very close by passing just one, very
long run.
"""
can_make_sentences = parsed_sentences is not None or input_text is not None
self.retain_original = retain_original and can_make_sentences
self.state_size = state_size
if self.retain_original:
# not used in BleachBit
pass
else:
if not chain:
# not used in BleachBit
pass
self.chain = chain or Chain(parsed, state_size)
def to_dict(self):
"""
Returns the underlying data as a Python dict.
"""
# not used in BleachBit
pass
def to_json(self):
"""
Returns the underlying data as a JSON string.
"""
# not used in BleachBit
pass
@classmethod
def from_dict(cls, obj, **kwargs):
return cls(
None,
state_size=obj["state_size"],
chain=Chain.from_json(obj["chain"]),
parsed_sentences=obj.get("parsed_sentences")
)
@classmethod
def from_json(cls, json_str):
# not used in BleachBit
pass
def sentence_split(self, text):
"""
Splits full-text string into a list of sentences.
"""
return split_into_sentences(text)
def sentence_join(self, sentences):
"""
Re-joins a list of sentences into the full text.
"""
return " ".join(sentences)
word_split_pattern = re.compile(r"\s+")
def word_split(self, sentence):
"""
Splits a sentence into a list of words.
"""
return re.split(self.word_split_pattern, sentence)
def word_join(self, words):
"""
Re-joins a list of words into a sentence.
"""
return " ".join(words)
def test_sentence_input(self, sentence):
"""
A basic sentence filter. This one rejects sentences that contain
the type of punctuation that would look strange on its own
in a randomly-generated sentence.
"""
# not used in BleachBit
pass
def generate_corpus(self, text):
"""
Given a text string, returns a list of lists; that is, a list of
"sentences," each of which is a list of words. Before splitting into
words, the sentences are filtered through `self.test_sentence_input`
"""
# not used in BleachBit
pass
def test_sentence_output(self, words, max_overlap_ratio, max_overlap_total):
"""
Given a generated list of words, accept or reject it. This one rejects
sentences that too closely match the original text, namely those that
contain any identical sequence of words of X length, where X is the
smaller number of (a) `max_overlap_ratio` (default: 0.7) of the total
number of words, and (b) `max_overlap_total` (default: 15).
"""
# not used in BleachBit
pass
def make_sentence(self, init_state=None, **kwargs):
"""
Attempts `tries` (default: 10) times to generate a valid sentence,
based on the model and `test_sentence_output`. Passes `max_overlap_ratio`
and `max_overlap_total` to `test_sentence_output`.
If successful, returns the sentence as a string. If not, returns None.
If `init_state` (a tuple of `self.chain.state_size` words) is not specified,
this method chooses a sentence-start at random, in accordance with
the model.
If `test_output` is set as False then the `test_sentence_output` check
will be skipped.
If `max_words` is specified, the word count for the sentence will be
evaluated against the provided limit.
"""
tries = kwargs.get('tries', DEFAULT_TRIES)
mor = kwargs.get('max_overlap_ratio', DEFAULT_MAX_OVERLAP_RATIO)
mot = kwargs.get('max_overlap_total', DEFAULT_MAX_OVERLAP_TOTAL)
test_output = kwargs.get('test_output', True)
max_words = kwargs.get('max_words', None)
if init_state != None:
prefix = list(init_state)
for word in prefix:
if word == BEGIN:
prefix = prefix[1:]
else:
break
else:
prefix = []
for _ in range(tries):
words = prefix + self.chain.walk(init_state)
if max_words != None and len(words) > max_words:
continue
if test_output and hasattr(self, "rejoined_text"):
if self.test_sentence_output(words, mor, mot):
return self.word_join(words)
else:
return self.word_join(words)
return None
def make_short_sentence(self, max_chars, min_chars=0, **kwargs):
"""
Tries making a sentence of no more than `max_chars` characters and optionally
no less than `min_chars` charcaters, passing **kwargs to `self.make_sentence`.
"""
tries = kwargs.get('tries', DEFAULT_TRIES)
for _ in range(tries):
sentence = self.make_sentence(**kwargs)
if sentence and len(sentence) <= max_chars and len(sentence) >= min_chars:
return sentence
def make_sentence_with_start(self, beginning, strict=True, **kwargs):
"""
Tries making a sentence that begins with `beginning` string,
which should be a string of one to `self.state` words known
to exist in the corpus.
If strict == True, then markovify will draw its initial inspiration
only from sentences that start with the specified word/phrase.
If strict == False, then markovify will draw its initial inspiration
from any sentence containing the specified word/phrase.
**kwargs are passed to `self.make_sentence`
"""
split = tuple(self.word_split(beginning))
word_count = len(split)
if word_count == self.state_size:
init_states = [ split ]
elif word_count > 0 and word_count < self.state_size:
if strict:
init_states = [ (BEGIN,) * (self.state_size - word_count) + split ]
else:
init_states = [ key for key in self.chain.model.keys()
# check for starting with begin as well ordered lists
if tuple(filter(lambda x: x != BEGIN, key))[:word_count] == split ]
random.shuffle(init_states)
else:
err_msg = "`make_sentence_with_start` for this model requires a string containing 1 to {0} words. Yours has {1}: {2}".format(self.state_size, word_count, str(split))
raise ParamError(err_msg)
for init_state in init_states:
output = self.make_sentence(init_state, **kwargs)
if output is not None:
return output
return None
@classmethod
def from_chain(cls, chain_json, corpus=None, parsed_sentences=None):
"""
Init a Text class based on an existing chain JSON string or object
If corpus is None, overlap checking won't work.
"""
chain = Chain.from_json(chain_json)
return cls(corpus or None, parsed_sentences=parsed_sentences, state_size=chain.state_size, chain=chain)
class NewlineText(Text):
"""
A (usable) example of subclassing markovify.Text. This one lets you markovify
text where the sentences are separated by newlines instead of ". "
"""
def sentence_split(self, text):
return re.split(r"\s*\n\s*", text)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit/markovify/utils.py 0000664 0001750 0001750 00000004031 14522012661 016437 0 ustar 00z z # markovify
# Copyright (c) 2015, Jeremy Singer-Vine
# Origin: https://github.com/jsvine/markovify
# MIT License: https://github.com/jsvine/markovify/blob/master/LICENSE.txt
from .chain import Chain
from .text import Text
def get_model_dict(thing):
if isinstance(thing, Chain):
return thing.model
if isinstance(thing, Text):
return thing.chain.model
if isinstance(thing, list):
return dict(thing)
if isinstance(thing, dict):
return thing
raise ValueError("`models` should be instances of list, dict, markovify.Chain, or markovify.Text")
def combine(models, weights=None):
if weights is None:
weights = [ 1 for _ in range(len(models)) ]
if len(models) != len(weights):
raise ValueError("`models` and `weights` lengths must be equal.")
model_dicts = list(map(get_model_dict, models))
state_sizes = [ len(list(md.keys())[0])
for md in model_dicts ]
if len(set(state_sizes)) != 1:
raise ValueError("All `models` must have the same state size.")
if len(set(map(type, models))) != 1:
raise ValueError("All `models` must be of the same type.")
c = {}
for m, w in zip(model_dicts, weights):
for state, options in m.items():
current = c.get(state, {})
for subseq_k, subseq_v in options.items():
subseq_prev = current.get(subseq_k, 0)
current[subseq_k] = subseq_prev + (subseq_v * w)
c[state] = current
ret_inst = models[0]
if isinstance(ret_inst, Chain):
return Chain.from_json(c)
if isinstance(ret_inst, Text):
if not any(m.retain_original for m in models):
return ret_inst.from_chain(c)
combined_sentences = []
for m in models:
if m.retain_original:
combined_sentences += m.parsed_sentences
return ret_inst.from_chain(c, parsed_sentences=combined_sentences)
if isinstance(ret_inst, list):
return list(c.items())
if isinstance(ret_inst, dict):
return c
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1699222962.159012
bleachbit-4.6.0/bleachbit.egg-info/ 0000775 0001750 0001750 00000000000 14522012662 014413 5 ustar 00z z ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222962.0
bleachbit-4.6.0/bleachbit.egg-info/PKG-INFO 0000664 0001750 0001750 00000001032 14522012662 015504 0 ustar 00z z Metadata-Version: 2.1
Name: bleachbit
Version: 4.6.0
Summary: BleachBit - Free space and maintain privacy
Home-page: https://www.bleachbit.org
Author: Andrew Ziem
Author-email: andrew@bleachbit.org
License: GPLv3
Download-URL: https://www.bleachbit.org/download
Platform: Linux and Windows; Python v2.6 and 2.7; GTK v3.12+
License-File: COPYING
BleachBit frees space and maintains privacy by quickly wiping files you don't need and didn't know you had. Supported applications include Edge, Firefox, Google Chrome, VLC, and many others.
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222962.0
bleachbit-4.6.0/bleachbit.egg-info/SOURCES.txt 0000664 0001750 0001750 00000011010 14522012662 016270 0 ustar 00z z COPYING
MANIFEST.in
Makefile
README.md
bleachbit.png
bleachbit.py
bleachbit.spec
org.bleachbit.BleachBit.desktop
org.bleachbit.BleachBit.metainfo.xml
org.bleachbit.policy
setup.py
bleachbit/Action.py
bleachbit/CLI.py
bleachbit/Chaff.py
bleachbit/Cleaner.py
bleachbit/CleanerML.py
bleachbit/Command.py
bleachbit/DeepScan.py
bleachbit/DesktopMenuOptions.py
bleachbit/FileUtilities.py
bleachbit/GUI.py
bleachbit/General.py
bleachbit/GuiBasic.py
bleachbit/GuiChaff.py
bleachbit/GuiPreferences.py
bleachbit/Log.py
bleachbit/Memory.py
bleachbit/Options.py
bleachbit/RecognizeCleanerML.py
bleachbit/Revision.py
bleachbit/Special.py
bleachbit/SystemInformation.py
bleachbit/Unix.py
bleachbit/Update.py
bleachbit/Winapp.py
bleachbit/Windows.py
bleachbit/WindowsWipe.py
bleachbit/Worker.py
bleachbit/__init__.py
bleachbit/_platform.py
bleachbit.egg-info/PKG-INFO
bleachbit.egg-info/SOURCES.txt
bleachbit.egg-info/dependency_links.txt
bleachbit.egg-info/top_level.txt
bleachbit/markovify/__init__.py
bleachbit/markovify/chain.py
bleachbit/markovify/splitters.py
bleachbit/markovify/text.py
bleachbit/markovify/utils.py
cleaners/Makefile
cleaners/adobe_reader.xml
cleaners/amsn.xml
cleaners/amule.xml
cleaners/apt.xml
cleaners/audacious.xml
cleaners/bash.xml
cleaners/beagle.xml
cleaners/brave.xml
cleaners/chromium.xml
cleaners/d4x.xml
cleaners/deepscan.xml
cleaners/discord.xml
cleaners/dnf.xml
cleaners/easytag.xml
cleaners/elinks.xml
cleaners/emesene.xml
cleaners/epiphany.xml
cleaners/evolution.xml
cleaners/exaile.xml
cleaners/filezilla.xml
cleaners/firefox.xml
cleaners/flash.xml
cleaners/gedit.xml
cleaners/gftp.xml
cleaners/gimp.xml
cleaners/gl-117.xml
cleaners/gnome.xml
cleaners/google_chrome.xml
cleaners/google_earth.xml
cleaners/google_toolbar.xml
cleaners/gpodder.xml
cleaners/gwenview.xml
cleaners/hexchat.xml
cleaners/hippo_opensim_viewer.xml
cleaners/internet_explorer.xml
cleaners/java.xml
cleaners/journald.xml
cleaners/kde.xml
cleaners/konqueror.xml
cleaners/libreoffice.xml
cleaners/liferea.xml
cleaners/links2.xml
cleaners/localizations.xml
cleaners/mc.xml
cleaners/microsoft_edge.xml
cleaners/microsoft_office.xml
cleaners/miro.xml
cleaners/nautilus.xml
cleaners/nexuiz.xml
cleaners/octave.xml
cleaners/opera.xml
cleaners/paint.xml
cleaners/palemoon.xml
cleaners/pidgin.xml
cleaners/realplayer.xml
cleaners/recoll.xml
cleaners/rhythmbox.xml
cleaners/safari.xml
cleaners/screenlets.xml
cleaners/seamonkey.xml
cleaners/secondlife_viewer.xml
cleaners/silverlight.xml
cleaners/skype.xml
cleaners/slack.xml
cleaners/smartftp.xml
cleaners/sqlite3.xml
cleaners/teamviewer.xml
cleaners/thumbnails.xml
cleaners/thunderbird.xml
cleaners/tortoisesvn.xml
cleaners/transmission.xml
cleaners/tremulous.xml
cleaners/vim.xml
cleaners/vlc.xml
cleaners/vuze.xml
cleaners/warzone2100.xml
cleaners/waterfox.xml
cleaners/winamp.xml
cleaners/windows_defender.xml
cleaners/windows_explorer.xml
cleaners/windows_media_player.xml
cleaners/wine.xml
cleaners/winetricks.xml
cleaners/winrar.xml
cleaners/winzip.xml
cleaners/wordpad.xml
cleaners/x11.xml
cleaners/xine.xml
cleaners/yahoo_messenger.xml
cleaners/yum.xml
cleaners/zoom.xml
data/app-menu.ui
debian/bleachbit.dsc
debian/compat
debian/copyright
debian/debian.changelog
debian/debian.control
debian/debian.rules
doc/CONTRIBUTING.md
doc/cleaner_markup_language.xsd
doc/example_cleaner.xml
po/Makefile
po/af.po
po/ar.po
po/ast.po
po/be.po
po/bg.po
po/bn.po
po/bs.po
po/ca.po
po/cs.po
po/da.po
po/de.po
po/el.po
po/en_AU.po
po/en_CA.po
po/en_GB.po
po/eo.po
po/es.po
po/et.po
po/eu.po
po/fa.po
po/fi.po
po/fr.po
po/gl.po
po/he.po
po/hi.po
po/hr.po
po/hu.po
po/ia.po
po/id.po
po/ie.po
po/it.po
po/ja.po
po/ka.po
po/ko.po
po/ku.po
po/ky.po
po/lt.po
po/lv.po
po/ms.po
po/my.po
po/nb.po
po/nds.po
po/nl.po
po/nn.po
po/pl.po
po/pt.po
po/pt_BR.po
po/ro.po
po/ru.po
po/se.po
po/sk.po
po/sl.po
po/sq.po
po/sr.po
po/sv.po
po/ta.po
po/te.po
po/th.po
po/tr.po
po/ug.po
po/uk.po
po/uz.po
po/vi.po
po/yi.po
po/zh_CN.po
po/zh_TW.po
tests/TestAction.py
tests/TestAll.py
tests/TestCLI.py
tests/TestChaff.py
tests/TestCleaner.py
tests/TestCleanerML.py
tests/TestCommand.py
tests/TestCommon.py
tests/TestDeepScan.py
tests/TestExternalCommand.py
tests/TestFileUtilities.py
tests/TestGUI.py
tests/TestGeneral.py
tests/TestInit.py
tests/TestMakefile.py
tests/TestMemory.py
tests/TestNsisUtilities.py
tests/TestOptions.py
tests/TestRecognizeCleanerML.py
tests/TestSpecial.py
tests/TestSystemInformation.py
tests/TestUnix.py
tests/TestUpdate.py
tests/TestWinapp.py
tests/TestWindows.py
tests/TestWipe.py
tests/TestWorker.py
tests/__init__.py
tests/common.py
windows/bleachbit.ico
windows/bleachbit.nsi ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222962.0
bleachbit-4.6.0/bleachbit.egg-info/dependency_links.txt 0000664 0001750 0001750 00000000001 14522012662 020461 0 ustar 00z z
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222962.0
bleachbit-4.6.0/bleachbit.egg-info/top_level.txt 0000664 0001750 0001750 00000000012 14522012662 017136 0 ustar 00z z bleachbit
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1699222961.0
bleachbit-4.6.0/bleachbit.png 0000664 0001750 0001750 00000102770 14522012661 013435 0 ustar 00z z PNG
IHDR \rf gAMA a IDATx}VouűBA*B+pG$^IHEOeIzk[2Ip;q{ w w w. pp p;\ p;; w w w. pp p;\ p;; w w w. pp p;\ p;1y<_m`jFfߌ~W~蛌>RFߺqo;q c*FṏyÌFb Fqˎr8 zync3FfTJFa?Iع~pnj ڲnj&73z;_iq;1C1Mm</FTt#T7=&gwHmwڀ;1 MnFg>(gV]fUo xkވK_
7Wsyف,Foߺw;1 [ySvPg/>,*EeM-|>H{hiK+9ހꦥv᎓ գy54{:A+qֹoW)ۯ
p fװ/KFUK`2%IBdb#CA,,VY˯AYE?1'OQ'/
PaD<][{~GxG}<8 2Zqo@((P>uBGw` `?4p7BU}{]Gt\C;?GDiQ]We7oyp q粗Eɰ_߱X!=^Gؘ峚V,?*x~9dq'x#8ϞBς"06<}?4źĨ0{Ju~}\pljK?jۖ߳m;R6Y7xͧ>0^t0p?x?ұQx1o*S୷_>2w {sG|ޜzNNcV.[n\~݇܋۾W7}}