Release Privacy Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/*.zip
|
||||
release/*.zip.sha256
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
@@ -0,0 +1 @@
|
||||
@add-ideas:registry=https://git.add-ideas.de/api/packages/lotobo/npm/
|
||||
@@ -0,0 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0 - 2026-09-01
|
||||
|
||||
- Added byte-signature inventory, claimed-vs-detected media types, safe names,
|
||||
SHA-256 hashes, and bounded batches.
|
||||
- Added project-owned JPEG, PNG, WebP, EXIF/TIFF, IPTC, and XMP scanners with
|
||||
explicit structural and decompression limits.
|
||||
- Added ExifReader as a second scanner and best-effort inspect-only coverage for
|
||||
TIFF, HEIC/HEIF, AVIF, and JPEG XL.
|
||||
- Added categorized metadata findings, coverage and warning reports, including
|
||||
selected colour-profile, thumbnail, trailing-data, MPF, JUMBF, and C2PA
|
||||
indicators.
|
||||
- Added orientation-normalized pixel re-encoding for supported static JPEG, PNG,
|
||||
and WebP images, followed by mandatory output hashing, independent re-scan,
|
||||
dimension/orientation checks, and a bounded pixel-sample comparison.
|
||||
- Added individual outputs, deterministic JSON reports, bounded batch ZIPs,
|
||||
cancellation, Toolbox shell integration, offline static packaging, and
|
||||
explicit no-anonymity boundaries.
|
||||
- Added generated format/adversarial fixtures, unit gates, and Chromium/Firefox
|
||||
nested-path and no-network browser gates.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Contributing
|
||||
|
||||
Run `npm ci`, `npm run check`, and `npm run test:browser` before proposing a change. Keep processing local, preserve explicit limits, and include fixtures for every parser boundary.
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Licences
|
||||
|
||||
The application is GPL-3.0-or-later. Generated release artifacts include an exact runtime dependency licence inventory in `npm-runtime-licenses.txt`.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Privacy Tools
|
||||
|
||||
Privacy Tools is a standalone, local-first browser workbench for inspecting
|
||||
image metadata and producing deliberately re-encoded sharing copies. It is part
|
||||
of the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal), but
|
||||
the static release also runs independently at any nested path.
|
||||
|
||||
Selected files stay in the browser. There are no accounts, analytics, remote
|
||||
lookups, telemetry, or runtime network calls. A source file is never modified.
|
||||
|
||||
## Version 0.1 workflow
|
||||
|
||||
1. Select or drop a bounded batch. Every file gets a safe display/download name,
|
||||
SHA-256 hash, byte-signature type detection, and claimed-vs-detected type
|
||||
result.
|
||||
2. Review categorized findings, parser coverage, dimensions, animation or
|
||||
multi-image status, and warnings.
|
||||
3. For a supported static JPEG, PNG, or WebP, explicitly re-encode the decoded
|
||||
pixels. EXIF orientation is normalized; source container blocks, embedded
|
||||
thumbnails, trailing bytes, and source profiles are not copied.
|
||||
4. An independently parsed output is mandatory. The result states what was
|
||||
removed, preserved, generated, unsupported, or incompletely checked, and
|
||||
includes source/output SHA-256 hashes and a bounded decoded-pixel sample
|
||||
comparison.
|
||||
5. Download an individual re-encoded image, a JSON report, or a ZIP containing
|
||||
re-encoded images and the report. The report itself may be sensitive because
|
||||
it contains source filenames and metadata values.
|
||||
|
||||
## Format support
|
||||
|
||||
| Format | Inventory | Deep project scan | Secondary scan | Pixel re-encode |
|
||||
| ------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------ |
|
||||
| Static JPEG | Yes | EXIF/TIFF, IPTC/Photoshop, XMP, JFIF, ICC, COM, MPF, selected JUMBF/C2PA and trailing bytes | ExifReader | JPEG |
|
||||
| Static PNG | Yes | tEXt, zTXt, iTXt/XMP, eXIf/TIFF, iCCP, pHYs, tIME, private/unknown ancillary chunks, selected caBX/C2PA and trailing bytes | ExifReader | PNG |
|
||||
| Static WebP | Yes | RIFF/VP8 dimensions, EXIF/TIFF, XMP, ICC, META, animation and trailing bytes | ExifReader | WebP where the browser encoder supports it |
|
||||
| TIFF, HEIC/HEIF, AVIF, JPEG XL | Yes | No | Best-effort ExifReader inspection | No |
|
||||
| GIF | Yes | No | No supported deep adapter | No |
|
||||
| PDF, ZIP/Office, OLE/legacy Office, unknown | Yes | No | No | No |
|
||||
|
||||
Animation, multi-picture JPEG/MPF, and malformed or partially scanned
|
||||
JPEG/PNG/WebP inputs are inspect-only. Secondary coverage depends on what
|
||||
ExifReader can establish for the particular container. A browser may decode a
|
||||
format it cannot encode; that still does not make it eligible for output.
|
||||
|
||||
Findings are grouped as location; people/authorship/rights; dates; device,
|
||||
serial and lens; software/history; document identifiers; comments/titles/
|
||||
keywords; embedded previews; colour profiles; provenance; technical; or
|
||||
unclassified. Raw XMP is shown only as bounded inert text—never injected as
|
||||
markup.
|
||||
|
||||
## Security boundaries and limits
|
||||
|
||||
Inputs are untrusted. Container parsing runs in a terminable worker and checks
|
||||
declared lengths, CRCs, offsets, TIFF cycles/depth/counts, chunk/segment counts,
|
||||
compressed metadata expansion, dimensions, and aggregate batch size before
|
||||
continuing. Defaults are 100 files, 128 MiB per file, 512 MiB per batch, 4,096
|
||||
metadata blocks/findings, 8 MiB per metadata block, 4 MiB decompressed metadata,
|
||||
512-character labels, 16,384-character values, 256 KiB normalized finding text
|
||||
per file, 40 megapixels, a 32,768-pixel edge, and a 256 MiB ZIP payload.
|
||||
|
||||
The pixel decode/encode step uses browser-native image and Canvas APIs. It runs
|
||||
only after a complete project scan and bounded dimensions. The output gate does
|
||||
not trust successful encoding: it hashes and scans the newly encoded bytes
|
||||
again. C2PA/JUMBF provenance is authenticity information rather than ordinary
|
||||
tracking metadata; pixel re-encoding removes or invalidates it, and the report
|
||||
calls that out.
|
||||
|
||||
This is not an anonymity tool. Metadata removal does not remove visible faces
|
||||
or text, steganography, invisible or forensic watermarks, reverse-image
|
||||
matching, sidecar files, filesystem history, application caches, or cloud and
|
||||
recipient copies. JPEG and lossy WebP output may alter pixels. Colour profiles,
|
||||
resolution metadata, and provenance may be lost. Inspect the actual output and
|
||||
report before sharing it.
|
||||
|
||||
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and
|
||||
[docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md) for the implementation and
|
||||
threat model.
|
||||
|
||||
## Browser and accessibility support
|
||||
|
||||
Current evergreen Chromium and Firefox are exercised in the browser gate;
|
||||
current Safari is an intended target. JavaScript modules, Web Workers, Blob,
|
||||
Canvas 2D, `createImageBitmap` where available, and Web Crypto are used. WebP
|
||||
output follows browser encoder support. The application supports the shared
|
||||
Toolbox system/light/dark themes, keyboard file selection, native table and
|
||||
disclosure semantics, visible focus, live progress, and non-colour status text.
|
||||
|
||||
## Development
|
||||
|
||||
Requirements: Node.js 22 or newer and npm 11 or newer.
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run check
|
||||
npm run test:browser
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite uses `base: './'`, so `dist/` can be hosted at `/` or below a nested
|
||||
Toolbox path. `toolbox-check` validates the production manifest and bundle.
|
||||
|
||||
## Release
|
||||
|
||||
```sh
|
||||
npm run release:artifact
|
||||
```
|
||||
|
||||
This checks the manifest, types, lint, formatting, unit fixtures, production
|
||||
build, Toolbox contract, and Chromium/Firefox workflows, then creates the
|
||||
deterministic `release/privacy-tools-0.1.0.zip` plus its SHA-256 sidecar. The
|
||||
archive contains the static application, project documents, and exact runtime
|
||||
dependency licence texts.
|
||||
|
||||
## Licence
|
||||
|
||||
Privacy Tools is free software under `GPL-3.0-or-later`; see [LICENSE](LICENSE).
|
||||
Runtime dependencies keep their licences, including ExifReader under MPL-2.0.
|
||||
See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Security
|
||||
|
||||
Report vulnerabilities privately to the repository owner. Do not attach a
|
||||
sensitive source image, generated report, or real metadata to a public issue;
|
||||
construct a minimal synthetic reproduction instead.
|
||||
|
||||
## Input model
|
||||
|
||||
Every selected file and every metadata value is untrusted. The application
|
||||
detects containers from bytes, does not execute imported content, renders text
|
||||
through React rather than raw HTML, performs no metadata-linked fetches, and
|
||||
does not preserve source blocks in a re-encoded output. The scanning worker can
|
||||
be terminated on cancellation. Browser-native pixel decoding remains part of
|
||||
the browser's trusted computing base.
|
||||
|
||||
Default bounds cover file/batch bytes, container blocks, individual metadata,
|
||||
decompressed PNG metadata, finding lengths/counts, TIFF offsets/entries/depth,
|
||||
pixel count/edge, and batch ZIP size. Malformed structures become partial or
|
||||
failed coverage; they must never receive clean-copy eligibility or a verified
|
||||
output result.
|
||||
|
||||
## Output model
|
||||
|
||||
A successful Canvas encode is not sufficient. The new bytes are independently
|
||||
hashed and scanned, expected oriented dimensions are checked, sensitive or
|
||||
provenance findings fail the verification gate, and incomplete coverage is
|
||||
reported. C2PA/JUMBF signatures are expected to be removed or invalidated by
|
||||
pixel re-encoding. Reports and output names are escaped/sanitized and ZIP paths
|
||||
are application-generated.
|
||||
|
||||
No status is an anonymity guarantee. Visible content, steganography, invisible
|
||||
watermarks, image fingerprinting, sidecars, local filesystem metadata, browser
|
||||
history, and remote copies are outside the scan.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Corresponding source
|
||||
|
||||
The corresponding source for Privacy Tools 0.1.0 is published at:
|
||||
|
||||
https://git.add-ideas.de/lotobo/privacy-tools/src/tag/v0.1.0
|
||||
|
||||
Build with Node.js 22 and npm 11:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run release:artifact
|
||||
```
|
||||
|
||||
The release command performs all source, test, browser, manifest, and Toolbox
|
||||
checks before assembling the deterministic static ZIP. The artifact includes
|
||||
this file, the GPL licence, project documentation, and the runtime dependency
|
||||
licence texts generated from the exact lockfile.
|
||||
|
||||
ExifReader 4.44.0 is unmodified MPL-2.0 covered software within the bundled
|
||||
application. Its preferred source form is available from the exact npm package
|
||||
and upstream release identified in `package-lock.json` and
|
||||
`THIRD_PARTY_NOTICES.md`; its MPL licence text is included in the generated
|
||||
runtime licence inventory.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Third-party notices
|
||||
|
||||
Privacy Tools is GPL-3.0-or-later. Dependencies retain their own licences. A
|
||||
production build generates `LICENSES/npm-runtime-licenses.txt` from the exact
|
||||
locked runtime packages and copies every available licence/notice text into the
|
||||
release.
|
||||
|
||||
Material runtime components include:
|
||||
|
||||
| Component | Version | Licence | Purpose/source |
|
||||
| -------------------------------- | ------- | ---------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| ExifReader | 4.44.0 | MPL-2.0 | Secondary metadata parser; <https://github.com/mattiasw/ExifReader/tree/v4.44.0> |
|
||||
| fflate | 0.8.2 | MIT | Bounded PNG metadata inflation and ZIP creation; <https://github.com/101arrowz/fflate/tree/v0.8.2> |
|
||||
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | Hashing, safe names, download and deterministic JSON primitives |
|
||||
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | Toolbox manifest contract |
|
||||
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | Shared application shell |
|
||||
| React / React DOM | 19.2.8 | MIT | User interface |
|
||||
|
||||
ExifReader is used unmodified. Its MPL-2.0 covered source remains available at
|
||||
the exact upstream link above and through the npm package resolved by the
|
||||
lockfile. The bundled executable does not relicense or restrict the MPL-covered
|
||||
files. Consult the generated licence inventory for the full MPL-2.0 text and
|
||||
the exact notices shipped with every runtime package.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Accessibility
|
||||
|
||||
The workbench uses semantic landmarks, headings, a native multiple-file input,
|
||||
buttons, tables, definition lists, progress, status/alert regions, and native
|
||||
disclosures/dialogs. All workflows are keyboard operable; drop is an optional
|
||||
pointer shortcut. Visible focus and the shared Toolbox system/light/dark themes
|
||||
are preserved.
|
||||
|
||||
Status is always written as text and never encoded by colour alone. Finding
|
||||
categories expose labels and counts, hashes and media types remain selectable,
|
||||
and responsive layouts allow horizontal table scrolling without clipping the
|
||||
whole page. Motion is not required to understand progress or results.
|
||||
|
||||
Automated component and Chromium/Firefox browser gates cover semantics and core
|
||||
workflows. They supplement, rather than replace, manual keyboard, zoom,
|
||||
screen-reader, high-contrast, and reduced-motion review before release.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Architecture
|
||||
|
||||
Privacy Tools is a relocatable Vite/React static application. The core domain
|
||||
model in `src/privacy` is serializable: scan inputs cross a worker boundary as
|
||||
transferred `ArrayBuffer`s and results contain only strings, numbers, booleans,
|
||||
arrays, and plain objects. No parser returns HTML or a live third-party object.
|
||||
|
||||
## Pipeline
|
||||
|
||||
1. `scan-client` checks file count and declared byte totals before reading the
|
||||
batch, transfers buffers to a dedicated worker, reports progress, and
|
||||
terminates the worker on cancellation.
|
||||
2. `detect` compares magic bytes, filename extension, and browser-claimed MIME.
|
||||
`scanner` hashes bytes, dispatches the bounded project parser, runs
|
||||
ExifReader as a distinct secondary adapter, and normalizes findings and
|
||||
coverage.
|
||||
3. Project parsers walk JPEG segments, PNG chunks, WebP RIFF chunks, TIFF IFDs,
|
||||
IPTC datasets, and bounded inert XMP text. They validate offsets, lengths,
|
||||
counts, PNG CRCs, compression expansion, TIFF cycles/depth, and trailing
|
||||
bytes. They do not follow URLs or instantiate XML/HTML.
|
||||
4. `sanitize` runs only for complete, static, bounded JPEG/PNG/WebP scans. A
|
||||
browser decoder produces pixels, the EXIF display orientation is normalized,
|
||||
and Canvas creates a fresh same-format file. No input container block is
|
||||
copied.
|
||||
5. The encoded bytes are passed back through the same independent scanner API,
|
||||
hashed, dimension-checked, and compared using a deterministic 256-pixel-edge
|
||||
decoded sample. `buildSanitizationReport` assigns verified/warning/failed
|
||||
based on explicit coverage and output findings.
|
||||
6. `archive` serializes bounded deterministic JSON and creates stored ZIP
|
||||
entries from application-generated `images/` paths and sanitized unique
|
||||
names.
|
||||
|
||||
Pixel decoding and Canvas encoding currently run on the main browser context
|
||||
because portable cross-browser image encoder support is there; metadata parsing
|
||||
and hashing run in the terminable worker. UI state does not retain data after a
|
||||
page refresh and no IndexedDB/localStorage persistence is used by this app.
|
||||
|
||||
## Trust boundaries
|
||||
|
||||
- Project parsers and ExifReader independently contribute coverage; one parser's
|
||||
success does not suppress the other's warning.
|
||||
- Unknown structures are reported or cause partial coverage. Clean-copy
|
||||
eligibility requires a complete project container scan.
|
||||
- Browser image decoders, Canvas encoders, Web Crypto, and the JS runtime are in
|
||||
the trusted computing base.
|
||||
- ExifReader is pinned to 4.44.0. Dependency updates require malformed-container
|
||||
regression fixtures and licence review.
|
||||
- The Toolbox shell receives the application manifest but never receives file
|
||||
bytes or findings.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Privacy and security
|
||||
|
||||
## Data handling
|
||||
|
||||
Files are read with browser file APIs and transferred to an in-page worker.
|
||||
There is no upload endpoint, account, telemetry, analytics, remote metadata
|
||||
lookup, or automatic persistence. Temporary object URLs exist only for the
|
||||
legacy image-element decode fallback and are revoked immediately after decode.
|
||||
Download object URLs are created and revoked by the shared helper. Refreshing or
|
||||
closing the page discards application state.
|
||||
|
||||
Service-worker support may fetch and cache the same-origin application shell;
|
||||
it does not cache imported files, metadata findings, reports, or generated
|
||||
images. Metadata URLs and XMP/XML markup are inert text and are never fetched or
|
||||
rendered as HTML.
|
||||
|
||||
## What is inspected
|
||||
|
||||
Every file receives byte-signature inventory and SHA-256. Static JPEG, PNG, and
|
||||
WebP receive project-owned deep container parsing plus ExifReader. TIFF,
|
||||
HEIC/HEIF, AVIF, and JPEG XL receive best-effort ExifReader inspection only.
|
||||
GIF and document/archive formats receive inventory only. Exact per-format
|
||||
fields are listed in the README support table.
|
||||
|
||||
All parsers are bounded, but a “complete” scan means complete for implemented
|
||||
structures—not proof that no steganographic or novel carrier exists. Unknown
|
||||
private chunks and trailing bytes are surfaced. ExifReader failures and format
|
||||
limitations remain visible in coverage.
|
||||
|
||||
## Re-encoded outputs
|
||||
|
||||
Only complete, static, dimension-bounded JPEG/PNG/WebP project scans are
|
||||
eligible. The application decodes pixels, normalizes display orientation, and
|
||||
uses Canvas to encode a new file of the detected type. It does not selectively
|
||||
strip GPS while copying everything else, because duplicated values can live in
|
||||
multiple namespaces and embedded previews. It copies no source metadata block.
|
||||
|
||||
The output is always scanned and hashed again. Sensitive or provenance findings,
|
||||
partial project coverage, or an oriented-dimension mismatch produce a failed
|
||||
gate. Secondary-parser limitations and provenance invalidation produce explicit
|
||||
warnings. JPEG and lossy WebP may change pixels; profiles, resolution metadata,
|
||||
thumbnails, comments, and application history may be lost. PNG is expected to
|
||||
preserve decoded pixels, subject to browser colour management.
|
||||
|
||||
C2PA/JUMBF carries authenticity/provenance claims. Re-encoding normally removes
|
||||
or invalidates those claims. The tool reports that effect rather than describing
|
||||
it as an ordinary privacy win.
|
||||
|
||||
## What this cannot promise
|
||||
|
||||
No result promises anonymity or safe publication. The tool does not remove or
|
||||
detect visible faces/text/locations, steganography, invisible watermarks,
|
||||
camera-pattern or image fingerprints, reverse-image matches, sidecar files,
|
||||
filesystem metadata, clipboard/history records, application caches, backed-up
|
||||
originals, or cloud/recipient copies. Review the visible output, destination,
|
||||
report, and surrounding files yourself.
|
||||
|
||||
The JSON report is sensitive by design: it can include original filenames,
|
||||
hashes, timestamps, and metadata values. Share or retain it only intentionally.
|
||||
@@ -0,0 +1,37 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"dist",
|
||||
"release",
|
||||
"coverage",
|
||||
"test-results",
|
||||
"playwright-report",
|
||||
],
|
||||
},
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
globals: { ...globals.browser, ...globals.worker },
|
||||
},
|
||||
plugins: { "react-hooks": reactHooks, "react-refresh": reactRefresh },
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["scripts/**/*.mjs", "playwright.config.ts"],
|
||||
languageOptions: { globals: globals.node },
|
||||
},
|
||||
);
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Inspect and remove shareable-file metadata locally in the browser."
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#29255f" />
|
||||
<title>Privacy Tools</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3524
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "privacy-tools",
|
||||
"version": "0.1.0",
|
||||
"description": "Inspect and remove shareable-file metadata locally in the browser.",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Albrecht Degering",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://git.add-ideas.de/lotobo/privacy-tools.git"
|
||||
},
|
||||
"homepage": "https://git.add-ideas.de/lotobo/privacy-tools",
|
||||
"bugs": {
|
||||
"url": "https://git.add-ideas.de/lotobo/privacy-tools/issues"
|
||||
},
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"predev": "npm run manifest:generate",
|
||||
"dev": "vite",
|
||||
"prebuild": "npm run release:prepare && npm run manifest:generate",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:browser": "playwright test",
|
||||
"manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
|
||||
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
|
||||
"release:prepare": "node scripts/prepare-release-files.mjs",
|
||||
"toolbox:check": "toolbox-check dist",
|
||||
"package:release": "node scripts/package-release.mjs",
|
||||
"check": "npm run manifest:check && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check",
|
||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
||||
},
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"exifreader": "4.44.0",
|
||||
"fflate": "0.8.2",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/node": "25.9.5",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.5",
|
||||
"@vitejs/plugin-react": "6.0.3",
|
||||
"eslint": "10.7.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.5.2",
|
||||
"globals": "17.7.0",
|
||||
"jsdom": "29.1.1",
|
||||
"prettier": "3.9.5",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.64.0",
|
||||
"vite": "8.2.2",
|
||||
"vitest": "4.1.11"
|
||||
},
|
||||
"packageManager": "npm@11.17.0"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/browser",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 120_000,
|
||||
expect: { timeout: 10_000 },
|
||||
reporter: [["list"]],
|
||||
use: { baseURL: "http://127.0.0.1:4173", trace: "retain-on-failure" },
|
||||
webServer: {
|
||||
command: "npm run build && node scripts/serve-test.mjs",
|
||||
url: "http://127.0.0.1:4173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000,
|
||||
},
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0 - 2026-09-01
|
||||
|
||||
- Added byte-signature inventory, claimed-vs-detected media types, safe names,
|
||||
SHA-256 hashes, and bounded batches.
|
||||
- Added project-owned JPEG, PNG, WebP, EXIF/TIFF, IPTC, and XMP scanners with
|
||||
explicit structural and decompression limits.
|
||||
- Added ExifReader as a second scanner and best-effort inspect-only coverage for
|
||||
TIFF, HEIC/HEIF, AVIF, and JPEG XL.
|
||||
- Added categorized metadata findings, coverage and warning reports, including
|
||||
selected colour-profile, thumbnail, trailing-data, MPF, JUMBF, and C2PA
|
||||
indicators.
|
||||
- Added orientation-normalized pixel re-encoding for supported static JPEG, PNG,
|
||||
and WebP images, followed by mandatory output hashing, independent re-scan,
|
||||
dimension/orientation checks, and a bounded pixel-sample comparison.
|
||||
- Added individual outputs, deterministic JSON reports, bounded batch ZIPs,
|
||||
cancellation, Toolbox shell integration, offline static packaging, and
|
||||
explicit no-anonymity boundaries.
|
||||
- Added generated format/adversarial fixtures, unit gates, and Chromium/Firefox
|
||||
nested-path and no-network browser gates.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Contributing
|
||||
|
||||
Run `npm ci`, `npm run check`, and `npm run test:browser` before proposing a change. Keep processing local, preserve explicit limits, and include fixtures for every parser boundary.
|
||||
+674
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Licences
|
||||
|
||||
The application is GPL-3.0-or-later. Generated release artifacts include an exact runtime dependency licence inventory in `npm-runtime-licenses.txt`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
# Privacy Tools
|
||||
|
||||
Privacy Tools is a standalone, local-first browser workbench for inspecting
|
||||
image metadata and producing deliberately re-encoded sharing copies. It is part
|
||||
of the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal), but
|
||||
the static release also runs independently at any nested path.
|
||||
|
||||
Selected files stay in the browser. There are no accounts, analytics, remote
|
||||
lookups, telemetry, or runtime network calls. A source file is never modified.
|
||||
|
||||
## Version 0.1 workflow
|
||||
|
||||
1. Select or drop a bounded batch. Every file gets a safe display/download name,
|
||||
SHA-256 hash, byte-signature type detection, and claimed-vs-detected type
|
||||
result.
|
||||
2. Review categorized findings, parser coverage, dimensions, animation or
|
||||
multi-image status, and warnings.
|
||||
3. For a supported static JPEG, PNG, or WebP, explicitly re-encode the decoded
|
||||
pixels. EXIF orientation is normalized; source container blocks, embedded
|
||||
thumbnails, trailing bytes, and source profiles are not copied.
|
||||
4. An independently parsed output is mandatory. The result states what was
|
||||
removed, preserved, generated, unsupported, or incompletely checked, and
|
||||
includes source/output SHA-256 hashes and a bounded decoded-pixel sample
|
||||
comparison.
|
||||
5. Download an individual re-encoded image, a JSON report, or a ZIP containing
|
||||
re-encoded images and the report. The report itself may be sensitive because
|
||||
it contains source filenames and metadata values.
|
||||
|
||||
## Format support
|
||||
|
||||
| Format | Inventory | Deep project scan | Secondary scan | Pixel re-encode |
|
||||
| ------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------ |
|
||||
| Static JPEG | Yes | EXIF/TIFF, IPTC/Photoshop, XMP, JFIF, ICC, COM, MPF, selected JUMBF/C2PA and trailing bytes | ExifReader | JPEG |
|
||||
| Static PNG | Yes | tEXt, zTXt, iTXt/XMP, eXIf/TIFF, iCCP, pHYs, tIME, private/unknown ancillary chunks, selected caBX/C2PA and trailing bytes | ExifReader | PNG |
|
||||
| Static WebP | Yes | RIFF/VP8 dimensions, EXIF/TIFF, XMP, ICC, META, animation and trailing bytes | ExifReader | WebP where the browser encoder supports it |
|
||||
| TIFF, HEIC/HEIF, AVIF, JPEG XL | Yes | No | Best-effort ExifReader inspection | No |
|
||||
| GIF | Yes | No | No supported deep adapter | No |
|
||||
| PDF, ZIP/Office, OLE/legacy Office, unknown | Yes | No | No | No |
|
||||
|
||||
Animation, multi-picture JPEG/MPF, and malformed or partially scanned
|
||||
JPEG/PNG/WebP inputs are inspect-only. Secondary coverage depends on what
|
||||
ExifReader can establish for the particular container. A browser may decode a
|
||||
format it cannot encode; that still does not make it eligible for output.
|
||||
|
||||
Findings are grouped as location; people/authorship/rights; dates; device,
|
||||
serial and lens; software/history; document identifiers; comments/titles/
|
||||
keywords; embedded previews; colour profiles; provenance; technical; or
|
||||
unclassified. Raw XMP is shown only as bounded inert text—never injected as
|
||||
markup.
|
||||
|
||||
## Security boundaries and limits
|
||||
|
||||
Inputs are untrusted. Container parsing runs in a terminable worker and checks
|
||||
declared lengths, CRCs, offsets, TIFF cycles/depth/counts, chunk/segment counts,
|
||||
compressed metadata expansion, dimensions, and aggregate batch size before
|
||||
continuing. Defaults are 100 files, 128 MiB per file, 512 MiB per batch, 4,096
|
||||
metadata blocks/findings, 8 MiB per metadata block, 4 MiB decompressed metadata,
|
||||
512-character labels, 16,384-character values, 256 KiB normalized finding text
|
||||
per file, 40 megapixels, a 32,768-pixel edge, and a 256 MiB ZIP payload.
|
||||
|
||||
The pixel decode/encode step uses browser-native image and Canvas APIs. It runs
|
||||
only after a complete project scan and bounded dimensions. The output gate does
|
||||
not trust successful encoding: it hashes and scans the newly encoded bytes
|
||||
again. C2PA/JUMBF provenance is authenticity information rather than ordinary
|
||||
tracking metadata; pixel re-encoding removes or invalidates it, and the report
|
||||
calls that out.
|
||||
|
||||
This is not an anonymity tool. Metadata removal does not remove visible faces
|
||||
or text, steganography, invisible or forensic watermarks, reverse-image
|
||||
matching, sidecar files, filesystem history, application caches, or cloud and
|
||||
recipient copies. JPEG and lossy WebP output may alter pixels. Colour profiles,
|
||||
resolution metadata, and provenance may be lost. Inspect the actual output and
|
||||
report before sharing it.
|
||||
|
||||
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and
|
||||
[docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md) for the implementation and
|
||||
threat model.
|
||||
|
||||
## Browser and accessibility support
|
||||
|
||||
Current evergreen Chromium and Firefox are exercised in the browser gate;
|
||||
current Safari is an intended target. JavaScript modules, Web Workers, Blob,
|
||||
Canvas 2D, `createImageBitmap` where available, and Web Crypto are used. WebP
|
||||
output follows browser encoder support. The application supports the shared
|
||||
Toolbox system/light/dark themes, keyboard file selection, native table and
|
||||
disclosure semantics, visible focus, live progress, and non-colour status text.
|
||||
|
||||
## Development
|
||||
|
||||
Requirements: Node.js 22 or newer and npm 11 or newer.
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run check
|
||||
npm run test:browser
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite uses `base: './'`, so `dist/` can be hosted at `/` or below a nested
|
||||
Toolbox path. `toolbox-check` validates the production manifest and bundle.
|
||||
|
||||
## Release
|
||||
|
||||
```sh
|
||||
npm run release:artifact
|
||||
```
|
||||
|
||||
This checks the manifest, types, lint, formatting, unit fixtures, production
|
||||
build, Toolbox contract, and Chromium/Firefox workflows, then creates the
|
||||
deterministic `release/privacy-tools-0.1.0.zip` plus its SHA-256 sidecar. The
|
||||
archive contains the static application, project documents, and exact runtime
|
||||
dependency licence texts.
|
||||
|
||||
## Licence
|
||||
|
||||
Privacy Tools is free software under `GPL-3.0-or-later`; see [LICENSE](LICENSE).
|
||||
Runtime dependencies keep their licences, including ExifReader under MPL-2.0.
|
||||
See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||
@@ -0,0 +1,33 @@
|
||||
# Security
|
||||
|
||||
Report vulnerabilities privately to the repository owner. Do not attach a
|
||||
sensitive source image, generated report, or real metadata to a public issue;
|
||||
construct a minimal synthetic reproduction instead.
|
||||
|
||||
## Input model
|
||||
|
||||
Every selected file and every metadata value is untrusted. The application
|
||||
detects containers from bytes, does not execute imported content, renders text
|
||||
through React rather than raw HTML, performs no metadata-linked fetches, and
|
||||
does not preserve source blocks in a re-encoded output. The scanning worker can
|
||||
be terminated on cancellation. Browser-native pixel decoding remains part of
|
||||
the browser's trusted computing base.
|
||||
|
||||
Default bounds cover file/batch bytes, container blocks, individual metadata,
|
||||
decompressed PNG metadata, finding lengths/counts, TIFF offsets/entries/depth,
|
||||
pixel count/edge, and batch ZIP size. Malformed structures become partial or
|
||||
failed coverage; they must never receive clean-copy eligibility or a verified
|
||||
output result.
|
||||
|
||||
## Output model
|
||||
|
||||
A successful Canvas encode is not sufficient. The new bytes are independently
|
||||
hashed and scanned, expected oriented dimensions are checked, sensitive or
|
||||
provenance findings fail the verification gate, and incomplete coverage is
|
||||
reported. C2PA/JUMBF signatures are expected to be removed or invalidated by
|
||||
pixel re-encoding. Reports and output names are escaped/sanitized and ZIP paths
|
||||
are application-generated.
|
||||
|
||||
No status is an anonymity guarantee. Visible content, steganography, invisible
|
||||
watermarks, image fingerprinting, sidecars, local filesystem metadata, browser
|
||||
history, and remote copies are outside the scan.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Corresponding source
|
||||
|
||||
The corresponding source for Privacy Tools 0.1.0 is published at:
|
||||
|
||||
https://git.add-ideas.de/lotobo/privacy-tools/src/tag/v0.1.0
|
||||
|
||||
Build with Node.js 22 and npm 11:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run release:artifact
|
||||
```
|
||||
|
||||
The release command performs all source, test, browser, manifest, and Toolbox
|
||||
checks before assembling the deterministic static ZIP. The artifact includes
|
||||
this file, the GPL licence, project documentation, and the runtime dependency
|
||||
licence texts generated from the exact lockfile.
|
||||
|
||||
ExifReader 4.44.0 is unmodified MPL-2.0 covered software within the bundled
|
||||
application. Its preferred source form is available from the exact npm package
|
||||
and upstream release identified in `package-lock.json` and
|
||||
`THIRD_PARTY_NOTICES.md`; its MPL licence text is included in the generated
|
||||
runtime licence inventory.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Third-party notices
|
||||
|
||||
Privacy Tools is GPL-3.0-or-later. Dependencies retain their own licences. A
|
||||
production build generates `LICENSES/npm-runtime-licenses.txt` from the exact
|
||||
locked runtime packages and copies every available licence/notice text into the
|
||||
release.
|
||||
|
||||
Material runtime components include:
|
||||
|
||||
| Component | Version | Licence | Purpose/source |
|
||||
| -------------------------------- | ------- | ---------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| ExifReader | 4.44.0 | MPL-2.0 | Secondary metadata parser; <https://github.com/mattiasw/ExifReader/tree/v4.44.0> |
|
||||
| fflate | 0.8.2 | MIT | Bounded PNG metadata inflation and ZIP creation; <https://github.com/101arrowz/fflate/tree/v0.8.2> |
|
||||
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | Hashing, safe names, download and deterministic JSON primitives |
|
||||
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | Toolbox manifest contract |
|
||||
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | Shared application shell |
|
||||
| React / React DOM | 19.2.8 | MIT | User interface |
|
||||
|
||||
ExifReader is used unmodified. Its MPL-2.0 covered source remains available at
|
||||
the exact upstream link above and through the npm package resolved by the
|
||||
lockfile. The bundled executable does not relicense or restrict the MPL-covered
|
||||
files. Consult the generated licence inventory for the full MPL-2.0 text and
|
||||
the exact notices shipped with every runtime package.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Accessibility
|
||||
|
||||
The workbench uses semantic landmarks, headings, a native multiple-file input,
|
||||
buttons, tables, definition lists, progress, status/alert regions, and native
|
||||
disclosures/dialogs. All workflows are keyboard operable; drop is an optional
|
||||
pointer shortcut. Visible focus and the shared Toolbox system/light/dark themes
|
||||
are preserved.
|
||||
|
||||
Status is always written as text and never encoded by colour alone. Finding
|
||||
categories expose labels and counts, hashes and media types remain selectable,
|
||||
and responsive layouts allow horizontal table scrolling without clipping the
|
||||
whole page. Motion is not required to understand progress or results.
|
||||
|
||||
Automated component and Chromium/Firefox browser gates cover semantics and core
|
||||
workflows. They supplement, rather than replace, manual keyboard, zoom,
|
||||
screen-reader, high-contrast, and reduced-motion review before release.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Architecture
|
||||
|
||||
Privacy Tools is a relocatable Vite/React static application. The core domain
|
||||
model in `src/privacy` is serializable: scan inputs cross a worker boundary as
|
||||
transferred `ArrayBuffer`s and results contain only strings, numbers, booleans,
|
||||
arrays, and plain objects. No parser returns HTML or a live third-party object.
|
||||
|
||||
## Pipeline
|
||||
|
||||
1. `scan-client` checks file count and declared byte totals before reading the
|
||||
batch, transfers buffers to a dedicated worker, reports progress, and
|
||||
terminates the worker on cancellation.
|
||||
2. `detect` compares magic bytes, filename extension, and browser-claimed MIME.
|
||||
`scanner` hashes bytes, dispatches the bounded project parser, runs
|
||||
ExifReader as a distinct secondary adapter, and normalizes findings and
|
||||
coverage.
|
||||
3. Project parsers walk JPEG segments, PNG chunks, WebP RIFF chunks, TIFF IFDs,
|
||||
IPTC datasets, and bounded inert XMP text. They validate offsets, lengths,
|
||||
counts, PNG CRCs, compression expansion, TIFF cycles/depth, and trailing
|
||||
bytes. They do not follow URLs or instantiate XML/HTML.
|
||||
4. `sanitize` runs only for complete, static, bounded JPEG/PNG/WebP scans. A
|
||||
browser decoder produces pixels, the EXIF display orientation is normalized,
|
||||
and Canvas creates a fresh same-format file. No input container block is
|
||||
copied.
|
||||
5. The encoded bytes are passed back through the same independent scanner API,
|
||||
hashed, dimension-checked, and compared using a deterministic 256-pixel-edge
|
||||
decoded sample. `buildSanitizationReport` assigns verified/warning/failed
|
||||
based on explicit coverage and output findings.
|
||||
6. `archive` serializes bounded deterministic JSON and creates stored ZIP
|
||||
entries from application-generated `images/` paths and sanitized unique
|
||||
names.
|
||||
|
||||
Pixel decoding and Canvas encoding currently run on the main browser context
|
||||
because portable cross-browser image encoder support is there; metadata parsing
|
||||
and hashing run in the terminable worker. UI state does not retain data after a
|
||||
page refresh and no IndexedDB/localStorage persistence is used by this app.
|
||||
|
||||
## Trust boundaries
|
||||
|
||||
- Project parsers and ExifReader independently contribute coverage; one parser's
|
||||
success does not suppress the other's warning.
|
||||
- Unknown structures are reported or cause partial coverage. Clean-copy
|
||||
eligibility requires a complete project container scan.
|
||||
- Browser image decoders, Canvas encoders, Web Crypto, and the JS runtime are in
|
||||
the trusted computing base.
|
||||
- ExifReader is pinned to 4.44.0. Dependency updates require malformed-container
|
||||
regression fixtures and licence review.
|
||||
- The Toolbox shell receives the application manifest but never receives file
|
||||
bytes or findings.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Privacy and security
|
||||
|
||||
## Data handling
|
||||
|
||||
Files are read with browser file APIs and transferred to an in-page worker.
|
||||
There is no upload endpoint, account, telemetry, analytics, remote metadata
|
||||
lookup, or automatic persistence. Temporary object URLs exist only for the
|
||||
legacy image-element decode fallback and are revoked immediately after decode.
|
||||
Download object URLs are created and revoked by the shared helper. Refreshing or
|
||||
closing the page discards application state.
|
||||
|
||||
Service-worker support may fetch and cache the same-origin application shell;
|
||||
it does not cache imported files, metadata findings, reports, or generated
|
||||
images. Metadata URLs and XMP/XML markup are inert text and are never fetched or
|
||||
rendered as HTML.
|
||||
|
||||
## What is inspected
|
||||
|
||||
Every file receives byte-signature inventory and SHA-256. Static JPEG, PNG, and
|
||||
WebP receive project-owned deep container parsing plus ExifReader. TIFF,
|
||||
HEIC/HEIF, AVIF, and JPEG XL receive best-effort ExifReader inspection only.
|
||||
GIF and document/archive formats receive inventory only. Exact per-format
|
||||
fields are listed in the README support table.
|
||||
|
||||
All parsers are bounded, but a “complete” scan means complete for implemented
|
||||
structures—not proof that no steganographic or novel carrier exists. Unknown
|
||||
private chunks and trailing bytes are surfaced. ExifReader failures and format
|
||||
limitations remain visible in coverage.
|
||||
|
||||
## Re-encoded outputs
|
||||
|
||||
Only complete, static, dimension-bounded JPEG/PNG/WebP project scans are
|
||||
eligible. The application decodes pixels, normalizes display orientation, and
|
||||
uses Canvas to encode a new file of the detected type. It does not selectively
|
||||
strip GPS while copying everything else, because duplicated values can live in
|
||||
multiple namespaces and embedded previews. It copies no source metadata block.
|
||||
|
||||
The output is always scanned and hashed again. Sensitive or provenance findings,
|
||||
partial project coverage, or an oriented-dimension mismatch produce a failed
|
||||
gate. Secondary-parser limitations and provenance invalidation produce explicit
|
||||
warnings. JPEG and lossy WebP may change pixels; profiles, resolution metadata,
|
||||
thumbnails, comments, and application history may be lost. PNG is expected to
|
||||
preserve decoded pixels, subject to browser colour management.
|
||||
|
||||
C2PA/JUMBF carries authenticity/provenance claims. Re-encoding normally removes
|
||||
or invalidates those claims. The tool reports that effect rather than describing
|
||||
it as an ordinary privacy win.
|
||||
|
||||
## What this cannot promise
|
||||
|
||||
No result promises anonymity or safe publication. The tool does not remove or
|
||||
detect visible faces/text/locations, steganography, invisible watermarks,
|
||||
camera-pattern or image fingerprints, reverse-image matches, sidecar files,
|
||||
filesystem metadata, clipboard/history records, application caches, backed-up
|
||||
originals, or cloud/recipient copies. Review the visible output, destination,
|
||||
report, and surrounding files yourself.
|
||||
|
||||
The JSON report is sensitive by design: it can include original filenames,
|
||||
hashes, timestamps, and metadata values. Share or retain it only intentionally.
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Privacy Tools"><path fill="#5b4ec4" d="M11 4h30l12 12v44H11z"/><path fill="#fff" opacity=".28" d="M41 4v14h12"/><text x="32" y="43" fill="#fff" font-family="system-ui,sans-serif" font-size="18" font-weight="800" text-anchor="middle">PR</text></svg>
|
||||
|
After Width: | Height: | Size: 332 B |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Privacy Tools",
|
||||
"short_name": "Privacy",
|
||||
"description": "Inspect and remove shareable-file metadata locally in the browser.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#f6f7fb",
|
||||
"theme_color": "#29255f",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
const CACHE_PREFIX = "privacy-tools-shell-";
|
||||
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
|
||||
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(async (cache) => {
|
||||
await cache.addAll(CORE);
|
||||
const html = await (await fetch("./")).text();
|
||||
const assets = [...html.matchAll(/(?:src|href)="(\.\/assets\/[^"]+)"/g)]
|
||||
.map((match) => match[1])
|
||||
.filter(Boolean);
|
||||
await cache.addAll(assets);
|
||||
}),
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME)
|
||||
.map((key) => caches.delete(key)),
|
||||
),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.method !== "GET") return;
|
||||
const url = new URL(event.request.url);
|
||||
if (
|
||||
url.origin !== self.location.origin ||
|
||||
!url.pathname.startsWith(new URL(self.registration.scope).pathname)
|
||||
)
|
||||
return;
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(
|
||||
(cached) =>
|
||||
cached ??
|
||||
fetch(event.request).then((response) => {
|
||||
if (response.ok) {
|
||||
const copy = response.clone();
|
||||
void caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => cache.put(event.request, copy));
|
||||
}
|
||||
return response;
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.privacy-tools",
|
||||
"name": "Privacy Tools",
|
||||
"version": "0.1.0",
|
||||
"description": "Inspect and remove shareable-file metadata locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["privacy", "files", "security"],
|
||||
"tags": ["metadata", "exif", "privacy", "sanitize", "share"],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
"embedding": "unsupported"
|
||||
},
|
||||
"requirements": {
|
||||
"secureContext": true,
|
||||
"workers": true,
|
||||
"indexedDb": false,
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
"telemetry": false,
|
||||
"label": "Inputs stay in this browser; nothing is uploaded."
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/lotobo/privacy-tools",
|
||||
"license": "GPL-3.0-or-later"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "source",
|
||||
"label": "Source",
|
||||
"url": "https://git.add-ideas.de/lotobo/privacy-tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { format } from "prettier";
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourcePath = path.join(root, "src/toolbox/manifest.source.json");
|
||||
const outputPath = path.join(root, "public/toolbox-app.json");
|
||||
const source = JSON.parse(await readFile(sourcePath, "utf8"));
|
||||
const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
||||
const versionSource = await readFile(path.join(root, "src/version.ts"), "utf8");
|
||||
const appVersion = /^export const APP_VERSION = "([^"]+)";$/mu.exec(
|
||||
versionSource,
|
||||
)?.[1];
|
||||
const repository = "https://git.add-ideas.de/lotobo/" + pkg.name;
|
||||
if (source.version !== pkg.version || appVersion !== pkg.version)
|
||||
throw new Error("Version identity drift");
|
||||
if (
|
||||
source.id !== "de.add-ideas." + pkg.name ||
|
||||
source.source?.repository !== repository ||
|
||||
source.source?.license !== "GPL-3.0-or-later"
|
||||
)
|
||||
throw new Error("Manifest source identity is invalid");
|
||||
const serialized = await format(JSON.stringify(source), {
|
||||
filepath: outputPath,
|
||||
});
|
||||
if (process.argv.includes("--check")) {
|
||||
if ((await readFile(outputPath, "utf8").catch(() => "")) !== serialized)
|
||||
throw new Error("public/toolbox-app.json is stale");
|
||||
console.log("Toolbox manifest is synchronized");
|
||||
} else {
|
||||
await writeFile(outputPath, serialized);
|
||||
console.log("Generated public/toolbox-app.json");
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
chmod,
|
||||
copyFile,
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
utimes,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const execute = promisify(execFile);
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
||||
const argument = (name, fallback) => {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : fallback;
|
||||
};
|
||||
const output = path.resolve(
|
||||
root,
|
||||
argument("--output", "release/" + pkg.name + "-" + pkg.version + ".zip"),
|
||||
);
|
||||
const checksumOutput = output + ".sha256";
|
||||
const force = process.argv.includes("--force");
|
||||
if (
|
||||
path.extname(output).toLowerCase() !== ".zip" ||
|
||||
output === root ||
|
||||
output === path.parse(output).root
|
||||
)
|
||||
throw new Error("Unsafe release target");
|
||||
const exists = (file) =>
|
||||
access(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (!force && ((await exists(output)) || (await exists(checksumOutput))))
|
||||
throw new Error("Release output exists");
|
||||
const input = path.join(root, "dist");
|
||||
for (const name of [
|
||||
"index.html",
|
||||
"manifest.webmanifest",
|
||||
"sw.js",
|
||||
"toolbox-app.json",
|
||||
"favicon.svg",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"CONTRIBUTING.md",
|
||||
"LICENSE",
|
||||
"SECURITY.md",
|
||||
"SOURCE.md",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
"LICENSES/README.md",
|
||||
"LICENSES/npm-runtime-licenses.txt",
|
||||
"docs/ACCESSIBILITY.md",
|
||||
"docs/ARCHITECTURE.md",
|
||||
"docs/PRIVACY-SECURITY.md",
|
||||
]) {
|
||||
const details = await lstat(path.join(input, name)).catch(() => null);
|
||||
if (!details?.isFile() || details.isSymbolicLink())
|
||||
throw new Error("Missing release file: " + name);
|
||||
}
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(input, "toolbox-app.json"), "utf8"),
|
||||
);
|
||||
const repository = "https://git.add-ideas.de/lotobo/" + pkg.name;
|
||||
if (
|
||||
manifest.id !== "de.add-ideas." + pkg.name ||
|
||||
manifest.version !== pkg.version ||
|
||||
manifest.entry !== "./" ||
|
||||
manifest.icon !== "./favicon.svg" ||
|
||||
manifest.source?.repository !== repository
|
||||
)
|
||||
throw new Error("Packaged manifest identity is invalid");
|
||||
if (
|
||||
/\b(?:src|href)=["']\//iu.test(
|
||||
await readFile(path.join(input, "index.html"), "utf8"),
|
||||
)
|
||||
)
|
||||
throw new Error("Root-absolute asset reference");
|
||||
async function collect(directory, prefix = "") {
|
||||
const files = [];
|
||||
for (const entry of (await readdir(directory, { withFileTypes: true })).sort(
|
||||
(a, b) => a.name.localeCompare(b.name),
|
||||
)) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
const relative = prefix ? prefix + "/" + entry.name : entry.name;
|
||||
if (entry.isSymbolicLink())
|
||||
throw new Error("Symlink in release: " + relative);
|
||||
if (entry.isDirectory()) files.push(...(await collect(absolute, relative)));
|
||||
else if (entry.isFile()) files.push({ absolute, relative });
|
||||
else throw new Error("Unsupported release entry: " + relative);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
const sourceFiles = await collect(input);
|
||||
for (const file of sourceFiles)
|
||||
if (
|
||||
file.relative.endsWith(".map") ||
|
||||
/(?:^|\/)(?:\.env(?:\.|$)|id_rsa|id_ed25519|.*\.pem$|.*\.key$)/iu.test(
|
||||
file.relative,
|
||||
) ||
|
||||
file.relative.split("/").includes("..")
|
||||
)
|
||||
throw new Error("Forbidden release entry: " + file.relative);
|
||||
await mkdir(path.dirname(output), { recursive: true });
|
||||
const stagingRoot = await mkdtemp(
|
||||
path.join(os.tmpdir(), pkg.name + "-release-"),
|
||||
);
|
||||
const publicationRoot = await mkdtemp(
|
||||
path.join(path.dirname(output), "." + pkg.name + "-publish-"),
|
||||
);
|
||||
const stagedTree = path.join(stagingRoot, "tree");
|
||||
const stagedArchive = path.join(stagingRoot, path.basename(output));
|
||||
try {
|
||||
await cp(input, stagedTree, { recursive: true });
|
||||
const timestamp = new Date("1980-01-01T00:00:00.000Z");
|
||||
for (const file of await collect(stagedTree)) {
|
||||
await chmod(file.absolute, 0o644);
|
||||
await utimes(file.absolute, timestamp, timestamp);
|
||||
}
|
||||
await execute(
|
||||
"zip",
|
||||
[
|
||||
"-X",
|
||||
"-q",
|
||||
"-9",
|
||||
stagedArchive,
|
||||
...sourceFiles.map((file) => file.relative),
|
||||
],
|
||||
{
|
||||
cwd: stagedTree,
|
||||
env: { ...process.env, TZ: "UTC" },
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const archive = await readFile(stagedArchive);
|
||||
const digest = createHash("sha256").update(archive).digest("hex");
|
||||
const stagedChecksum = stagedArchive + ".sha256";
|
||||
await writeFile(stagedChecksum, digest + " " + path.basename(output) + "\n");
|
||||
const publicationArchive = path.join(publicationRoot, path.basename(output));
|
||||
const publicationChecksum = publicationArchive + ".sha256";
|
||||
await copyFile(stagedArchive, publicationArchive);
|
||||
await copyFile(stagedChecksum, publicationChecksum);
|
||||
if (force) {
|
||||
await rm(output, { force: true });
|
||||
await rm(checksumOutput, { force: true });
|
||||
}
|
||||
await rename(publicationArchive, output);
|
||||
await rename(publicationChecksum, checksumOutput);
|
||||
console.log("Created " + path.relative(root, output) + "\nSHA-256 " + digest);
|
||||
} finally {
|
||||
await rm(stagingRoot, { recursive: true, force: true });
|
||||
await rm(publicationRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const destination = path.join(root, "public");
|
||||
for (const name of [
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"CONTRIBUTING.md",
|
||||
"SECURITY.md",
|
||||
"SOURCE.md",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
]) {
|
||||
await readFile(path.join(root, name));
|
||||
await cp(path.join(root, name), path.join(destination, name));
|
||||
}
|
||||
for (const directory of ["LICENSES", "docs"]) {
|
||||
const output = path.join(destination, directory);
|
||||
await rm(output, { recursive: true, force: true });
|
||||
await cp(path.join(root, directory), output, { recursive: true });
|
||||
}
|
||||
const lock = JSON.parse(
|
||||
await readFile(path.join(root, "package-lock.json"), "utf8"),
|
||||
);
|
||||
const sections = [];
|
||||
for (const [location, locked] of Object.entries(lock.packages ?? {}).sort(
|
||||
([a], [b]) => a.localeCompare(b),
|
||||
)) {
|
||||
if (!location.includes("node_modules/") || locked.dev === true) continue;
|
||||
const packageDirectory = path.join(root, location);
|
||||
const details = JSON.parse(
|
||||
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
|
||||
);
|
||||
const candidates = (await readdir(packageDirectory))
|
||||
.filter((name) => /^(?:licen[cs]e|copying|notice)(?:\.|$)/iu.test(name))
|
||||
.sort();
|
||||
const texts = [];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
texts.push(
|
||||
"--- " +
|
||||
candidate +
|
||||
" ---\n" +
|
||||
(await readFile(path.join(packageDirectory, candidate), "utf8")),
|
||||
);
|
||||
} catch {
|
||||
/* directory */
|
||||
}
|
||||
}
|
||||
sections.push(
|
||||
"=".repeat(78) +
|
||||
"\n" +
|
||||
details.name +
|
||||
"@" +
|
||||
details.version +
|
||||
"\nDeclared licence: " +
|
||||
(details.license ?? locked.license ?? "See upstream") +
|
||||
"\n" +
|
||||
"=".repeat(78) +
|
||||
"\n" +
|
||||
(texts.join("\n\n") || "See upstream package metadata."),
|
||||
);
|
||||
}
|
||||
await mkdir(path.join(destination, "LICENSES"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(destination, "LICENSES/npm-runtime-licenses.txt"),
|
||||
sections.join("\n\n").trimEnd() + "\n",
|
||||
);
|
||||
console.log("Prepared release documentation");
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createServer } from "node:http";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"dist",
|
||||
);
|
||||
const prefix = "/deep/nested/privacy/";
|
||||
const types = new Map([
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".json", "application/json; charset=utf-8"],
|
||||
[".webmanifest", "application/manifest+json; charset=utf-8"],
|
||||
[".svg", "image/svg+xml"],
|
||||
[".md", "text/markdown; charset=utf-8"],
|
||||
[".txt", "text/plain; charset=utf-8"],
|
||||
[".wasm", "application/wasm"],
|
||||
[".png", "image/png"],
|
||||
[".jpg", "image/jpeg"],
|
||||
[".webp", "image/webp"],
|
||||
]);
|
||||
const headers = {
|
||||
"Content-Security-Policy":
|
||||
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'",
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"Permissions-Policy":
|
||||
"camera=(), microphone=(), geolocation=(), usb=(), payment=()",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
};
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
const relative = decodeURIComponent(url.pathname).startsWith(prefix)
|
||||
? decodeURIComponent(url.pathname).slice(prefix.length)
|
||||
: decodeURIComponent(url.pathname).replace(/^\/+/, "");
|
||||
const normalized = path.posix.normalize(relative || "index.html");
|
||||
if (
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
path.isAbsolute(normalized)
|
||||
) {
|
||||
response.writeHead(400).end("Bad request");
|
||||
return;
|
||||
}
|
||||
let file = path.join(root, normalized);
|
||||
if ((await stat(file).catch(() => null))?.isDirectory())
|
||||
file = path.join(file, "index.html");
|
||||
const content = await readFile(file);
|
||||
response.writeHead(200, {
|
||||
"Content-Type":
|
||||
types.get(path.extname(file)) ?? "application/octet-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
...headers,
|
||||
});
|
||||
response.end(content);
|
||||
} catch {
|
||||
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
response.end("Not found");
|
||||
}
|
||||
});
|
||||
server.listen(4173, "127.0.0.1", () => console.log("Test server ready"));
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { AppShell } from "@add-ideas/toolbox-shell-react";
|
||||
import "@add-ideas/toolbox-shell-react/styles.css";
|
||||
import "./styles.css";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { HelpDialog } from "./components/HelpDialog";
|
||||
import { manifest } from "./toolbox/manifest";
|
||||
|
||||
const Workbench = lazy(async () => ({
|
||||
default: (await import("./components/Workbench")).Workbench,
|
||||
}));
|
||||
|
||||
export function App() {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AppShell
|
||||
app={manifest}
|
||||
manifestUrl="./toolbox-app.json"
|
||||
helpAction={{ onClick: () => setHelpOpen(true) }}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<p className="loading" role="status">
|
||||
Preparing Privacy Tools…
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Workbench />
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error?: Error }
|
||||
> {
|
||||
state: { error?: Error } = {};
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("Application failure", error, info);
|
||||
}
|
||||
render() {
|
||||
if (this.state.error)
|
||||
return (
|
||||
<main className="fatal">
|
||||
<h1>Privacy Tools could not continue</h1>
|
||||
<p>{this.state.error.message}</p>
|
||||
<button type="button" onClick={() => location.reload()}>
|
||||
Reload
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function HelpDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose(): void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => {
|
||||
const node = dialog.current;
|
||||
if (!node) return;
|
||||
if (open && !node.open) node.showModal();
|
||||
if (!open && node.open) node.close();
|
||||
}, [open]);
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="help-dialog"
|
||||
onClose={onClose}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
aria-labelledby="help-title"
|
||||
>
|
||||
<div className="dialog-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Local-first help</p>
|
||||
<h2 id="help-title">About Privacy Tools</h2>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close help">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="help-sections">
|
||||
<section>
|
||||
<h3>What inspection covers</h3>
|
||||
<p>
|
||||
All files receive magic-byte inventory. JPEG, PNG and WebP receive
|
||||
bounded container scans for EXIF, IPTC, XMP, text, profiles,
|
||||
thumbnails and selected provenance blocks. A separate ExifReader
|
||||
pass expands coverage and inspects TIFF, HEIC, AVIF and JXL on a
|
||||
best-effort basis. GIF and document formats receive inventory only.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>What a clean copy means</h3>
|
||||
<p>
|
||||
Supported static images are decoded to pixels, displayed orientation
|
||||
is normalized, and a new JPEG, PNG or WebP is encoded. No source
|
||||
metadata block is copied. The output must pass both scanners before
|
||||
it can receive a verified result.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Important limits</h3>
|
||||
<p>
|
||||
Animation and multi-image files are inspect-only. Re-encoding may
|
||||
change JPEG/WebP pixels, colour profiles, resolution metadata and
|
||||
file size. C2PA/JUMBF provenance is removed or invalidated and is
|
||||
never silently described as preserved.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>No anonymity promise</h3>
|
||||
<p>
|
||||
Visible content, steganography, invisible watermarks, reverse-image
|
||||
matching, sidecars, filesystem history and remote copies remain
|
||||
outside this tool. Review the actual image before sharing it.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
||||
|
||||
import {
|
||||
createBatchArchive,
|
||||
createBatchReport,
|
||||
sanitizeStaticImage,
|
||||
scanFilesInWorker,
|
||||
serializeReport,
|
||||
type FindingCategory,
|
||||
type ImageScanResult,
|
||||
type SanitizedAsset,
|
||||
} from "../privacy";
|
||||
|
||||
interface FileRecord {
|
||||
file: File;
|
||||
scan: ImageScanResult;
|
||||
asset?: SanitizedAsset;
|
||||
sanitizing?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Progress {
|
||||
completed: number;
|
||||
total: number;
|
||||
currentName: string;
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Readonly<Record<FindingCategory, string>> =
|
||||
Object.freeze({
|
||||
location: "Location",
|
||||
identity: "People, authorship & rights",
|
||||
timestamp: "Dates & times",
|
||||
device: "Device, serial & lens",
|
||||
software: "Software & history",
|
||||
"document-id": "Document identifiers",
|
||||
comment: "Comments, titles & keywords",
|
||||
thumbnail: "Embedded previews & extra images",
|
||||
"colour-profile": "Colour profiles",
|
||||
provenance: "Provenance & signatures",
|
||||
technical: "Technical metadata",
|
||||
unknown: "Unclassified metadata",
|
||||
});
|
||||
|
||||
export function Workbench() {
|
||||
const [records, setRecords] = useState<FileRecord[]>([]);
|
||||
const [busy, setBusy] = useState<"scan" | "sanitize" | "archive" | null>(
|
||||
null,
|
||||
);
|
||||
const [progress, setProgress] = useState<Progress | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const cleanable = useMemo(
|
||||
() => records.filter((record) => record.scan.cleanable),
|
||||
[records],
|
||||
);
|
||||
const assets = useMemo(
|
||||
() => records.flatMap((record) => (record.asset ? [record.asset] : [])),
|
||||
[records],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
abortRef.current?.abort();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const importFiles = async (selection: FileList | readonly File[]) => {
|
||||
const files = Array.from(selection);
|
||||
if (files.length === 0) return;
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setBusy("scan");
|
||||
setProgress({
|
||||
completed: 0,
|
||||
total: files.length,
|
||||
currentName: files[0]?.name ?? "",
|
||||
});
|
||||
setError("");
|
||||
setRecords([]);
|
||||
try {
|
||||
const scans = await scanFilesInWorker(
|
||||
files,
|
||||
setProgress,
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setRecords(scans.map((scan, index) => ({ file: files[index]!, scan })));
|
||||
} catch (caught) {
|
||||
if (!isAbort(caught)) setError(errorMessage(caught));
|
||||
} finally {
|
||||
if (abortRef.current === controller) {
|
||||
abortRef.current = null;
|
||||
setBusy(null);
|
||||
setProgress(null);
|
||||
}
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const sanitizeOne = async (id: string) => {
|
||||
const record = records.find((item) => item.scan.id === id);
|
||||
if (!record?.scan.cleanable) return;
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setBusy("sanitize");
|
||||
setError("");
|
||||
setRecords((current) =>
|
||||
current.map((item) =>
|
||||
item.scan.id === id
|
||||
? { ...item, sanitizing: true, error: undefined }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
try {
|
||||
const asset = await sanitizeStaticImage(record.file, record.scan, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
setRecords((current) =>
|
||||
current.map((item) =>
|
||||
item.scan.id === id
|
||||
? { ...item, asset, sanitizing: false, error: undefined }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
} catch (caught) {
|
||||
if (!isAbort(caught))
|
||||
setRecords((current) =>
|
||||
current.map((item) =>
|
||||
item.scan.id === id
|
||||
? {
|
||||
...item,
|
||||
sanitizing: false,
|
||||
error: errorMessage(caught),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (abortRef.current === controller) abortRef.current = null;
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const sanitizeAll = async () => {
|
||||
const pending = records.filter(
|
||||
(record) => record.scan.cleanable && !record.asset,
|
||||
);
|
||||
if (pending.length === 0) return;
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setBusy("sanitize");
|
||||
setError("");
|
||||
for (let index = 0; index < pending.length; index += 1) {
|
||||
const record = pending[index];
|
||||
if (!record || controller.signal.aborted) break;
|
||||
setProgress({
|
||||
completed: index,
|
||||
total: pending.length,
|
||||
currentName: record.file.name,
|
||||
});
|
||||
setRecords((current) =>
|
||||
current.map((item) =>
|
||||
item.scan.id === record.scan.id
|
||||
? { ...item, sanitizing: true, error: undefined }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
try {
|
||||
const asset = await sanitizeStaticImage(record.file, record.scan, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
setRecords((current) =>
|
||||
current.map((item) =>
|
||||
item.scan.id === record.scan.id
|
||||
? { ...item, asset, sanitizing: false }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
} catch (caught) {
|
||||
if (isAbort(caught)) break;
|
||||
setRecords((current) =>
|
||||
current.map((item) =>
|
||||
item.scan.id === record.scan.id
|
||||
? {
|
||||
...item,
|
||||
sanitizing: false,
|
||||
error: errorMessage(caught),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (abortRef.current === controller) abortRef.current = null;
|
||||
setRecords((current) =>
|
||||
current.map((record) =>
|
||||
record.sanitizing ? { ...record, sanitizing: false } : record,
|
||||
),
|
||||
);
|
||||
setProgress(null);
|
||||
setBusy(null);
|
||||
};
|
||||
|
||||
const downloadReport = () => {
|
||||
setError("");
|
||||
try {
|
||||
const report = createBatchReport(
|
||||
records.map((record) => record.scan),
|
||||
assets,
|
||||
);
|
||||
triggerBlobDownload(
|
||||
new Blob([serializeReport(report)], { type: "application/json" }),
|
||||
"privacy-tools-report.json",
|
||||
);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
}
|
||||
};
|
||||
|
||||
const downloadArchive = async () => {
|
||||
if (assets.length === 0) return;
|
||||
setBusy("archive");
|
||||
setError("");
|
||||
try {
|
||||
const blob = await createBatchArchive(
|
||||
records.map((record) => record.scan),
|
||||
assets,
|
||||
);
|
||||
triggerBlobDownload(blob, "privacy-tools-re-encoded-images.zip");
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setRecords([]);
|
||||
setBusy(null);
|
||||
setProgress(null);
|
||||
setError("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="workbench">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Image metadata workbench</p>
|
||||
<h1>Privacy Tools</h1>
|
||||
<p>
|
||||
Inventory files, inspect static-image metadata, then create and
|
||||
independently re-scan pixel-only sharing copies—all in this browser.
|
||||
</p>
|
||||
</div>
|
||||
<span className="privacy-pill">Local & ephemeral</span>
|
||||
</header>
|
||||
|
||||
<section className="panel import-panel" aria-labelledby="import-heading">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Step 1</p>
|
||||
<h2 id="import-heading">Choose files to inspect</h2>
|
||||
</div>
|
||||
<span className="limit-note">
|
||||
100 files · 128 MiB each · 512 MiB batch
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`drop-zone${dragging ? " is-dragging" : ""}`}
|
||||
disabled={busy !== null}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDragLeave={(event) => {
|
||||
if (
|
||||
!event.currentTarget.contains(event.relatedTarget as Node | null)
|
||||
)
|
||||
setDragging(false);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
void importFiles(event.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<span className="drop-icon" aria-hidden="true">
|
||||
⇧
|
||||
</span>
|
||||
<strong>Drop files here or choose files</strong>
|
||||
<span>
|
||||
Every file gets claimed-vs-detected type inventory. JPEG, PNG and
|
||||
static WebP receive the deep clean-copy workflow.
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="sr-only"
|
||||
type="file"
|
||||
multiple
|
||||
aria-label="Choose files to inspect"
|
||||
onChange={(event) => {
|
||||
if (event.currentTarget.files)
|
||||
void importFiles(event.currentTarget.files);
|
||||
}}
|
||||
/>
|
||||
{busy && progress ? (
|
||||
<div className="progress" role="status">
|
||||
<progress value={progress.completed} max={progress.total} />
|
||||
<span>
|
||||
{busy === "scan" ? "Inspecting" : "Re-encoding"}{" "}
|
||||
{progress.currentName} · {progress.completed} of {progress.total}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="error-message" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{records.length > 0 ? (
|
||||
<>
|
||||
<section className="panel" aria-labelledby="inventory-heading">
|
||||
<div className="panel-heading action-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Step 2</p>
|
||||
<h2 id="inventory-heading">Batch inventory</h2>
|
||||
<p className="muted">
|
||||
Detection uses file bytes, not just the extension or browser
|
||||
claim.
|
||||
</p>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={
|
||||
busy !== null || cleanable.every((record) => record.asset)
|
||||
}
|
||||
onClick={() => void sanitizeAll()}
|
||||
>
|
||||
Re-encode {cleanable.filter((record) => !record.asset).length}{" "}
|
||||
supported
|
||||
</button>
|
||||
{busy === "scan" || busy === "sanitize" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => abortRef.current?.abort()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" disabled={busy !== null} onClick={clear}>
|
||||
Clear batch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inventory-table-wrap">
|
||||
<table className="inventory-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Claimed</th>
|
||||
<th>Detected</th>
|
||||
<th>Findings</th>
|
||||
<th>Clean copy</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => (
|
||||
<tr key={record.scan.id}>
|
||||
<td>
|
||||
<strong>{record.scan.name}</strong>
|
||||
<span>
|
||||
{formatBytes(record.scan.size)} ·{" "}
|
||||
{record.scan.sha256.slice(0, 12)}…
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<code>
|
||||
{record.scan.identity.claimedType || "not claimed"}
|
||||
</code>
|
||||
<span>.{record.scan.identity.extension || "none"}</span>
|
||||
</td>
|
||||
<td>
|
||||
<code>{record.scan.identity.detectedType}</code>
|
||||
<StatusBadge status={record.scan.identity.typeMatch} />
|
||||
</td>
|
||||
<td>
|
||||
<strong>{record.scan.findings.length}</strong>
|
||||
<span>
|
||||
{record.scan.coverage.projectScanner} project scan
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{record.asset ? (
|
||||
<StatusBadge status={record.asset.report.status} />
|
||||
) : record.scan.cleanable ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void sanitizeOne(record.scan.id)}
|
||||
>
|
||||
{record.sanitizing ? "Working…" : "Re-encode"}
|
||||
</button>
|
||||
) : (
|
||||
<span className="muted">Inspect only</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="file-results" aria-label="Detailed file findings">
|
||||
{records.map((record) => (
|
||||
<FileResult
|
||||
key={record.scan.id}
|
||||
record={record}
|
||||
busy={busy !== null}
|
||||
onSanitize={() => void sanitizeOne(record.scan.id)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="panel export-panel"
|
||||
aria-labelledby="export-heading"
|
||||
>
|
||||
<div className="panel-heading action-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Step 3</p>
|
||||
<h2 id="export-heading">Export deliberately</h2>
|
||||
<p className="muted">
|
||||
The JSON report includes original filenames and detected
|
||||
values and may itself be sensitive.
|
||||
</p>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadReport}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
Download JSON report
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={() => void downloadArchive()}
|
||||
disabled={busy !== null || assets.length === 0}
|
||||
>
|
||||
Download {assets.length} re-encoded{" "}
|
||||
{assets.length === 1 ? "image" : "images"} + report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<aside className="boundary-note">
|
||||
<strong>No anonymity promise.</strong> Metadata removal cannot remove
|
||||
visible faces or text, steganography, invisible watermarks,
|
||||
reverse-image matches, sidecar files, filesystem records, or copies
|
||||
stored elsewhere.
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileResult({
|
||||
record,
|
||||
busy,
|
||||
onSanitize,
|
||||
}: {
|
||||
record: FileRecord;
|
||||
busy: boolean;
|
||||
onSanitize(): void;
|
||||
}) {
|
||||
const grouped = groupFindings(record.scan);
|
||||
return (
|
||||
<article className="panel file-card">
|
||||
<header className="file-card-heading">
|
||||
<div>
|
||||
<p className="eyebrow">
|
||||
{record.scan.identity.detectedKind.toUpperCase()}
|
||||
</p>
|
||||
<h2>{record.scan.name}</h2>
|
||||
<p className="muted hash-line">
|
||||
{record.scan.width && record.scan.height
|
||||
? `${record.scan.width} × ${record.scan.height} pixels · `
|
||||
: ""}
|
||||
SHA-256 <code>{record.scan.sha256}</code>
|
||||
</p>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
{record.scan.cleanable && !record.asset ? (
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={busy}
|
||||
onClick={onSanitize}
|
||||
>
|
||||
Re-encode & verify
|
||||
</button>
|
||||
) : null}
|
||||
{record.asset ? (
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={() =>
|
||||
triggerBlobDownload(
|
||||
record.asset!.blob,
|
||||
record.asset!.report.outputName,
|
||||
)
|
||||
}
|
||||
>
|
||||
Download re-encoded output
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
{record.error ? (
|
||||
<p className="error-message" role="alert">
|
||||
{record.error}
|
||||
</p>
|
||||
) : null}
|
||||
{record.scan.warnings.length > 0 ? (
|
||||
<ul className="warning-list">
|
||||
{record.scan.warnings.map((warning) => (
|
||||
<li key={warning}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<div className="finding-summary">
|
||||
{grouped.length === 0 ? (
|
||||
<p>
|
||||
No recognized metadata findings were reported within scanner
|
||||
coverage.
|
||||
</p>
|
||||
) : (
|
||||
grouped.map(([category, findings]) => (
|
||||
<details
|
||||
key={category}
|
||||
open={findings.some((finding) => finding.risk === "sensitive")}
|
||||
>
|
||||
<summary>
|
||||
<span>{CATEGORY_LABELS[category]}</span>
|
||||
<span>{findings.length}</span>
|
||||
</summary>
|
||||
<div className="finding-list">
|
||||
{findings.slice(0, 50).map((finding) => (
|
||||
<div key={finding.id}>
|
||||
<div>
|
||||
<strong>{finding.label}</strong>
|
||||
<span>{finding.source}</span>
|
||||
</div>
|
||||
<code>{finding.value || "(empty)"}</code>
|
||||
</div>
|
||||
))}
|
||||
{findings.length > 50 ? (
|
||||
<p className="omitted-findings">
|
||||
{findings.length - 50} more findings are included in the
|
||||
JSON report.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{record.asset ? <Verification report={record.asset.report} /> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Verification({ report }: { report: SanitizedAsset["report"] }) {
|
||||
return (
|
||||
<section
|
||||
className={`verification is-${report.status}`}
|
||||
aria-label="Output verification"
|
||||
>
|
||||
<div className="verification-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Mandatory output re-scan</p>
|
||||
<h3>{report.summary}</h3>
|
||||
</div>
|
||||
<StatusBadge status={report.status} />
|
||||
</div>
|
||||
<dl className="verification-grid">
|
||||
<div>
|
||||
<dt>Output hash</dt>
|
||||
<dd>
|
||||
<code>{report.outputSha256}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Metadata</dt>
|
||||
<dd>
|
||||
{report.removed.length} removed · {report.preserved.length}{" "}
|
||||
preserved · {report.generated.length} generated
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Orientation</dt>
|
||||
<dd>
|
||||
{report.orientationNormalized ? "Normalized" : "Review required"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Pixel sample</dt>
|
||||
<dd>
|
||||
{report.pixelComparison.identical
|
||||
? "Identical"
|
||||
: "Changed after encoding"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{report.generated.length > 0 ? (
|
||||
<details>
|
||||
<summary>Generated or preserved output metadata</summary>
|
||||
<ul>
|
||||
{report.generated.map((finding) => (
|
||||
<li key={finding.id}>
|
||||
{finding.label}: {finding.value}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
{[...report.unsupported, ...report.incomplete].length > 0 ? (
|
||||
<ul className="warning-list">
|
||||
{[...report.unsupported, ...report.incomplete].map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<p className="disclaimer">{report.disclaimer}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const normalized = status.replace(/[^a-z]+/gu, "-");
|
||||
return <span className={`status-badge is-${normalized}`}>{status}</span>;
|
||||
}
|
||||
|
||||
function groupFindings(
|
||||
scan: ImageScanResult,
|
||||
): Array<[FindingCategory, ImageScanResult["findings"]]> {
|
||||
const groups = new Map<FindingCategory, ImageScanResult["findings"]>();
|
||||
for (const finding of scan.findings) {
|
||||
const values = groups.get(finding.category) ?? [];
|
||||
values.push(finding);
|
||||
groups.set(finding.category, values);
|
||||
}
|
||||
return [...groups.entries()];
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KiB`;
|
||||
return `${(value / 1024 ** 2).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "The operation failed.";
|
||||
}
|
||||
|
||||
function isAbort(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === "AbortError";
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener("load", () => {
|
||||
const url = new URL("./sw.js", document.baseURI);
|
||||
void navigator.serviceWorker
|
||||
.register(url, { scope: new URL("./", document.baseURI).pathname })
|
||||
.catch(() => undefined);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
encodeText,
|
||||
sanitizeDownloadFilename,
|
||||
stableStringify,
|
||||
} from "@add-ideas/toolbox-helpers";
|
||||
import { zipSync } from "fflate";
|
||||
|
||||
import { APP_VERSION } from "../version";
|
||||
import {
|
||||
assertLimit,
|
||||
DEFAULT_PRIVACY_LIMITS,
|
||||
PrivacyLimitError,
|
||||
} from "./limits";
|
||||
import type {
|
||||
BatchReport,
|
||||
ImageScanResult,
|
||||
SanitizedAsset,
|
||||
SanitizationReport,
|
||||
} from "./model";
|
||||
|
||||
export function createBatchReport(
|
||||
files: readonly ImageScanResult[],
|
||||
assets: readonly SanitizedAsset[],
|
||||
generatedAt = new Date().toISOString(),
|
||||
): BatchReport {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt,
|
||||
application: { name: "Privacy Tools", version: APP_VERSION },
|
||||
files: [...files],
|
||||
sanitizations: assets.map((asset) => asset.report),
|
||||
warnings: [
|
||||
"This report may itself contain sensitive source metadata and filenames; store and share it deliberately.",
|
||||
"A successful re-scan is not an anonymity guarantee; see each sanitization disclaimer.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeReport(
|
||||
report: BatchReport | SanitizationReport,
|
||||
): string {
|
||||
return stableStringify(report, 2, {
|
||||
maxDepth: 64,
|
||||
maxNodes: 500_000,
|
||||
maxTextChars: 2_000_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createBatchArchive(
|
||||
files: readonly ImageScanResult[],
|
||||
assets: readonly SanitizedAsset[],
|
||||
generatedAt = new Date().toISOString(),
|
||||
): Promise<Blob> {
|
||||
assertLimit(
|
||||
files.length,
|
||||
DEFAULT_PRIVACY_LIMITS.maxFiles,
|
||||
"Report file count",
|
||||
);
|
||||
assertLimit(
|
||||
assets.length,
|
||||
DEFAULT_PRIVACY_LIMITS.maxFiles,
|
||||
"Archive image count",
|
||||
);
|
||||
const entries: Record<string, Uint8Array> = Object.create(null) as Record<
|
||||
string,
|
||||
Uint8Array
|
||||
>;
|
||||
const names = new Set<string>();
|
||||
let total = 0;
|
||||
for (const asset of assets) {
|
||||
const name = uniqueName(asset.report.outputName, names);
|
||||
const bytes = new Uint8Array(await asset.blob.arrayBuffer());
|
||||
total += bytes.byteLength;
|
||||
if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes)
|
||||
throw new PrivacyLimitError(
|
||||
"Batch archive input size",
|
||||
total,
|
||||
DEFAULT_PRIVACY_LIMITS.maxZipBytes,
|
||||
);
|
||||
entries[`images/${name}`] = bytes;
|
||||
}
|
||||
const report = encodeText(
|
||||
serializeReport(createBatchReport(files, assets, generatedAt)),
|
||||
);
|
||||
total += report.byteLength;
|
||||
if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes)
|
||||
throw new PrivacyLimitError(
|
||||
"Batch archive input size",
|
||||
total,
|
||||
DEFAULT_PRIVACY_LIMITS.maxZipBytes,
|
||||
);
|
||||
entries["privacy-tools-report.json"] = report;
|
||||
const archive = zipSync(entries, {
|
||||
level: 0,
|
||||
mtime: new Date("1980-01-01T00:00:00.000Z"),
|
||||
});
|
||||
if (archive.byteLength > DEFAULT_PRIVACY_LIMITS.maxZipBytes)
|
||||
throw new PrivacyLimitError(
|
||||
"Batch archive output size",
|
||||
archive.byteLength,
|
||||
DEFAULT_PRIVACY_LIMITS.maxZipBytes,
|
||||
);
|
||||
const ownedArchive = archive.slice().buffer as ArrayBuffer;
|
||||
return new Blob([ownedArchive], { type: "application/zip" });
|
||||
}
|
||||
|
||||
function uniqueName(input: string, used: Set<string>): string {
|
||||
const safe = sanitizeDownloadFilename(input, "image.clean");
|
||||
if (!used.has(safe)) {
|
||||
used.add(safe);
|
||||
return safe;
|
||||
}
|
||||
const dot = safe.lastIndexOf(".");
|
||||
const stem = dot > 0 ? safe.slice(0, dot) : safe;
|
||||
const extension = dot > 0 ? safe.slice(dot) : "";
|
||||
let counter = 2;
|
||||
while (used.has(`${stem}-${counter}${extension}`)) counter += 1;
|
||||
const candidate = `${stem}-${counter}${extension}`;
|
||||
used.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { DetectedKind, InventoryIdentity } from "./model";
|
||||
|
||||
const MIME_BY_KIND: Readonly<Record<DetectedKind, string>> = Object.freeze({
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
webp: "image/webp",
|
||||
gif: "image/gif",
|
||||
tiff: "image/tiff",
|
||||
heic: "image/heic",
|
||||
avif: "image/avif",
|
||||
jxl: "image/jxl",
|
||||
pdf: "application/pdf",
|
||||
zip: "application/zip",
|
||||
ole: "application/x-ole-storage",
|
||||
unknown: "application/octet-stream",
|
||||
});
|
||||
|
||||
const EXTENSIONS: Readonly<Record<string, DetectedKind>> = Object.freeze({
|
||||
jpg: "jpeg",
|
||||
jpeg: "jpeg",
|
||||
jpe: "jpeg",
|
||||
png: "png",
|
||||
webp: "webp",
|
||||
gif: "gif",
|
||||
tif: "tiff",
|
||||
tiff: "tiff",
|
||||
heic: "heic",
|
||||
heif: "heic",
|
||||
avif: "avif",
|
||||
jxl: "jxl",
|
||||
pdf: "pdf",
|
||||
zip: "zip",
|
||||
docx: "zip",
|
||||
xlsx: "zip",
|
||||
pptx: "zip",
|
||||
odt: "zip",
|
||||
ods: "zip",
|
||||
odp: "zip",
|
||||
doc: "ole",
|
||||
xls: "ole",
|
||||
ppt: "ole",
|
||||
});
|
||||
|
||||
export function fileExtension(name: string): string {
|
||||
const leaf = name.replace(/\\/gu, "/").split("/").at(-1) ?? "";
|
||||
const index = leaf.lastIndexOf(".");
|
||||
return index > 0 && index < leaf.length - 1
|
||||
? leaf.slice(index + 1).toLowerCase()
|
||||
: "";
|
||||
}
|
||||
|
||||
export function detectKind(bytes: Uint8Array): DetectedKind {
|
||||
if (starts(bytes, [0xff, 0xd8, 0xff])) return "jpeg";
|
||||
if (starts(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
return "png";
|
||||
if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WEBP")
|
||||
return "webp";
|
||||
if (ascii(bytes, 0, 6) === "GIF87a" || ascii(bytes, 0, 6) === "GIF89a")
|
||||
return "gif";
|
||||
if (
|
||||
starts(bytes, [0x49, 0x49, 0x2a, 0x00]) ||
|
||||
starts(bytes, [0x4d, 0x4d, 0x00, 0x2a])
|
||||
)
|
||||
return "tiff";
|
||||
if (starts(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "pdf";
|
||||
if (
|
||||
starts(bytes, [0x50, 0x4b, 0x03, 0x04]) ||
|
||||
starts(bytes, [0x50, 0x4b, 0x05, 0x06]) ||
|
||||
starts(bytes, [0x50, 0x4b, 0x07, 0x08])
|
||||
)
|
||||
return "zip";
|
||||
if (starts(bytes, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]))
|
||||
return "ole";
|
||||
if (starts(bytes, [0xff, 0x0a])) return "jxl";
|
||||
if (
|
||||
starts(
|
||||
bytes,
|
||||
[0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a],
|
||||
)
|
||||
)
|
||||
return "jxl";
|
||||
const brand = isoBmffBrand(bytes);
|
||||
if (["avif", "avis"].includes(brand)) return "avif";
|
||||
if (["heic", "heix", "hevc", "hevx", "mif1", "msf1"].includes(brand))
|
||||
return "heic";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function inventoryIdentity(
|
||||
name: string,
|
||||
claimedType: string,
|
||||
bytes: Uint8Array,
|
||||
): InventoryIdentity {
|
||||
const extension = fileExtension(name);
|
||||
const detectedKind = detectKind(bytes);
|
||||
const detectedType = MIME_BY_KIND[detectedKind];
|
||||
const normalizedClaim = claimedType.trim().toLowerCase();
|
||||
const extensionKind = EXTENSIONS[extension];
|
||||
const claimedMatches =
|
||||
!normalizedClaim || normalizedClaim === "application/octet-stream"
|
||||
? undefined
|
||||
: normalizeMime(normalizedClaim) === detectedType;
|
||||
const extensionMatches = !extensionKind || extensionKind === detectedKind;
|
||||
const typeMatch =
|
||||
detectedKind === "unknown"
|
||||
? "unknown"
|
||||
: claimedMatches === false || !extensionMatches
|
||||
? "mismatch"
|
||||
: claimedMatches === undefined && !extensionKind
|
||||
? "unclaimed"
|
||||
: "match";
|
||||
return {
|
||||
claimedType: normalizedClaim,
|
||||
extension,
|
||||
detectedKind,
|
||||
detectedType,
|
||||
typeMatch,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMime(value: string): string {
|
||||
if (value === "image/jpg" || value === "image/pjpeg") return "image/jpeg";
|
||||
if (value === "image/x-png") return "image/png";
|
||||
if (value === "image/heif") return "image/heic";
|
||||
return value;
|
||||
}
|
||||
|
||||
function starts(bytes: Uint8Array, signature: readonly number[]): boolean {
|
||||
return signature.every((value, index) => bytes[index] === value);
|
||||
}
|
||||
|
||||
function ascii(bytes: Uint8Array, offset: number, length: number): string {
|
||||
if (offset < 0 || length < 0 || offset + length > bytes.byteLength) return "";
|
||||
let result = "";
|
||||
for (let index = 0; index < length; index += 1)
|
||||
result += String.fromCharCode(bytes[offset + index] ?? 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
function isoBmffBrand(bytes: Uint8Array): string {
|
||||
if (bytes.byteLength < 12 || ascii(bytes, 4, 4) !== "ftyp") return "";
|
||||
const length =
|
||||
((bytes[0] ?? 0) << 24) |
|
||||
((bytes[1] ?? 0) << 16) |
|
||||
((bytes[2] ?? 0) << 8) |
|
||||
(bytes[3] ?? 0);
|
||||
if (length < 12 || length > bytes.byteLength) return "";
|
||||
return ascii(bytes, 8, 4).toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import ExifReader, { type ExpandedTags } from "exifreader";
|
||||
|
||||
import { categoryForName, FindingCollector } from "./findings";
|
||||
import type { PrivacyLimits } from "./model";
|
||||
import { scanXmp } from "./xmp";
|
||||
|
||||
export interface SecondaryScan {
|
||||
status: "complete" | "partial" | "unsupported" | "failed";
|
||||
width?: number;
|
||||
height?: number;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
const GROUP_LABELS: Readonly<Record<string, string>> = Object.freeze({
|
||||
exif: "ExifReader EXIF",
|
||||
iptc: "ExifReader IPTC",
|
||||
icc: "ExifReader ICC",
|
||||
jfif: "ExifReader JFIF",
|
||||
png: "ExifReader PNG",
|
||||
pngText: "ExifReader PNG text",
|
||||
riff: "ExifReader WebP",
|
||||
gps: "ExifReader GPS",
|
||||
photoshop: "ExifReader Photoshop",
|
||||
makerNotes: "ExifReader maker notes",
|
||||
composite: "ExifReader computed",
|
||||
});
|
||||
|
||||
export function scanWithExifReader(
|
||||
bytes: ArrayBuffer,
|
||||
collector: FindingCollector,
|
||||
limits: Readonly<PrivacyLimits>,
|
||||
): SecondaryScan {
|
||||
try {
|
||||
const tags = ExifReader.load(bytes, {
|
||||
expanded: true,
|
||||
async: false,
|
||||
computed: true,
|
||||
includeUnknown: false,
|
||||
excludeTags: { xmp: true, mpf: true },
|
||||
decompress: { maxDecompressedSize: limits.maxInflatedMetadataBytes },
|
||||
});
|
||||
const fileType = tags.file?.FileType?.value;
|
||||
if (!fileType)
|
||||
return {
|
||||
status: "unsupported",
|
||||
notes: ["ExifReader did not recognize this format."],
|
||||
};
|
||||
const width = numericTag(tags.file?.["Image Width"]);
|
||||
const height = numericTag(tags.file?.["Image Height"]);
|
||||
let nodes = 0;
|
||||
for (const [groupName, source] of Object.entries(GROUP_LABELS)) {
|
||||
const group = tags[groupName as keyof ExpandedTags] as unknown;
|
||||
if (!group || typeof group !== "object") continue;
|
||||
for (const [name, tag] of Object.entries(group)) {
|
||||
nodes += 1;
|
||||
if (nodes > limits.maxFindings) {
|
||||
collector.warn(
|
||||
"ExifReader finding count reached the application limit.",
|
||||
);
|
||||
return {
|
||||
status: "partial",
|
||||
width,
|
||||
height,
|
||||
notes: ["ExifReader output was truncated at the finding limit."],
|
||||
};
|
||||
}
|
||||
if (name.startsWith("_") || name === "base64" || name === "image")
|
||||
continue;
|
||||
const value = tagValue(tag);
|
||||
if (!value) continue;
|
||||
const classification =
|
||||
groupName === "gps"
|
||||
? ({ category: "location", risk: "sensitive" } as const)
|
||||
: categoryForName(`${groupName} ${name}`);
|
||||
collector.add({
|
||||
...classification,
|
||||
source,
|
||||
label: name,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (tags.Thumbnail) {
|
||||
const image = tags.Thumbnail.image;
|
||||
const length =
|
||||
image instanceof ArrayBuffer || image instanceof SharedArrayBuffer
|
||||
? image.byteLength
|
||||
: image?.byteLength;
|
||||
collector.add({
|
||||
category: "thumbnail",
|
||||
risk: "sensitive",
|
||||
source: "ExifReader thumbnail",
|
||||
label: "Embedded thumbnail",
|
||||
value: `${length ?? "unknown"} bytes`,
|
||||
});
|
||||
}
|
||||
const rawXmp = tags.xmp?._raw;
|
||||
if (typeof rawXmp === "string")
|
||||
scanXmp(rawXmp, "ExifReader XMP", undefined, collector);
|
||||
if (width === undefined || height === undefined)
|
||||
return {
|
||||
status: "partial",
|
||||
width,
|
||||
height,
|
||||
notes: [
|
||||
"ExifReader recognized the container but could not establish both pixel dimensions.",
|
||||
],
|
||||
};
|
||||
return { status: "complete", width, height, notes: [] };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "failed",
|
||||
notes: [
|
||||
`ExifReader could not complete: ${error instanceof Error ? error.message : "unknown error"}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function numericTag(tag: unknown): number | undefined {
|
||||
if (!tag || typeof tag !== "object") return undefined;
|
||||
const value = (tag as { value?: unknown }).value;
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function tagValue(tag: unknown): string {
|
||||
if (tag == null) return "";
|
||||
if (typeof tag === "string" || typeof tag === "number") return String(tag);
|
||||
if (Array.isArray(tag)) return tag.slice(0, 128).map(tagValue).join(", ");
|
||||
if (ArrayBuffer.isView(tag)) return `${tag.byteLength} bytes`;
|
||||
if (tag instanceof ArrayBuffer || tag instanceof SharedArrayBuffer)
|
||||
return `${tag.byteLength} bytes`;
|
||||
if (typeof tag === "object") {
|
||||
const item = tag as { description?: unknown; value?: unknown };
|
||||
if (typeof item.description === "string") return item.description;
|
||||
if (item.value !== undefined) return tagValue(item.value);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type {
|
||||
FindingCategory,
|
||||
FindingRisk,
|
||||
MetadataFinding,
|
||||
PrivacyLimits,
|
||||
} from "./model";
|
||||
|
||||
export class FindingCollector {
|
||||
readonly findings: MetadataFinding[] = [];
|
||||
readonly warnings: string[] = [];
|
||||
limited = false;
|
||||
readonly #keys = new Set<string>();
|
||||
readonly #limits: Readonly<PrivacyLimits>;
|
||||
#textChars = 0;
|
||||
|
||||
constructor(limits: Readonly<PrivacyLimits>) {
|
||||
this.#limits = limits;
|
||||
}
|
||||
|
||||
add(
|
||||
finding: Omit<MetadataFinding, "id" | "value"> & {
|
||||
id?: string;
|
||||
value: unknown;
|
||||
},
|
||||
): void {
|
||||
if (this.limited) return;
|
||||
const source = boundedLabel(
|
||||
finding.source,
|
||||
this.#limits.maxFindingLabelChars,
|
||||
);
|
||||
const label = boundedLabel(
|
||||
finding.label,
|
||||
this.#limits.maxFindingLabelChars,
|
||||
);
|
||||
let value = displayValue(finding.value);
|
||||
if (value.length > this.#limits.maxFindingValueChars) {
|
||||
value = `${value.slice(0, this.#limits.maxFindingValueChars)}…`;
|
||||
this.warn(
|
||||
`${source} ${label} was truncated to ${this.#limits.maxFindingValueChars} characters.`,
|
||||
);
|
||||
}
|
||||
const key = `${source}\u0000${label}\u0000${value}\u0000${finding.offset ?? ""}`;
|
||||
if (this.#keys.has(key)) return;
|
||||
const addedText = source.length + label.length + value.length;
|
||||
if (
|
||||
this.findings.length >= this.#limits.maxFindings ||
|
||||
this.#textChars + addedText > this.#limits.maxFindingTextChars
|
||||
) {
|
||||
this.limited = true;
|
||||
this.warn(
|
||||
`Metadata findings were truncated at ${this.#limits.maxFindings} entries or ${this.#limits.maxFindingTextChars} text characters.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.#keys.add(key);
|
||||
this.#textChars += addedText;
|
||||
this.findings.push({
|
||||
...finding,
|
||||
source,
|
||||
label,
|
||||
id:
|
||||
finding.id ??
|
||||
`${source.toLowerCase().replace(/[^a-z0-9]+/gu, "-")}-${this.findings.length + 1}`,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
warn(message: string): void {
|
||||
if (!this.warnings.includes(message)) this.warnings.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedLabel(value: string, maximum: number): string {
|
||||
const sanitized = sanitizeText(value);
|
||||
return sanitized.length <= maximum
|
||||
? sanitized
|
||||
: `${sanitized.slice(0, Math.max(0, maximum - 1))}…`;
|
||||
}
|
||||
|
||||
export function categoryForName(name: string): {
|
||||
category: FindingCategory;
|
||||
risk: FindingRisk;
|
||||
} {
|
||||
const normalized = name.toLowerCase();
|
||||
if (
|
||||
/(?:gps|latitude|longitude|location|city|country|province|state|sublocation|altitude)/u.test(
|
||||
normalized,
|
||||
)
|
||||
)
|
||||
return { category: "location", risk: "sensitive" };
|
||||
if (
|
||||
/(?:artist|author|creator|byline|credit|owner|person|copyright|rights|email)/u.test(
|
||||
normalized,
|
||||
)
|
||||
)
|
||||
return { category: "identity", risk: "sensitive" };
|
||||
if (/(?:date|time|created|modified|timestamp)/u.test(normalized))
|
||||
return { category: "timestamp", risk: "sensitive" };
|
||||
if (
|
||||
/(?:serial|camera|device|make|model|lens|makernote|ownername)/u.test(
|
||||
normalized,
|
||||
)
|
||||
)
|
||||
return { category: "device", risk: "sensitive" };
|
||||
if (
|
||||
/(?:software|history|creatortool|processing|hostcomputer)/u.test(normalized)
|
||||
)
|
||||
return { category: "software", risk: "sensitive" };
|
||||
if (
|
||||
/(?:documentid|instanceid|uniqueid|originaldocumentid|assetid)/u.test(
|
||||
normalized,
|
||||
)
|
||||
)
|
||||
return { category: "document-id", risk: "sensitive" };
|
||||
if (
|
||||
/(?:comment|description|caption|headline|keyword|subject|title)/u.test(
|
||||
normalized,
|
||||
)
|
||||
)
|
||||
return { category: "comment", risk: "context" };
|
||||
if (/(?:thumbnail|preview)/u.test(normalized))
|
||||
return { category: "thumbnail", risk: "sensitive" };
|
||||
if (/(?:icc|profile|colorspace|colourspace)/u.test(normalized))
|
||||
return { category: "colour-profile", risk: "technical" };
|
||||
if (/(?:c2pa|jumbf|provenance|manifest|signature)/u.test(normalized))
|
||||
return { category: "provenance", risk: "context" };
|
||||
if (
|
||||
/(?:orientation|resolution|jfif|density|dimensions|pixel)/u.test(normalized)
|
||||
)
|
||||
return { category: "technical", risk: "technical" };
|
||||
return { category: "unknown", risk: "context" };
|
||||
}
|
||||
|
||||
function displayValue(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return sanitizeText(value);
|
||||
if (typeof value === "number" || typeof value === "bigint")
|
||||
return String(value);
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
if (value instanceof Uint8Array) return `${value.byteLength} bytes`;
|
||||
if (Array.isArray(value)) return value.map(displayValue).join(", ");
|
||||
try {
|
||||
return sanitizeText(JSON.stringify(value));
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeText(value: string): string {
|
||||
return Array.from(value, (character) => {
|
||||
const point = character.codePointAt(0) ?? 0;
|
||||
if (point === 0) return "�";
|
||||
if ((point >= 1 && point <= 8) || point === 11 || point === 12) return " ";
|
||||
if ((point >= 14 && point <= 31) || point === 127) return " ";
|
||||
return character;
|
||||
})
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./archive";
|
||||
export * from "./detect";
|
||||
export * from "./limits";
|
||||
export * from "./model";
|
||||
export * from "./sanitize";
|
||||
export * from "./scan-client";
|
||||
export * from "./scanner";
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Unzlib } from "fflate";
|
||||
|
||||
import { PrivacyLimitError } from "./limits";
|
||||
|
||||
export function inflateZlibBounded(
|
||||
input: Uint8Array,
|
||||
maximumBytes: number,
|
||||
): Uint8Array {
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
const inflater = new Unzlib((chunk) => {
|
||||
total += chunk.byteLength;
|
||||
if (total > maximumBytes)
|
||||
throw new PrivacyLimitError(
|
||||
"Inflated metadata size",
|
||||
total,
|
||||
maximumBytes,
|
||||
);
|
||||
chunks.push(chunk.slice());
|
||||
});
|
||||
inflater.push(input, true);
|
||||
const output = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
output.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { categoryForName, FindingCollector } from "./findings";
|
||||
|
||||
const DATASET_NAMES: Readonly<Record<number, string>> = Object.freeze({
|
||||
5: "Object Name",
|
||||
25: "Keywords",
|
||||
55: "Date Created",
|
||||
60: "Time Created",
|
||||
80: "By-line",
|
||||
85: "By-line Title",
|
||||
90: "City",
|
||||
92: "Sublocation",
|
||||
95: "Province/State",
|
||||
100: "Country Code",
|
||||
101: "Country",
|
||||
105: "Headline",
|
||||
110: "Credit",
|
||||
115: "Source",
|
||||
116: "Copyright Notice",
|
||||
120: "Caption/Abstract",
|
||||
122: "Writer/Editor",
|
||||
});
|
||||
|
||||
export function scanIptc(
|
||||
bytes: Uint8Array,
|
||||
source: string,
|
||||
baseOffset: number,
|
||||
collector: FindingCollector,
|
||||
): void {
|
||||
let offset = 0;
|
||||
let datasets = 0;
|
||||
while (offset + 5 <= bytes.byteLength) {
|
||||
if (bytes[offset] !== 0x1c) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
const record = bytes[offset + 1] ?? 0;
|
||||
const dataset = bytes[offset + 2] ?? 0;
|
||||
let length = ((bytes[offset + 3] ?? 0) << 8) | (bytes[offset + 4] ?? 0);
|
||||
let header = 5;
|
||||
if ((length & 0x8000) !== 0) {
|
||||
const lengthBytes = length & 0x7fff;
|
||||
if (
|
||||
lengthBytes < 1 ||
|
||||
lengthBytes > 4 ||
|
||||
offset + 5 + lengthBytes > bytes.byteLength
|
||||
) {
|
||||
collector.warn(`${source} contains an invalid extended IPTC length.`);
|
||||
return;
|
||||
}
|
||||
length = 0;
|
||||
for (let index = 0; index < lengthBytes; index += 1)
|
||||
length = length * 256 + (bytes[offset + 5 + index] ?? 0);
|
||||
header += lengthBytes;
|
||||
}
|
||||
if (length > 1024 * 1024 || offset + header + length > bytes.byteLength) {
|
||||
collector.warn(
|
||||
`${source} contains a truncated or oversized IPTC dataset.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const valueBytes = bytes.subarray(
|
||||
offset + header,
|
||||
offset + header + length,
|
||||
);
|
||||
const label =
|
||||
record === 2
|
||||
? (DATASET_NAMES[dataset] ?? `IPTC 2:${dataset}`)
|
||||
: `IPTC ${record}:${dataset}`;
|
||||
const classification = categoryForName(label);
|
||||
collector.add({
|
||||
...classification,
|
||||
source,
|
||||
label,
|
||||
value: new TextDecoder("utf-8", { fatal: false }).decode(valueBytes),
|
||||
offset: baseOffset + offset,
|
||||
length: header + length,
|
||||
});
|
||||
datasets += 1;
|
||||
if (datasets > 2048) {
|
||||
collector.warn(`${source} IPTC dataset count exceeded 2048.`);
|
||||
return;
|
||||
}
|
||||
offset += header + length;
|
||||
}
|
||||
}
|
||||
|
||||
export function findPhotoshopIptc(
|
||||
bytes: Uint8Array,
|
||||
): Array<{ bytes: Uint8Array; relativeOffset: number }> {
|
||||
const result: Array<{ bytes: Uint8Array; relativeOffset: number }> = [];
|
||||
let offset = bytes.byteLength >= 14 ? 14 : 0;
|
||||
while (offset + 12 <= bytes.byteLength) {
|
||||
if (
|
||||
bytes[offset] !== 0x38 ||
|
||||
bytes[offset + 1] !== 0x42 ||
|
||||
bytes[offset + 2] !== 0x49 ||
|
||||
bytes[offset + 3] !== 0x4d
|
||||
) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
const resource = ((bytes[offset + 4] ?? 0) << 8) | (bytes[offset + 5] ?? 0);
|
||||
const nameLength = bytes[offset + 6] ?? 0;
|
||||
const paddedName = 1 + nameLength + ((1 + nameLength) % 2);
|
||||
const sizeOffset = offset + 6 + paddedName;
|
||||
if (sizeOffset + 4 > bytes.byteLength) break;
|
||||
const length = readU32be(bytes, sizeOffset);
|
||||
const dataOffset = sizeOffset + 4;
|
||||
if (length > 8 * 1024 * 1024 || dataOffset + length > bytes.byteLength)
|
||||
break;
|
||||
if (resource === 0x0404)
|
||||
result.push({
|
||||
bytes: bytes.subarray(dataOffset, dataOffset + length),
|
||||
relativeOffset: dataOffset,
|
||||
});
|
||||
offset = dataOffset + length + (length % 2);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function readU32be(bytes: Uint8Array, offset: number): number {
|
||||
return (
|
||||
(bytes[offset] ?? 0) * 0x1000000 +
|
||||
((bytes[offset + 1] ?? 0) << 16) +
|
||||
((bytes[offset + 2] ?? 0) << 8) +
|
||||
(bytes[offset + 3] ?? 0)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { FindingCollector } from "./findings";
|
||||
import { findPhotoshopIptc, scanIptc } from "./iptc";
|
||||
import type { MetadataBlock, PrivacyLimits } from "./model";
|
||||
import { scanTiff } from "./tiff";
|
||||
import { decodeMetadataText, scanXmp } from "./xmp";
|
||||
|
||||
export interface FormatScan {
|
||||
width?: number;
|
||||
height?: number;
|
||||
orientation?: number;
|
||||
animated: boolean;
|
||||
multiImage: boolean;
|
||||
complete: boolean;
|
||||
blocks: MetadataBlock[];
|
||||
}
|
||||
|
||||
const XMP_HEADER = "http://ns.adobe.com/xap/1.0/\u0000";
|
||||
const XMP_EXTENDED_HEADER = "http://ns.adobe.com/xmp/extension/\u0000";
|
||||
|
||||
export function scanJpeg(
|
||||
bytes: Uint8Array,
|
||||
collector: FindingCollector,
|
||||
limits: Readonly<PrivacyLimits>,
|
||||
): FormatScan {
|
||||
const blocks: MetadataBlock[] = [];
|
||||
if (bytes.byteLength < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
|
||||
collector.warn("JPEG start-of-image marker is missing.");
|
||||
return { animated: false, multiImage: false, complete: false, blocks };
|
||||
}
|
||||
let offset = 2;
|
||||
let complete = true;
|
||||
let width: number | undefined;
|
||||
let height: number | undefined;
|
||||
let orientation: number | undefined;
|
||||
let multiImage = false;
|
||||
let segments = 0;
|
||||
let enteredScan = false;
|
||||
while (offset < bytes.byteLength) {
|
||||
if (bytes[offset] !== 0xff) {
|
||||
collector.warn(`JPEG marker sync was lost at byte ${offset}.`);
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
while (offset < bytes.byteLength && bytes[offset] === 0xff) offset += 1;
|
||||
if (offset >= bytes.byteLength) {
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
const marker = bytes[offset] ?? 0;
|
||||
offset += 1;
|
||||
if (marker === 0xd9) break;
|
||||
if (marker === 0xda) {
|
||||
enteredScan = true;
|
||||
break;
|
||||
}
|
||||
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue;
|
||||
if (offset + 2 > bytes.byteLength) {
|
||||
collector.warn("JPEG segment length is truncated.");
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
const segmentLength =
|
||||
((bytes[offset] ?? 0) << 8) | (bytes[offset + 1] ?? 0);
|
||||
if (segmentLength < 2 || offset + segmentLength > bytes.byteLength) {
|
||||
collector.warn(
|
||||
`JPEG marker 0x${marker.toString(16)} has an invalid length.`,
|
||||
);
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
segments += 1;
|
||||
if (segments > limits.maxMetadataBlocks) {
|
||||
collector.warn(
|
||||
`JPEG segment count exceeded ${limits.maxMetadataBlocks}.`,
|
||||
);
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
const dataOffset = offset + 2;
|
||||
const dataLength = segmentLength - 2;
|
||||
const data = bytes.subarray(dataOffset, dataOffset + dataLength);
|
||||
if (isStartOfFrame(marker) && data.byteLength >= 5) {
|
||||
height = ((data[1] ?? 0) << 8) | (data[2] ?? 0);
|
||||
width = ((data[3] ?? 0) << 8) | (data[4] ?? 0);
|
||||
}
|
||||
if (marker >= 0xe0 && marker <= 0xef) {
|
||||
if (dataLength > limits.maxMetadataBlockBytes) {
|
||||
collector.warn(
|
||||
`JPEG APP${marker - 0xe0} metadata exceeds the block limit.`,
|
||||
);
|
||||
complete = false;
|
||||
} else {
|
||||
blocks.push({
|
||||
kind: `APP${marker - 0xe0}`,
|
||||
offset: dataOffset,
|
||||
length: dataLength,
|
||||
});
|
||||
const result = scanJpegApplication(
|
||||
marker,
|
||||
data,
|
||||
dataOffset,
|
||||
collector,
|
||||
limits,
|
||||
);
|
||||
if (result.orientation !== undefined) orientation = result.orientation;
|
||||
multiImage ||= result.multiImage;
|
||||
complete &&= result.complete;
|
||||
}
|
||||
} else if (marker === 0xfe) {
|
||||
blocks.push({ kind: "COM", offset: dataOffset, length: dataLength });
|
||||
collector.add({
|
||||
category: "comment",
|
||||
risk: "context",
|
||||
source: "JPEG COM",
|
||||
label: "Comment",
|
||||
value: decodeMetadataText(data),
|
||||
offset: dataOffset,
|
||||
length: dataLength,
|
||||
});
|
||||
}
|
||||
offset += segmentLength;
|
||||
}
|
||||
const eoi = enteredScan
|
||||
? findJpegEnd(bytes, offset)
|
||||
: bytes.lastIndexOf(0xd9);
|
||||
if (eoi < 1 || bytes[eoi - 1] !== 0xff) {
|
||||
collector.warn("JPEG end-of-image marker was not found.");
|
||||
complete = false;
|
||||
} else if (eoi + 1 < bytes.byteLength) {
|
||||
const trailing = bytes.byteLength - eoi - 1;
|
||||
blocks.push({ kind: "trailing-data", offset: eoi + 1, length: trailing });
|
||||
collector.add({
|
||||
category: "unknown",
|
||||
risk: "sensitive",
|
||||
source: "JPEG",
|
||||
label: "Trailing data",
|
||||
value: `${trailing} bytes after end-of-image`,
|
||||
offset: eoi + 1,
|
||||
length: trailing,
|
||||
});
|
||||
}
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
orientation,
|
||||
animated: false,
|
||||
multiImage,
|
||||
complete,
|
||||
blocks,
|
||||
};
|
||||
}
|
||||
|
||||
function scanJpegApplication(
|
||||
marker: number,
|
||||
data: Uint8Array,
|
||||
offset: number,
|
||||
collector: FindingCollector,
|
||||
limits: Readonly<PrivacyLimits>,
|
||||
): { orientation?: number; multiImage: boolean; complete: boolean } {
|
||||
if (marker === 0xe0 && ascii(data, 0, 5) === "JFIF\u0000") {
|
||||
const thumbnailWidth = data[12] ?? 0;
|
||||
const thumbnailHeight = data[13] ?? 0;
|
||||
collector.add({
|
||||
category: thumbnailWidth && thumbnailHeight ? "thumbnail" : "technical",
|
||||
risk: thumbnailWidth && thumbnailHeight ? "sensitive" : "technical",
|
||||
source: "JPEG APP0",
|
||||
label: "JFIF header",
|
||||
value: `version ${data[5] ?? 0}.${String(data[6] ?? 0).padStart(2, "0")}; density ${readU16be(data, 8)}×${readU16be(data, 10)}; thumbnail ${thumbnailWidth}×${thumbnailHeight}`,
|
||||
offset,
|
||||
length: data.byteLength,
|
||||
});
|
||||
return { multiImage: false, complete: true };
|
||||
}
|
||||
if (marker === 0xe1 && ascii(data, 0, 6) === "Exif\u0000\u0000") {
|
||||
const result = scanTiff(
|
||||
data.subarray(6),
|
||||
"JPEG EXIF",
|
||||
offset + 6,
|
||||
collector,
|
||||
limits,
|
||||
);
|
||||
return {
|
||||
orientation: result.orientation,
|
||||
multiImage: false,
|
||||
complete: result.complete,
|
||||
};
|
||||
}
|
||||
if (marker === 0xe1 && ascii(data, 0, XMP_HEADER.length) === XMP_HEADER) {
|
||||
scanXmp(data.subarray(XMP_HEADER.length), "JPEG XMP", offset, collector);
|
||||
return { multiImage: false, complete: true };
|
||||
}
|
||||
if (
|
||||
marker === 0xe1 &&
|
||||
ascii(data, 0, XMP_EXTENDED_HEADER.length) === XMP_EXTENDED_HEADER
|
||||
) {
|
||||
scanXmp(
|
||||
data.subarray(XMP_EXTENDED_HEADER.length),
|
||||
"JPEG extended XMP",
|
||||
offset,
|
||||
collector,
|
||||
);
|
||||
return { multiImage: false, complete: true };
|
||||
}
|
||||
if (marker === 0xed && ascii(data, 0, 13) === "Photoshop 3.0") {
|
||||
const resources = findPhotoshopIptc(data);
|
||||
for (const resource of resources)
|
||||
scanIptc(
|
||||
resource.bytes,
|
||||
"JPEG IPTC",
|
||||
offset + resource.relativeOffset,
|
||||
collector,
|
||||
);
|
||||
if (resources.length === 0)
|
||||
collector.add({
|
||||
category: "unknown",
|
||||
risk: "context",
|
||||
source: "JPEG APP13",
|
||||
label: "Photoshop image resources",
|
||||
value: `${data.byteLength} bytes`,
|
||||
offset,
|
||||
length: data.byteLength,
|
||||
});
|
||||
return { multiImage: false, complete: true };
|
||||
}
|
||||
if (marker === 0xe2 && ascii(data, 0, 12) === "ICC_PROFILE\u0000") {
|
||||
collector.add({
|
||||
category: "colour-profile",
|
||||
risk: "technical",
|
||||
source: "JPEG APP2",
|
||||
label: "ICC profile chunk",
|
||||
value: `chunk ${data[12] ?? 0} of ${data[13] ?? 0}; ${Math.max(0, data.byteLength - 14)} bytes`,
|
||||
offset,
|
||||
length: data.byteLength,
|
||||
});
|
||||
return { multiImage: false, complete: true };
|
||||
}
|
||||
if (marker === 0xe2 && ascii(data, 0, 4) === "MPF\u0000") {
|
||||
collector.add({
|
||||
category: "thumbnail",
|
||||
risk: "sensitive",
|
||||
source: "JPEG APP2",
|
||||
label: "Multi-Picture Format",
|
||||
value: `${data.byteLength} bytes; additional images may be embedded`,
|
||||
offset,
|
||||
length: data.byteLength,
|
||||
});
|
||||
return { multiImage: true, complete: true };
|
||||
}
|
||||
if (marker === 0xeb && containsAscii(data, ["jumb", "c2pa"])) {
|
||||
collector.add({
|
||||
category: "provenance",
|
||||
risk: "context",
|
||||
source: "JPEG APP11",
|
||||
label: "JUMBF / C2PA provenance data",
|
||||
value: `${data.byteLength} bytes; re-encoding will remove or invalidate this provenance`,
|
||||
offset,
|
||||
length: data.byteLength,
|
||||
});
|
||||
return { multiImage: false, complete: true };
|
||||
}
|
||||
if (marker === 0xee && ascii(data, 0, 5) === "Adobe") {
|
||||
collector.add({
|
||||
category: "technical",
|
||||
risk: "technical",
|
||||
source: "JPEG APP14",
|
||||
label: "Adobe colour transform header",
|
||||
value: `${data.byteLength} bytes`,
|
||||
offset,
|
||||
length: data.byteLength,
|
||||
});
|
||||
return { multiImage: false, complete: true };
|
||||
}
|
||||
collector.add({
|
||||
category: containsAscii(data, ["c2pa", "jumb"]) ? "provenance" : "unknown",
|
||||
risk: "context",
|
||||
source: `JPEG APP${marker - 0xe0}`,
|
||||
label: "Unrecognized application metadata",
|
||||
value: `${data.byteLength} bytes`,
|
||||
offset,
|
||||
length: data.byteLength,
|
||||
});
|
||||
return { multiImage: false, complete: true };
|
||||
}
|
||||
|
||||
function isStartOfFrame(marker: number): boolean {
|
||||
return (
|
||||
marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)
|
||||
);
|
||||
}
|
||||
|
||||
function findJpegEnd(bytes: Uint8Array, offset: number): number {
|
||||
for (let index = offset; index + 1 < bytes.byteLength; index += 1) {
|
||||
if (bytes[index] !== 0xff) continue;
|
||||
const marker = bytes[index + 1] ?? 0;
|
||||
if (marker === 0x00 || (marker >= 0xd0 && marker <= 0xd7)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (marker === 0xd9) return index + 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function ascii(bytes: Uint8Array, offset: number, length: number): string {
|
||||
if (offset < 0 || offset + length > bytes.byteLength) return "";
|
||||
let result = "";
|
||||
for (let index = 0; index < length; index += 1)
|
||||
result += String.fromCharCode(bytes[offset + index] ?? 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
function containsAscii(bytes: Uint8Array, needles: readonly string[]): boolean {
|
||||
const sample = ascii(
|
||||
bytes.subarray(0, Math.min(bytes.byteLength, 4096)),
|
||||
0,
|
||||
Math.min(bytes.byteLength, 4096),
|
||||
).toLowerCase();
|
||||
return needles.some((needle) => sample.includes(needle));
|
||||
}
|
||||
|
||||
function readU16be(bytes: Uint8Array, offset: number): number {
|
||||
return ((bytes[offset] ?? 0) << 8) | (bytes[offset + 1] ?? 0);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { PrivacyLimits } from "./model";
|
||||
|
||||
export const DEFAULT_PRIVACY_LIMITS: Readonly<PrivacyLimits> = Object.freeze({
|
||||
maxFiles: 100,
|
||||
maxFileBytes: 128 * 1024 * 1024,
|
||||
maxBatchBytes: 512 * 1024 * 1024,
|
||||
maxMetadataBlocks: 4096,
|
||||
maxMetadataBlockBytes: 8 * 1024 * 1024,
|
||||
maxInflatedMetadataBytes: 4 * 1024 * 1024,
|
||||
maxFindingLabelChars: 512,
|
||||
maxFindingValueChars: 16_384,
|
||||
maxFindingTextChars: 256 * 1024,
|
||||
maxFindings: 4096,
|
||||
maxTiffEntries: 4096,
|
||||
maxTiffDepth: 16,
|
||||
maxPixels: 40_000_000,
|
||||
maxEdge: 32_768,
|
||||
maxZipBytes: 256 * 1024 * 1024,
|
||||
});
|
||||
|
||||
export class PrivacyLimitError extends RangeError {
|
||||
readonly actual: number;
|
||||
readonly limit: number;
|
||||
|
||||
constructor(label: string, actual: number, limit: number) {
|
||||
super(`${label} is ${actual}; the limit is ${limit}`);
|
||||
this.name = "PrivacyLimitError";
|
||||
this.actual = actual;
|
||||
this.limit = limit;
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLimit(
|
||||
actual: number,
|
||||
limit: number,
|
||||
label: string,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(actual) || actual < 0)
|
||||
throw new TypeError(`${label} is not a valid non-negative integer`);
|
||||
if (!Number.isSafeInteger(limit) || limit <= 0)
|
||||
throw new TypeError(`${label} limit is not a positive safe integer`);
|
||||
if (actual > limit) throw new PrivacyLimitError(label, actual, limit);
|
||||
}
|
||||
|
||||
export function resolveLimits(
|
||||
overrides: Partial<PrivacyLimits> = {},
|
||||
): Readonly<PrivacyLimits> {
|
||||
const merged = { ...DEFAULT_PRIVACY_LIMITS, ...overrides };
|
||||
for (const [name, value] of Object.entries(merged)) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0)
|
||||
throw new TypeError(`${name} must be a positive safe integer`);
|
||||
}
|
||||
return Object.freeze(merged);
|
||||
}
|
||||
|
||||
export function assertBatchFiles(
|
||||
files: readonly { size: number }[],
|
||||
limits: Readonly<PrivacyLimits> = DEFAULT_PRIVACY_LIMITS,
|
||||
): void {
|
||||
assertLimit(files.length, limits.maxFiles, "File count");
|
||||
let total = 0;
|
||||
for (const file of files) {
|
||||
assertLimit(file.size, limits.maxFileBytes, "File size");
|
||||
total += file.size;
|
||||
if (!Number.isSafeInteger(total))
|
||||
throw new PrivacyLimitError(
|
||||
"Batch byte size",
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
limits.maxBatchBytes,
|
||||
);
|
||||
assertLimit(total, limits.maxBatchBytes, "Batch byte size");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
export type DetectedKind =
|
||||
| "jpeg"
|
||||
| "png"
|
||||
| "webp"
|
||||
| "gif"
|
||||
| "tiff"
|
||||
| "heic"
|
||||
| "avif"
|
||||
| "jxl"
|
||||
| "pdf"
|
||||
| "zip"
|
||||
| "ole"
|
||||
| "unknown";
|
||||
|
||||
export type FindingCategory =
|
||||
| "location"
|
||||
| "identity"
|
||||
| "timestamp"
|
||||
| "device"
|
||||
| "software"
|
||||
| "document-id"
|
||||
| "comment"
|
||||
| "thumbnail"
|
||||
| "colour-profile"
|
||||
| "provenance"
|
||||
| "technical"
|
||||
| "unknown";
|
||||
|
||||
export type FindingRisk = "sensitive" | "context" | "technical";
|
||||
|
||||
export interface MetadataFinding {
|
||||
id: string;
|
||||
category: FindingCategory;
|
||||
risk: FindingRisk;
|
||||
source: string;
|
||||
label: string;
|
||||
value: string;
|
||||
offset?: number;
|
||||
length?: number;
|
||||
}
|
||||
|
||||
export interface MetadataBlock {
|
||||
kind: string;
|
||||
offset: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
export interface ParserCoverage {
|
||||
projectScanner: "complete" | "partial" | "unsupported";
|
||||
secondaryScanner: "complete" | "partial" | "unsupported" | "failed";
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface InventoryIdentity {
|
||||
claimedType: string;
|
||||
extension: string;
|
||||
detectedKind: DetectedKind;
|
||||
detectedType: string;
|
||||
typeMatch: "match" | "mismatch" | "unclaimed" | "unknown";
|
||||
}
|
||||
|
||||
export interface ImageScanResult {
|
||||
id: string;
|
||||
name: string;
|
||||
safeName: string;
|
||||
size: number;
|
||||
lastModified: number;
|
||||
sha256: string;
|
||||
identity: InventoryIdentity;
|
||||
width?: number;
|
||||
height?: number;
|
||||
orientation?: number;
|
||||
animated: boolean;
|
||||
multiImage: boolean;
|
||||
deepSupported: boolean;
|
||||
cleanable: boolean;
|
||||
findings: MetadataFinding[];
|
||||
blocks: MetadataBlock[];
|
||||
warnings: string[];
|
||||
coverage: ParserCoverage;
|
||||
}
|
||||
|
||||
export interface ScanInput {
|
||||
id: string;
|
||||
name: string;
|
||||
claimedType: string;
|
||||
size: number;
|
||||
lastModified: number;
|
||||
bytes: ArrayBuffer;
|
||||
}
|
||||
|
||||
export type SanitizationStatus = "verified" | "warning" | "failed";
|
||||
|
||||
export interface PixelComparison {
|
||||
method: "oriented-256px-sample";
|
||||
sourceDigest: string;
|
||||
outputDigest: string;
|
||||
identical: boolean;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface SanitizationReport {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
outputName: string;
|
||||
outputType: string;
|
||||
sourceSha256: string;
|
||||
outputSha256: string;
|
||||
sourceBytes: number;
|
||||
outputBytes: number;
|
||||
sourceDimensions?: { width: number; height: number };
|
||||
outputDimensions?: { width: number; height: number };
|
||||
orientationNormalized: boolean;
|
||||
removed: MetadataFinding[];
|
||||
preserved: MetadataFinding[];
|
||||
generated: MetadataFinding[];
|
||||
unsupported: string[];
|
||||
incomplete: string[];
|
||||
outputScan: ImageScanResult;
|
||||
pixelComparison: PixelComparison;
|
||||
status: SanitizationStatus;
|
||||
summary: string;
|
||||
disclaimer: string;
|
||||
}
|
||||
|
||||
export interface SanitizedAsset {
|
||||
blob: Blob;
|
||||
report: SanitizationReport;
|
||||
}
|
||||
|
||||
export interface BatchReport {
|
||||
schemaVersion: 1;
|
||||
generatedAt: string;
|
||||
application: { name: "Privacy Tools"; version: "0.1.0" };
|
||||
files: ImageScanResult[];
|
||||
sanitizations: SanitizationReport[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface PrivacyLimits {
|
||||
maxFiles: number;
|
||||
maxFileBytes: number;
|
||||
maxBatchBytes: number;
|
||||
maxMetadataBlocks: number;
|
||||
maxMetadataBlockBytes: number;
|
||||
maxInflatedMetadataBytes: number;
|
||||
maxFindingLabelChars: number;
|
||||
maxFindingValueChars: number;
|
||||
maxFindingTextChars: number;
|
||||
maxFindings: number;
|
||||
maxTiffEntries: number;
|
||||
maxTiffDepth: number;
|
||||
maxPixels: number;
|
||||
maxEdge: number;
|
||||
maxZipBytes: number;
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { crc32 } from "@add-ideas/toolbox-helpers";
|
||||
|
||||
import { FindingCollector } from "./findings";
|
||||
import { inflateZlibBounded } from "./inflate";
|
||||
import type { MetadataBlock, PrivacyLimits } from "./model";
|
||||
import type { FormatScan } from "./jpeg";
|
||||
import { scanTiff } from "./tiff";
|
||||
import { decodeMetadataText, scanXmp } from "./xmp";
|
||||
|
||||
export function scanPng(
|
||||
bytes: Uint8Array,
|
||||
collector: FindingCollector,
|
||||
limits: Readonly<PrivacyLimits>,
|
||||
): FormatScan {
|
||||
const blocks: MetadataBlock[] = [];
|
||||
let offset = 8;
|
||||
let width: number | undefined;
|
||||
let height: number | undefined;
|
||||
let orientation: number | undefined;
|
||||
let animated = false;
|
||||
let complete = true;
|
||||
let chunks = 0;
|
||||
let sawEnd = false;
|
||||
let sawHeader = false;
|
||||
let sawImageData = false;
|
||||
while (offset + 12 <= bytes.byteLength) {
|
||||
const length = readU32be(bytes, offset);
|
||||
const type = ascii(bytes, offset + 4, 4);
|
||||
const dataOffset = offset + 8;
|
||||
const end = dataOffset + length;
|
||||
if (!/^[A-Za-z]{4}$/u.test(type) || end + 4 > bytes.byteLength) {
|
||||
collector.warn(`PNG chunk at byte ${offset} is malformed or truncated.`);
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
chunks += 1;
|
||||
if (chunks > limits.maxMetadataBlocks) {
|
||||
collector.warn(`PNG chunk count exceeded ${limits.maxMetadataBlocks}.`);
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
const data = bytes.subarray(dataOffset, end);
|
||||
const expectedCrc = readU32be(bytes, end);
|
||||
const crcInput = bytes.subarray(offset + 4, end);
|
||||
if (crc32(crcInput, limits.maxFileBytes) !== expectedCrc) {
|
||||
collector.warn(`PNG ${type} chunk at byte ${offset} has an invalid CRC.`);
|
||||
complete = false;
|
||||
}
|
||||
if (chunks === 1 && type !== "IHDR") {
|
||||
collector.warn("PNG IHDR is not the first chunk.");
|
||||
complete = false;
|
||||
}
|
||||
if (type === "IHDR") {
|
||||
if (sawHeader || chunks !== 1 || length !== 13) {
|
||||
collector.warn("PNG IHDR is duplicated, misplaced, or malformed.");
|
||||
complete = false;
|
||||
} else {
|
||||
width = readU32be(data, 0);
|
||||
height = readU32be(data, 4);
|
||||
}
|
||||
sawHeader = true;
|
||||
} else if (type === "IDAT") {
|
||||
sawImageData = true;
|
||||
} else if (type === "IEND") {
|
||||
if (length !== 0) {
|
||||
collector.warn("PNG IEND chunk is not empty.");
|
||||
complete = false;
|
||||
}
|
||||
sawEnd = true;
|
||||
offset = end + 4;
|
||||
break;
|
||||
} else if (type === "acTL" || type === "fcTL" || type === "fdAT") {
|
||||
animated = true;
|
||||
blocks.push({ kind: type, offset: dataOffset, length });
|
||||
collector.add({
|
||||
category: "technical",
|
||||
risk: "context",
|
||||
source: "PNG",
|
||||
label: "APNG animation",
|
||||
value: `${type} chunk (${length} bytes)`,
|
||||
offset: dataOffset,
|
||||
length,
|
||||
});
|
||||
} else if (isMetadataChunk(type)) {
|
||||
if (length > limits.maxMetadataBlockBytes) {
|
||||
collector.warn(`PNG ${type} metadata exceeds the block limit.`);
|
||||
complete = false;
|
||||
} else {
|
||||
blocks.push({ kind: type, offset: dataOffset, length });
|
||||
const result = scanPngMetadata(
|
||||
type,
|
||||
data,
|
||||
dataOffset,
|
||||
collector,
|
||||
limits,
|
||||
);
|
||||
if (result.orientation !== undefined) orientation = result.orientation;
|
||||
complete &&= result.complete;
|
||||
}
|
||||
} else if (type[1] === type[1]?.toLowerCase()) {
|
||||
blocks.push({ kind: type, offset: dataOffset, length });
|
||||
collector.add({
|
||||
category: type === "caBX" ? "provenance" : "unknown",
|
||||
risk: "context",
|
||||
source: "PNG",
|
||||
label:
|
||||
type === "caBX" ? "C2PA provenance chunk" : `Private chunk ${type}`,
|
||||
value: `${length} bytes`,
|
||||
offset: dataOffset,
|
||||
length,
|
||||
});
|
||||
}
|
||||
offset = end + 4;
|
||||
}
|
||||
if (!sawEnd) {
|
||||
collector.warn("PNG IEND chunk was not found.");
|
||||
complete = false;
|
||||
} else if (offset < bytes.byteLength) {
|
||||
const trailing = bytes.byteLength - offset;
|
||||
blocks.push({ kind: "trailing-data", offset, length: trailing });
|
||||
collector.add({
|
||||
category: "unknown",
|
||||
risk: "sensitive",
|
||||
source: "PNG",
|
||||
label: "Trailing data",
|
||||
value: `${trailing} bytes after IEND`,
|
||||
offset,
|
||||
length: trailing,
|
||||
});
|
||||
}
|
||||
if (!sawHeader) {
|
||||
collector.warn("PNG IHDR chunk was not found.");
|
||||
complete = false;
|
||||
}
|
||||
if (!sawImageData) {
|
||||
collector.warn("PNG IDAT image data was not found.");
|
||||
complete = false;
|
||||
}
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
orientation,
|
||||
animated,
|
||||
multiImage: false,
|
||||
complete,
|
||||
blocks,
|
||||
};
|
||||
}
|
||||
|
||||
function scanPngMetadata(
|
||||
type: string,
|
||||
data: Uint8Array,
|
||||
offset: number,
|
||||
collector: FindingCollector,
|
||||
limits: Readonly<PrivacyLimits>,
|
||||
): { orientation?: number; complete: boolean } {
|
||||
try {
|
||||
if (type === "eXIf") {
|
||||
const result = scanTiff(data, "PNG eXIf", offset, collector, limits);
|
||||
return { orientation: result.orientation, complete: result.complete };
|
||||
}
|
||||
if (type === "tEXt") {
|
||||
const separator = data.indexOf(0);
|
||||
const keyword = decodeLatin(
|
||||
data.subarray(0, separator < 0 ? data.length : separator),
|
||||
);
|
||||
const value =
|
||||
separator < 0 ? "" : decodeLatin(data.subarray(separator + 1));
|
||||
addPngText(keyword, value, type, offset, data.byteLength, collector);
|
||||
} else if (type === "zTXt") {
|
||||
const separator = data.indexOf(0);
|
||||
if (separator < 0 || data[separator + 1] !== 0)
|
||||
throw new SyntaxError("invalid zTXt header");
|
||||
const keyword = decodeLatin(data.subarray(0, separator));
|
||||
const inflated = inflateZlibBounded(
|
||||
data.subarray(separator + 2),
|
||||
limits.maxInflatedMetadataBytes,
|
||||
);
|
||||
addPngText(
|
||||
keyword,
|
||||
decodeLatin(inflated),
|
||||
type,
|
||||
offset,
|
||||
data.byteLength,
|
||||
collector,
|
||||
);
|
||||
} else if (type === "iTXt") {
|
||||
const parsed = parseInternationalText(
|
||||
data,
|
||||
limits.maxInflatedMetadataBytes,
|
||||
);
|
||||
addPngText(
|
||||
parsed.keyword,
|
||||
parsed.text,
|
||||
type,
|
||||
offset,
|
||||
data.byteLength,
|
||||
collector,
|
||||
);
|
||||
} else if (type === "iCCP") {
|
||||
const separator = data.indexOf(0);
|
||||
if (separator < 0 || data[separator + 1] !== 0)
|
||||
throw new SyntaxError("invalid iCCP header");
|
||||
const profile = inflateZlibBounded(
|
||||
data.subarray(separator + 2),
|
||||
limits.maxInflatedMetadataBytes,
|
||||
);
|
||||
collector.add({
|
||||
category: "colour-profile",
|
||||
risk: "technical",
|
||||
source: "PNG iCCP",
|
||||
label: "ICC profile",
|
||||
value: `${decodeLatin(data.subarray(0, separator)) || "unnamed"}; ${profile.byteLength} bytes inflated`,
|
||||
offset,
|
||||
length: data.byteLength,
|
||||
});
|
||||
} else if (type === "pHYs") {
|
||||
collector.add({
|
||||
category: "technical",
|
||||
risk: "technical",
|
||||
source: "PNG pHYs",
|
||||
label: "Pixel density",
|
||||
value: `${readU32be(data, 0)}×${readU32be(data, 4)} per ${data[8] === 1 ? "metre" : "unknown unit"}`,
|
||||
offset,
|
||||
length: data.byteLength,
|
||||
});
|
||||
} else if (type === "tIME" && data.byteLength === 7) {
|
||||
collector.add({
|
||||
category: "timestamp",
|
||||
risk: "sensitive",
|
||||
source: "PNG tIME",
|
||||
label: "Last modification time",
|
||||
value: `${readU16be(data, 0)}-${pad(data[2])}-${pad(data[3])} ${pad(data[4])}:${pad(data[5])}:${pad(data[6])} UTC-like fields`,
|
||||
offset,
|
||||
length: data.byteLength,
|
||||
});
|
||||
} else if (type === "caBX") {
|
||||
collector.add({
|
||||
category: "provenance",
|
||||
risk: "context",
|
||||
source: "PNG caBX",
|
||||
label: "C2PA provenance data",
|
||||
value: `${data.byteLength} bytes; re-encoding will remove or invalidate this provenance`,
|
||||
offset,
|
||||
length: data.byteLength,
|
||||
});
|
||||
}
|
||||
return { complete: true };
|
||||
} catch (error) {
|
||||
collector.warn(
|
||||
`PNG ${type} metadata could not be fully read: ${error instanceof Error ? error.message : "unknown error"}.`,
|
||||
);
|
||||
return { complete: false };
|
||||
}
|
||||
}
|
||||
|
||||
function addPngText(
|
||||
keyword: string,
|
||||
value: string,
|
||||
type: string,
|
||||
offset: number,
|
||||
length: number,
|
||||
collector: FindingCollector,
|
||||
): void {
|
||||
if (/xmp/iu.test(keyword) || /<\?xpacket|<rdf:/iu.test(value)) {
|
||||
scanXmp(value, `PNG ${type} (${keyword || "XMP"})`, offset, collector);
|
||||
return;
|
||||
}
|
||||
const normalized = keyword.toLowerCase();
|
||||
const category = /author|copyright|creator/iu.test(keyword)
|
||||
? "identity"
|
||||
: /date|time/iu.test(keyword)
|
||||
? "timestamp"
|
||||
: /software/iu.test(keyword)
|
||||
? "software"
|
||||
: /description|comment|title|keyword/iu.test(keyword)
|
||||
? "comment"
|
||||
: normalized === "raw profile type exif"
|
||||
? "device"
|
||||
: "unknown";
|
||||
collector.add({
|
||||
category,
|
||||
risk: category === "unknown" ? "context" : "sensitive",
|
||||
source: `PNG ${type}`,
|
||||
label: keyword || "Text entry",
|
||||
value,
|
||||
offset,
|
||||
length,
|
||||
});
|
||||
}
|
||||
|
||||
function parseInternationalText(
|
||||
data: Uint8Array,
|
||||
maximumInflatedBytes: number,
|
||||
): { keyword: string; text: string } {
|
||||
const first = data.indexOf(0);
|
||||
if (first < 0 || first + 2 >= data.byteLength)
|
||||
throw new SyntaxError("invalid iTXt header");
|
||||
const compressed = data[first + 1] === 1;
|
||||
if ((data[first + 1] !== 0 && !compressed) || data[first + 2] !== 0)
|
||||
throw new SyntaxError("unsupported iTXt compression");
|
||||
const languageEnd = data.indexOf(0, first + 3);
|
||||
if (languageEnd < 0) throw new SyntaxError("truncated iTXt language tag");
|
||||
const translatedEnd = data.indexOf(0, languageEnd + 1);
|
||||
if (translatedEnd < 0)
|
||||
throw new SyntaxError("truncated iTXt translated keyword");
|
||||
const payload = data.subarray(translatedEnd + 1);
|
||||
return {
|
||||
keyword: decodeLatin(data.subarray(0, first)),
|
||||
text: decodeMetadataText(
|
||||
compressed ? inflateZlibBounded(payload, maximumInflatedBytes) : payload,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function isMetadataChunk(type: string): boolean {
|
||||
return [
|
||||
"tEXt",
|
||||
"zTXt",
|
||||
"iTXt",
|
||||
"eXIf",
|
||||
"iCCP",
|
||||
"pHYs",
|
||||
"tIME",
|
||||
"caBX",
|
||||
].includes(type);
|
||||
}
|
||||
|
||||
function readU32be(bytes: Uint8Array, offset: number): number {
|
||||
return (
|
||||
(bytes[offset] ?? 0) * 0x1000000 +
|
||||
((bytes[offset + 1] ?? 0) << 16) +
|
||||
((bytes[offset + 2] ?? 0) << 8) +
|
||||
(bytes[offset + 3] ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
function readU16be(bytes: Uint8Array, offset: number): number {
|
||||
return ((bytes[offset] ?? 0) << 8) | (bytes[offset + 1] ?? 0);
|
||||
}
|
||||
|
||||
function ascii(bytes: Uint8Array, offset: number, length: number): string {
|
||||
let value = "";
|
||||
for (
|
||||
let index = 0;
|
||||
index < length && offset + index < bytes.length;
|
||||
index += 1
|
||||
)
|
||||
value += String.fromCharCode(bytes[offset + index] ?? 0);
|
||||
return value;
|
||||
}
|
||||
|
||||
function decodeLatin(bytes: Uint8Array): string {
|
||||
return new TextDecoder("latin1", { fatal: false }).decode(bytes);
|
||||
}
|
||||
|
||||
function pad(value: number | undefined): string {
|
||||
return String(value ?? 0).padStart(2, "0");
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import {
|
||||
digestHex,
|
||||
sanitizeDownloadFilename,
|
||||
} from "@add-ideas/toolbox-helpers";
|
||||
|
||||
import type {
|
||||
ImageScanResult,
|
||||
MetadataFinding,
|
||||
PixelComparison,
|
||||
SanitizedAsset,
|
||||
SanitizationReport,
|
||||
} from "./model";
|
||||
import { scanImageBytes } from "./scanner";
|
||||
|
||||
export interface SanitizeOptions {
|
||||
jpegQuality?: number;
|
||||
webpQuality?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function sanitizeStaticImage(
|
||||
file: File,
|
||||
source: ImageScanResult,
|
||||
options: SanitizeOptions = {},
|
||||
): Promise<SanitizedAsset> {
|
||||
if (!source.cleanable)
|
||||
throw new TypeError(
|
||||
`${source.name} is not a supported static clean-copy input.`,
|
||||
);
|
||||
if (source.coverage.projectScanner !== "complete")
|
||||
throw new TypeError("The source project scan is not complete.");
|
||||
if (file.size !== source.size || file.name !== source.name)
|
||||
throw new TypeError(
|
||||
"The selected source file no longer matches its scan result.",
|
||||
);
|
||||
throwIfAborted(options.signal);
|
||||
await verifySourceHash(file, source);
|
||||
throwIfAborted(options.signal);
|
||||
const mime = outputMime(source.identity.detectedKind);
|
||||
const decoded = await decodeDrawable(file, source, options.signal);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = decoded.outputWidth;
|
||||
canvas.height = decoded.outputHeight;
|
||||
const context = canvas.getContext("2d", {
|
||||
alpha: true,
|
||||
colorSpace: "srgb",
|
||||
willReadFrequently: false,
|
||||
});
|
||||
if (!context) throw new Error("The browser did not provide a 2D canvas.");
|
||||
context.save();
|
||||
applyOrientationTransform(
|
||||
context,
|
||||
decoded.orientationToApply,
|
||||
decoded.rawWidth,
|
||||
decoded.rawHeight,
|
||||
);
|
||||
context.drawImage(decoded.drawable, 0, 0);
|
||||
context.restore();
|
||||
decoded.close();
|
||||
throwIfAborted(options.signal);
|
||||
const sourceSample = await sampleDigest(canvas);
|
||||
const quality =
|
||||
mime === "image/jpeg"
|
||||
? boundedQuality(options.jpegQuality ?? 0.92)
|
||||
: mime === "image/webp"
|
||||
? boundedQuality(options.webpQuality ?? 0.92)
|
||||
: undefined;
|
||||
const blob = await canvasToBlob(canvas, mime, quality);
|
||||
if (blob.type !== mime)
|
||||
throw new Error(
|
||||
`This browser encoded ${blob.type || "an unknown format"} instead of ${mime}.`,
|
||||
);
|
||||
throwIfAborted(options.signal);
|
||||
const outputBytes = await blob.arrayBuffer();
|
||||
const outputName = cleanOutputName(
|
||||
source.safeName,
|
||||
source.identity.detectedKind,
|
||||
);
|
||||
const outputScan = await scanImageBytes({
|
||||
id: `${source.id}-clean`,
|
||||
name: outputName,
|
||||
claimedType: mime,
|
||||
size: outputBytes.byteLength,
|
||||
lastModified: 0,
|
||||
bytes: outputBytes,
|
||||
});
|
||||
const outputSample = await sampleBlobDigest(blob);
|
||||
const pixelComparison: PixelComparison = {
|
||||
method: "oriented-256px-sample",
|
||||
sourceDigest: sourceSample,
|
||||
outputDigest: outputSample,
|
||||
identical: sourceSample === outputSample,
|
||||
note:
|
||||
mime === "image/png"
|
||||
? "The decoded, orientation-normalized sample should normally be identical."
|
||||
: "JPEG and lossy WebP encoding may change decoded sample pixels even when the visible image is preserved.",
|
||||
};
|
||||
const report = buildSanitizationReport(
|
||||
source,
|
||||
outputScan,
|
||||
outputName,
|
||||
mime,
|
||||
blob.size,
|
||||
pixelComparison,
|
||||
);
|
||||
return { blob, report };
|
||||
}
|
||||
|
||||
export function buildSanitizationReport(
|
||||
source: ImageScanResult,
|
||||
output: ImageScanResult,
|
||||
outputName: string,
|
||||
outputType: string,
|
||||
outputBytes: number,
|
||||
pixelComparison: PixelComparison,
|
||||
): SanitizationReport {
|
||||
const outputKeys = new Set(output.findings.map(findingSignature));
|
||||
const sourceKeys = new Set(source.findings.map(findingSignature));
|
||||
const removed = source.findings.filter(
|
||||
(finding) => !outputKeys.has(findingSignature(finding)),
|
||||
);
|
||||
const preserved = source.findings.filter((finding) =>
|
||||
outputKeys.has(findingSignature(finding)),
|
||||
);
|
||||
const generated = output.findings.filter(
|
||||
(finding) => !sourceKeys.has(findingSignature(finding)),
|
||||
);
|
||||
const unsupported: string[] = [];
|
||||
const incomplete: string[] = [];
|
||||
if (source.coverage.projectScanner !== "complete")
|
||||
incomplete.push(
|
||||
"The source project scanner did not reach complete coverage.",
|
||||
);
|
||||
if (source.coverage.secondaryScanner !== "complete")
|
||||
incomplete.push(
|
||||
`The source secondary scanner status was ${source.coverage.secondaryScanner}.`,
|
||||
);
|
||||
if (output.coverage.projectScanner !== "complete")
|
||||
incomplete.push(
|
||||
"The output project scanner did not reach complete coverage.",
|
||||
);
|
||||
if (output.coverage.secondaryScanner !== "complete")
|
||||
incomplete.push(
|
||||
`The output secondary scanner status was ${output.coverage.secondaryScanner}.`,
|
||||
);
|
||||
if (source.findings.some((finding) => finding.category === "provenance"))
|
||||
unsupported.push(
|
||||
"Source provenance/signature data was removed or invalidated; authenticity cannot be carried through pixel re-encoding.",
|
||||
);
|
||||
const dimensionsMatch =
|
||||
source.width === undefined ||
|
||||
source.height === undefined ||
|
||||
(output.width === expectedWidth(source) &&
|
||||
output.height === expectedHeight(source));
|
||||
if (!dimensionsMatch)
|
||||
incomplete.push(
|
||||
"Output dimensions do not match the expected oriented dimensions.",
|
||||
);
|
||||
const orientationNormalized =
|
||||
(output.orientation === undefined || output.orientation === 1) &&
|
||||
dimensionsMatch;
|
||||
if (!orientationNormalized)
|
||||
incomplete.push("Output orientation was not normalized to pixel order.");
|
||||
if (output.identity.typeMatch !== "match")
|
||||
incomplete.push("Output type, filename, or detected bytes do not agree.");
|
||||
if (outputType === "image/png" && !pixelComparison.identical)
|
||||
incomplete.push(
|
||||
"Lossless PNG output did not preserve the decoded pixel sample.",
|
||||
);
|
||||
const unsafeOutput = output.findings.filter(
|
||||
(finding) =>
|
||||
finding.risk === "sensitive" ||
|
||||
finding.category === "comment" ||
|
||||
finding.category === "provenance",
|
||||
);
|
||||
const unexpectedBlocks = findUnexpectedOutputBlocks(output);
|
||||
if (unexpectedBlocks.length > 0)
|
||||
incomplete.push(
|
||||
`Output contains unexpected metadata blocks: ${unexpectedBlocks.join(", ")}.`,
|
||||
);
|
||||
const failed =
|
||||
output.coverage.projectScanner !== "complete" ||
|
||||
unsafeOutput.length > 0 ||
|
||||
output.animated ||
|
||||
output.multiImage ||
|
||||
!orientationNormalized ||
|
||||
output.identity.typeMatch !== "match" ||
|
||||
(outputType === "image/png" && !pixelComparison.identical);
|
||||
const warning =
|
||||
incomplete.length > 0 ||
|
||||
output.coverage.secondaryScanner !== "complete" ||
|
||||
unsupported.length > 0;
|
||||
const status = failed ? "failed" : warning ? "warning" : "verified";
|
||||
return {
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
outputName,
|
||||
outputType,
|
||||
sourceSha256: source.sha256,
|
||||
outputSha256: output.sha256,
|
||||
sourceBytes: source.size,
|
||||
outputBytes,
|
||||
sourceDimensions:
|
||||
source.width !== undefined && source.height !== undefined
|
||||
? { width: source.width, height: source.height }
|
||||
: undefined,
|
||||
outputDimensions:
|
||||
output.width !== undefined && output.height !== undefined
|
||||
? { width: output.width, height: output.height }
|
||||
: undefined,
|
||||
orientationNormalized,
|
||||
removed,
|
||||
preserved,
|
||||
generated,
|
||||
unsupported,
|
||||
incomplete,
|
||||
outputScan: output,
|
||||
pixelComparison,
|
||||
status,
|
||||
summary:
|
||||
status === "verified"
|
||||
? "The pixel re-encode completed and both bounded output scanners found no sensitive metadata."
|
||||
: status === "warning"
|
||||
? "The pixel re-encode completed, but one or more verification limits require review."
|
||||
: "The output did not pass the mandatory metadata verification gate.",
|
||||
disclaimer:
|
||||
"This report is not an anonymity guarantee. Visible faces or text, steganography, invisible watermarks, reverse-image matching, sidecars, filesystem records, and cloud copies are outside this tool's checks.",
|
||||
};
|
||||
}
|
||||
|
||||
async function verifySourceHash(
|
||||
file: File,
|
||||
source: ImageScanResult,
|
||||
): Promise<void> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const currentHash = await digestHex(bytes, "SHA-256", source.size);
|
||||
if (currentHash !== source.sha256)
|
||||
throw new TypeError(
|
||||
"The selected source bytes no longer match their scan hash.",
|
||||
);
|
||||
}
|
||||
|
||||
function findUnexpectedOutputBlocks(output: ImageScanResult): string[] {
|
||||
const allowed =
|
||||
output.identity.detectedKind === "jpeg"
|
||||
? new Set(["APP0", "APP2"])
|
||||
: output.identity.detectedKind === "png"
|
||||
? new Set(["iCCP", "pHYs"])
|
||||
: output.identity.detectedKind === "webp"
|
||||
? new Set(["ICCP"])
|
||||
: new Set<string>();
|
||||
return [...new Set(output.blocks.map((block) => block.kind))].filter(
|
||||
(kind) => !allowed.has(kind),
|
||||
);
|
||||
}
|
||||
|
||||
interface DecodedDrawable {
|
||||
drawable: CanvasImageSource;
|
||||
rawWidth: number;
|
||||
rawHeight: number;
|
||||
outputWidth: number;
|
||||
outputHeight: number;
|
||||
orientationToApply: number;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
async function decodeDrawable(
|
||||
file: File,
|
||||
source: ImageScanResult,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DecodedDrawable> {
|
||||
if ("createImageBitmap" in globalThis) {
|
||||
const bitmap = await createImageBitmap(file, { imageOrientation: "none" });
|
||||
throwIfAborted(signal);
|
||||
const orientation = normalizeOrientation(source.orientation);
|
||||
const scannerWidth = source.width ?? bitmap.width;
|
||||
const scannerHeight = source.height ?? bitmap.height;
|
||||
const browserAlreadyOriented =
|
||||
swapsAxes(orientation) &&
|
||||
bitmap.width === scannerHeight &&
|
||||
bitmap.height === scannerWidth;
|
||||
const orientationToApply = browserAlreadyOriented ? 1 : orientation;
|
||||
return {
|
||||
drawable: bitmap,
|
||||
rawWidth: bitmap.width,
|
||||
rawHeight: bitmap.height,
|
||||
outputWidth: swapsAxes(orientationToApply) ? bitmap.height : bitmap.width,
|
||||
outputHeight: swapsAxes(orientationToApply)
|
||||
? bitmap.width
|
||||
: bitmap.height,
|
||||
orientationToApply,
|
||||
close: () => bitmap.close(),
|
||||
};
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
image.src = url;
|
||||
await image.decode();
|
||||
throwIfAborted(signal);
|
||||
return {
|
||||
drawable: image,
|
||||
rawWidth: image.naturalWidth,
|
||||
rawHeight: image.naturalHeight,
|
||||
outputWidth: image.naturalWidth,
|
||||
outputHeight: image.naturalHeight,
|
||||
orientationToApply: 1,
|
||||
close: () => undefined,
|
||||
};
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function applyOrientationTransform(
|
||||
context: CanvasRenderingContext2D,
|
||||
orientation: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
if (orientation === 2) context.transform(-1, 0, 0, 1, width, 0);
|
||||
else if (orientation === 3) context.transform(-1, 0, 0, -1, width, height);
|
||||
else if (orientation === 4) context.transform(1, 0, 0, -1, 0, height);
|
||||
else if (orientation === 5) context.transform(0, 1, 1, 0, 0, 0);
|
||||
else if (orientation === 6) context.transform(0, 1, -1, 0, height, 0);
|
||||
else if (orientation === 7) context.transform(0, -1, -1, 0, height, width);
|
||||
else if (orientation === 8) context.transform(0, -1, 1, 0, 0, width);
|
||||
}
|
||||
|
||||
async function sampleDigest(canvas: HTMLCanvasElement): Promise<string> {
|
||||
const maximum = 256;
|
||||
const scale = Math.min(1, maximum / canvas.width, maximum / canvas.height);
|
||||
const width = Math.max(1, Math.round(canvas.width * scale));
|
||||
const height = Math.max(1, Math.round(canvas.height * scale));
|
||||
const sample = document.createElement("canvas");
|
||||
sample.width = width;
|
||||
sample.height = height;
|
||||
const context = sample.getContext("2d", { willReadFrequently: true });
|
||||
if (!context)
|
||||
throw new Error("The browser could not create a verification canvas.");
|
||||
context.drawImage(canvas, 0, 0, width, height);
|
||||
const pixels = context.getImageData(0, 0, width, height).data;
|
||||
return digestHex(pixels, "SHA-256", maximum * maximum * 4);
|
||||
}
|
||||
|
||||
async function sampleBlobDigest(blob: Blob): Promise<string> {
|
||||
const bitmap = await createImageBitmap(blob, { imageOrientation: "none" });
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context)
|
||||
throw new Error("The browser could not decode output pixels.");
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
return sampleDigest(canvas);
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
function canvasToBlob(
|
||||
canvas: HTMLCanvasElement,
|
||||
type: string,
|
||||
quality?: number,
|
||||
): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) resolve(blob);
|
||||
else reject(new Error(`The browser could not encode ${type}.`));
|
||||
},
|
||||
type,
|
||||
quality,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function outputMime(kind: ImageScanResult["identity"]["detectedKind"]): string {
|
||||
if (kind === "jpeg") return "image/jpeg";
|
||||
if (kind === "png") return "image/png";
|
||||
if (kind === "webp") return "image/webp";
|
||||
throw new TypeError(`No clean-copy encoder is available for ${kind}.`);
|
||||
}
|
||||
|
||||
function cleanOutputName(
|
||||
input: string,
|
||||
kind: ImageScanResult["identity"]["detectedKind"],
|
||||
): string {
|
||||
const extension = kind === "jpeg" ? "jpg" : kind;
|
||||
const withoutExtension = input.replace(/\.[^.]*$/u, "") || "image";
|
||||
return sanitizeDownloadFilename(`${withoutExtension}.clean.${extension}`);
|
||||
}
|
||||
|
||||
function findingSignature(finding: MetadataFinding): string {
|
||||
const label = finding.label
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/gu, " ")
|
||||
.trim();
|
||||
return `${finding.category}\u0000${label}\u0000${finding.value}`;
|
||||
}
|
||||
|
||||
function normalizeOrientation(value: number | undefined): number {
|
||||
return value !== undefined && value >= 1 && value <= 8 ? value : 1;
|
||||
}
|
||||
|
||||
function swapsAxes(orientation: number): boolean {
|
||||
return orientation >= 5 && orientation <= 8;
|
||||
}
|
||||
|
||||
function expectedWidth(scan: ImageScanResult): number | undefined {
|
||||
if (scan.width === undefined || scan.height === undefined) return undefined;
|
||||
return swapsAxes(normalizeOrientation(scan.orientation))
|
||||
? scan.height
|
||||
: scan.width;
|
||||
}
|
||||
|
||||
function expectedHeight(scan: ImageScanResult): number | undefined {
|
||||
if (scan.width === undefined || scan.height === undefined) return undefined;
|
||||
return swapsAxes(normalizeOrientation(scan.orientation))
|
||||
? scan.width
|
||||
: scan.height;
|
||||
}
|
||||
|
||||
function boundedQuality(value: number): number {
|
||||
if (!Number.isFinite(value) || value < 0.5 || value > 1)
|
||||
throw new RangeError("Image quality must be between 0.5 and 1.");
|
||||
return value;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted)
|
||||
throw new DOMException("Operation cancelled", "AbortError");
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { assertBatchFiles } from "./limits";
|
||||
import type { ImageScanResult, ScanInput } from "./model";
|
||||
import type { ScanWorkerRequest, ScanWorkerResponse } from "../worker/protocol";
|
||||
|
||||
export interface ScanProgress {
|
||||
completed: number;
|
||||
total: number;
|
||||
currentName: string;
|
||||
}
|
||||
|
||||
export async function scanFilesInWorker(
|
||||
files: readonly File[],
|
||||
onProgress: (progress: ScanProgress) => void = () => undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ImageScanResult[]> {
|
||||
assertBatchFiles(files);
|
||||
throwIfAborted(signal);
|
||||
const worker = new Worker(
|
||||
new URL("../worker/scan.worker.ts", import.meta.url),
|
||||
{
|
||||
type: "module",
|
||||
name: "privacy-metadata-scanner",
|
||||
},
|
||||
);
|
||||
const cancel = () => worker.terminate();
|
||||
signal?.addEventListener("abort", cancel, { once: true });
|
||||
const results: ImageScanResult[] = [];
|
||||
try {
|
||||
for (let index = 0; index < files.length; index += 1) {
|
||||
const file = files[index];
|
||||
if (!file) continue;
|
||||
throwIfAborted(signal);
|
||||
onProgress({
|
||||
completed: index,
|
||||
total: files.length,
|
||||
currentName: file.name,
|
||||
});
|
||||
const bytes = await file.arrayBuffer();
|
||||
throwIfAborted(signal);
|
||||
const requestId = `scan-${index}-${fileId(file, index)}`;
|
||||
const input: ScanInput = {
|
||||
id: fileId(file, index),
|
||||
name: file.name,
|
||||
claimedType: file.type,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
bytes,
|
||||
};
|
||||
results.push(
|
||||
await scanOne(
|
||||
worker,
|
||||
{ type: "scan-file", requestId, file: input },
|
||||
signal,
|
||||
),
|
||||
);
|
||||
onProgress({
|
||||
completed: index + 1,
|
||||
total: files.length,
|
||||
currentName: file.name,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
} finally {
|
||||
worker.terminate();
|
||||
signal?.removeEventListener("abort", cancel);
|
||||
}
|
||||
}
|
||||
|
||||
function scanOne(
|
||||
worker: Worker,
|
||||
request: ScanWorkerRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ImageScanResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const dispose = () => {
|
||||
worker.removeEventListener("message", handleMessage);
|
||||
worker.removeEventListener("error", handleError);
|
||||
signal?.removeEventListener("abort", handleAbort);
|
||||
};
|
||||
const handleMessage = (event: MessageEvent<ScanWorkerResponse>) => {
|
||||
const response = event.data;
|
||||
if (response.requestId !== request.requestId) return;
|
||||
dispose();
|
||||
if (response.type === "scanned") resolve(response.result);
|
||||
else reject(new Error(response.message));
|
||||
};
|
||||
const handleError = (event: ErrorEvent) => {
|
||||
dispose();
|
||||
reject(new Error(event.message || "Metadata worker failed."));
|
||||
};
|
||||
const handleAbort = () => {
|
||||
dispose();
|
||||
reject(new DOMException("Scan cancelled", "AbortError"));
|
||||
};
|
||||
worker.addEventListener("message", handleMessage);
|
||||
worker.addEventListener("error", handleError);
|
||||
signal?.addEventListener("abort", handleAbort, { once: true });
|
||||
worker.postMessage(request, [request.file.bytes]);
|
||||
});
|
||||
}
|
||||
|
||||
function fileId(file: File, index: number): string {
|
||||
const safe = file.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/gu, "-")
|
||||
.replace(/^-|-$/gu, "");
|
||||
return `${index + 1}-${safe || "file"}-${file.size}-${file.lastModified}`;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw new DOMException("Scan cancelled", "AbortError");
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
digestHex,
|
||||
sanitizeDownloadFilename,
|
||||
} from "@add-ideas/toolbox-helpers";
|
||||
|
||||
import { inventoryIdentity } from "./detect";
|
||||
import { scanWithExifReader } from "./exif-reader-adapter";
|
||||
import { FindingCollector } from "./findings";
|
||||
import { scanJpeg, type FormatScan } from "./jpeg";
|
||||
import { assertLimit, resolveLimits } from "./limits";
|
||||
import type { ImageScanResult, PrivacyLimits, ScanInput } from "./model";
|
||||
import { scanPng } from "./png";
|
||||
import { scanWebp } from "./webp";
|
||||
|
||||
const EMPTY_FORMAT: FormatScan = Object.freeze({
|
||||
animated: false,
|
||||
multiImage: false,
|
||||
complete: false,
|
||||
blocks: [],
|
||||
});
|
||||
|
||||
export async function scanImageBytes(
|
||||
input: ScanInput,
|
||||
overrides: Partial<PrivacyLimits> = {},
|
||||
): Promise<ImageScanResult> {
|
||||
const limits = resolveLimits(overrides);
|
||||
assertLimit(input.size, limits.maxFileBytes, "File size");
|
||||
if (input.bytes.byteLength !== input.size)
|
||||
throw new RangeError(
|
||||
`Declared file size ${input.size} does not match ${input.bytes.byteLength} bytes read`,
|
||||
);
|
||||
const bytes = new Uint8Array(input.bytes);
|
||||
const identity = inventoryIdentity(input.name, input.claimedType, bytes);
|
||||
const collector = new FindingCollector(limits);
|
||||
let format: FormatScan = EMPTY_FORMAT;
|
||||
if (identity.detectedKind === "jpeg")
|
||||
format = scanJpeg(bytes, collector, limits);
|
||||
else if (identity.detectedKind === "png")
|
||||
format = scanPng(bytes, collector, limits);
|
||||
else if (identity.detectedKind === "webp")
|
||||
format = scanWebp(bytes, collector, limits);
|
||||
|
||||
const deepSupported = ["jpeg", "png", "webp"].includes(identity.detectedKind);
|
||||
const secondarySupported = [
|
||||
"jpeg",
|
||||
"png",
|
||||
"webp",
|
||||
"gif",
|
||||
"tiff",
|
||||
"heic",
|
||||
"avif",
|
||||
"jxl",
|
||||
].includes(identity.detectedKind);
|
||||
const secondary = secondarySupported
|
||||
? scanWithExifReader(input.bytes, collector, limits)
|
||||
: {
|
||||
status: "unsupported" as const,
|
||||
notes: ["No deep metadata adapter is available for this format."],
|
||||
};
|
||||
const width = format.width ?? secondary.width;
|
||||
const height = format.height ?? secondary.height;
|
||||
let dimensionsAllowed = true;
|
||||
if (width !== undefined && height !== undefined) {
|
||||
if (width <= 0 || height <= 0) {
|
||||
collector.warn("Image dimensions are invalid.");
|
||||
dimensionsAllowed = false;
|
||||
} else if (
|
||||
width > limits.maxEdge ||
|
||||
height > limits.maxEdge ||
|
||||
width * height > limits.maxPixels
|
||||
) {
|
||||
collector.warn(
|
||||
`Image dimensions ${width}×${height} exceed the ${limits.maxEdge}-pixel edge or ${limits.maxPixels.toLocaleString("en-US")}-pixel processing limit.`,
|
||||
);
|
||||
dimensionsAllowed = false;
|
||||
}
|
||||
} else if (deepSupported) {
|
||||
collector.warn(
|
||||
"Pixel dimensions could not be established from the container.",
|
||||
);
|
||||
dimensionsAllowed = false;
|
||||
}
|
||||
if (identity.typeMatch === "mismatch")
|
||||
collector.warn(
|
||||
"The filename or browser-claimed media type does not match the detected bytes.",
|
||||
);
|
||||
if (!deepSupported)
|
||||
collector.warn(
|
||||
"This format receives inventory and best-effort secondary inspection only; clean-copy output is unavailable.",
|
||||
);
|
||||
if (format.animated)
|
||||
collector.warn("Animated images are inspect-only in version 0.1.");
|
||||
if (format.multiImage)
|
||||
collector.warn("Multi-image containers are inspect-only in version 0.1.");
|
||||
|
||||
const cleanable =
|
||||
deepSupported &&
|
||||
format.complete &&
|
||||
!collector.limited &&
|
||||
!format.animated &&
|
||||
!format.multiImage &&
|
||||
dimensionsAllowed;
|
||||
const sha256 = await digestHex(bytes, "SHA-256", limits.maxFileBytes);
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
safeName: sanitizeDownloadFilename(input.name, "image"),
|
||||
size: input.size,
|
||||
lastModified: input.lastModified,
|
||||
sha256,
|
||||
identity,
|
||||
width,
|
||||
height,
|
||||
orientation: format.orientation,
|
||||
animated: format.animated,
|
||||
multiImage: format.multiImage,
|
||||
deepSupported,
|
||||
cleanable,
|
||||
findings: collector.findings.sort(compareFindings),
|
||||
blocks: format.blocks,
|
||||
warnings: collector.warnings,
|
||||
coverage: {
|
||||
projectScanner: deepSupported
|
||||
? format.complete && !collector.limited
|
||||
? "complete"
|
||||
: "partial"
|
||||
: "unsupported",
|
||||
secondaryScanner:
|
||||
collector.limited && secondary.status === "complete"
|
||||
? "partial"
|
||||
: secondary.status,
|
||||
notes: collector.limited
|
||||
? [
|
||||
...secondary.notes,
|
||||
"Normalized finding output reached its text limit.",
|
||||
]
|
||||
: secondary.notes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function compareFindings(
|
||||
left: ImageScanResult["findings"][number],
|
||||
right: ImageScanResult["findings"][number],
|
||||
): number {
|
||||
return (
|
||||
left.category.localeCompare(right.category) ||
|
||||
left.source.localeCompare(right.source) ||
|
||||
left.label.localeCompare(right.label)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { categoryForName, FindingCollector } from "./findings";
|
||||
import { scanIptc } from "./iptc";
|
||||
import type { PrivacyLimits } from "./model";
|
||||
import { scanXmp } from "./xmp";
|
||||
|
||||
const TYPE_BYTES: Readonly<Record<number, number>> = Object.freeze({
|
||||
1: 1,
|
||||
2: 1,
|
||||
3: 2,
|
||||
4: 4,
|
||||
5: 8,
|
||||
7: 1,
|
||||
9: 4,
|
||||
10: 8,
|
||||
11: 4,
|
||||
12: 8,
|
||||
13: 4,
|
||||
});
|
||||
|
||||
const TAG_NAMES: Readonly<Record<number, string>> = Object.freeze({
|
||||
0x010e: "Image Description",
|
||||
0x010f: "Camera Make",
|
||||
0x0110: "Camera Model",
|
||||
0x0112: "Orientation",
|
||||
0x0131: "Software",
|
||||
0x0132: "Date/Time",
|
||||
0x013b: "Artist",
|
||||
0x0201: "JPEG Thumbnail Offset",
|
||||
0x0202: "JPEG Thumbnail Length",
|
||||
0x02bc: "XMP",
|
||||
0x8298: "Copyright",
|
||||
0x83bb: "IPTC/NAA",
|
||||
0x8769: "Exif IFD",
|
||||
0x8825: "GPS IFD",
|
||||
0x9003: "Date/Time Original",
|
||||
0x9004: "Date/Time Digitized",
|
||||
0x927c: "Maker Note",
|
||||
0x9286: "User Comment",
|
||||
0xa005: "Interoperability IFD",
|
||||
0xa420: "Image Unique ID",
|
||||
0xa430: "Camera Owner Name",
|
||||
0xa431: "Camera Body Serial Number",
|
||||
0xa432: "Lens Specification",
|
||||
0xa433: "Lens Make",
|
||||
0xa434: "Lens Model",
|
||||
0xa435: "Lens Serial Number",
|
||||
0x9c9b: "Windows Title",
|
||||
0x9c9c: "Windows Comment",
|
||||
0x9c9d: "Windows Author",
|
||||
0x9c9e: "Windows Keywords",
|
||||
0x9c9f: "Windows Subject",
|
||||
0x8773: "ICC Profile",
|
||||
});
|
||||
|
||||
const GPS_NAMES: Readonly<Record<number, string>> = Object.freeze({
|
||||
0: "GPS Version",
|
||||
1: "GPS Latitude Reference",
|
||||
2: "GPS Latitude",
|
||||
3: "GPS Longitude Reference",
|
||||
4: "GPS Longitude",
|
||||
5: "GPS Altitude Reference",
|
||||
6: "GPS Altitude",
|
||||
7: "GPS Time Stamp",
|
||||
11: "GPS Dilution of Precision",
|
||||
12: "GPS Speed Reference",
|
||||
13: "GPS Speed",
|
||||
16: "GPS Direction Reference",
|
||||
17: "GPS Direction",
|
||||
18: "GPS Map Datum",
|
||||
27: "GPS Processing Method",
|
||||
28: "GPS Area Information",
|
||||
29: "GPS Date Stamp",
|
||||
31: "GPS Horizontal Positioning Error",
|
||||
});
|
||||
|
||||
export interface TiffScan {
|
||||
orientation?: number;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
interface IfdTask {
|
||||
offset: number;
|
||||
role: "main" | "exif" | "gps" | "interop" | "thumbnail";
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export function scanTiff(
|
||||
bytes: Uint8Array,
|
||||
source: string,
|
||||
baseOffset: number,
|
||||
collector: FindingCollector,
|
||||
limits: Readonly<PrivacyLimits>,
|
||||
): TiffScan {
|
||||
if (bytes.byteLength < 8) {
|
||||
collector.warn(`${source} TIFF data is truncated.`);
|
||||
return { complete: false };
|
||||
}
|
||||
const littleEndian = bytes[0] === 0x49 && bytes[1] === 0x49;
|
||||
const bigEndian = bytes[0] === 0x4d && bytes[1] === 0x4d;
|
||||
if (!littleEndian && !bigEndian) {
|
||||
collector.warn(`${source} TIFF byte order is invalid.`);
|
||||
return { complete: false };
|
||||
}
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const u16 = (offset: number) => view.getUint16(offset, littleEndian);
|
||||
const u32 = (offset: number) => view.getUint32(offset, littleEndian);
|
||||
if (u16(2) !== 42) {
|
||||
collector.warn(`${source} TIFF magic is unsupported.`);
|
||||
return { complete: false };
|
||||
}
|
||||
const tasks: IfdTask[] = [{ offset: u32(4), role: "main", depth: 0 }];
|
||||
const visited = new Set<number>();
|
||||
let totalEntries = 0;
|
||||
let orientation: number | undefined;
|
||||
let complete = true;
|
||||
|
||||
while (tasks.length > 0) {
|
||||
const task = tasks.pop();
|
||||
if (!task) break;
|
||||
if (task.depth > limits.maxTiffDepth) {
|
||||
collector.warn(
|
||||
`${source} TIFF IFD depth exceeded ${limits.maxTiffDepth}.`,
|
||||
);
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
if (task.offset === 0) continue;
|
||||
if (visited.has(task.offset)) {
|
||||
collector.warn(
|
||||
`${source} TIFF IFD cycle was stopped at offset ${task.offset}.`,
|
||||
);
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
visited.add(task.offset);
|
||||
if (task.offset < 8 || task.offset + 2 > bytes.byteLength) {
|
||||
collector.warn(
|
||||
`${source} TIFF IFD offset is outside the metadata block.`,
|
||||
);
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
const count = u16(task.offset);
|
||||
totalEntries += count;
|
||||
if (totalEntries > limits.maxTiffEntries) {
|
||||
collector.warn(
|
||||
`${source} TIFF entry count exceeded ${limits.maxTiffEntries}.`,
|
||||
);
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
const tableEnd = task.offset + 2 + count * 12;
|
||||
if (tableEnd + 4 > bytes.byteLength) {
|
||||
collector.warn(`${source} TIFF IFD entry table is truncated.`);
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const entryOffset = task.offset + 2 + index * 12;
|
||||
const tag = u16(entryOffset);
|
||||
const type = u16(entryOffset + 2);
|
||||
const itemCount = u32(entryOffset + 4);
|
||||
const unit = TYPE_BYTES[type];
|
||||
if (!unit) continue;
|
||||
const byteLength = itemCount * unit;
|
||||
if (
|
||||
!Number.isSafeInteger(byteLength) ||
|
||||
byteLength > limits.maxMetadataBlockBytes
|
||||
) {
|
||||
collector.warn(
|
||||
`${source} TIFF tag 0x${tag.toString(16)} is oversized.`,
|
||||
);
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
const dataOffset =
|
||||
byteLength <= 4 ? entryOffset + 8 : u32(entryOffset + 8);
|
||||
if (dataOffset + byteLength > bytes.byteLength) {
|
||||
collector.warn(
|
||||
`${source} TIFF tag 0x${tag.toString(16)} points outside its block.`,
|
||||
);
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
const data = bytes.subarray(dataOffset, dataOffset + byteLength);
|
||||
const values = readValues(
|
||||
view,
|
||||
dataOffset,
|
||||
itemCount,
|
||||
type,
|
||||
littleEndian,
|
||||
);
|
||||
const first = values[0];
|
||||
if (tag === 0x8769 || tag === 0x8825 || tag === 0xa005) {
|
||||
if (typeof first === "number") {
|
||||
tasks.push({
|
||||
offset: first,
|
||||
role: tag === 0x8825 ? "gps" : tag === 0xa005 ? "interop" : "exif",
|
||||
depth: task.depth + 1,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (tag === 0x014a) {
|
||||
for (const value of values)
|
||||
if (typeof value === "number")
|
||||
tasks.push({ offset: value, role: "main", depth: task.depth + 1 });
|
||||
continue;
|
||||
}
|
||||
if (tag === 0x0112 && typeof first === "number") orientation = first;
|
||||
if (tag === 0x02bc) {
|
||||
scanXmp(data, `${source} XMP tag`, baseOffset + dataOffset, collector);
|
||||
continue;
|
||||
}
|
||||
if (tag === 0x83bb) {
|
||||
scanIptc(
|
||||
data,
|
||||
`${source} IPTC tag`,
|
||||
baseOffset + dataOffset,
|
||||
collector,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const label =
|
||||
task.role === "gps"
|
||||
? (GPS_NAMES[tag] ?? `GPS tag 0x${tag.toString(16)}`)
|
||||
: TAG_NAMES[tag];
|
||||
if (!label) continue;
|
||||
const classification =
|
||||
task.role === "gps"
|
||||
? ({ category: "location", risk: "sensitive" } as const)
|
||||
: categoryForName(label);
|
||||
collector.add({
|
||||
...classification,
|
||||
source,
|
||||
label,
|
||||
value:
|
||||
tag === 0x0112 && typeof first === "number"
|
||||
? orientationName(first)
|
||||
: displayTiffValue(data, values, type, tag),
|
||||
offset: baseOffset + dataOffset,
|
||||
length: byteLength,
|
||||
});
|
||||
}
|
||||
const next = u32(tableEnd);
|
||||
if (next !== 0)
|
||||
tasks.push({
|
||||
offset: next,
|
||||
role: task.role === "main" ? "thumbnail" : task.role,
|
||||
depth: task.depth + 1,
|
||||
});
|
||||
}
|
||||
return { orientation, complete };
|
||||
}
|
||||
|
||||
function readValues(
|
||||
view: DataView,
|
||||
offset: number,
|
||||
count: number,
|
||||
type: number,
|
||||
littleEndian: boolean,
|
||||
): Array<number | string> {
|
||||
if (type === 2) {
|
||||
const bytes = new Uint8Array(view.buffer, view.byteOffset + offset, count);
|
||||
return [
|
||||
stripTerminalNulls(
|
||||
new TextDecoder("utf-8", { fatal: false }).decode(bytes),
|
||||
),
|
||||
];
|
||||
}
|
||||
const result: Array<number | string> = [];
|
||||
const maximum = Math.min(count, 128);
|
||||
for (let index = 0; index < maximum; index += 1) {
|
||||
const itemOffset =
|
||||
offset +
|
||||
index *
|
||||
(type === 3
|
||||
? 2
|
||||
: type === 4 || type === 9 || type === 11
|
||||
? 4
|
||||
: type === 5 || type === 10 || type === 12
|
||||
? 8
|
||||
: 1);
|
||||
if (type === 1 || type === 7) result.push(view.getUint8(itemOffset));
|
||||
else if (type === 3) result.push(view.getUint16(itemOffset, littleEndian));
|
||||
else if (type === 4 || type === 13)
|
||||
result.push(view.getUint32(itemOffset, littleEndian));
|
||||
else if (type === 9) result.push(view.getInt32(itemOffset, littleEndian));
|
||||
else if (type === 11)
|
||||
result.push(view.getFloat32(itemOffset, littleEndian));
|
||||
else if (type === 12)
|
||||
result.push(view.getFloat64(itemOffset, littleEndian));
|
||||
else if (type === 5 || type === 10) {
|
||||
const numerator =
|
||||
type === 5
|
||||
? view.getUint32(itemOffset, littleEndian)
|
||||
: view.getInt32(itemOffset, littleEndian);
|
||||
const denominator =
|
||||
type === 5
|
||||
? view.getUint32(itemOffset + 4, littleEndian)
|
||||
: view.getInt32(itemOffset + 4, littleEndian);
|
||||
result.push(
|
||||
denominator === 0 ? `${numerator}/0` : numerator / denominator,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (count > maximum) result.push(`… ${count - maximum} more values`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function displayTiffValue(
|
||||
data: Uint8Array,
|
||||
values: Array<number | string>,
|
||||
type: number,
|
||||
tag: number,
|
||||
): string {
|
||||
if (type === 2) return String(values[0] ?? "");
|
||||
if (tag >= 0x9c9b && tag <= 0x9c9f && data.byteLength % 2 === 0)
|
||||
return stripTerminalNulls(
|
||||
new TextDecoder("utf-16le", { fatal: false }).decode(data),
|
||||
);
|
||||
if (tag === 0x927c || tag === 0x8773) return `${data.byteLength} bytes`;
|
||||
return values.join(", ");
|
||||
}
|
||||
|
||||
function stripTerminalNulls(value: string): string {
|
||||
let end = value.length;
|
||||
while (end > 0 && value.charCodeAt(end - 1) === 0) end -= 1;
|
||||
return value.slice(0, end);
|
||||
}
|
||||
|
||||
function orientationName(value: number): string {
|
||||
return (
|
||||
[
|
||||
"Unknown",
|
||||
"1 — normal",
|
||||
"2 — mirrored horizontally",
|
||||
"3 — rotated 180°",
|
||||
"4 — mirrored vertically",
|
||||
"5 — mirrored then rotated 90° clockwise",
|
||||
"6 — rotated 90° clockwise",
|
||||
"7 — mirrored then rotated 90° counter-clockwise",
|
||||
"8 — rotated 90° counter-clockwise",
|
||||
][value] ?? `${value} — invalid orientation`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { FindingCollector } from "./findings";
|
||||
import type { MetadataBlock, PrivacyLimits } from "./model";
|
||||
import type { FormatScan } from "./jpeg";
|
||||
import { scanTiff } from "./tiff";
|
||||
import { scanXmp } from "./xmp";
|
||||
|
||||
export function scanWebp(
|
||||
bytes: Uint8Array,
|
||||
collector: FindingCollector,
|
||||
limits: Readonly<PrivacyLimits>,
|
||||
): FormatScan {
|
||||
const blocks: MetadataBlock[] = [];
|
||||
const declared = readU32le(bytes, 4) + 8;
|
||||
let complete = declared === bytes.byteLength;
|
||||
if (declared > bytes.byteLength || declared < 12) {
|
||||
collector.warn("WebP RIFF size is invalid or truncated.");
|
||||
complete = false;
|
||||
} else if (declared < bytes.byteLength) {
|
||||
const trailing = bytes.byteLength - declared;
|
||||
blocks.push({ kind: "trailing-data", offset: declared, length: trailing });
|
||||
collector.add({
|
||||
category: "unknown",
|
||||
risk: "sensitive",
|
||||
source: "WebP",
|
||||
label: "Trailing data",
|
||||
value: `${trailing} bytes after the declared RIFF container`,
|
||||
offset: declared,
|
||||
length: trailing,
|
||||
});
|
||||
}
|
||||
const containerEnd = Math.min(declared, bytes.byteLength);
|
||||
let offset = 12;
|
||||
let chunks = 0;
|
||||
let width: number | undefined;
|
||||
let height: number | undefined;
|
||||
let orientation: number | undefined;
|
||||
let animated = false;
|
||||
let sawImageData = false;
|
||||
while (offset + 8 <= containerEnd) {
|
||||
const type = ascii(bytes, offset, 4);
|
||||
const length = readU32le(bytes, offset + 4);
|
||||
const dataOffset = offset + 8;
|
||||
const end = dataOffset + length;
|
||||
if (!/^[\x20-\x7e]{4}$/u.test(type) || end > containerEnd) {
|
||||
collector.warn(`WebP chunk at byte ${offset} is malformed or truncated.`);
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
chunks += 1;
|
||||
if (chunks > limits.maxMetadataBlocks) {
|
||||
collector.warn(`WebP chunk count exceeded ${limits.maxMetadataBlocks}.`);
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
const data = bytes.subarray(dataOffset, end);
|
||||
if (type === "VP8X" && data.byteLength >= 10) {
|
||||
animated ||= ((data[0] ?? 0) & 0x02) !== 0;
|
||||
width = 1 + readU24le(data, 4);
|
||||
height = 1 + readU24le(data, 7);
|
||||
} else if (type === "VP8 " && data.byteLength >= 10) {
|
||||
sawImageData = true;
|
||||
if (data[3] === 0x9d && data[4] === 0x01 && data[5] === 0x2a) {
|
||||
width = readU16le(data, 6) & 0x3fff;
|
||||
height = readU16le(data, 8) & 0x3fff;
|
||||
}
|
||||
} else if (type === "VP8L" && data.byteLength >= 5 && data[0] === 0x2f) {
|
||||
sawImageData = true;
|
||||
const bits = readU32le(data, 1);
|
||||
width = 1 + (bits & 0x3fff);
|
||||
height = 1 + ((bits >>> 14) & 0x3fff);
|
||||
} else if (type === "ANIM" || type === "ANMF") {
|
||||
animated = true;
|
||||
blocks.push({ kind: type, offset: dataOffset, length });
|
||||
} else if (["EXIF", "XMP ", "ICCP", "META"].includes(type)) {
|
||||
if (length > limits.maxMetadataBlockBytes) {
|
||||
collector.warn(`WebP ${type.trim()} metadata exceeds the block limit.`);
|
||||
complete = false;
|
||||
} else {
|
||||
blocks.push({ kind: type.trim(), offset: dataOffset, length });
|
||||
if (type === "EXIF") {
|
||||
const prefix = ascii(data, 0, 6) === "Exif\u0000\u0000" ? 6 : 0;
|
||||
const result = scanTiff(
|
||||
data.subarray(prefix),
|
||||
"WebP EXIF",
|
||||
dataOffset + prefix,
|
||||
collector,
|
||||
limits,
|
||||
);
|
||||
orientation = result.orientation;
|
||||
complete &&= result.complete;
|
||||
} else if (type === "XMP ") {
|
||||
scanXmp(data, "WebP XMP", dataOffset, collector);
|
||||
} else if (type === "ICCP") {
|
||||
collector.add({
|
||||
category: "colour-profile",
|
||||
risk: "technical",
|
||||
source: "WebP ICCP",
|
||||
label: "ICC profile",
|
||||
value: `${length} bytes`,
|
||||
offset: dataOffset,
|
||||
length,
|
||||
});
|
||||
} else {
|
||||
collector.add({
|
||||
category: "unknown",
|
||||
risk: "context",
|
||||
source: "WebP META",
|
||||
label: "Generic metadata chunk",
|
||||
value: `${length} bytes`,
|
||||
offset: dataOffset,
|
||||
length,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (type === "ALPH") {
|
||||
// Pixel alpha data, not metadata.
|
||||
} else if (!["VP8X", "VP8 ", "VP8L", "ANIM", "ANMF"].includes(type)) {
|
||||
const lower = ascii(
|
||||
data,
|
||||
0,
|
||||
Math.min(data.byteLength, 4096),
|
||||
).toLowerCase();
|
||||
const provenance = lower.includes("c2pa") || lower.includes("jumb");
|
||||
blocks.push({
|
||||
kind: type.trim() || "unknown",
|
||||
offset: dataOffset,
|
||||
length,
|
||||
});
|
||||
collector.add({
|
||||
category: provenance ? "provenance" : "unknown",
|
||||
risk: "context",
|
||||
source: "WebP",
|
||||
label: provenance
|
||||
? "Unrecognized provenance chunk"
|
||||
: `Unrecognized ${type} chunk`,
|
||||
value: `${length} bytes`,
|
||||
offset: dataOffset,
|
||||
length,
|
||||
});
|
||||
}
|
||||
offset = end + (length % 2);
|
||||
}
|
||||
if (offset !== containerEnd) {
|
||||
collector.warn("WebP chunk padding or RIFF boundary is inconsistent.");
|
||||
complete = false;
|
||||
}
|
||||
if (!sawImageData && !animated) {
|
||||
collector.warn("WebP pixel data was not found.");
|
||||
complete = false;
|
||||
}
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
orientation,
|
||||
animated,
|
||||
multiImage: false,
|
||||
complete,
|
||||
blocks,
|
||||
};
|
||||
}
|
||||
|
||||
function ascii(bytes: Uint8Array, offset: number, length: number): string {
|
||||
if (offset < 0 || offset + length > bytes.byteLength) return "";
|
||||
let value = "";
|
||||
for (let index = 0; index < length; index += 1)
|
||||
value += String.fromCharCode(bytes[offset + index] ?? 0);
|
||||
return value;
|
||||
}
|
||||
|
||||
function readU16le(bytes: Uint8Array, offset: number): number {
|
||||
return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8);
|
||||
}
|
||||
|
||||
function readU24le(bytes: Uint8Array, offset: number): number {
|
||||
return (
|
||||
(bytes[offset] ?? 0) |
|
||||
((bytes[offset + 1] ?? 0) << 8) |
|
||||
((bytes[offset + 2] ?? 0) << 16)
|
||||
);
|
||||
}
|
||||
|
||||
function readU32le(bytes: Uint8Array, offset: number): number {
|
||||
return (
|
||||
(bytes[offset] ?? 0) +
|
||||
(bytes[offset + 1] ?? 0) * 0x100 +
|
||||
(bytes[offset + 2] ?? 0) * 0x10000 +
|
||||
(bytes[offset + 3] ?? 0) * 0x1000000
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { categoryForName, FindingCollector } from "./findings";
|
||||
|
||||
const NAMED_VALUES =
|
||||
/(?:<|\s)([A-Za-z_][\w.-]*:[A-Za-z_][\w.-]*)(?:\s*=\s*["']([^"']*)["']|[^>]*>([^<]{1,65536})<)/gu;
|
||||
|
||||
export function scanXmp(
|
||||
input: Uint8Array | string,
|
||||
source: string,
|
||||
offset: number | undefined,
|
||||
collector: FindingCollector,
|
||||
): void {
|
||||
const raw = typeof input === "string" ? input : decodeMetadataText(input);
|
||||
if (!raw.trim()) return;
|
||||
let matched = 0;
|
||||
for (const match of raw.matchAll(NAMED_VALUES)) {
|
||||
const name = match[1] ?? "XMP value";
|
||||
const value = decodeXmlEntities(match[2] ?? match[3] ?? "");
|
||||
if (!value.trim()) continue;
|
||||
const classification = categoryForName(name);
|
||||
if (
|
||||
classification.category !== "unknown" ||
|
||||
/(?:xmp|rdf|dc|exif|photoshop|tiff):/iu.test(name)
|
||||
) {
|
||||
collector.add({
|
||||
...classification,
|
||||
source,
|
||||
label: name,
|
||||
value,
|
||||
offset,
|
||||
length: typeof input === "string" ? input.length : input.byteLength,
|
||||
});
|
||||
matched += 1;
|
||||
}
|
||||
}
|
||||
for (const token of ["c2pa", "jumbf", "provenance", "manifest"]) {
|
||||
if (raw.toLowerCase().includes(token)) {
|
||||
collector.add({
|
||||
category: "provenance",
|
||||
risk: "context",
|
||||
source,
|
||||
label: "Provenance marker",
|
||||
value: token.toUpperCase(),
|
||||
offset,
|
||||
length: typeof input === "string" ? input.length : input.byteLength,
|
||||
});
|
||||
}
|
||||
}
|
||||
collector.add({
|
||||
category: matched > 0 ? "technical" : "unknown",
|
||||
risk: matched > 0 ? "technical" : "context",
|
||||
source,
|
||||
label: "Raw XMP packet",
|
||||
value: raw,
|
||||
offset,
|
||||
length: typeof input === "string" ? input.length : input.byteLength,
|
||||
});
|
||||
}
|
||||
|
||||
export function decodeMetadataText(bytes: Uint8Array): string {
|
||||
if (bytes.byteLength >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe)
|
||||
return new TextDecoder("utf-16le", { fatal: false }).decode(
|
||||
bytes.subarray(2),
|
||||
);
|
||||
if (bytes.byteLength >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff)
|
||||
return new TextDecoder("utf-16be", { fatal: false }).decode(
|
||||
bytes.subarray(2),
|
||||
);
|
||||
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||
}
|
||||
|
||||
function decodeXmlEntities(value: string): string {
|
||||
return value.replace(
|
||||
/&(?:amp|lt|gt|quot|apos|#\d+|#x[\da-f]+);/giu,
|
||||
(entity) => {
|
||||
if (entity === "&") return "&";
|
||||
if (entity === "<") return "<";
|
||||
if (entity === ">") return ">";
|
||||
if (entity === """) return '"';
|
||||
if (entity === "'") return "'";
|
||||
const hex = /^&#x([\da-f]+);$/iu.exec(entity);
|
||||
const decimal = /^&#(\d+);$/u.exec(entity);
|
||||
const codePoint = hex
|
||||
? Number.parseInt(hex[1] ?? "", 16)
|
||||
: decimal
|
||||
? Number.parseInt(decimal[1] ?? "", 10)
|
||||
: Number.NaN;
|
||||
return Number.isInteger(codePoint) &&
|
||||
codePoint >= 0 &&
|
||||
codePoint <= 0x10ffff
|
||||
? String.fromCodePoint(codePoint)
|
||||
: entity;
|
||||
},
|
||||
);
|
||||
}
|
||||
+757
@@ -0,0 +1,757 @@
|
||||
:root {
|
||||
--privacy-purple: #5946bc;
|
||||
--privacy-teal: #147d72;
|
||||
--privacy-warning: #9a5a08;
|
||||
--privacy-success: #147057;
|
||||
--toolbox-background: #f6f7fb;
|
||||
--toolbox-surface: #ffffff;
|
||||
--toolbox-surface-soft: #eff1f7;
|
||||
--toolbox-text: #202332;
|
||||
--toolbox-muted: #656b7d;
|
||||
--toolbox-border: #d9dce7;
|
||||
--toolbox-accent: var(--privacy-purple);
|
||||
--toolbox-accent-hover: #493caf;
|
||||
--toolbox-accent-soft: #ece9ff;
|
||||
--toolbox-accent-contrast: #ffffff;
|
||||
--toolbox-focus: var(--privacy-teal);
|
||||
--toolbox-danger: #b42342;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 20rem;
|
||||
min-height: 100%;
|
||||
background: var(--toolbox-background);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 20rem;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--toolbox-background);
|
||||
color: var(--toolbox-text);
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: color-mix(in srgb, var(--toolbox-focus) 36%, transparent);
|
||||
}
|
||||
|
||||
.toolbox-shell__main {
|
||||
width: min(100%, 90rem);
|
||||
padding: clamp(0.75rem, 1.8vw, 1.5rem);
|
||||
}
|
||||
|
||||
:where(button, input, select, textarea) {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
:where(button, .button) {
|
||||
min-height: 2.55rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 720;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
background-color 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled),
|
||||
.button:hover {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
:where(button, input, select, textarea, summary, a):focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast);
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
background: var(--toolbox-accent-hover);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.workbench {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.panel,
|
||||
.boundary-note {
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--toolbox-surface);
|
||||
box-shadow:
|
||||
0 1px 2px rgb(24 30 60 / 4%),
|
||||
0 9px 28px rgb(24 30 60 / 3%);
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
padding: clamp(1.1rem, 3vw, 2rem);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 88% 20%,
|
||||
color-mix(in srgb, var(--toolbox-focus) 12%, transparent),
|
||||
transparent 33%
|
||||
),
|
||||
var(--toolbox-surface);
|
||||
}
|
||||
|
||||
:where(.hero, .panel, .help-dialog, .fatal) :where(h1, h2, h3) {
|
||||
margin: 0;
|
||||
color: var(--toolbox-text);
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.17;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(1.7rem, 3vw, 2.5rem);
|
||||
}
|
||||
|
||||
.panel h2,
|
||||
.help-dialog h2 {
|
||||
font-size: clamp(1.25rem, 2vw, 1.65rem);
|
||||
}
|
||||
|
||||
.panel h3,
|
||||
.help-dialog h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.hero p:not(.eyebrow) {
|
||||
max-width: 58rem;
|
||||
margin: 0.55rem 0 0;
|
||||
color: var(--toolbox-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.3rem;
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.69rem;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.115em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.privacy-pill,
|
||||
.status-badge {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: max-content;
|
||||
padding: 0.38rem 0.62rem;
|
||||
border: 1px solid color-mix(in srgb, var(--toolbox-accent) 25%, transparent);
|
||||
border-radius: 999px;
|
||||
background: var(--toolbox-accent-soft);
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 760;
|
||||
line-height: 1.15;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
padding: clamp(0.85rem, 2vw, 1.15rem);
|
||||
}
|
||||
|
||||
.panel-heading,
|
||||
.file-card-heading,
|
||||
.verification-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.action-heading {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.limit-note {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
width: 100%;
|
||||
min-height: 10rem;
|
||||
flex-direction: column;
|
||||
padding: 1.25rem;
|
||||
border: 1.5px dashed
|
||||
color-mix(in srgb, var(--toolbox-accent) 45%, var(--toolbox-border));
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-accent-soft) 54%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.drop-zone > strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.drop-zone > span:last-child {
|
||||
max-width: 42rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 520;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.drop-zone.is-dragging {
|
||||
border-style: solid;
|
||||
border-color: var(--toolbox-focus);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-focus) 12%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast) !important;
|
||||
font-size: 1.2rem !important;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.8rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.progress progress {
|
||||
width: 100%;
|
||||
height: 0.55rem;
|
||||
accent-color: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.inventory-table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.72rem;
|
||||
}
|
||||
|
||||
.inventory-table {
|
||||
width: 100%;
|
||||
min-width: 52rem;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.77rem;
|
||||
}
|
||||
|
||||
.inventory-table :where(th, td) {
|
||||
padding: 0.65rem 0.72rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.inventory-table th {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
background: var(--toolbox-surface-soft);
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.inventory-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.inventory-table td {
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
.inventory-table td > :where(strong, span, code) {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.inventory-table td > span:not(.status-badge) {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.inventory-table .status-badge {
|
||||
margin-top: 0.28rem;
|
||||
}
|
||||
|
||||
.status-badge.is-mismatch,
|
||||
.status-badge.is-failed {
|
||||
border-color: color-mix(in srgb, var(--toolbox-danger) 35%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-danger) 10%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
|
||||
.status-badge.is-warning,
|
||||
.status-badge.is-unknown,
|
||||
.status-badge.is-unclaimed {
|
||||
border-color: color-mix(in srgb, var(--privacy-warning) 35%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--privacy-warning) 10%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
color: var(--privacy-warning);
|
||||
}
|
||||
|
||||
.status-badge.is-verified,
|
||||
.status-badge.is-match {
|
||||
border-color: color-mix(in srgb, var(--privacy-success) 35%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--privacy-success) 10%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
color: var(--privacy-success);
|
||||
}
|
||||
|
||||
.file-results {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.file-card-heading {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hash-line {
|
||||
max-width: 70rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hash-line code,
|
||||
.finding-list code,
|
||||
.verification-grid code {
|
||||
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.warning-list,
|
||||
.error-message,
|
||||
.boundary-note {
|
||||
margin: 0;
|
||||
padding: 0.72rem 0.86rem;
|
||||
border-radius: 0.68rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.warning-list {
|
||||
padding-left: 2rem;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--privacy-warning) 32%, var(--toolbox-border));
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--privacy-warning) 7%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
color: color-mix(in srgb, var(--privacy-warning) 82%, var(--toolbox-text));
|
||||
}
|
||||
|
||||
.error-message {
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--toolbox-danger) 32%, var(--toolbox-border));
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-danger) 7%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
|
||||
.finding-summary {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.finding-summary > p {
|
||||
margin: 0;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.finding-summary details,
|
||||
.verification details {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--toolbox-surface);
|
||||
}
|
||||
|
||||
.finding-summary summary,
|
||||
.verification summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.7rem;
|
||||
padding: 0.62rem 0.72rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
font-size: 0.77rem;
|
||||
font-weight: 730;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.finding-list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.finding-list > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(9rem, 0.32fr) minmax(0, 1fr);
|
||||
gap: 0.8rem;
|
||||
padding: 0.62rem 0.72rem;
|
||||
}
|
||||
|
||||
.finding-list > div + div {
|
||||
border-top: 1px solid var(--toolbox-border);
|
||||
}
|
||||
|
||||
.finding-list > div > div {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.finding-list strong {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.finding-list span {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.finding-list code {
|
||||
overflow: auto;
|
||||
max-height: 12rem;
|
||||
padding: 0.45rem;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
color: var(--toolbox-text);
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.finding-list .omitted-findings {
|
||||
margin: 0;
|
||||
padding: 0.55rem 0.72rem;
|
||||
border-top: 1px solid var(--toolbox-border);
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.verification {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--privacy-success) 30%, var(--toolbox-border));
|
||||
border-radius: 0.75rem;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--privacy-success) 5%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
}
|
||||
|
||||
.verification.is-warning {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--privacy-warning) 35%,
|
||||
var(--toolbox-border)
|
||||
);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--privacy-warning) 5%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
}
|
||||
|
||||
.verification.is-failed {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-danger) 35%,
|
||||
var(--toolbox-border)
|
||||
);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-danger) 5%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
}
|
||||
|
||||
.verification-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.verification-grid > div {
|
||||
min-width: 0;
|
||||
padding: 0.62rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.58rem;
|
||||
background: var(--toolbox-surface);
|
||||
}
|
||||
|
||||
.verification-grid dt {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.67rem;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.verification-grid dd {
|
||||
margin: 0.3rem 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.verification details ul {
|
||||
margin: 0;
|
||||
padding: 0.7rem 0.7rem 0.7rem 2rem;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin: 0;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.73rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.boundary-note {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--privacy-warning) 30%,
|
||||
var(--toolbox-border)
|
||||
);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--privacy-warning) 6%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
}
|
||||
|
||||
.help-dialog {
|
||||
width: min(48rem, calc(100% - 2rem));
|
||||
max-height: calc(100vh - 2rem);
|
||||
padding: clamp(1rem, 2.5vw, 1.4rem);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
box-shadow: 0 24px 80px rgb(10 16 38 / 28%);
|
||||
}
|
||||
|
||||
.help-dialog::backdrop {
|
||||
background: rgb(20 24 45 / 55%);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.dialog-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.dialog-heading button {
|
||||
width: 2.55rem;
|
||||
min-width: 2.55rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.help-sections {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.help-sections section {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.45rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.68rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.help-sections p {
|
||||
margin: 0;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.fatal {
|
||||
width: min(100% - 2rem, 60rem);
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 64rem) {
|
||||
.verification-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.hero,
|
||||
.panel-heading,
|
||||
.file-card-heading,
|
||||
.verification-heading {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.privacy-pill {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.action-heading {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.finding-list > div,
|
||||
.help-sections {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 32rem) {
|
||||
.toolbox-shell__main {
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
.button-row > button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.verification-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
globalThis.localStorage?.clear();
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.privacy-tools",
|
||||
"name": "Privacy Tools",
|
||||
"version": "0.1.0",
|
||||
"description": "Inspect and remove shareable-file metadata locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["privacy", "files", "security"],
|
||||
"tags": ["metadata", "exif", "privacy", "sanitize", "share"],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
"embedding": "unsupported"
|
||||
},
|
||||
"requirements": {
|
||||
"secureContext": true,
|
||||
"workers": true,
|
||||
"indexedDb": false,
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
"telemetry": false,
|
||||
"label": "Inputs stay in this browser; nothing is uploaded."
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/lotobo/privacy-tools",
|
||||
"license": "GPL-3.0-or-later"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "source",
|
||||
"label": "Source",
|
||||
"url": "https://git.add-ideas.de/lotobo/privacy-tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
|
||||
import source from "./manifest.source.json";
|
||||
|
||||
export const manifest = defineToolboxApp(parseToolboxApp(source));
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ImageScanResult, ScanInput } from "../privacy/model";
|
||||
|
||||
export interface ScanFileRequest {
|
||||
type: "scan-file";
|
||||
requestId: string;
|
||||
file: ScanInput;
|
||||
}
|
||||
|
||||
export type ScanWorkerRequest = ScanFileRequest;
|
||||
|
||||
export type ScanWorkerResponse =
|
||||
| {
|
||||
type: "scanned";
|
||||
requestId: string;
|
||||
result: ImageScanResult;
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
requestId: string;
|
||||
message: string;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { scanImageBytes } from "../privacy/scanner";
|
||||
import type { ScanWorkerRequest, ScanWorkerResponse } from "./protocol";
|
||||
|
||||
const scope = self as DedicatedWorkerGlobalScope;
|
||||
|
||||
scope.addEventListener("message", (event: MessageEvent<ScanWorkerRequest>) => {
|
||||
if (event.data.type === "scan-file") void scanFile(event.data);
|
||||
});
|
||||
|
||||
async function scanFile(request: ScanWorkerRequest): Promise<void> {
|
||||
try {
|
||||
post({
|
||||
type: "scanned",
|
||||
requestId: request.requestId,
|
||||
result: await scanImageBytes(request.file),
|
||||
});
|
||||
} catch (error) {
|
||||
post({
|
||||
type: "error",
|
||||
requestId: request.requestId,
|
||||
message:
|
||||
error instanceof Error ? error.message : "Metadata scanning failed.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function post(message: ScanWorkerResponse): void {
|
||||
scope.postMessage(message);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { pngFixture, tiffFixture } from "../fixtures/images";
|
||||
|
||||
const ORIGIN = "http://127.0.0.1:4173";
|
||||
async function localOnly(page: Page) {
|
||||
const external: string[] = [];
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== ORIGIN) {
|
||||
external.push(url.href);
|
||||
await route.abort();
|
||||
} else await route.continue();
|
||||
});
|
||||
return external;
|
||||
}
|
||||
|
||||
test("runs from a nested path without external requests", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
const external = await localOnly(page);
|
||||
await page.goto("/deep/nested/privacy/");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Privacy Tools" }),
|
||||
).toBeVisible();
|
||||
expect(external).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("serves the release identity and hardened headers", async ({
|
||||
request,
|
||||
}) => {
|
||||
const index = await request.get("/deep/nested/privacy/");
|
||||
expect(index.ok()).toBe(true);
|
||||
expect(index.headers()["content-security-policy"]).toContain(
|
||||
"default-src 'self'",
|
||||
);
|
||||
expect(await index.text()).not.toMatch(/\b(?:src|href)=["']\//u);
|
||||
const manifest = await request.get("/deep/nested/privacy/toolbox-app.json");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.privacy-tools",
|
||||
version: "0.1.0",
|
||||
entry: "./",
|
||||
});
|
||||
});
|
||||
|
||||
test("inspects and independently verifies a re-encoded PNG without network access", async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
const external = await localOnly(page);
|
||||
await page.goto("/deep/nested/privacy/");
|
||||
|
||||
await page.locator('input[type="file"]').setInputFiles([
|
||||
{
|
||||
name: "metadata-fixture.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(pngFixture()),
|
||||
},
|
||||
{
|
||||
name: "inventory-only.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("%PDF-1.7\n% fixture"),
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Batch inventory" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Alice PNG").first()).toBeVisible();
|
||||
await expect(page.getByText("PNG Alice").first()).toBeVisible();
|
||||
await expect(page.getByText("inventory-only.pdf").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Re-encode & verify" }).click();
|
||||
await expect(page.getByText("Mandatory output re-scan")).toBeVisible();
|
||||
await expect(page.locator(".verification")).not.toHaveClass(/is-failed/u);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Download re-encoded output" }),
|
||||
).toBeEnabled();
|
||||
|
||||
const imageDownload = page.waitForEvent("download");
|
||||
await page
|
||||
.getByRole("button", { name: "Download re-encoded output" })
|
||||
.click();
|
||||
expect((await imageDownload).suggestedFilename()).toBe(
|
||||
"metadata-fixture.clean.png",
|
||||
);
|
||||
|
||||
const reportDownload = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Download JSON report" }).click();
|
||||
expect((await reportDownload).suggestedFilename()).toBe(
|
||||
"privacy-tools-report.json",
|
||||
);
|
||||
const archiveDownload = page.waitForEvent("download");
|
||||
await page
|
||||
.getByRole("button", { name: "Download 1 re-encoded image + report" })
|
||||
.click();
|
||||
expect((await archiveDownload).suggestedFilename()).toBe(
|
||||
"privacy-tools-re-encoded-images.zip",
|
||||
);
|
||||
expect(external).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("normalizes EXIF orientation while re-encoding JPEG pixels", async ({
|
||||
page,
|
||||
}) => {
|
||||
const external = await localOnly(page);
|
||||
await page.goto("/deep/nested/privacy/");
|
||||
const encoded = await page.evaluate(async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 2;
|
||||
canvas.height = 1;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Missing test canvas");
|
||||
context.fillStyle = "#ff0000";
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
context.fillStyle = "#0000ff";
|
||||
context.fillRect(1, 0, 1, 1);
|
||||
const blob = await new Promise<Blob>((resolve, reject) =>
|
||||
canvas.toBlob(
|
||||
(value) =>
|
||||
value ? resolve(value) : reject(new Error("JPEG encode failed")),
|
||||
"image/jpeg",
|
||||
0.95,
|
||||
),
|
||||
);
|
||||
return [...new Uint8Array(await blob.arrayBuffer())];
|
||||
});
|
||||
const jpeg = addExifOrientation(Buffer.from(encoded), 6);
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: "oriented.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
buffer: jpeg,
|
||||
});
|
||||
await expect(page.getByText("2 × 1 pixels")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Re-encode & verify" }).click();
|
||||
await expect(page.getByText("Mandatory output re-scan")).toBeVisible();
|
||||
await expect(page.locator(".verification")).not.toHaveClass(/is-failed/u);
|
||||
await expect(page.getByText("Normalized", { exact: true })).toBeVisible();
|
||||
expect(external).toEqual([]);
|
||||
});
|
||||
|
||||
function addExifOrientation(jpeg: Buffer, orientation: number): Buffer {
|
||||
const payload = Buffer.concat([
|
||||
Buffer.from("Exif\0\0", "binary"),
|
||||
Buffer.from(tiffFixture({ orientation })),
|
||||
]);
|
||||
const segment = Buffer.from([
|
||||
0xff,
|
||||
0xe1,
|
||||
((payload.length + 2) >>> 8) & 0xff,
|
||||
(payload.length + 2) & 0xff,
|
||||
]);
|
||||
return Buffer.concat([
|
||||
jpeg.subarray(0, 2),
|
||||
segment,
|
||||
payload,
|
||||
jpeg.subarray(2),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { App } from "../../src/App";
|
||||
|
||||
describe("Privacy Tools", () => {
|
||||
it("renders the local workbench and standard shell", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("Not found", { status: 404 })),
|
||||
);
|
||||
render(<App />);
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Privacy Tools" }),
|
||||
).toBeVisible();
|
||||
expect(await screen.findByText("Local & ephemeral")).toBeVisible();
|
||||
expect(screen.getAllByText(/No anonymity promise/iu)).not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Vendored
+253
@@ -0,0 +1,253 @@
|
||||
import { crc32 } from "@add-ideas/toolbox-helpers";
|
||||
import { zlibSync } from "fflate";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function jpegFixture(options: { trailing?: boolean } = {}): Uint8Array {
|
||||
const xmp = encoder.encode(
|
||||
"http://ns.adobe.com/xap/1.0/\0" +
|
||||
'<?xpacket begin=""><x:xmpmeta xmlns:x="adobe:ns:meta/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:exif="http://ns.adobe.com/exif/1.0/" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/"><rdf:RDF><rdf:Description><dc:creator>Alice Example</dc:creator><exif:GPSLatitude>48.1 N</exif:GPSLatitude><xmp:CreatorTool>Fixture Camera</xmp:CreatorTool><xmpMM:DocumentID>doc-123</xmpMM:DocumentID></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>',
|
||||
);
|
||||
const jfif = concat(
|
||||
encoder.encode("JFIF\0"),
|
||||
bytes(1, 2, 0),
|
||||
u16be(72),
|
||||
u16be(72),
|
||||
bytes(0, 0),
|
||||
);
|
||||
const iptc = concat(
|
||||
iptcDataset(80, "Alice Reporter"),
|
||||
iptcDataset(90, "Berlin"),
|
||||
iptcDataset(120, "Private caption"),
|
||||
);
|
||||
const photoshop = concat(
|
||||
encoder.encode("Photoshop 3.0\0"),
|
||||
encoder.encode("8BIM"),
|
||||
u16be(0x0404),
|
||||
bytes(0, 0),
|
||||
u32be(iptc.byteLength),
|
||||
iptc,
|
||||
iptc.byteLength % 2 ? bytes(0) : bytes(),
|
||||
);
|
||||
const body = concat(
|
||||
bytes(0xff, 0xd8),
|
||||
jpegSegment(0xe0, jfif),
|
||||
jpegSegment(0xe1, concat(encoder.encode("Exif\0\0"), tiffFixture())),
|
||||
jpegSegment(0xe1, xmp),
|
||||
jpegSegment(
|
||||
0xe2,
|
||||
concat(
|
||||
encoder.encode("ICC_PROFILE\0"),
|
||||
bytes(1, 1),
|
||||
encoder.encode("icc"),
|
||||
),
|
||||
),
|
||||
jpegSegment(0xeb, encoder.encode("jumb/c2pa test manifest")),
|
||||
jpegSegment(0xed, photoshop),
|
||||
jpegSegment(0xfe, encoder.encode("private jpeg comment")),
|
||||
jpegSegment(0xc0, bytes(8, 0, 2, 0, 3, 1, 1, 0x11, 0)),
|
||||
bytes(0xff, 0xda, 0xff, 0xd9),
|
||||
);
|
||||
return options.trailing ? concat(body, encoder.encode("hidden")) : body;
|
||||
}
|
||||
|
||||
export function pngFixture(
|
||||
options: {
|
||||
cycleTiff?: boolean;
|
||||
inflatedCommentBytes?: number;
|
||||
animated?: boolean;
|
||||
trailing?: boolean;
|
||||
} = {},
|
||||
): Uint8Array {
|
||||
const width = 2;
|
||||
const height = 1;
|
||||
const ihdr = concat(u32be(width), u32be(height), bytes(8, 6, 0, 0, 0));
|
||||
const xmp = encoder.encode(
|
||||
"XML:com.adobe.xmp\0\0\0\0\0" +
|
||||
'<rdf:Description xmlns:rdf="urn:rdf"><dc:creator xmlns:dc="urn:dc">PNG Alice</dc:creator><xmp:CreatorTool xmlns:xmp="urn:xmp">PNG Fixture</xmp:CreatorTool></rdf:Description>',
|
||||
);
|
||||
const inflated = encoder.encode(
|
||||
"x".repeat(options.inflatedCommentBytes ?? 32),
|
||||
);
|
||||
const scanline = bytes(0, 255, 0, 0, 255, 0, 0, 255, 255);
|
||||
const chunks = [
|
||||
pngChunk("IHDR", ihdr),
|
||||
pngChunk("tEXt", encoder.encode("Author\0Alice PNG")),
|
||||
pngChunk(
|
||||
"zTXt",
|
||||
concat(encoder.encode("Comment\0"), bytes(0), zlibSync(inflated)),
|
||||
),
|
||||
pngChunk("iTXt", xmp),
|
||||
pngChunk("eXIf", tiffFixture({ cycle: options.cycleTiff })),
|
||||
pngChunk(
|
||||
"iCCP",
|
||||
concat(
|
||||
encoder.encode("fixture profile\0"),
|
||||
bytes(0),
|
||||
zlibSync(encoder.encode("ICC profile bytes")),
|
||||
),
|
||||
),
|
||||
pngChunk("vpAg", encoder.encode("private ancillary data")),
|
||||
...(options.animated ? [pngChunk("acTL", concat(u32be(2), u32be(0)))] : []),
|
||||
pngChunk("IDAT", zlibSync(scanline)),
|
||||
pngChunk("IEND", bytes()),
|
||||
];
|
||||
const image = concat(
|
||||
bytes(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
|
||||
...chunks,
|
||||
);
|
||||
return options.trailing
|
||||
? concat(image, encoder.encode("trailing secret"))
|
||||
: image;
|
||||
}
|
||||
|
||||
export function webpFixture(options: { animated?: boolean } = {}): Uint8Array {
|
||||
const vp8x = bytes(options.animated ? 0x02 : 0, 0, 0, 0, 3, 0, 0, 2, 0, 0);
|
||||
const dimensions = 3 | (2 << 14);
|
||||
const vp8l = concat(bytes(0x2f), u32le(dimensions));
|
||||
const xmp = encoder.encode(
|
||||
'<rdf:Description xmlns:rdf="urn:rdf"><dc:creator xmlns:dc="urn:dc">WebP Alice</dc:creator><exif:GPSLongitude xmlns:exif="urn:exif">13.4 E</exif:GPSLongitude></rdf:Description>',
|
||||
);
|
||||
const chunks = concat(
|
||||
riffChunk("VP8X", vp8x),
|
||||
riffChunk("VP8L", vp8l),
|
||||
riffChunk("EXIF", concat(encoder.encode("Exif\0\0"), tiffFixture())),
|
||||
riffChunk("XMP ", xmp),
|
||||
riffChunk("ICCP", encoder.encode("ICC profile bytes")),
|
||||
...(options.animated ? [riffChunk("ANIM", bytes(0, 0, 0, 0, 0, 0))] : []),
|
||||
);
|
||||
return concat(
|
||||
encoder.encode("RIFF"),
|
||||
u32le(chunks.byteLength + 4),
|
||||
encoder.encode("WEBP"),
|
||||
chunks,
|
||||
);
|
||||
}
|
||||
|
||||
export function tiffFixture(
|
||||
options: { cycle?: boolean; orientation?: number } = {},
|
||||
): Uint8Array {
|
||||
const make = encoder.encode("CameraCo\0");
|
||||
const date = encoder.encode("2026:09:01 12:34:56\0");
|
||||
const gpsDate = encoder.encode("2026:09:01\0");
|
||||
const mainOffset = 8;
|
||||
const mainEntries = 4;
|
||||
const mainEnd = mainOffset + 2 + mainEntries * 12;
|
||||
const dataStart = mainEnd + 4;
|
||||
const makeOffset = dataStart;
|
||||
const dateOffset = makeOffset + make.byteLength;
|
||||
const gpsOffset = dateOffset + date.byteLength;
|
||||
const gpsEntries = 2;
|
||||
const gpsEnd = gpsOffset + 2 + gpsEntries * 12;
|
||||
const gpsDateOffset = gpsEnd + 4;
|
||||
const result = new Uint8Array(gpsDateOffset + gpsDate.byteLength);
|
||||
const view = new DataView(result.buffer);
|
||||
result.set(bytes(0x49, 0x49), 0);
|
||||
view.setUint16(2, 42, true);
|
||||
view.setUint32(4, mainOffset, true);
|
||||
view.setUint16(mainOffset, mainEntries, true);
|
||||
writeIfdEntry(view, mainOffset + 2, 0x010f, 2, make.byteLength, makeOffset);
|
||||
writeIfdEntry(view, mainOffset + 14, 0x0112, 3, 1, options.orientation ?? 1);
|
||||
writeIfdEntry(view, mainOffset + 26, 0x0132, 2, date.byteLength, dateOffset);
|
||||
writeIfdEntry(view, mainOffset + 38, 0x8825, 4, 1, gpsOffset);
|
||||
view.setUint32(mainEnd, options.cycle ? mainOffset : 0, true);
|
||||
result.set(make, makeOffset);
|
||||
result.set(date, dateOffset);
|
||||
view.setUint16(gpsOffset, gpsEntries, true);
|
||||
writeIfdEntry(view, gpsOffset + 2, 1, 2, 2, 0x4e);
|
||||
writeIfdEntry(view, gpsOffset + 14, 29, 2, gpsDate.byteLength, gpsDateOffset);
|
||||
view.setUint32(gpsEnd, 0, true);
|
||||
result.set(gpsDate, gpsDateOffset);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function malformedPngLengthFixture(): Uint8Array {
|
||||
const image = pngFixture();
|
||||
const malformed = image.slice();
|
||||
new DataView(malformed.buffer).setUint32(8, 0xfffffff0, false);
|
||||
return malformed;
|
||||
}
|
||||
|
||||
export function toArrayBuffer(value: Uint8Array): ArrayBuffer {
|
||||
return value.slice().buffer as ArrayBuffer;
|
||||
}
|
||||
|
||||
function jpegSegment(marker: number, payload: Uint8Array): Uint8Array {
|
||||
return concat(bytes(0xff, marker), u16be(payload.byteLength + 2), payload);
|
||||
}
|
||||
|
||||
function iptcDataset(dataset: number, value: string): Uint8Array {
|
||||
const encoded = encoder.encode(value);
|
||||
return concat(bytes(0x1c, 2, dataset), u16be(encoded.byteLength), encoded);
|
||||
}
|
||||
|
||||
function pngChunk(type: string, data: Uint8Array): Uint8Array {
|
||||
const typeBytes = encoder.encode(type);
|
||||
const checksumInput = concat(typeBytes, data);
|
||||
return concat(
|
||||
u32be(data.byteLength),
|
||||
checksumInput,
|
||||
u32be(crc32(checksumInput, 16 * 1024 * 1024)),
|
||||
);
|
||||
}
|
||||
|
||||
function riffChunk(type: string, data: Uint8Array): Uint8Array {
|
||||
return concat(
|
||||
encoder.encode(type),
|
||||
u32le(data.byteLength),
|
||||
data,
|
||||
data.byteLength % 2 ? bytes(0) : bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
function writeIfdEntry(
|
||||
view: DataView,
|
||||
offset: number,
|
||||
tag: number,
|
||||
type: number,
|
||||
count: number,
|
||||
value: number,
|
||||
): void {
|
||||
view.setUint16(offset, tag, true);
|
||||
view.setUint16(offset + 2, type, true);
|
||||
view.setUint32(offset + 4, count, true);
|
||||
if (type === 3 && count === 1) view.setUint16(offset + 8, value, true);
|
||||
else view.setUint32(offset + 8, value, true);
|
||||
}
|
||||
|
||||
function concat(...parts: readonly Uint8Array[]): Uint8Array {
|
||||
const size = parts.reduce((total, part) => total + part.byteLength, 0);
|
||||
const result = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
result.set(part, offset);
|
||||
offset += part.byteLength;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function bytes(...values: readonly number[]): Uint8Array {
|
||||
return Uint8Array.from(values);
|
||||
}
|
||||
|
||||
function u16be(value: number): Uint8Array {
|
||||
return bytes((value >>> 8) & 0xff, value & 0xff);
|
||||
}
|
||||
|
||||
function u32be(value: number): Uint8Array {
|
||||
return bytes(
|
||||
(value >>> 24) & 0xff,
|
||||
(value >>> 16) & 0xff,
|
||||
(value >>> 8) & 0xff,
|
||||
value & 0xff,
|
||||
);
|
||||
}
|
||||
|
||||
function u32le(value: number): Uint8Array {
|
||||
return bytes(
|
||||
value & 0xff,
|
||||
(value >>> 8) & 0xff,
|
||||
(value >>> 16) & 0xff,
|
||||
(value >>> 24) & 0xff,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
detectKind,
|
||||
fileExtension,
|
||||
inventoryIdentity,
|
||||
} from "../../src/privacy";
|
||||
import { jpegFixture, pngFixture, webpFixture } from "../fixtures/images";
|
||||
|
||||
describe("file identity", () => {
|
||||
it("detects supported containers from bytes", () => {
|
||||
expect(detectKind(jpegFixture())).toBe("jpeg");
|
||||
expect(detectKind(pngFixture())).toBe("png");
|
||||
expect(detectKind(webpFixture())).toBe("webp");
|
||||
expect(detectKind(Uint8Array.from([0x25, 0x50, 0x44, 0x46, 0x2d]))).toBe(
|
||||
"pdf",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes leaf extensions without trusting paths", () => {
|
||||
expect(fileExtension("folder\\PHOTO.JPEG")).toBe("jpeg");
|
||||
expect(fileExtension(".hidden")).toBe("");
|
||||
expect(fileExtension("no-extension")).toBe("");
|
||||
});
|
||||
|
||||
it("reports claimed and extension mismatches", () => {
|
||||
const identity = inventoryIdentity(
|
||||
"portrait.jpg",
|
||||
"image/jpeg",
|
||||
pngFixture(),
|
||||
);
|
||||
expect(identity).toMatchObject({
|
||||
detectedKind: "png",
|
||||
detectedType: "image/png",
|
||||
typeMatch: "mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer an unknown payload from its extension", () => {
|
||||
const identity = inventoryIdentity(
|
||||
"claim.png",
|
||||
"image/png",
|
||||
Uint8Array.from([1, 2, 3]),
|
||||
);
|
||||
expect(identity.detectedKind).toBe("unknown");
|
||||
expect(identity.typeMatch).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
assertBatchFiles,
|
||||
PrivacyLimitError,
|
||||
resolveLimits,
|
||||
} from "../../src/privacy";
|
||||
|
||||
describe("privacy resource limits", () => {
|
||||
it("rejects too many, oversized and collectively oversized files", () => {
|
||||
expect(() =>
|
||||
assertBatchFiles([{ size: 1 }, { size: 1 }], {
|
||||
...resolveLimits(),
|
||||
maxFiles: 1,
|
||||
}),
|
||||
).toThrow(PrivacyLimitError);
|
||||
expect(() =>
|
||||
assertBatchFiles([{ size: 11 }], {
|
||||
...resolveLimits(),
|
||||
maxFileBytes: 10,
|
||||
}),
|
||||
).toThrow(PrivacyLimitError);
|
||||
expect(() =>
|
||||
assertBatchFiles([{ size: 6 }, { size: 6 }], {
|
||||
...resolveLimits(),
|
||||
maxFileBytes: 10,
|
||||
maxBatchBytes: 10,
|
||||
}),
|
||||
).toThrow(PrivacyLimitError);
|
||||
});
|
||||
|
||||
it("rejects invalid limit overrides", () => {
|
||||
expect(() => resolveLimits({ maxFiles: 0 })).toThrow(
|
||||
/positive safe integer/iu,
|
||||
);
|
||||
expect(() => resolveLimits({ maxFileBytes: Number.NaN })).toThrow(
|
||||
/positive safe integer/iu,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { unzipSync } from "fflate";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildSanitizationReport,
|
||||
createBatchArchive,
|
||||
createBatchReport,
|
||||
serializeReport,
|
||||
type ImageScanResult,
|
||||
type MetadataFinding,
|
||||
} from "../../src/privacy";
|
||||
|
||||
const sensitive: MetadataFinding = {
|
||||
id: "gps-1",
|
||||
category: "location",
|
||||
risk: "sensitive",
|
||||
source: "EXIF",
|
||||
label: "GPS Latitude",
|
||||
value: "48.1 N",
|
||||
};
|
||||
|
||||
describe("sanitization and batch reports", () => {
|
||||
it("marks a complete sensitive-free output verified and records removals", () => {
|
||||
const source = result({ findings: [sensitive] });
|
||||
const output = result({ id: "clean", name: "clean.png", findings: [] });
|
||||
const report = buildSanitizationReport(
|
||||
source,
|
||||
output,
|
||||
"clean.png",
|
||||
"image/png",
|
||||
20,
|
||||
comparison(true),
|
||||
);
|
||||
expect(report.status).toBe("verified");
|
||||
expect(report.removed).toEqual([sensitive]);
|
||||
expect(report.preserved).toEqual([]);
|
||||
expect(report.orientationNormalized).toBe(true);
|
||||
expect(report.disclaimer).toMatch(/not an anonymity guarantee/iu);
|
||||
});
|
||||
|
||||
it("fails a partial or sensitive output re-scan", () => {
|
||||
const source = result({ findings: [sensitive] });
|
||||
const output = result({
|
||||
id: "clean",
|
||||
findings: [sensitive],
|
||||
coverage: {
|
||||
projectScanner: "partial",
|
||||
secondaryScanner: "complete",
|
||||
notes: [],
|
||||
},
|
||||
});
|
||||
const report = buildSanitizationReport(
|
||||
source,
|
||||
output,
|
||||
"clean.png",
|
||||
"image/png",
|
||||
20,
|
||||
comparison(false),
|
||||
);
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.preserved).toEqual([sensitive]);
|
||||
expect(report.incomplete).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("reports an unknown browser-generated output chunk as a warning", () => {
|
||||
const source = result();
|
||||
const output = result({
|
||||
id: "clean",
|
||||
findings: [
|
||||
{
|
||||
id: "private-1",
|
||||
category: "unknown",
|
||||
risk: "context",
|
||||
source: "PNG",
|
||||
label: "Private chunk deBG",
|
||||
value: "16 bytes",
|
||||
},
|
||||
],
|
||||
blocks: [{ kind: "deBG", offset: 40, length: 16 }],
|
||||
});
|
||||
const report = buildSanitizationReport(
|
||||
source,
|
||||
output,
|
||||
"clean.png",
|
||||
"image/png",
|
||||
20,
|
||||
comparison(true),
|
||||
);
|
||||
expect(report.status).toBe("warning");
|
||||
expect(report.generated).toHaveLength(1);
|
||||
expect(report.incomplete.join(" ")).toContain("deBG");
|
||||
});
|
||||
|
||||
it("fails a changed decoded sample for lossless PNG", () => {
|
||||
const report = buildSanitizationReport(
|
||||
result(),
|
||||
result({ id: "clean" }),
|
||||
"clean.png",
|
||||
"image/png",
|
||||
20,
|
||||
comparison(false),
|
||||
);
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.incomplete.join(" ")).toMatch(/pixel sample/iu);
|
||||
});
|
||||
|
||||
it("produces deterministic, safe JSON and a stored batch ZIP", async () => {
|
||||
const source = result({ name: "../private.png", findings: [sensitive] });
|
||||
const output = result({ id: "clean", name: "private.clean.png" });
|
||||
const report = buildSanitizationReport(
|
||||
source,
|
||||
output,
|
||||
"../same.png",
|
||||
"image/png",
|
||||
3,
|
||||
comparison(true),
|
||||
);
|
||||
const assets = [
|
||||
{ blob: new Blob([Uint8Array.of(1, 2, 3)]), report },
|
||||
{ blob: new Blob([Uint8Array.of(4, 5, 6)]), report },
|
||||
];
|
||||
const generatedAt = "2026-09-01T00:00:00.000Z";
|
||||
const batch = createBatchReport([source], assets, generatedAt);
|
||||
const json = serializeReport(batch);
|
||||
expect(JSON.parse(json)).toMatchObject({ schemaVersion: 1, generatedAt });
|
||||
expect(json).toContain("report may itself contain sensitive");
|
||||
|
||||
const archive = await createBatchArchive([source], assets, generatedAt);
|
||||
const entries = unzipSync(new Uint8Array(await archive.arrayBuffer()));
|
||||
expect(Object.keys(entries).sort()).toEqual([
|
||||
"images/_same-2.png",
|
||||
"images/_same.png",
|
||||
"privacy-tools-report.json",
|
||||
]);
|
||||
expect(
|
||||
JSON.parse(
|
||||
new TextDecoder().decode(entries["privacy-tools-report.json"]),
|
||||
),
|
||||
).toMatchObject({ schemaVersion: 1, generatedAt });
|
||||
});
|
||||
|
||||
it("bounds archive entry count before reading output blobs", async () => {
|
||||
const source = result();
|
||||
const output = result({ id: "clean" });
|
||||
const report = buildSanitizationReport(
|
||||
source,
|
||||
output,
|
||||
"clean.png",
|
||||
"image/png",
|
||||
1,
|
||||
comparison(true),
|
||||
);
|
||||
const asset = { blob: new Blob([Uint8Array.of(0)]), report };
|
||||
await expect(
|
||||
createBatchArchive(
|
||||
[source],
|
||||
Array.from({ length: 101 }, () => asset),
|
||||
),
|
||||
).rejects.toThrow(/Archive image count/iu);
|
||||
});
|
||||
});
|
||||
|
||||
function comparison(identical: boolean) {
|
||||
return {
|
||||
method: "oriented-256px-sample" as const,
|
||||
sourceDigest: "source-sample",
|
||||
outputDigest: identical ? "source-sample" : "output-sample",
|
||||
identical,
|
||||
note: "test comparison",
|
||||
};
|
||||
}
|
||||
|
||||
function result(overrides: Partial<ImageScanResult> = {}): ImageScanResult {
|
||||
return {
|
||||
id: "source",
|
||||
name: "source.png",
|
||||
safeName: "source.png",
|
||||
size: 100,
|
||||
lastModified: 0,
|
||||
sha256: "a".repeat(64),
|
||||
identity: {
|
||||
claimedType: "image/png",
|
||||
extension: "png",
|
||||
detectedKind: "png",
|
||||
detectedType: "image/png",
|
||||
typeMatch: "match",
|
||||
},
|
||||
width: 2,
|
||||
height: 1,
|
||||
orientation: 1,
|
||||
animated: false,
|
||||
multiImage: false,
|
||||
deepSupported: true,
|
||||
cleanable: true,
|
||||
findings: [],
|
||||
blocks: [],
|
||||
warnings: [],
|
||||
coverage: {
|
||||
projectScanner: "complete",
|
||||
secondaryScanner: "complete",
|
||||
notes: [],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PrivacyLimitError, scanImageBytes } from "../../src/privacy";
|
||||
import {
|
||||
jpegFixture,
|
||||
malformedPngLengthFixture,
|
||||
pngFixture,
|
||||
toArrayBuffer,
|
||||
webpFixture,
|
||||
} from "../fixtures/images";
|
||||
|
||||
describe("bounded image metadata scanner", () => {
|
||||
it("extracts JPEG EXIF, IPTC, XMP, JFIF, ICC and provenance", async () => {
|
||||
const result = await scan("camera.jpg", "image/jpeg", jpegFixture());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
width: 3,
|
||||
height: 2,
|
||||
orientation: 1,
|
||||
animated: false,
|
||||
multiImage: false,
|
||||
deepSupported: true,
|
||||
cleanable: true,
|
||||
});
|
||||
expect(result.coverage.projectScanner).toBe("complete");
|
||||
expect(result.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ category: "location" }),
|
||||
expect.objectContaining({ category: "identity" }),
|
||||
expect.objectContaining({ category: "timestamp" }),
|
||||
expect.objectContaining({ category: "device" }),
|
||||
expect.objectContaining({ category: "colour-profile" }),
|
||||
expect.objectContaining({ category: "provenance" }),
|
||||
]),
|
||||
);
|
||||
expect(result.blocks.map((block) => block.kind)).toEqual(
|
||||
expect.arrayContaining(["APP0", "APP1", "APP2", "APP11", "APP13", "COM"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts PNG text, compressed text, EXIF, XMP, ICC and private chunks", async () => {
|
||||
const result = await scan("fixture.png", "image/png", pngFixture());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
width: 2,
|
||||
height: 1,
|
||||
orientation: 1,
|
||||
cleanable: true,
|
||||
});
|
||||
expect(result.coverage.projectScanner).toBe("complete");
|
||||
expect(
|
||||
result.findings.some((finding) => finding.value.includes("Alice PNG")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.findings.some((finding) => finding.value.includes("PNG Alice")),
|
||||
).toBe(true);
|
||||
expect(result.blocks.map((block) => block.kind)).toEqual(
|
||||
expect.arrayContaining(["tEXt", "zTXt", "iTXt", "eXIf", "iCCP", "vpAg"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts WebP EXIF, XMP, ICC and extended dimensions", async () => {
|
||||
const result = await scan("fixture.webp", "image/webp", webpFixture());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
width: 4,
|
||||
height: 3,
|
||||
orientation: 1,
|
||||
animated: false,
|
||||
cleanable: true,
|
||||
});
|
||||
expect(result.coverage.projectScanner).toBe("complete");
|
||||
expect(result.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ category: "location" }),
|
||||
expect.objectContaining({ category: "identity" }),
|
||||
expect.objectContaining({ category: "colour-profile" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("makes animated images inspect-only", async () => {
|
||||
const png = await scan(
|
||||
"animated.png",
|
||||
"image/png",
|
||||
pngFixture({ animated: true }),
|
||||
);
|
||||
const webp = await scan(
|
||||
"animated.webp",
|
||||
"image/webp",
|
||||
webpFixture({ animated: true }),
|
||||
);
|
||||
expect(png.animated).toBe(true);
|
||||
expect(webp.animated).toBe(true);
|
||||
expect(png.cleanable).toBe(false);
|
||||
expect(webp.cleanable).toBe(false);
|
||||
});
|
||||
|
||||
it("reports trailing data rather than hiding it", async () => {
|
||||
const jpeg = await scan(
|
||||
"tail.jpg",
|
||||
"image/jpeg",
|
||||
jpegFixture({ trailing: true }),
|
||||
);
|
||||
const png = await scan(
|
||||
"tail.png",
|
||||
"image/png",
|
||||
pngFixture({ trailing: true }),
|
||||
);
|
||||
expect(
|
||||
jpeg.findings.some((finding) => finding.label === "Trailing data"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
png.findings.some((finding) => finding.label === "Trailing data"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("stops malformed chunks and TIFF cycles and refuses clean-copy eligibility", async () => {
|
||||
const malformed = await scan(
|
||||
"malformed.png",
|
||||
"image/png",
|
||||
malformedPngLengthFixture(),
|
||||
);
|
||||
const cyclic = await scan(
|
||||
"cycle.png",
|
||||
"image/png",
|
||||
pngFixture({ cycleTiff: true }),
|
||||
);
|
||||
expect(malformed.coverage.projectScanner).toBe("partial");
|
||||
expect(malformed.cleanable).toBe(false);
|
||||
expect(cyclic.coverage.projectScanner).toBe("partial");
|
||||
expect(cyclic.warnings.join(" ")).toMatch(/cycle/iu);
|
||||
expect(cyclic.cleanable).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds compressed metadata and finding values", async () => {
|
||||
const image = pngFixture({ inflatedCommentBytes: 1024 });
|
||||
const result = await scanImageBytes(input("bomb.png", "image/png", image), {
|
||||
maxInflatedMetadataBytes: 32,
|
||||
maxFindingValueChars: 24,
|
||||
});
|
||||
expect(result.coverage.projectScanner).toBe("partial");
|
||||
expect(result.warnings.join(" ")).toMatch(/limit|fully read/iu);
|
||||
expect(result.findings.every((finding) => finding.value.length <= 25)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds chunk counts and decoded dimensions", async () => {
|
||||
const image = pngFixture();
|
||||
const tooManyChunks = await scanImageBytes(
|
||||
input("chunks.png", "image/png", image),
|
||||
{ maxMetadataBlocks: 3 },
|
||||
);
|
||||
const tooManyPixels = await scanImageBytes(
|
||||
input("pixels.png", "image/png", image),
|
||||
{ maxPixels: 1 },
|
||||
);
|
||||
expect(tooManyChunks.coverage.projectScanner).toBe("partial");
|
||||
expect(tooManyChunks.cleanable).toBe(false);
|
||||
expect(tooManyPixels.cleanable).toBe(false);
|
||||
expect(tooManyPixels.warnings.join(" ")).toMatch(/processing limit/iu);
|
||||
});
|
||||
|
||||
it("bounds aggregate normalized finding text", async () => {
|
||||
const image = pngFixture();
|
||||
const result = await scanImageBytes(input("text.png", "image/png", image), {
|
||||
maxFindingTextChars: 100,
|
||||
});
|
||||
expect(result.coverage.projectScanner).toBe("partial");
|
||||
expect(result.coverage.notes.join(" ")).toMatch(/text limit/iu);
|
||||
expect(result.cleanable).toBe(false);
|
||||
});
|
||||
|
||||
it("treats invalid PNG CRCs as partial coverage", async () => {
|
||||
const image = pngFixture().slice();
|
||||
image[image.length - 1] = (image[image.length - 1] ?? 0) ^ 0xff;
|
||||
const result = await scan("crc.png", "image/png", image);
|
||||
expect(result.coverage.projectScanner).toBe("partial");
|
||||
expect(result.warnings.join(" ")).toMatch(/invalid CRC/iu);
|
||||
expect(result.cleanable).toBe(false);
|
||||
});
|
||||
|
||||
it("contains malformed HEIF-family input in the secondary adapter", async () => {
|
||||
const malformedHeic = Uint8Array.from([
|
||||
0, 0, 0, 16, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63, 0, 0, 0, 0,
|
||||
]);
|
||||
const result = await scan("broken.heic", "image/heic", malformedHeic);
|
||||
expect(result.identity.detectedKind).toBe("heic");
|
||||
expect(["failed", "unsupported", "partial"]).toContain(
|
||||
result.coverage.secondaryScanner,
|
||||
);
|
||||
expect(result.cleanable).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects dishonest declared sizes and file limits before parsing", async () => {
|
||||
const image = pngFixture();
|
||||
await expect(
|
||||
scanImageBytes({
|
||||
...input("wrong.png", "image/png", image),
|
||||
size: image.length + 1,
|
||||
}),
|
||||
).rejects.toThrow(/does not match/iu);
|
||||
await expect(
|
||||
scanImageBytes(input("large.png", "image/png", image), {
|
||||
maxFileBytes: image.length - 1,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PrivacyLimitError);
|
||||
});
|
||||
|
||||
it("keeps non-image formats at inventory-only coverage", async () => {
|
||||
const pdf = Uint8Array.from([
|
||||
0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37,
|
||||
]);
|
||||
const result = await scan("document.pdf", "application/pdf", pdf);
|
||||
expect(result.identity.detectedKind).toBe("pdf");
|
||||
expect(result.deepSupported).toBe(false);
|
||||
expect(result.cleanable).toBe(false);
|
||||
expect(result.coverage.projectScanner).toBe("unsupported");
|
||||
});
|
||||
});
|
||||
|
||||
async function scan(name: string, type: string, bytes: Uint8Array) {
|
||||
return scanImageBytes(input(name, type, bytes));
|
||||
}
|
||||
|
||||
function input(name: string, type: string, bytes: Uint8Array) {
|
||||
return {
|
||||
id: `fixture-${name}`,
|
||||
name,
|
||||
claimedType: type,
|
||||
size: bytes.byteLength,
|
||||
lastModified: 0,
|
||||
bytes: toArrayBuffer(bytes),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"useDefineForClassFields": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"playwright.config.ts",
|
||||
"eslint.config.mjs",
|
||||
"scripts/**/*.mjs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
css: true,
|
||||
maxWorkers: 2,
|
||||
restoreMocks: true,
|
||||
exclude: ["tests/browser/**", "node_modules/**", "dist/**"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user