mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(adb): preserve existing bugreport archives (#2221)
* fix(adb): preserve existing bugreport archives * fix(adb): retain bugreports after publication failure Record acquired archive facts before publishing, preserve the staging archive when the exclusive copy fails, and keep retained evidence out of automatic cleanup. Reuse the same observation through publication and digest failures. Add a real-filesystem ENOSPC regression covering error projection, existing evidence, subsequent collection and provider shutdown. Verified 96 focused tests, static checks and docs on macOS with simulated ADB; no real-device verification. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
1 parent
85c5477930
commit
d80b55e11b
5 files changed
+511
-102
No files matched your search
@@ -148,6 +148,11 @@ existing analysis families directly, for example
|
||||
- Cancellation during a package pull preserves caller-selected output. The
|
||||
error includes partial observations identifying completed artifacts and the
|
||||
interrupted local path; remaining APKs were not attempted.
|
||||
- Bugreports collect into isolated staging and publish with exclusive creation;
|
||||
filename collisions select the next numeric suffix. If publication fails, the
|
||||
completed archive remains in staging. The error's partial observation gives
|
||||
its recoverable `local_path`, the original `adb_reported_path`, and an unknown
|
||||
digest status. Later operations and shutdown preserve this retained evidence.
|
||||
- Failed package acquisition rollback retains its directory identity and retries
|
||||
cleanup before later work and during shutdown. Cleanup refuses a replacement
|
||||
directory. Terminal shutdown stops admission and drains active operations.
|
||||
|
||||
@@ -1086,10 +1086,10 @@ it("rejects mistyped intent extras before touching the device", async () => {
|
||||
|
||||
it("collects a device bugreport archive with its digest", async () => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
(root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "bugreport" ]; then
|
||||
printf 'bugreport-bytes' > "${join(root, "bugreport test.zip")}"
|
||||
printf 'Bug report copied to ${join(root, "bugreport test.zip")}\\n'
|
||||
printf 'bugreport-bytes' > "$4/bugreport test.zip"
|
||||
printf 'Bug report copied to %s/bugreport test.zip\\n' "$4"
|
||||
exit 0
|
||||
fi
|
||||
exit 1`,
|
||||
@@ -1103,7 +1103,7 @@ exit 1`,
|
||||
local_path: join(root, "bugreport test.zip"),
|
||||
bytes: 15,
|
||||
sha256: digest,
|
||||
adb_reported_path: join(root, "bugreport test.zip"),
|
||||
adb_reported_path: expect.stringContaining(".rea-bugreport-"),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+224
-88
@@ -2,14 +2,16 @@ import { createHash } from "node:crypto";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import {
|
||||
access,
|
||||
copyFile,
|
||||
lstat,
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
rmdir,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { basename, dirname, extname, join, resolve } from "node:path";
|
||||
|
||||
import type {
|
||||
ExecutionOptions,
|
||||
@@ -20,7 +22,10 @@ import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisExecution,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { AdbRequest } from "../domain/adb/adbDeviceAnalysis.js";
|
||||
import type {
|
||||
AdbAcquiredFilePartialObservation,
|
||||
AdbRequest,
|
||||
} from "../domain/adb/adbDeviceAnalysis.js";
|
||||
import {
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisCancelledError,
|
||||
@@ -305,30 +310,56 @@ const refuseExistingTarget = async (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Publish an ADB-produced archive without replacing an existing caller file. */
|
||||
const publishBugreport = async (
|
||||
source: string,
|
||||
directory: string,
|
||||
): Promise<string> => {
|
||||
const fileName = basename(source);
|
||||
const extension = extname(fileName);
|
||||
const stem =
|
||||
extension === "" ? fileName : fileName.slice(0, -extension.length);
|
||||
for (let suffix = 0; ; suffix += 1) {
|
||||
const destination = join(
|
||||
directory,
|
||||
suffix === 0 ? fileName : `${stem} (${String(suffix)})${extension}`,
|
||||
);
|
||||
try {
|
||||
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
||||
return destination;
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "EEXIST")
|
||||
continue;
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export interface AdbProviderOptions {
|
||||
readonly environment?: Readonly<Record<string, string | undefined>>;
|
||||
}
|
||||
|
||||
interface PendingPackageDirectoryCleanup {
|
||||
interface PendingOutputDirectoryCleanup {
|
||||
readonly operation: "pull_adb_package" | "collect_adb_bugreport";
|
||||
readonly path: string;
|
||||
readonly identity: PackageDirectoryIdentity | undefined;
|
||||
readonly identity: OutputDirectoryIdentity | undefined;
|
||||
readonly primary: AnalysisError;
|
||||
readonly remove: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface PackageDirectoryIdentity {
|
||||
interface OutputDirectoryIdentity {
|
||||
readonly dev: number;
|
||||
readonly ino: number;
|
||||
readonly mode: number;
|
||||
}
|
||||
|
||||
const removeOwnedPackageDirectory = async (
|
||||
const removeOwnedOutputDirectory = async (
|
||||
path: string,
|
||||
identity: PackageDirectoryIdentity | undefined,
|
||||
identity: OutputDirectoryIdentity | undefined,
|
||||
): Promise<void> => {
|
||||
if (identity === undefined)
|
||||
throw new Error(
|
||||
`Cannot verify the created package directory identity; refusing to remove ${path}`,
|
||||
`Cannot verify the created output directory identity; refusing to remove ${path}`,
|
||||
);
|
||||
let current: Awaited<ReturnType<typeof lstat>>;
|
||||
try {
|
||||
@@ -346,7 +377,7 @@ const removeOwnedPackageDirectory = async (
|
||||
current.mode !== identity.mode
|
||||
)
|
||||
throw new Error(
|
||||
`The created package directory was replaced or changed; refusing to remove ${path}`,
|
||||
`The created output directory was replaced or changed; refusing to remove ${path}`,
|
||||
);
|
||||
await rm(path, { recursive: true, force: true });
|
||||
};
|
||||
@@ -363,7 +394,7 @@ const reservePackageDestinations = async (
|
||||
): Promise<AnalysisOutputError | undefined> => {
|
||||
const reservations: {
|
||||
readonly path: string;
|
||||
readonly identity: PackageDirectoryIdentity;
|
||||
readonly identity: OutputDirectoryIdentity;
|
||||
}[] = [];
|
||||
for (const entry of entries) {
|
||||
const path = join(packageDirectory, entry.file_name);
|
||||
@@ -424,12 +455,12 @@ const reservePackageDestinations = async (
|
||||
*/
|
||||
export class AdbProvider {
|
||||
private readonly environment: Readonly<Record<string, string | undefined>>;
|
||||
readonly #pendingPackageDirectoryCleanup =
|
||||
new Set<PendingPackageDirectoryCleanup>();
|
||||
readonly #pendingOutputDirectoryCleanup =
|
||||
new Set<PendingOutputDirectoryCleanup>();
|
||||
readonly #activeOperations = new Set<Promise<void>>();
|
||||
#closing = false;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
#packageCleanupPromise: Promise<void> | undefined;
|
||||
#outputCleanupPromise: Promise<void> | undefined;
|
||||
|
||||
constructor(options: AdbProviderOptions = {}) {
|
||||
this.environment = options.environment ?? process.env;
|
||||
@@ -462,13 +493,13 @@ export class AdbProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry removal of package directories retained after failed rollback. */
|
||||
/** Retry removal of owned output directories retained after failed cleanup. */
|
||||
close(): Promise<void> {
|
||||
this.#closing = true;
|
||||
if (this.#closePromise !== undefined) return this.#closePromise;
|
||||
const closing = (async () => {
|
||||
await Promise.all(this.#activeOperations);
|
||||
await this.#retryPackageDirectoryCleanup();
|
||||
await this.#retryOutputDirectoryCleanup();
|
||||
})().finally(() => {
|
||||
if (this.#closePromise === closing) this.#closePromise = undefined;
|
||||
});
|
||||
@@ -476,23 +507,23 @@ export class AdbProvider {
|
||||
return this.#closePromise;
|
||||
}
|
||||
|
||||
#retryPackageDirectoryCleanup(): Promise<void> {
|
||||
if (this.#packageCleanupPromise !== undefined)
|
||||
return this.#packageCleanupPromise;
|
||||
const cleanup = this.#performPackageDirectoryCleanup().finally(() => {
|
||||
if (this.#packageCleanupPromise === cleanup)
|
||||
this.#packageCleanupPromise = undefined;
|
||||
#retryOutputDirectoryCleanup(): Promise<void> {
|
||||
if (this.#outputCleanupPromise !== undefined)
|
||||
return this.#outputCleanupPromise;
|
||||
const cleanup = this.#performOutputDirectoryCleanup().finally(() => {
|
||||
if (this.#outputCleanupPromise === cleanup)
|
||||
this.#outputCleanupPromise = undefined;
|
||||
});
|
||||
this.#packageCleanupPromise = cleanup;
|
||||
this.#outputCleanupPromise = cleanup;
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
async #performPackageDirectoryCleanup(): Promise<void> {
|
||||
async #performOutputDirectoryCleanup(): Promise<void> {
|
||||
const failures: AnalysisError[] = [];
|
||||
for (const owner of this.#pendingPackageDirectoryCleanup) {
|
||||
for (const owner of this.#pendingOutputDirectoryCleanup) {
|
||||
try {
|
||||
await owner.remove();
|
||||
this.#pendingPackageDirectoryCleanup.delete(owner);
|
||||
this.#pendingOutputDirectoryCleanup.delete(owner);
|
||||
} catch (cause: unknown) {
|
||||
const cleanup = new ProviderCleanupError(
|
||||
ADB_PROVIDER_IDENTITY.id,
|
||||
@@ -500,13 +531,13 @@ export class AdbProvider {
|
||||
{
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
{ operation: "pull_adb_package", cause },
|
||||
{ operation: owner.operation, cause },
|
||||
);
|
||||
failures.push(
|
||||
analysisErrorWithCleanupFailure(
|
||||
owner.primary,
|
||||
cleanup,
|
||||
"pull_adb_package",
|
||||
owner.operation,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -515,20 +546,22 @@ export class AdbProvider {
|
||||
if (failures.length > 1)
|
||||
throw new ProviderCleanupError(
|
||||
ADB_PROVIDER_IDENTITY.id,
|
||||
[...this.#pendingPackageDirectoryCleanup].map(({ path }) => path),
|
||||
[...this.#pendingOutputDirectoryCleanup].map(({ path }) => path),
|
||||
{
|
||||
reason:
|
||||
"Failed package output directories remain after cleanup retry",
|
||||
reason: "Failed ADB output directories remain after cleanup retry",
|
||||
operations: [...this.#pendingOutputDirectoryCleanup].map(
|
||||
({ operation }) => operation,
|
||||
),
|
||||
failures: failures.map(({ message, cleanupResources }) => ({
|
||||
message,
|
||||
resources: [...cleanupResources],
|
||||
})),
|
||||
},
|
||||
{
|
||||
operation: "pull_adb_package",
|
||||
operation: "close_binary",
|
||||
cause: new AggregateError(
|
||||
failures,
|
||||
"Multiple ADB package rollback cleanups failed",
|
||||
"Multiple ADB output directory cleanups failed",
|
||||
),
|
||||
},
|
||||
);
|
||||
@@ -546,16 +579,16 @@ export class AdbProvider {
|
||||
});
|
||||
this.#activeOperations.add(active);
|
||||
try {
|
||||
if (this.#pendingPackageDirectoryCleanup.size > 0) {
|
||||
if (this.#pendingOutputDirectoryCleanup.size > 0) {
|
||||
try {
|
||||
await this.#retryPackageDirectoryCleanup();
|
||||
await this.#retryOutputDirectoryCleanup();
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderCleanupError(
|
||||
ADB_PROVIDER_IDENTITY.id,
|
||||
[...this.#pendingPackageDirectoryCleanup].map(
|
||||
[...this.#pendingOutputDirectoryCleanup].map(
|
||||
({ path }) => path,
|
||||
),
|
||||
{
|
||||
@@ -1245,7 +1278,7 @@ export class AdbProvider {
|
||||
if (!directory.ok) return directory;
|
||||
const packageDirectory = join(directory.value, packageName);
|
||||
let packageDirectoryCreated = false;
|
||||
let packageDirectoryIdentity: PackageDirectoryIdentity | undefined;
|
||||
let packageDirectoryIdentity: OutputDirectoryIdentity | undefined;
|
||||
try {
|
||||
if (
|
||||
await lstat(packageDirectory).then(
|
||||
@@ -1275,17 +1308,18 @@ export class AdbProvider {
|
||||
{ cause },
|
||||
);
|
||||
if (!packageDirectoryCreated) return err(primary);
|
||||
const owner: PendingPackageDirectoryCleanup = {
|
||||
const owner: PendingOutputDirectoryCleanup = {
|
||||
operation,
|
||||
path: packageDirectory,
|
||||
identity: packageDirectoryIdentity,
|
||||
primary,
|
||||
remove: () =>
|
||||
removeOwnedPackageDirectory(
|
||||
removeOwnedOutputDirectory(
|
||||
packageDirectory,
|
||||
packageDirectoryIdentity,
|
||||
),
|
||||
};
|
||||
this.#pendingPackageDirectoryCleanup.add(owner);
|
||||
this.#pendingOutputDirectoryCleanup.add(owner);
|
||||
const cleanup = new ProviderCleanupError(
|
||||
ADB_PROVIDER_IDENTITY.id,
|
||||
[packageDirectory],
|
||||
@@ -1299,12 +1333,13 @@ export class AdbProvider {
|
||||
const failOwnedPackage = async (
|
||||
primary: AnalysisError,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> => {
|
||||
const owner: PendingPackageDirectoryCleanup = {
|
||||
const owner: PendingOutputDirectoryCleanup = {
|
||||
operation,
|
||||
path: packageDirectory,
|
||||
identity: packageDirectoryIdentity,
|
||||
primary,
|
||||
remove: () =>
|
||||
removeOwnedPackageDirectory(
|
||||
removeOwnedOutputDirectory(
|
||||
packageDirectory,
|
||||
packageDirectoryIdentity,
|
||||
),
|
||||
@@ -1312,7 +1347,7 @@ export class AdbProvider {
|
||||
try {
|
||||
await owner.remove();
|
||||
} catch (cause: unknown) {
|
||||
this.#pendingPackageDirectoryCleanup.add(owner);
|
||||
this.#pendingOutputDirectoryCleanup.add(owner);
|
||||
const cleanup = new ProviderCleanupError(
|
||||
ADB_PROVIDER_IDENTITY.id,
|
||||
[packageDirectory],
|
||||
@@ -2084,48 +2119,71 @@ export class AdbProvider {
|
||||
input.output_directory,
|
||||
);
|
||||
if (!directory.ok) return directory;
|
||||
const collected = await runAdb(
|
||||
operation,
|
||||
input.serial,
|
||||
selection.binary,
|
||||
adbArguments(input.serial, ["bugreport", directory.value]),
|
||||
{
|
||||
timeoutMs: BUGREPORT_TIMEOUT_MS,
|
||||
maxOutputBytes: BUGREPORT_MAX_OUTPUT_BYTES,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (!collected.ok) return collected;
|
||||
const reportedPath = parseBugreportCopiedPath(collected.value.stdout);
|
||||
if (reportedPath === null)
|
||||
return err(
|
||||
new AnalysisProtocolError(
|
||||
"adb bugreport did not report an archive path; the build may use an unsupported format",
|
||||
),
|
||||
);
|
||||
let stagingDirectory: string;
|
||||
let stagingIdentity: OutputDirectoryIdentity | undefined;
|
||||
let retainedObservation: AdbAcquiredFilePartialObservation | undefined;
|
||||
let publishedPath: string | undefined;
|
||||
try {
|
||||
const digest = await hashAdbFile(reportedPath, operation, signal);
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
client,
|
||||
serial: input.serial,
|
||||
local_path: reportedPath,
|
||||
bytes: digest.bytes,
|
||||
sha256: digest.sha256,
|
||||
adb_reported_path: reportedPath,
|
||||
},
|
||||
provider,
|
||||
{
|
||||
limitations: [
|
||||
"The archive is device-generated: dumpstate, dumpsys, and logcat content is the device's own report",
|
||||
],
|
||||
},
|
||||
),
|
||||
stagingDirectory = await mkdtemp(
|
||||
join(directory.value, ".rea-bugreport-"),
|
||||
);
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisCancelledError) {
|
||||
cause.retainPartialObservation({
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"The bugreport staging directory could not be created",
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
const collectedResult = await (async (): Promise<
|
||||
Result<AnalysisExecution, AnalysisError>
|
||||
> => {
|
||||
let reportedPath: string | null = null;
|
||||
try {
|
||||
const created = await lstat(stagingDirectory);
|
||||
if (!created.isDirectory() || created.isSymbolicLink())
|
||||
throw new Error(
|
||||
`The created bugreport staging output is not a regular directory: ${stagingDirectory}`,
|
||||
);
|
||||
stagingIdentity = {
|
||||
dev: created.dev,
|
||||
ino: created.ino,
|
||||
mode: created.mode,
|
||||
};
|
||||
const collected = await runAdb(
|
||||
operation,
|
||||
input.serial,
|
||||
selection.binary,
|
||||
adbArguments(input.serial, ["bugreport", stagingDirectory]),
|
||||
{
|
||||
timeoutMs: BUGREPORT_TIMEOUT_MS,
|
||||
maxOutputBytes: BUGREPORT_MAX_OUTPUT_BYTES,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (!collected.ok) return collected;
|
||||
reportedPath = parseBugreportCopiedPath(collected.value.stdout);
|
||||
if (reportedPath === null)
|
||||
return err(
|
||||
new AnalysisProtocolError(
|
||||
"adb bugreport did not report an archive path; the build may use an unsupported format",
|
||||
),
|
||||
);
|
||||
if (dirname(resolve(reportedPath)) !== stagingDirectory)
|
||||
return err(
|
||||
new AnalysisProtocolError(
|
||||
`adb bugreport reported an archive outside its staging directory: ${reportedPath}`,
|
||||
),
|
||||
);
|
||||
const archive = await lstat(reportedPath);
|
||||
if (!archive.isFile() || archive.isSymbolicLink())
|
||||
return err(
|
||||
new AnalysisProtocolError(
|
||||
`adb bugreport did not report a regular archive file: ${reportedPath}`,
|
||||
),
|
||||
);
|
||||
retainedObservation = {
|
||||
provider_id: "adb",
|
||||
operation,
|
||||
result: {
|
||||
@@ -2135,16 +2193,94 @@ export class AdbProvider {
|
||||
adb_reported_path: reportedPath,
|
||||
digest_status: "unknown",
|
||||
},
|
||||
});
|
||||
return err(cause);
|
||||
}
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
};
|
||||
try {
|
||||
publishedPath = await publishBugreport(reportedPath, directory.value);
|
||||
} catch (cause) {
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
`The reported bugreport archive could not be published: ${reportedPath}`,
|
||||
{ cause, partialObservation: retainedObservation },
|
||||
),
|
||||
);
|
||||
}
|
||||
retainedObservation = {
|
||||
...retainedObservation,
|
||||
result: { ...retainedObservation.result, local_path: publishedPath },
|
||||
};
|
||||
const digest = await hashAdbFile(publishedPath, operation, signal);
|
||||
const report = {
|
||||
client,
|
||||
serial: input.serial,
|
||||
local_path: publishedPath,
|
||||
bytes: digest.bytes,
|
||||
sha256: digest.sha256,
|
||||
adb_reported_path: reportedPath,
|
||||
};
|
||||
retainedObservation = {
|
||||
provider_id: "adb",
|
||||
operation,
|
||||
result: report,
|
||||
};
|
||||
return ok(
|
||||
createAnalysisExecution(report, provider, {
|
||||
limitations: [
|
||||
"The archive is device-generated: dumpstate, dumpsys, and logcat content is the device's own report",
|
||||
],
|
||||
}),
|
||||
);
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisCancelledError) {
|
||||
if (retainedObservation !== undefined)
|
||||
cause.retainPartialObservation(retainedObservation);
|
||||
return err(cause);
|
||||
}
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
`The reported bugreport archive could not be read back: ${reportedPath ?? stagingDirectory}`,
|
||||
{ cause, partialObservation: retainedObservation },
|
||||
),
|
||||
);
|
||||
}
|
||||
})();
|
||||
// A completed acquisition becomes caller evidence even if publication fails.
|
||||
// Its staging directory must not enter automatic cleanup or cleanup retries.
|
||||
if (retainedObservation !== undefined && publishedPath === undefined)
|
||||
return collectedResult;
|
||||
try {
|
||||
await removeOwnedOutputDirectory(stagingDirectory, stagingIdentity);
|
||||
} catch (cause) {
|
||||
const primary = collectedResult.ok
|
||||
? new AnalysisOutputError(
|
||||
operation,
|
||||
"The archive was published, but its staging directory could not be removed",
|
||||
)
|
||||
: collectedResult.error;
|
||||
if (retainedObservation !== undefined)
|
||||
primary.retainPartialObservation(retainedObservation);
|
||||
this.#pendingOutputDirectoryCleanup.add({
|
||||
operation,
|
||||
path: stagingDirectory,
|
||||
identity: stagingIdentity,
|
||||
primary,
|
||||
remove: () =>
|
||||
removeOwnedOutputDirectory(stagingDirectory, stagingIdentity),
|
||||
});
|
||||
return err(
|
||||
analysisErrorWithCleanupFailure(
|
||||
primary,
|
||||
new ProviderCleanupError(
|
||||
ADB_PROVIDER_IDENTITY.id,
|
||||
[stagingDirectory],
|
||||
{ reason: cause instanceof Error ? cause.message : String(cause) },
|
||||
{ operation, cause },
|
||||
),
|
||||
operation,
|
||||
`The reported bugreport archive could not be read back: ${reportedPath}`,
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
return collectedResult;
|
||||
}
|
||||
}
|
||||
@@ -709,7 +709,7 @@ export type AdbPackagePullPartialObservation = {
|
||||
readonly result: z.infer<typeof adbResultSchemas.pull_adb_package>;
|
||||
};
|
||||
|
||||
/** A completed acquisition whose digest read was cancelled before verification. */
|
||||
/** Acquired file facts retained when publication, digest verification or cleanup fails. */
|
||||
export type AdbAcquiredFilePartialObservation =
|
||||
| {
|
||||
readonly provider_id: "adb";
|
||||
@@ -727,13 +727,15 @@ export type AdbAcquiredFilePartialObservation =
|
||||
| {
|
||||
readonly provider_id: "adb";
|
||||
readonly operation: "collect_adb_bugreport";
|
||||
readonly result: {
|
||||
readonly client: z.infer<
|
||||
typeof adbResultSchemas.collect_adb_bugreport
|
||||
>["client"];
|
||||
readonly serial: string;
|
||||
readonly local_path: string;
|
||||
readonly adb_reported_path: string;
|
||||
readonly digest_status: "unknown";
|
||||
};
|
||||
readonly result:
|
||||
| z.infer<typeof adbResultSchemas.collect_adb_bugreport>
|
||||
| {
|
||||
readonly client: z.infer<
|
||||
typeof adbResultSchemas.collect_adb_bugreport
|
||||
>["client"];
|
||||
readonly serial: string;
|
||||
readonly local_path: string;
|
||||
readonly adb_reported_path: string;
|
||||
readonly digest_status: "unknown";
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
copyFile,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { AdbProvider } from "../../../../src/adb/AdbProvider.js";
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
import {
|
||||
execFileBytes,
|
||||
execFileOutput,
|
||||
} from "../../../../src/process/ExecFileOutput.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
|
||||
// Exercise real filesystem publication; the Android process reply is simulated.
|
||||
vi.mock(
|
||||
"../../../../src/process/ExecFileOutput.js",
|
||||
async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("../../../../src/process/ExecFileOutput.js")
|
||||
>()),
|
||||
execFileBytes: vi.fn(),
|
||||
execFileOutput: vi.fn(),
|
||||
}),
|
||||
);
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...actual,
|
||||
copyFile: vi.fn(actual.copyFile),
|
||||
mkdtemp: vi.fn(actual.mkdtemp),
|
||||
rm: vi.fn(actual.rm),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const fixture = async (duringCollection?: (root: string) => Promise<void>) => {
|
||||
const root = await createTestTempDirectory("rea-adb-bugreport-");
|
||||
const binary = join(root, "adb");
|
||||
let reportedPath = "";
|
||||
vi.mocked(execFileOutput).mockImplementation(async (_binary, args) => {
|
||||
expect(args).toEqual(["version"]);
|
||||
return {
|
||||
stdout: `Android Debug Bridge version 1.0.41\nVersion 34.0.5-stub\nInstalled as ${binary}\n`,
|
||||
stderr: "",
|
||||
};
|
||||
});
|
||||
vi.mocked(execFileBytes).mockImplementation(async (_binary, args) => {
|
||||
expect(args.slice(0, 3)).toEqual(["-s", "device", "bugreport"]);
|
||||
const staging = args[3]!;
|
||||
reportedPath = join(staging, "bugreport test.zip");
|
||||
await writeFile(reportedPath, "bugreport-bytes");
|
||||
await duringCollection?.(root);
|
||||
return {
|
||||
stdout: Buffer.from(`Bug report copied to ${reportedPath}\n`),
|
||||
stderr: Buffer.alloc(0),
|
||||
};
|
||||
});
|
||||
const provider = new AdbProvider({ environment: { REA_ADB_PATH: binary } });
|
||||
return {
|
||||
root,
|
||||
provider,
|
||||
reportedPath: () => reportedPath,
|
||||
request: {
|
||||
operation: "collect_adb_bugreport" as const,
|
||||
input: { serial: "device", output_directory: root },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
test.each(["before", "during"] as const)(
|
||||
"preserves a same-name caller archive created %s collection",
|
||||
async (when) => {
|
||||
const existing = async (root: string) => {
|
||||
await writeFile(join(root, "bugreport test.zip"), "existing evidence");
|
||||
await writeFile(join(root, "bugreport test (1).zip"), "other evidence");
|
||||
};
|
||||
const f = await fixture(when === "during" ? existing : undefined);
|
||||
if (when === "before") await existing(f.root);
|
||||
const result = await f.provider.execute(f.request);
|
||||
if (!result.ok) throw result.error;
|
||||
const localPath = join(f.root, "bugreport test (2).zip");
|
||||
expect(result.value.result).toMatchObject({
|
||||
local_path: localPath,
|
||||
adb_reported_path: f.reportedPath(),
|
||||
bytes: 15,
|
||||
sha256: createHash("sha256").update("bugreport-bytes").digest("hex"),
|
||||
});
|
||||
expect(await readFile(join(f.root, "bugreport test.zip"), "utf8")).toBe(
|
||||
"existing evidence",
|
||||
);
|
||||
expect(await readFile(join(f.root, "bugreport test (1).zip"), "utf8")).toBe(
|
||||
"other evidence",
|
||||
);
|
||||
expect(await readFile(localPath, "utf8")).toBe("bugreport-bytes");
|
||||
expect((await readdir(f.root)).sort()).toEqual([
|
||||
"bugreport test (1).zip",
|
||||
"bugreport test (2).zip",
|
||||
"bugreport test.zip",
|
||||
]);
|
||||
await f.provider.close();
|
||||
},
|
||||
);
|
||||
|
||||
test("retains a completed archive after failed publication and later provider work", async () => {
|
||||
const f = await fixture();
|
||||
const existing = join(f.root, "bugreport test.zip");
|
||||
await writeFile(existing, "existing evidence");
|
||||
const publicationFailure = Object.assign(new Error("no space left"), {
|
||||
code: "ENOSPC",
|
||||
});
|
||||
vi.mocked(copyFile).mockRejectedValueOnce(publicationFailure);
|
||||
const failed = await f.provider.execute(f.request);
|
||||
if (failed.ok) throw new Error("expected publication failure");
|
||||
const retainedPath = f.reportedPath();
|
||||
expect(failed.error._tag).toBe("AnalysisOutputError");
|
||||
expect(failed.error.cause).toBe(publicationFailure);
|
||||
expect(
|
||||
projectAnalysisError(failed.error).details?.partial_observation,
|
||||
).toMatchObject({
|
||||
provider_id: "adb",
|
||||
operation: f.request.operation,
|
||||
result: {
|
||||
serial: "device",
|
||||
local_path: retainedPath,
|
||||
adb_reported_path: retainedPath,
|
||||
digest_status: "unknown",
|
||||
},
|
||||
});
|
||||
expect(await readFile(retainedPath, "utf8")).toBe("bugreport-bytes");
|
||||
expect(await readFile(existing, "utf8")).toBe("existing evidence");
|
||||
|
||||
// New work and terminal cleanup must not reclaim the retained acquisition.
|
||||
const next = await f.provider.execute(f.request);
|
||||
if (!next.ok) throw next.error;
|
||||
expect(next.value.result).toMatchObject({
|
||||
local_path: join(f.root, "bugreport test (1).zip"),
|
||||
bytes: 15,
|
||||
sha256: createHash("sha256").update("bugreport-bytes").digest("hex"),
|
||||
});
|
||||
await f.provider.close();
|
||||
expect(await readFile(retainedPath, "utf8")).toBe("bugreport-bytes");
|
||||
expect(await readFile(existing, "utf8")).toBe("existing evidence");
|
||||
expect(await readFile(join(f.root, "bugreport test (1).zip"), "utf8")).toBe(
|
||||
"bugreport-bytes",
|
||||
);
|
||||
expect((await readdir(f.root)).sort()).toEqual(
|
||||
[
|
||||
basename(dirname(retainedPath)),
|
||||
"bugreport test.zip",
|
||||
"bugreport test (1).zip",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test("retains final and producer paths when digest verification is cancelled", async () => {
|
||||
const f = await fixture();
|
||||
const controller = new AbortController();
|
||||
const actualCopy =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
vi.mocked(copyFile).mockImplementationOnce(async (...args) => {
|
||||
await actualCopy.copyFile(...args);
|
||||
controller.abort();
|
||||
});
|
||||
const result = await f.provider.execute(f.request, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected cancellation");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
operation: f.request.operation,
|
||||
result: {
|
||||
local_path: join(f.root, "bugreport test.zip"),
|
||||
adb_reported_path: f.reportedPath(),
|
||||
digest_status: "unknown",
|
||||
},
|
||||
});
|
||||
expect(await readFile(join(f.root, "bugreport test.zip"), "utf8")).toBe(
|
||||
"bugreport-bytes",
|
||||
);
|
||||
expect(await readdir(f.root)).toEqual(["bugreport test.zip"]);
|
||||
await f.provider.close();
|
||||
});
|
||||
|
||||
test("returns staging allocation failures without invoking device collection", async () => {
|
||||
const f = await fixture();
|
||||
vi.mocked(mkdtemp).mockRejectedValueOnce(new Error("staging denied"));
|
||||
const result = await f.provider.execute(f.request);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected output failure");
|
||||
expect(result.error._tag).toBe("AnalysisOutputError");
|
||||
expect(execFileBytes).not.toHaveBeenCalled();
|
||||
await f.provider.close();
|
||||
});
|
||||
|
||||
test("retains published evidence and retries failed staging cleanup", async () => {
|
||||
const f = await fixture();
|
||||
vi.mocked(rm).mockRejectedValueOnce(new Error("cleanup denied"));
|
||||
const result = await f.provider.execute(f.request);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected cleanup failure");
|
||||
expect(result.error.cleanupResources).toContain(dirname(f.reportedPath()));
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
operation: f.request.operation,
|
||||
result: {
|
||||
local_path: join(f.root, "bugreport test.zip"),
|
||||
adb_reported_path: f.reportedPath(),
|
||||
bytes: 15,
|
||||
sha256: createHash("sha256").update("bugreport-bytes").digest("hex"),
|
||||
},
|
||||
});
|
||||
await f.provider.close();
|
||||
expect(await readdir(f.root)).toEqual(["bugreport test.zip"]);
|
||||
});
|
||||
|
||||
test("refuses an archive reported outside the owned staging directory", async () => {
|
||||
const f = await fixture();
|
||||
const existing = join(f.root, "bugreport test.zip");
|
||||
await writeFile(existing, "existing evidence");
|
||||
vi.mocked(execFileBytes).mockResolvedValueOnce({
|
||||
stdout: Buffer.from(`Bug report copied to ${existing}\n`),
|
||||
stderr: Buffer.alloc(0),
|
||||
});
|
||||
const result = await f.provider.execute(f.request);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected protocol refusal");
|
||||
expect(result.error._tag).toBe("AnalysisProtocolError");
|
||||
expect(await readFile(existing, "utf8")).toBe("existing evidence");
|
||||
expect(await readdir(f.root)).toEqual(["bugreport test.zip"]);
|
||||
await f.provider.close();
|
||||
});
|
||||
|
||||
test("preserves the primary protocol failure when staging cleanup also fails", async () => {
|
||||
const f = await fixture();
|
||||
vi.mocked(execFileBytes).mockResolvedValueOnce({
|
||||
stdout: Buffer.from("unsupported completion format\n"),
|
||||
stderr: Buffer.alloc(0),
|
||||
});
|
||||
vi.mocked(rm).mockRejectedValueOnce(new Error("cleanup denied"));
|
||||
const result = await f.provider.execute(f.request);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected protocol and cleanup failure");
|
||||
expect(result.error.cause).toMatchObject({
|
||||
errors: [
|
||||
{
|
||||
_tag: "AnalysisProtocolError",
|
||||
message: expect.stringContaining("did not report an archive path"),
|
||||
},
|
||||
expect.any(Error),
|
||||
],
|
||||
});
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(result.error.cleanupResources).toHaveLength(1);
|
||||
await f.provider.close();
|
||||
expect(await readdir(f.root)).toEqual([]);
|
||||
});
|
||||
Reference in new issue
Block a user