mirror of
https://github.com/morluto/rea
synced 2026-10-25 14:01:13 -04:00
fix(contracts): share ADB limits and preserve Rosetta register facts
Use the domain-owned activity extra count in CLI parsing and advertisement. Preserve nested Rosetta registers at the selecting parser and qualified register projection, sharing the existing aggregate budget. Port the Rosetta work from Adolanium commits1889ac153andc9e93bd88in #6976, including morluto follow-up07fa932ee. Add malformed-container and mixed-thread budget controls. CLI/MCP coverage uses a recorded ReportCrash excerpt in an authored report, without generating a native crash or contacting an Android device. Refs: #6291, #6976, #6977 Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com>
This commit is contained in:
@@ -38,6 +38,12 @@ address uses that row's own image base and offset. The recorded `inline` flag
|
||||
does not supply a missing offset or authorize borrowing another row's address.
|
||||
When `faultingThread` is missing or does not resolve to a recorded thread
|
||||
object, row-zero roles are `unknown`; the recorded lookup remains available.
|
||||
A translated process records Rosetta's registers in a nested
|
||||
`threadState.rosetta` object; each one is reported as `rosetta.<name>` and
|
||||
counts toward the register budget. A [public ReportCrash `.ips` log](https://github.com/Mortennn/Dozer/issues/189)
|
||||
shows `rosetta.tmp2`, `tmp1`, and `tmp0` beside ordinary x86 registers. The
|
||||
test fixture quotes that register excerpt inside an authored report body; it
|
||||
does not generate a new Rosetta crash during the test.
|
||||
Observed JSON strings retain `""`; `null` means the field was missing or JSON
|
||||
`null`. This includes image paths, frame symbols, process text, and the older
|
||||
string `osVersion` form. Unlisted JSON keys and generic register names also
|
||||
|
||||
@@ -5,7 +5,10 @@ import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysis
|
||||
import { createAdbDeviceAnalysisProvider } from "../composition/adb.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import type { AdbOperation } from "../domain/adb/adbDeviceAnalysis.js";
|
||||
import {
|
||||
MAX_ADB_ACTIVITY_EXTRAS,
|
||||
type AdbOperation,
|
||||
} from "../domain/adb/adbDeviceAnalysis.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
@@ -343,6 +346,7 @@ export const registerAdbCommands = (
|
||||
.describe("Explicit component in package/activity form (-n)"),
|
||||
extra: z
|
||||
.array(z.string().min(1))
|
||||
.max(MAX_ADB_ACTIVITY_EXTRAS)
|
||||
.default([])
|
||||
.describe(
|
||||
"Typed extra as type:key:value (string, boolean, int, long, float), repeatable",
|
||||
|
||||
@@ -59,10 +59,12 @@ const activityExtraStringValueSchema = z
|
||||
.max(2048)
|
||||
.regex(/^[^\x00]*$/u, "Extra values cannot contain NUL");
|
||||
const activityExtraNonEmptyValueSchema = activityExtraStringValueSchema.min(1);
|
||||
/** Maximum typed extras admitted for one Android activity request. */
|
||||
export const MAX_ADB_ACTIVITY_EXTRAS = 16;
|
||||
// Android's `am start --ef` parses this text with Float.valueOf. Keep the
|
||||
// caller's spelling intact while admitting Java FloatValue forms.
|
||||
const javaFloatValuePattern =
|
||||
/^[\x01-\x20]*[+\-]?(?:NaN|Infinity|(?:(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+\-]?\d+)?[fFdD]?|0[xX](?:[\da-fA-F]+(?:\.[\da-fA-F]*)?|\.[\da-fA-F]+)[pP][+\-]?\d+[fFdD]?))[\x01-\x20]*$/u;
|
||||
/^[\x01-\x20]*[+-]?(?:NaN|Infinity|(?:(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[fFdD]?|0[xX](?:[\da-fA-F]+(?:\.[\da-fA-F]*)?|\.[\da-fA-F]+)[pP][+-]?\d+[fFdD]?))[\x01-\x20]*$/u;
|
||||
const activityExtraSchema = z.discriminatedUnion("type", [
|
||||
z.strictObject({
|
||||
key: activityExtraKeySchema,
|
||||
@@ -335,7 +337,7 @@ export const adbInputSchemas = {
|
||||
.describe("Explicit component to start (-n)"),
|
||||
extras: z
|
||||
.array(activityExtraSchema)
|
||||
.max(16)
|
||||
.max(MAX_ADB_ACTIVITY_EXTRAS)
|
||||
.default([])
|
||||
.describe("Typed intent extras (--es/--ez/--ei/--el/--ef)"),
|
||||
}),
|
||||
|
||||
@@ -96,6 +96,13 @@ const threadShape = (
|
||||
members: {
|
||||
flavor: SCALAR,
|
||||
x: { kind: "array", items: register, beforeItem: take.registers },
|
||||
// Rosetta-translated processes nest their translator registers.
|
||||
rosetta: {
|
||||
kind: "object",
|
||||
members: {},
|
||||
rest: register,
|
||||
beforeRest: take.registers,
|
||||
},
|
||||
},
|
||||
rest: register,
|
||||
beforeRest: take.registers,
|
||||
|
||||
@@ -572,6 +572,78 @@ it("reports wrong-type collections as partial instead of empty and complete", ()
|
||||
expect(empty.coverage.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("keeps the nested Rosetta registers of a translated process", () => {
|
||||
const parsed = parse(
|
||||
withBody(`{
|
||||
"translated": true,
|
||||
"threads": [
|
||||
{
|
||||
"threadState": {
|
||||
"flavor": "x86_THREAD_STATE",
|
||||
"rip": { "value": 4294983680 },
|
||||
"rosetta": { "tmp0": { "value": 7 }, "tmp1": 8 }
|
||||
}
|
||||
}
|
||||
],
|
||||
"usedImages": []
|
||||
}`),
|
||||
);
|
||||
expect(parsed.threads[0]?.registers).toEqual([
|
||||
{
|
||||
name: "rip",
|
||||
value: { decimal: "4294983680", hex: "0x100004000" },
|
||||
description: null,
|
||||
},
|
||||
{
|
||||
name: "rosetta.tmp0",
|
||||
value: { decimal: "7", hex: "0x7" },
|
||||
description: null,
|
||||
},
|
||||
{
|
||||
name: "rosetta.tmp1",
|
||||
value: { decimal: "8", hex: "0x8" },
|
||||
description: null,
|
||||
},
|
||||
]);
|
||||
expect(parsed.coverage).toMatchObject({
|
||||
status: "complete",
|
||||
registers: 3,
|
||||
type_mismatches: [],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ rosetta: "7", observed: "integer" },
|
||||
{ rosetta: '"invalid"', observed: "string" },
|
||||
{ rosetta: "[]", observed: "array" },
|
||||
])(
|
||||
"reports a $observed Rosetta container without losing ordinary registers",
|
||||
({ rosetta, observed }) => {
|
||||
const parsed = parse(
|
||||
withBody(`{
|
||||
"threads": [{ "threadState": { "rip": 1, "rosetta": ${rosetta}, "rsp": 2 } }],
|
||||
"usedImages": []
|
||||
}`),
|
||||
{ max_registers: 2 },
|
||||
);
|
||||
expect(parsed.threads[0]?.registers.map(({ name }) => name)).toEqual([
|
||||
"rip",
|
||||
"rsp",
|
||||
]);
|
||||
expect(parsed.coverage).toMatchObject({
|
||||
status: "partial",
|
||||
registers: 2,
|
||||
type_mismatches: [
|
||||
{
|
||||
path: "body.threads[0].threadState.rosetta",
|
||||
expected: "object",
|
||||
observed,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("records wrong-type rows, scalars and registers with their source path", () => {
|
||||
const parsed = parse(
|
||||
withBody(`{
|
||||
@@ -583,7 +655,7 @@ it("records wrong-type rows, scalars and registers with their source path", () =
|
||||
7,
|
||||
{
|
||||
"frames": [{ "imageIndex": 99999999999, "imageOffset": "8" }, []],
|
||||
"threadState": { "flavor": 1, "x": [1, "two", { "value": null }, {}], "rosetta": { "tmp0": 1 } }
|
||||
"threadState": { "flavor": 1, "x": [1, "two", { "value": null }, {}], "rosetta": { "tmp0": "one" } }
|
||||
}
|
||||
],
|
||||
"usedImages": [null, { "base": 1.0 }]
|
||||
@@ -639,9 +711,9 @@ it("records wrong-type rows, scalars and registers with their source path", () =
|
||||
observed: "absent",
|
||||
},
|
||||
{
|
||||
path: "body.threads[1].threadState.rosetta.value",
|
||||
expected: "integer",
|
||||
observed: "absent",
|
||||
path: "body.threads[1].threadState.rosetta.tmp0",
|
||||
expected: "integer or object with integer value",
|
||||
observed: "string",
|
||||
},
|
||||
{
|
||||
path: "body.threads[1].threadState.flavor",
|
||||
@@ -700,6 +772,69 @@ it("budgets register slots before expanding threadState rows", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("budgets Rosetta registers with the ordinary registers they follow", () => {
|
||||
const rosetta = (third: string): Buffer =>
|
||||
withBody(
|
||||
`{"threads":[{"threadState":{"x":[0],"rosetta":{"tmp0":0,"tmp1":0,"tmp2":${third}}}}],"usedImages":[]}`,
|
||||
);
|
||||
expect(parse(rosetta("0"), { max_registers: 4 }).coverage).toMatchObject({
|
||||
status: "complete",
|
||||
registers: 4,
|
||||
});
|
||||
// The register past the allowance is invalid JSON; the budget must refuse first.
|
||||
expect(() => parse(rosetta("?"), { max_registers: 3 })).toThrow(
|
||||
expect.objectContaining({ boundary: "registers", maximum: 3 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps valid registers around a malformed Rosetta member at the exact slot limit", () => {
|
||||
const parsed = parse(
|
||||
withBody(`{
|
||||
"translated": true,
|
||||
"threads": [{
|
||||
"threadState": {
|
||||
"flavor": "x86_THREAD_STATE",
|
||||
"rip": { "value": 1 },
|
||||
"rosetta": { "tmp0": { "value": 2 }, "tmp1": "invalid" },
|
||||
"rsp": { "value": 3 }
|
||||
}
|
||||
}],
|
||||
"usedImages": []
|
||||
}`),
|
||||
{ max_registers: 4 },
|
||||
);
|
||||
expect(parsed.threads[0]?.registers.map(({ name }) => name)).toEqual([
|
||||
"rip",
|
||||
"rosetta.tmp0",
|
||||
"rsp",
|
||||
]);
|
||||
expect(parsed.coverage).toMatchObject({
|
||||
status: "partial",
|
||||
registers: 3,
|
||||
type_mismatches: [
|
||||
{
|
||||
path: "body.threads[0].threadState.rosetta.tmp1",
|
||||
expected: "integer or object with integer value",
|
||||
observed: "string",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("shares the register allowance across Rosetta groups and later ordinary registers", () => {
|
||||
const mixed = (last: string): Buffer =>
|
||||
withBody(
|
||||
`{"threads":[{"threadState":{"rosetta":{"tmp0":0}}},{"threadState":{"rosetta":{"tmp1":0},"rip":${last}}}],"usedImages":[]}`,
|
||||
);
|
||||
expect(parse(mixed("0"), { max_registers: 3 }).coverage).toMatchObject({
|
||||
status: "complete",
|
||||
registers: 3,
|
||||
});
|
||||
expect(() => parse(mixed("?"), { max_registers: 2 })).toThrow(
|
||||
expect.objectContaining({ boundary: "registers", maximum: 2 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an over-budget collection before parsing the rows past the allowance", () => {
|
||||
// Rows after the allowance are invalid JSON; the budget must refuse first.
|
||||
const threads = withBody(`{"threads":[{},{},{},?],"usedImages":[]}`);
|
||||
|
||||
@@ -172,6 +172,20 @@ const threadStateOf = (
|
||||
});
|
||||
for (const [key, entry] of Object.entries(members)) {
|
||||
if (key === "x" || key === "flavor") continue;
|
||||
if (key === "rosetta") {
|
||||
// A translated process records Rosetta's registers as a nested state.
|
||||
const rosetta = read.object(entry, `${path}.rosetta`);
|
||||
for (const [name, nested] of Object.entries(rosetta?.members ?? {})) {
|
||||
const register = registerValue(
|
||||
`rosetta.${name}`,
|
||||
nested,
|
||||
`${path}.rosetta.${name}`,
|
||||
read,
|
||||
);
|
||||
if (register !== null) registers.push(register);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const register = registerValue(key, entry, `${path}.${key}`, read);
|
||||
if (register !== null) registers.push(register);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { toolContract } from "../../src/contracts/toolContracts.js";
|
||||
import { appleIpsBytes } from "../fixtures/binaryDiagnostics/appleIps.js";
|
||||
import {
|
||||
appleIpsBytes,
|
||||
appleIpsRosettaBytes,
|
||||
} from "../fixtures/binaryDiagnostics/appleIps.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
|
||||
@@ -129,6 +132,68 @@ cliTest(
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"keeps producer-shaped Rosetta registers through CLI and stdio MCP",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-apple-ips-rosetta-");
|
||||
const path = join(root, "translated.ips");
|
||||
await writeFile(path, appleIpsRosettaBytes());
|
||||
const cliReply = await cli.run({
|
||||
arguments: ["inspect-apple-ips", path, "--json"],
|
||||
});
|
||||
expect(cliReply.exitCode, cliReply.stderr).toBe(0);
|
||||
const call = await cli.sharedMcpCall?.();
|
||||
if (call === undefined) throw new Error("Expected a stdio MCP session");
|
||||
const mcpReply = await call("inspect_apple_ips", { path });
|
||||
expect(mcpReply.isError, JSON.stringify(mcpReply.content)).not.toBe(true);
|
||||
for (const reply of [cliReply.json, mcpReply.structuredContent]) {
|
||||
const report =
|
||||
toolContract("inspect_apple_ips").outputSchema.parse(
|
||||
reply,
|
||||
).normalized_result;
|
||||
expect(report.threads[0]?.state_flavor).toBe("x86_THREAD_STATE");
|
||||
expect(report.threads[0]?.registers.map(({ name }) => name)).toEqual([
|
||||
"rbp",
|
||||
"r12",
|
||||
"rosetta.tmp2",
|
||||
"rosetta.tmp1",
|
||||
"rosetta.tmp0",
|
||||
"rbx",
|
||||
]);
|
||||
expect(report.threads[0]?.registers[2]?.value.decimal).toBe(
|
||||
"140706223703876",
|
||||
);
|
||||
expect(report.coverage).toMatchObject({
|
||||
status: "complete",
|
||||
registers: 6,
|
||||
type_mismatches: [],
|
||||
});
|
||||
}
|
||||
|
||||
const malformedPath = join(root, "translated-malformed.ips");
|
||||
await writeFile(malformedPath, appleIpsRosettaBytes("invalid"));
|
||||
const malformedReply = await cli.run({
|
||||
arguments: ["inspect-apple-ips", malformedPath, "--json"],
|
||||
});
|
||||
expect(malformedReply.exitCode, malformedReply.stderr).toBe(0);
|
||||
const malformed = toolContract("inspect_apple_ips").outputSchema.parse(
|
||||
malformedReply.json,
|
||||
).normalized_result;
|
||||
expect(malformed.coverage).toMatchObject({
|
||||
status: "partial",
|
||||
registers: 5,
|
||||
type_mismatches: [
|
||||
{
|
||||
path: "body.threads[0].threadState.rosetta.tmp1.value",
|
||||
expected: "integer",
|
||||
observed: "string",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(malformed.threads[0]?.registers.at(-1)?.name).toBe("rbx");
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"resolves a relative IPS path against the working directory",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { chmod, lstat, readFile, unlink } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { basename, dirname } from "node:path";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
execFileOutput,
|
||||
execFileOutputFailure,
|
||||
@@ -188,6 +189,55 @@ it.each(["string:key", ":", "string"])(
|
||||
},
|
||||
);
|
||||
|
||||
it.each([16, 17])(
|
||||
"enforces the activity extra count before host admission: %d entries",
|
||||
async (count) => {
|
||||
const root = await createTestTempDirectory("rea-adb-extra-limit-");
|
||||
const missingAdb = join(root, "missing-adb");
|
||||
const extras = Array.from({ length: count }, (_, index) => [
|
||||
"--extra",
|
||||
`string:key${index}:value${index}`,
|
||||
]).flat();
|
||||
const result = await execFileOutput(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/rea.mjs",
|
||||
"start-adb-activity",
|
||||
"emulator-5554",
|
||||
"android.intent.action.VIEW",
|
||||
...extras,
|
||||
"--json",
|
||||
],
|
||||
{
|
||||
env: { ...process.env, REA_ADB_PATH: missingAdb },
|
||||
timeout: 20_000,
|
||||
stopSignal: "SIGTERM",
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
).then(
|
||||
(output) => ({ exitCode: 0, stdout: output.stdout }),
|
||||
(cause: unknown) => {
|
||||
const failure = execFileOutputFailure(cause);
|
||||
if (failure === undefined || typeof failure.code !== "number")
|
||||
throw cause;
|
||||
return { exitCode: failure.code, stdout: failure.stdout };
|
||||
},
|
||||
);
|
||||
if (count === 16) {
|
||||
expectConfigurationRefusal(
|
||||
result,
|
||||
`The selected adb binary was not found: ${missingAdb}. Set REA_ADB_PATH`,
|
||||
);
|
||||
} else {
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
code: "VALIDATION_ERROR",
|
||||
fieldErrors: [{ code: "too_big", path: "extra" }],
|
||||
});
|
||||
}
|
||||
expect(result.exitCode, result.stdout).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"starts an explicitly selected nested Android activity through the CLI",
|
||||
async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createCli } from "../../../src/cli.js";
|
||||
import { createCliInventory } from "../../../scripts/lib/catalog-cli.mjs";
|
||||
import { adbInputSchemas } from "../../../src/domain/adb/adbDeviceAnalysis.js";
|
||||
import {
|
||||
apktoolInputSchemas,
|
||||
MAX_SELECTED_LOCALES,
|
||||
@@ -315,6 +316,49 @@ describe("web module CLI advertised input schema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ADB activity extra input schema", () => {
|
||||
it("matches the owning workflow's extra count through advertisement and CLI parsing", async () => {
|
||||
const cli = createCli({});
|
||||
const advertised = await advertisedSchema(cli, "start-adb-activity");
|
||||
const parser = parserSchema(cli, "start-adb-activity", "options");
|
||||
if (parser === undefined)
|
||||
throw new Error("ADB activity options parser was not registered");
|
||||
const validate = new Ajv2020({ strict: false }).compile(
|
||||
advertised.options ?? {},
|
||||
);
|
||||
expect(advertised.options?.properties.extra).toMatchObject({
|
||||
type: "array",
|
||||
maxItems: 16,
|
||||
default: [],
|
||||
});
|
||||
const request = {
|
||||
serial: "emulator-5554",
|
||||
action: "android.intent.action.VIEW",
|
||||
};
|
||||
expect(validate({})).toBe(true);
|
||||
const omitted = adbInputSchemas.start_adb_activity.parse(request);
|
||||
expect(omitted.extras).toEqual([]);
|
||||
expect(parser.parse({}).extra).toEqual(omitted.extras);
|
||||
for (const count of [0, 16, 17]) {
|
||||
const extras = Array.from({ length: count }, (_, index) => ({
|
||||
type: "string",
|
||||
key: `key${index}`,
|
||||
value: `value${index}`,
|
||||
}));
|
||||
const input = {
|
||||
extra: extras.map(({ type, key, value }) => `${type}:${key}:${value}`),
|
||||
};
|
||||
const expected = adbInputSchemas.start_adb_activity.safeParse({
|
||||
...request,
|
||||
extras,
|
||||
}).success;
|
||||
expect(expected).toBe(count <= 16);
|
||||
expect(parser.safeParse(input).success).toBe(expected);
|
||||
expect(validate(input)).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Apktool locale selection input schema", () => {
|
||||
it("preserves the owner's array bounds through CLI advertisement and parsing", async () => {
|
||||
const cli = createCli({});
|
||||
|
||||
+29
@@ -166,3 +166,32 @@ export const appleIpsReport = (
|
||||
|
||||
export const appleIpsBytes = (options: AppleIpsFixtureOptions = {}): Buffer =>
|
||||
Buffer.from(appleIpsReport(options), "utf8");
|
||||
|
||||
/**
|
||||
* Register excerpt from a public ReportCrash .ips log:
|
||||
* https://github.com/Mortennn/Dozer/issues/189
|
||||
* The metadata and surrounding body are authored test scaffolding. The
|
||||
* malformed variant changes only tmp1 to exercise source-path reporting.
|
||||
*/
|
||||
export const appleIpsRosettaBytes = (tmp1: number | string = 532): Buffer =>
|
||||
Buffer.from(
|
||||
`${appleIpsMetadataLine()}\n{
|
||||
"translated": true,
|
||||
"cpuType": "X86-64",
|
||||
"threads": [{
|
||||
"threadState": {
|
||||
"flavor": "x86_THREAD_STATE",
|
||||
"rbp": { "value": 13029550560 },
|
||||
"r12": { "value": 0 },
|
||||
"rosetta": {
|
||||
"tmp2": { "value": 140706223703876 },
|
||||
"tmp1": { "value": ${JSON.stringify(tmp1)} },
|
||||
"tmp0": { "value": 140703707485410 }
|
||||
},
|
||||
"rbx": { "value": 140703456658352 }
|
||||
}
|
||||
}],
|
||||
"usedImages": []
|
||||
}`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user