|
diff --git a/content/background.js b/content/background.js |
|
index 8861..7d70 100644 |
|
--- a/content/background.js |
|
+++ b/content/background.js |
|
@@ -1,4 +1,5 @@ |
|
import {pref, App, Meta, RemoteUpdate, CheckMatches} from './app.js';
|
|
+import {ipV4Regex, ipV6Regex, publicSuffixList} from './vendor/index.js';
|
|
const RU = new RemoteUpdate();
|
|
|
|
// ----------------- Context Menu --------------------------
|
|
@@ -787,24 +788,387 @@ class API { |
|
.catch(error => App.log(name, `${message.api} ➜ ${error.message}`, 'error')); // failed notification
|
|
|
|
case 'fetch':
|
|
- return this.fetch(e, storeId);
|
|
+ return this.fetch(e, storeId, sender.tab.url);
|
|
|
|
case 'xmlHttpRequest':
|
|
return this.xmlHttpRequest(e, storeId);
|
|
}
|
|
}
|
|
|
|
- async addCookie(url, headers, storeId) {
|
|
+ // Here follows translation of C++ method `MakeTopLevelInfo`
|
|
+ // defined in `caps/OriginAttributes.cpp` file in Firefox sources:
|
|
+ // - commit ad7ecfa618ec3a65db8405d9f1125059fe4a6a15
|
|
+ // - date of access: 2022-03-07
|
|
+ // - see: https://searchfox.org/mozilla-central/rev/ad7ecfa618ec3a65db8405d9f1125059fe4a6a15/caps/OriginAttributes.cpp
|
|
+ //
|
|
+ // Order of arguments is changed to support invoking function without
|
|
+ // providing `port` argument. Original C++ code implements an overload for
|
|
+ // this purpose.
|
|
+ combineTopLevelInfoParts(useSite, scheme, host, port = '') {
|
|
+ if (!useSite) { return host; }
|
|
+ const parts = [scheme, host];
|
|
+ if (port !== '') { parts.push(port.toString()); }
|
|
+ const combinedParts = parts.join(',');
|
|
+ return `(${combinedParts})`;
|
|
+ }
|
|
+
|
|
+ // Here follows translation of C++ method `PopulateTopLevelInfoFromURI`
|
|
+ // defined in `caps/OriginAttributes.cpp` file in Firefox sources:
|
|
+ // - commit ad7ecfa618ec3a65db8405d9f1125059fe4a6a15
|
|
+ // - date of access: 2022-03-07
|
|
+ // - see: https://searchfox.org/mozilla-central/rev/ad7ecfa618ec3a65db8405d9f1125059fe4a6a15/caps/OriginAttributes.cpp
|
|
+ //
|
|
+ // (NOTE) Specific requirements to parameters of JavaScript version:
|
|
+ // - `url` - `hostname` part of URL must be canonicalized:
|
|
+ // * domain name must be lowercase, punycode, and only
|
|
+ // /[a-z0-9.-]+/ (requirement of publicsuffix.js library)
|
|
+ // * but IPv4 and IPv6 addresses, of course, can contain any
|
|
+ // valid (for IP addresses) characters in any case
|
|
+ //
|
|
+ // Returns either string with top-level info or `undefined`.
|
|
+ extractTopLevelInfo(url, useSite) {
|
|
+ // PopulateTopLevelInfoFromURI C++ method arguments:
|
|
+ //
|
|
+ // - `aIsTopLevelDocument`, `aIsFirstPartyEnabled`, `aForced`:
|
|
+ // only used in computing condition used for decision of early return
|
|
+ // from function. They should be ignored while early return should be
|
|
+ // just completely skipped.
|
|
+ //
|
|
+ // - `aOriginAttributes`, `OriginAttributes::*aTarget`:
|
|
+ // C++-specific method of passing pointer to object member (by passing
|
|
+ // pointer to class instance and pointer to class member). They are
|
|
+ // used only once in function in combination for setting variable
|
|
+ // `topLevelInfo`, which is the actual pointer to object member. Now,
|
|
+ // `topLevelInfo` is used as a target for returned value. So
|
|
+ // `aOriginAttributes` and `OriginAttributes::*aTarget` should be
|
|
+ // ignored and using `topLevelInfo` should be interpreted as returning
|
|
+ // result of `PopulateTopLevelInfoFromURI`.
|
|
+ //
|
|
+ // - `aURI`:
|
|
+ // string with an URL to compute topLevelInfo for
|
|
+ // (the same as `url` passed to this JavaScript function)
|
|
+ //
|
|
+ // - `aUseSite`:
|
|
+ // boolean to select whether `topLevelInfo` should include scheme and
|
|
+ // port
|
|
+ // (the same as `useSite` passed to this JavaScript function)
|
|
+
|
|
+ // PopulateTopLevelInfoFromURI: lines 58-69 are ignored.
|
|
+ //
|
|
+ // These lines contain:
|
|
+ // - irrelevant declaration of variable;
|
|
+ // - irrelevant checking of early return conditions.
|
|
+
|
|
+ const parsedUrl = new URL(url);
|
|
+ const urlScheme = parsedUrl.protocol.slice(0, -1); // Remove trailing ':'.
|
|
+
|
|
+ if (urlScheme === 'about') {
|
|
+ return this.combineTopLevelInfoParts(useSite, urlScheme,
|
|
+ // Expanded C++ macro `ABOUT_URI_FIRST_PARTY_DOMAIN`.
|
|
+ // For macro definition see:
|
|
+ // https://searchfox.org/mozilla-central/rev/cdab8eb3407f8fe216d5357c09aa5bfa479651f2/netwerk/base/nsNetUtil.h#643
|
|
+ 'about.ef2a7dd5-93bc-417f-a698-142c3116864f.mozilla'
|
|
+ );
|
|
+ }
|
|
+
|
|
+ if (urlScheme === 'moz-extension') { return /* undefined */; }
|
|
+
|
|
+ // PopulateTopLevelInfoFromURI: lines 88-94.
|
|
+ //
|
|
+ // These lines get topLevelInfo when `aURL` is a Blob URL (an URL,
|
|
+ // created by JavaScript function `URL.createObjectURL()`).
|
|
+ //
|
|
+ // According to
|
|
+ // https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL
|
|
+ // Blob URL lifetime is tied to the `document` in the window on which it
|
|
+ // was created.
|
|
+ //
|
|
+ // I believe, it means that such URL can not be an URL in browser location
|
|
+ // bar. Therefore, absence of its' handling is not a deficience.
|
|
+ //
|
|
+ // Try to support it anyway.
|
|
+ // It has format `blob:https://an.example.com/<uuid>`.
|
|
+
|
|
+ // PopulateTopLevelInfoFromURI: lines 96-97.
|
|
+ //
|
|
+ // Functions provided by `nsIEffectiveTLDService` are documented/defined
|
|
+ // in:
|
|
+ // * https://searchfox.org/mozilla-central/rev/980b50947e2a855c92f2df74209dadad3ee4d119/netwerk/dns/nsIEffectiveTLDService.idl
|
|
+ // * https://searchfox.org/mozilla-central/rev/cdab8eb3407f8fe216d5357c09aa5bfa479651f2/netwerk/dns/nsEffectiveTLDService.cpp
|
|
+ //
|
|
+ // See:
|
|
+ // * https://wiki.mozilla.org/Gecko:Effective_TLD_Service
|
|
+ // * https://web.archive.org/web/20191001223947/https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIEffectiveTLDService
|
|
+ // * comments in https://bugzilla.mozilla.org/show_bug.cgi?id=368989
|
|
+ // * https://bugzilla.mozilla.org/show_bug.cgi?id=1621168
|
|
+
|
|
+ // PopulateTopLevelInfoFromURI: lines 98-111 are skipped (for a moment).
|
|
+ //
|
|
+ // Getting base domain is performed after checking if host is IP address.
|
|
+
|
|
+ const {hostname: host, port, protocol} = (
|
|
+ (urlScheme === 'blob')
|
|
+ ? new URL(url.slice(urlScheme.length + 1))
|
|
+ : parsedUrl
|
|
+ );
|
|
+ const scheme = protocol.slice(0, -1); // Remove trailing ':'.
|
|
+
|
|
+ if (ipV4Regex.test(host)) {
|
|
+ return this.combineTopLevelInfoParts(useSite, scheme, host, port);
|
|
+ }
|
|
+
|
|
+ if (ipV6Regex.test(host)) {
|
|
+ return this.combineTopLevelInfoParts(useSite, scheme, `[${host}]`, port);
|
|
+ }
|
|
+ const maybeIpV6 = host.match(/^\[(.+)\]$/u);
|
|
+ if ((maybeIpV6 !== null) && ipV6Regex.test(maybeIpV6[1])) {
|
|
+ return this.combineTopLevelInfoParts(useSite, scheme, host, port);
|
|
+ }
|
|
+
|
|
+ // Here follows implementation of relevant parts of `GetBaseDomain` (and
|
|
+ // `GetBaseDomainInternal`) provided by `nsEffectiveTLDService`.
|
|
+ const trailingHostDot = host.endsWith('.') ? '.' : '';
|
|
+ const hostWithoutEndDot = (host.endsWith('.') ? host.slice(0, -1) : host);
|
|
+ if ((hostWithoutEndDot === '') || hostWithoutEndDot.endsWith('.')) {
|
|
+ // Returns NS_ERROR_INVALID_ARG in `GetBaseDomainInternal`.
|
|
+ return /* undefined */;
|
|
+ }
|
|
+ if (/(?:^\.|\.\.)/u.test(hostWithoutEndDot)) {
|
|
+ // `host` has either leading dot, or two (or more) consecutive dots.
|
|
+ // Returns NS_ERROR_INVALID_ARG in `GetBaseDomainInternal`.
|
|
+ return /* undefined */;
|
|
+ }
|
|
+ // Argument of `getPublicSuffix()` and `getDomain()` must be
|
|
+ // "canonicalized": it must be lowercase, punycode, only /[a-z0-9.-]+/
|
|
+ // and without trailing dot. (requirement of publicsuffix.js library)
|
|
+ const publicSuffix = publicSuffixList.dat.getPublicSuffix(hostWithoutEndDot);
|
|
+ const isInsuffcientDomainLevels = (publicSuffix === hostWithoutEndDot);
|
|
+ const baseDomain = publicSuffixList.dat.getDomain(hostWithoutEndDot);
|
|
+
|
|
+ // PopulateTopLevelInfoFromURI: lines 101-106.
|
|
+
|
|
+ if (baseDomain !== '') {
|
|
+ // Concatenating with trailing dot is done in `GetBaseDomainInternal`.
|
|
+ const domain = `${baseDomain}${trailingHostDot}`;
|
|
+ return this.combineTopLevelInfoParts(useSite, scheme, domain);
|
|
+ }
|
|
+
|
|
+ // PopulateTopLevelInfoFromURI: lines 140 to the end of function.
|
|
+
|
|
+ if (useSite) {
|
|
+ return this.combineTopLevelInfoParts(useSite, scheme, host, port);
|
|
+ }
|
|
+
|
|
+ if (isInsufficientDomainLevels && (publicSuffix !== '')) {
|
|
+ // Concatenating with trailing dot is done in `GetBaseDomainInternal`.
|
|
+ const domain = `${publicSuffix}${trailingHostDot}`;
|
|
+ return this.combineTopLevelInfoParts(useSite, scheme, domain, port);
|
|
+ }
|
|
+
|
|
+ return /* undefined */;
|
|
+ }
|
|
+
|
|
+ // Guess, whether `privacy.firstparty.isolate` is enabled in `about:config`.
|
|
+ async isPrivacyFirstpartyIsolateTrue(cookiesFilter) {
|
|
+ try {
|
|
+ // Exclude firstPartyDomain from cookiesFilter.
|
|
+ const {firstPartyDomain, ...incompleteCookiesFilter} = cookiesFilter;
|
|
+ await browser.cookies.getAll(incompleteCookiesFilter);
|
|
+ } catch (e) {
|
|
+ if (e.message === "First-Party Isolation is enabled, but the required 'firstPartyDomain' attribute was not set.") {
|
|
+ return true;
|
|
+ }
|
|
+ }
|
|
+ return false;
|
|
+ }
|
|
+
|
|
+ // Compute `firstPartyDomain` from `tabUrl`, making a guess whether
|
|
+ // `privacy.firstparty.isolate.use_site` is enabled in `about:config`.
|
|
+ //
|
|
+ // Note: the `firstPartyDomain` value is not guessed, but computed according
|
|
+ // to method re-implemented from Firefox C++ code. Only concrete method of
|
|
+ // its' computing is guessed.
|
|
+ async guessFirstPartyDomain(cookiesFilter, tabUrl) {
|
|
+ const schemeful = this.extractTopLevelInfo(tabUrl, /*useSite*/ true);
|
|
+ const schemefulFilter = {...cookiesFilter, firstPartyDomain: schemeful}
|
|
+ const cookies = await browser.cookies.getAll(schemefulFilter);
|
|
+ // Make a guess between schemeful and schemless `firstPartyDomain`.
|
|
+ return (
|
|
+ (cookies.length > 0)
|
|
+ ? schemeful
|
|
+ : this.extractTopLevelInfo(tabUrl, /*useSite*/ false)
|
|
+ );
|
|
+ }
|
|
+
|
|
+ // Return result of `browser.cookies.getAll(cookiesFilter)`, heuristically
|
|
+ // finding required `firstPartyDomain` or `partitionKey` values to include
|
|
+ // in `cookiesFilter`.
|
|
+ async getCookies(cookiesFilter, tabUrl) {
|
|
+ // Definitions:
|
|
+ // * "top-level info" is a string computed from URL's hostname according
|
|
+ // to rules defined in Firefox C++ code (practically, in most cases
|
|
+ // it's a "base domain": part of hostname up to, and including, first
|
|
+ // sub-domain of "public suffix"/"effective top-level domain"/"eTLD")
|
|
+ // - https://publicsuffix.org/
|
|
+ // - https://en.wikipedia.org/wiki/Public_Suffix_List
|
|
+ // - https://wiki.mozilla.org/Public_Suffix_List
|
|
+ // * "first-party" means all resources with "top-level info" identical to
|
|
+ // "top-level info" of an URL in Firefox location bar
|
|
+ // * "third-party" means all resources whose are not "first-party"
|
|
+ //
|
|
+ // Cookies included by browser in HTTP request are selected from cookie
|
|
+ // jar by "key". In standard browser behavior, this key is computed from
|
|
+ // requested URL and compared to combination of values provided by cookie
|
|
+ // itself (in "Domain", "SameSite, "Secure" cookie attributes etc.).
|
|
+ //
|
|
+ // Standard behavior opens a way for a third-party to track, store and
|
|
+ // analyze browsing history of concrete user across multiple independent
|
|
+ // first-parties, building user profile and defying user privacy.
|
|
+ //
|
|
+ // https://en.wikipedia.org/wiki/HTTP_cookie#Third-party_cookie
|
|
+ // https://en.wikipedia.org/wiki/HTTP_cookie#Privacy_and_third-party_cookies
|
|
+ // https://support.mozilla.org/en-US/kb/third-party-trackers
|
|
+ //
|
|
+ // To prevent these consequences, Firefox could be put in mode, when it
|
|
+ // additionaly computes first-party top-level info to store in cookie jar
|
|
+ // along with received cookie. Mechanism, implemented in Firefox for this
|
|
+ // purpose, was named "origin attributes" and first-party top-level info
|
|
+ // is one of "origin attributes".
|
|
+ // - https://bugzilla.mozilla.org/show_bug.cgi?id=565965
|
|
+ // "Key cookies on setting domain * toplevel load domain"
|
|
+ // - https://wiki.mozilla.org/Thirdparty
|
|
+ // (linked from comment 16 of that issue:
|
|
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=565965#c16)
|
|
+ //
|
|
+ // Origin attributes are used by Firefox for "isolating" cookies by using
|
|
+ // these attributes along with requested URL for computing "key" to select
|
|
+ // cookies (from cookie jar) to include in HTTP request.
|
|
+ //
|
|
+ // https://support.mozilla.org/en-US/kb/third-party-cookies-firefox-tracking-protection
|
|
+ //
|
|
+ // Firefox has two mechanisms that use origin attributes to provide
|
|
+ // cookie isolation:
|
|
+ // * First-party isolation ("FPI")
|
|
+ // * Total cookie protection ("dynamic first-party isolation", "dFPI",
|
|
+ // part of "state paritioning" mechanism).
|
|
+ //
|
|
+ // Overall view of these features is summarized at:
|
|
+ // - https://bugzilla.mozilla.org/show_bug.cgi?id=1669716#c10
|
|
+ // > 3. Parsing firstPartyDomain value from extension
|
|
+ // [...] Another issue is that it's not obvious to extensions that
|
|
+ // they have to compute the eTLD+1 of a tab's URL to use as the
|
|
+ // firstPartyDomain.
|
|
+ // - https://github.com/mozilla/multi-account-containers/issues/1974#issuecomment-785243612
|
|
+ // "Total Cookie Protection comparison"
|
|
+ // > As I understand it, Total Cookie Protect is a product name and
|
|
+ // evolution of dynamic first-party isolation. In dFPI Firefox
|
|
+ // isolates cookies to the first-party domain, with an exception
|
|
+ // (the "dynamic" part) for certain user-initiated resource requests
|
|
+ // like those used in Single Sign-On implementations.
|
|
+ // - https://github.com/arkenfox/user.js/issues/1051#issuecomment-809645683
|
|
+ // "move from FPI to dFPI"
|
|
+ // - https://github.com/arkenfox/user.js/issues/8
|
|
+ // "meta: tor uplift: privacy.firstparty.isolate"
|
|
+ // - https://github.com/arkenfox/user.js/issues/395
|
|
+ // "Temporary Containers vs First-Party Isolation"
|
|
+ //
|
|
+ // FPI and dFPI:
|
|
+ // * are mutually exclusive since Firefox 78 (FPI has a precedence, when
|
|
+ // both are enabled):
|
|
+ // - https://bugzilla.mozilla.org/show_bug.cgi?id=1631676
|
|
+ // "Disable dfpi when privacy.firstparty.isolate=true"
|
|
+ // * use separate origin attributes (firstPartyDomain and partitionKey)
|
|
+ // since Firefox 79:
|
|
+ // - https://bugzilla.mozilla.org/show_bug.cgi?id=1640135
|
|
+ // "Consider using separate attribute to store first-party domain for dFPI"
|
|
+ //
|
|
+ // First-party isolation feature:
|
|
+ // - https://bugzilla.mozilla.org/show_bug.cgi?id=1299996
|
|
+ // "[META] Support Tor first-party isolation"
|
|
+ // - introduced in Firefox 51 (released on January 24, 2017)
|
|
+ // - https://bugzilla.mozilla.org/show_bug.cgi?id=1260931
|
|
+ // "Add 1st party isolation pref and OriginAttribute."
|
|
+ // - with support in `cookies` WebExtension API added (by introducing
|
|
+ // `firstPartyDomain` key in parameters/return values) in Firefox 59
|
|
+ // - https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/59#webextensions
|
|
+ // - https://bugzilla.mozilla.org/show_bug.cgi?id=1381197
|
|
+ // "browser.cookies fails to get/remove cookies by domain/url when privacy.firstparty.isolate = true"
|
|
+ // + feature design was proposed in comment 6 of that issue
|
|
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1381197#c6
|
|
+ // and discussed further in following comments
|
|
+ // - with preference for computing schemful top-level info added
|
|
+ // in Firefox 78
|
|
+ // - https://bugzilla.mozilla.org/show_bug.cgi?id=1637516#c15
|
|
+ // "Consider changing the key of StoragePrincipal from registrable domain (eTLD+1) to site"
|
|
+ //
|
|
+ // Total cookie protection feature:
|
|
+ // - introduced in Firefox 86 (released on February 23, 2021)
|
|
+ // - https://www.mozilla.org/en-US/firefox/86.0/releasenotes/
|
|
+ // - https://blog.mozilla.org/security/2021/02/23/total-cookie-protection/
|
|
+ // - https://hacks.mozilla.org/2021/02/introducing-state-partitioning/
|
|
+ // - https://blog.mozilla.org/security/2021/01/26/supercookie-protections/
|
|
+ // - https://support.mozilla.org/en-US/kb/third-party-cookies-firefox-tracking-protection
|
|
+ // - https://reddit.com/r/privacy/comments/lqkouc/firefox_announces_total_cookie_protection/
|
|
+ // - with support in `cookies` WebExtension API added (by introducing
|
|
+ // `partitionKey` key in parameters/return values) in Firefox 94
|
|
+ // - https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/94#changes_for_add-on_developers
|
|
+ // - https://bugzilla.mozilla.org/show_bug.cgi?id=1669716
|
|
+ // "cookies extension API unaware of dFPI; firstPartyDomain does not work with dynamic First-Party Isolation"
|
|
+ //
|
|
+ // According to https://bugzilla.mozilla.org/show_bug.cgi?id=1669716#c10:
|
|
+ // - FPI could be enabled only by setting `privacy.firstparty.isolate`
|
|
+ // to `true` in `about:config` (it's `false` by default). Additionaly,
|
|
+ // `privacy.firstparty.isolate.use_site` controls schemefullness of
|
|
+ // first-party top-level info as it used by enabled FPI
|
|
+ // (`true`: schemeful, `false`: schemeless)
|
|
+ // - in stock Firefox dFPI feature is enabled by default
|
|
+ // - in all windows (for Firefox Nightly)
|
|
+ // - in Private browsing window (for other release channels)
|
|
+ // - dFPI is also enabled when "Enhanced Tracking Protection" at
|
|
+ // `about:preferences#privacy` is set to "Strict"
|
|
+ // - in fact, dFPI is enabled by value `5` of two independent settings
|
|
+ // at `about:config`:
|
|
+ // * network.cookie.cookieBehavior (for enabling in normal windows)
|
|
+ // * network.cookie.cookieBehavior.pbmode (for enabling in Private
|
|
+ // browsing windows)
|
|
+ //
|
|
+ // There are no clearly documented guidelines and direct answers about how
|
|
+ // exactly extension should get values of `firstPartyDomain` and
|
|
+ // `partitionKey` if it wants to keep first-party isolation guarantees.
|
|
+
|
|
+ // Check for `privacy.firstparty.isolate`.
|
|
+ if (await this.isPrivacyFirstpartyIsolateTrue(cookiesFilter)) {
|
|
+ const firstPartyDomain = await this.guessFirstPartyDomain(cookiesFilter, tabUrl);
|
|
+ if (firstPartyDomain === undefined) { return []; }
|
|
+ const isolatedCookiesFilter = {...cookiesFilter, firstPartyDomain};
|
|
+ const fpiCookies = await browser.cookies.getAll(isolatedCookiesFilter);
|
|
+ return fpiCookies;
|
|
+ }
|
|
+
|
|
+ // Check for Total cookie protection.
|
|
+ // Try to get cookies from partitioned storage. If there is any, dFPI is
|
|
+ // probably enabled.
|
|
+ const partitionKey = {topLevelSite: tabUrl};
|
|
+ const partitionedCookiesFilter = {...cookiesFilter, partitionKey};
|
|
+ const dfpiCookies = await browser.cookies.getAll(partitionedCookiesFilter);
|
|
+ if (dfpiCookies.length > 0) { return dfpiCookies; }
|
|
+
|
|
+ // FPI is not enabled, and, probably, dFPI is not enabled too.
|
|
+ const cookies = await browser.cookies.getAll(cookiesFilter);
|
|
+ return cookies;
|
|
+ }
|
|
+
|
|
+ async addCookie(url, headers, storeId, tabUrl) {
|
|
// add contexual cookies, only in container/incognito
|
|
- const cookies = await browser.cookies.getAll({url, storeId});
|
|
+ const cookies = await this.getCookies({url, storeId}, tabUrl);
|
|
const str = cookies && cookies.map(item => `${item.name}=${item.value}`).join('; ');
|
|
str && (headers['FM-Contextual-Cookie'] = str);
|
|
}
|
|
|
|
- async fetch(e, storeId) {
|
|
+ async fetch(e, storeId, tabUrl) {
|
|
if (e.init.credentials !== 'omit' && storeId) { // not anonymous AND in container/incognito
|
|
e.init.credentials = 'omit';
|
|
- await this.addCookie(e.url, e.init.headers, storeId);
|
|
+ await this.addCookie(e.url, e.init.headers, storeId, tabUrl);
|
|
}
|
|
Object.keys(e.init.headers)[0] || delete e.init.headers; // clean up
|
|
|