diff --git a/ci/phpunit/inc/utils/SupertaskUtilsTest.php b/ci/phpunit/inc/utils/SupertaskUtilsTest.php new file mode 100644 index 000000000..6f93c65c1 --- /dev/null +++ b/ci/phpunit/inc/utils/SupertaskUtilsTest.php @@ -0,0 +1,206 @@ +createDatabaseObject( + Factory::getPretaskFactory(), + new Pretask(null, 'pretask_' . uniqid(), $attackCmd, 60, 30, '', 0, 0, 0, 1, 0, 0, $crackerBinaryTypeId) + ); + $this->assertTrue($pretask instanceof Pretask); + foreach ($files as $file) { + $this->createDatabaseObject(Factory::getFilePretaskFactory(), new FilePretask(null, $file->getId(), $pretask->getId())); + } + return $pretask; + } + + /** + * @param Pretask[] $pretasks + * @return Supertask + * @throws Exception + */ + private function createSupertaskFrom(array $pretasks): Supertask { + $supertask = $this->createDatabaseObject(Factory::getSupertaskFactory(), new Supertask(null, 'supertask_' . uniqid())); + $this->assertTrue($supertask instanceof Supertask); + foreach ($pretasks as $pretask) { + $this->createDatabaseObject(Factory::getSupertaskPretaskFactory(), new SupertaskPretask(null, $supertask->getId(), $pretask->getId())); + } + return $supertask; + } + + /** + * Seeds a fully exhausted task on $hashlist that is the exact equivalent of $attackCmd / $files. + * + * @param mixed $accessGroup + * @param mixed $hashlist + * @param mixed $crackerBinary + * @param mixed $crackerBinaryType + * @param string $attackCmd + * @param array $files + * @return Task + * @throws Exception + */ + private function seedCompletedTask($accessGroup, $hashlist, $crackerBinary, $crackerBinaryType, string $attackCmd, array $files): Task { + $wrapper = $this->createTaskWrapper($accessGroup, $hashlist); + $task = $this->createTask($wrapper, $crackerBinary, $crackerBinaryType); + Factory::getTaskFactory()->mset($task, [ + Task::ATTACK_CMD => $attackCmd, + Task::KEYSPACE => 1000, + Task::KEYSPACE_PROGRESS => 1000, + ]); + foreach ($files as $file) { + $this->createFileTask($file, $task); + } + return Factory::getTaskFactory()->get($task->getId()); + } + + /** + * Registers the tasks and wrapper created in-flight by runSupertask so the TestBase + * teardown removes them (tasks first, then the wrapper, to respect the foreign key). + * + * @param mixed $taskWrapper + * @return void + */ + private function registerCreatedWrapper($taskWrapper): void { + $this->registerDatabaseObject(Factory::getTaskWrapperFactory(), $taskWrapper); + $this->registerDatabaseObjects(Factory::getTaskFactory(), TaskUtils::getTasksOfWrapper($taskWrapper->getId())); + } + + /** + * Applies a hashlist whose hexSalt prefixing is disabled, so the effective attack command + * equals the pretask attack command (keeps the equivalence assertions readable). + * + * @param mixed $accessGroup + * @param mixed $hashType + * @return mixed + * @throws Exception + */ + private function createPlainHashlist($accessGroup, $hashType) { + $hashlist = $this->createHashlist($accessGroup, $hashType); + Factory::getHashlistFactory()->set($hashlist, Hashlist::HEX_SALT, 0); + return Factory::getHashlistFactory()->get($hashlist->getId()); + } + + /** + * With skipCompleted on, a pretask whose equivalent is already exhausted is skipped while + * the remaining pretask is still instantiated into a new wrapper. + * + * @return void + * @throws Exception + */ + public function testRunSupertaskSkipsCompletedPretask(): void { + $accessGroup = $this->createAccessGroup("phpunit"); + $hashType = $this->createHashType(); + $hashlist = $this->createPlainHashlist($accessGroup, $hashType); + $crackerBinaryType = $this->createCrackerBinaryType(); + $crackerBinary = $this->createCrackerBinary($crackerBinaryType); + $file = $this->createFile($accessGroup); + + $pretaskCompleted = $this->createPretask("#HL# -a 0 dict.txt", $crackerBinaryType->getId(), [$file]); + // The fresh pretask (the one actually instantiated) is file-less so the in-flight task it + // produces carries no FileTask/FileDownload rows for the raw TestBase teardown to choke on. + $pretaskFresh = $this->createPretask("#HL# -a 3 ?d?d?d?d", $crackerBinaryType->getId(), []); + $supertask = $this->createSupertaskFrom([$pretaskCompleted, $pretaskFresh]); + + $completedTask = $this->seedCompletedTask($accessGroup, $hashlist, $crackerBinary, $crackerBinaryType, "#HL# -a 0 dict.txt", [$file]); + + $result = SupertaskUtils::runSupertask($supertask->getId(), $hashlist->getId(), $crackerBinary->getId(), true); + $this->assertNotNull($result["taskWrapper"]); + $this->registerCreatedWrapper($result["taskWrapper"]); + + $this->assertCount(1, $result["skippedPretasks"]); + $this->assertEquals($pretaskCompleted->getId(), $result["skippedPretasks"][0]["pretaskId"]); + $this->assertEquals($completedTask->getId(), $result["skippedPretasks"][0]["matchingTaskId"]); + + $createdTasks = TaskUtils::getTasksOfWrapper($result["taskWrapper"]->getId()); + $this->assertCount(1, $createdTasks); + $this->assertEquals("#HL# -a 3 ?d?d?d?d", $createdTasks[0]->getAttackCmd()); + } + + /** + * With skipCompleted on and every pretask already completed, no wrapper is created and all + * pretasks are reported as skipped. + * + * @return void + * @throws Exception + */ + public function testRunSupertaskAllSkippedCreatesNoWrapper(): void { + $accessGroup = $this->createAccessGroup("phpunit"); + $hashType = $this->createHashType(); + $hashlist = $this->createPlainHashlist($accessGroup, $hashType); + $crackerBinaryType = $this->createCrackerBinaryType(); + $crackerBinary = $this->createCrackerBinary($crackerBinaryType); + $file = $this->createFile($accessGroup); + + $pretask = $this->createPretask("#HL# -a 0 dict.txt", $crackerBinaryType->getId(), [$file]); + $supertask = $this->createSupertaskFrom([$pretask]); + $this->seedCompletedTask($accessGroup, $hashlist, $crackerBinary, $crackerBinaryType, "#HL# -a 0 dict.txt", [$file]); + + $result = SupertaskUtils::runSupertask($supertask->getId(), $hashlist->getId(), $crackerBinary->getId(), true); + + $this->assertNull($result["taskWrapper"]); + $this->assertCount(1, $result["skippedPretasks"]); + $this->assertEquals($pretask->getId(), $result["skippedPretasks"][0]["pretaskId"]); + } + + /** + * Default behavior (skipCompleted off) is unchanged: every pretask is instantiated even when + * an equivalent completed task already exists, and nothing is reported as skipped. + * + * @return void + * @throws Exception + */ + public function testRunSupertaskWithoutSkipInstantiatesAll(): void { + $accessGroup = $this->createAccessGroup("phpunit"); + $hashType = $this->createHashType(); + $hashlist = $this->createPlainHashlist($accessGroup, $hashType); + $crackerBinaryType = $this->createCrackerBinaryType(); + $crackerBinary = $this->createCrackerBinary($crackerBinaryType); + + // The instantiated pretask is intentionally file-less so the in-flight task carries no + // FileTask/FileDownload rows that the raw TestBase teardown could not clean up. + $pretask = $this->createPretask("#HL# -a 3 ?d?d?d?d", $crackerBinaryType->getId(), []); + $supertask = $this->createSupertaskFrom([$pretask]); + $this->seedCompletedTask($accessGroup, $hashlist, $crackerBinary, $crackerBinaryType, "#HL# -a 3 ?d?d?d?d", []); + + $result = SupertaskUtils::runSupertask($supertask->getId(), $hashlist->getId(), $crackerBinary->getId(), false); + $this->assertNotNull($result["taskWrapper"]); + $this->registerCreatedWrapper($result["taskWrapper"]); + + $this->assertCount(0, $result["skippedPretasks"]); + $createdTasks = TaskUtils::getTasksOfWrapper($result["taskWrapper"]->getId()); + $this->assertCount(1, $createdTasks); + } +} diff --git a/ci/phpunit/inc/utils/TaskUtilsTest.php b/ci/phpunit/inc/utils/TaskUtilsTest.php index 92bf8012d..cc0915df7 100644 --- a/ci/phpunit/inc/utils/TaskUtilsTest.php +++ b/ci/phpunit/inc/utils/TaskUtilsTest.php @@ -267,4 +267,277 @@ public function createTaskHelper(): array { return array("user"=> $user, "accessGroup"=>$accessGroup, "hashType"=>$hashType, "hashlist"=>$hashlist, "taskWrapper"=>$taskWrapper, "crackerBinaryType"=>$crackerBinaryType, "crackerBinary"=>$crackerBinary, "task"=>$task); } + + /** + * Builds the common fixtures used by the findCompletedEquivalent tests: + * an access group, a hashlist, a (normal) task wrapper on that hashlist, a + * cracker binary + type and a file. + * + * @return array + * @throws Exception + */ + private function findCompletedSetup(): array { + $accessGroup = $this->createAccessGroup("phpunit"); + $hashType = $this->createHashType(); + $hashlist = $this->createHashlist($accessGroup, $hashType); + $taskWrapper = $this->createTaskWrapper($accessGroup, $hashlist); + $crackerBinaryType = $this->createCrackerBinaryType(); + $crackerBinary = $this->createCrackerBinary($crackerBinaryType); + $file = $this->createFile($accessGroup); + return array( + "accessGroup" => $accessGroup, + "hashType" => $hashType, + "hashlist" => $hashlist, + "taskWrapper" => $taskWrapper, + "crackerBinaryType" => $crackerBinaryType, + "crackerBinary" => $crackerBinary, + "file" => $file, + ); + } + + /** + * Creates a task on the given wrapper with a specific attack command, file set and + * keyspace state, used to stand in for an already-completed task. + * + * @param TaskWrapper $taskWrapper + * @param mixed $crackerBinary + * @param mixed $crackerBinaryType + * @param string $attackCmd + * @param array $files + * @param int $keyspace + * @param int $keyspaceProgress + * @param int $isArchived + * @return Task + * @throws Exception + */ + private function makeCompletedTask($taskWrapper, $crackerBinary, $crackerBinaryType, string $attackCmd, array $files, int $keyspace = 1000, int $keyspaceProgress = 1000, int $isArchived = 0): Task { + $task = $this->createTask($taskWrapper, $crackerBinary, $crackerBinaryType); + Factory::getTaskFactory()->mset($task, [ + Task::ATTACK_CMD => $attackCmd, + Task::KEYSPACE => $keyspace, + Task::KEYSPACE_PROGRESS => $keyspaceProgress, + Task::IS_ARCHIVED => $isArchived, + ]); + foreach ($files as $file) { + $this->createFileTask($file, $task); + } + return Factory::getTaskFactory()->get($task->getId()); + } + + /** + * A fully exhausted task with a matching attack command, file set and cracker is found. + * + * @return void + * @throws Exception + */ + public function testFindCompletedEquivalentMatches(): void { + $s = $this->findCompletedSetup(); + $task = $this->makeCompletedTask($s["taskWrapper"], $s["crackerBinary"], $s["crackerBinaryType"], "#HL# -a 0 dict.txt", [$s["file"]]); + + $match = TaskUtils::findCompletedEquivalent( + $s["hashlist"]->getId(), + "#HL# -a 0 dict.txt", + [$s["file"]->getId()], + $s["crackerBinary"]->getId(), + $s["crackerBinaryType"]->getId() + ); + + $this->assertNotNull($match); + $this->assertEquals($task->getId(), $match->getId()); + } + + /** + * A task with no files matches a spec with no files. + * + * @return void + * @throws Exception + */ + public function testFindCompletedEquivalentNoFilesMatches(): void { + $s = $this->findCompletedSetup(); + $task = $this->makeCompletedTask($s["taskWrapper"], $s["crackerBinary"], $s["crackerBinaryType"], "#HL# -a 3 ?d?d?d?d", []); + + $match = TaskUtils::findCompletedEquivalent( + $s["hashlist"]->getId(), + "#HL# -a 3 ?d?d?d?d", + [], + $s["crackerBinary"]->getId(), + $s["crackerBinaryType"]->getId() + ); + + $this->assertNotNull($match); + $this->assertEquals($task->getId(), $match->getId()); + } + + /** + * An archived but fully exhausted task still counts as a match. + * + * @return void + * @throws Exception + */ + public function testFindCompletedEquivalentArchivedMatches(): void { + $s = $this->findCompletedSetup(); + $task = $this->makeCompletedTask($s["taskWrapper"], $s["crackerBinary"], $s["crackerBinaryType"], "#HL# -a 0 dict.txt", [$s["file"]], 1000, 1000, 1); + + $match = TaskUtils::findCompletedEquivalent( + $s["hashlist"]->getId(), + "#HL# -a 0 dict.txt", + [$s["file"]->getId()], + $s["crackerBinary"]->getId(), + $s["crackerBinaryType"]->getId() + ); + + $this->assertNotNull($match); + $this->assertEquals($task->getId(), $match->getId()); + } + + /** + * Whitespace differences in the attack command are normalized away. + * + * @return void + * @throws Exception + */ + public function testFindCompletedEquivalentAttackCmdWhitespaceNormalized(): void { + $s = $this->findCompletedSetup(); + $task = $this->makeCompletedTask($s["taskWrapper"], $s["crackerBinary"], $s["crackerBinaryType"], "#HL# -a 0 dict.txt", [$s["file"]]); + + $match = TaskUtils::findCompletedEquivalent( + $s["hashlist"]->getId(), + "#HL# -a 0 dict.txt", + [$s["file"]->getId()], + $s["crackerBinary"]->getId(), + $s["crackerBinaryType"]->getId() + ); + + $this->assertNotNull($match); + $this->assertEquals($task->getId(), $match->getId()); + } + + /** + * A partially completed task (progress below keyspace) is not a match. + * + * @return void + * @throws Exception + */ + public function testFindCompletedEquivalentPartialKeyspaceNoMatch(): void { + $s = $this->findCompletedSetup(); + $this->makeCompletedTask($s["taskWrapper"], $s["crackerBinary"], $s["crackerBinaryType"], "#HL# -a 0 dict.txt", [$s["file"]], 1000, 500); + + $match = TaskUtils::findCompletedEquivalent( + $s["hashlist"]->getId(), + "#HL# -a 0 dict.txt", + [$s["file"]->getId()], + $s["crackerBinary"]->getId(), + $s["crackerBinaryType"]->getId() + ); + + $this->assertNull($match); + } + + /** + * A task with keyspace 0 (never measured) is not a match. + * + * @return void + * @throws Exception + */ + public function testFindCompletedEquivalentZeroKeyspaceNoMatch(): void { + $s = $this->findCompletedSetup(); + $this->makeCompletedTask($s["taskWrapper"], $s["crackerBinary"], $s["crackerBinaryType"], "#HL# -a 0 dict.txt", [$s["file"]], 0, 0); + + $match = TaskUtils::findCompletedEquivalent( + $s["hashlist"]->getId(), + "#HL# -a 0 dict.txt", + [$s["file"]->getId()], + $s["crackerBinary"]->getId(), + $s["crackerBinaryType"]->getId() + ); + + $this->assertNull($match); + } + + /** + * A different cracker binary invalidates the match. + * + * @return void + * @throws Exception + */ + public function testFindCompletedEquivalentDifferentCrackerNoMatch(): void { + $s = $this->findCompletedSetup(); + $this->makeCompletedTask($s["taskWrapper"], $s["crackerBinary"], $s["crackerBinaryType"], "#HL# -a 0 dict.txt", [$s["file"]]); + $otherBinary = $this->createCrackerBinary($s["crackerBinaryType"]); + + $match = TaskUtils::findCompletedEquivalent( + $s["hashlist"]->getId(), + "#HL# -a 0 dict.txt", + [$s["file"]->getId()], + $otherBinary->getId(), + $s["crackerBinaryType"]->getId() + ); + + $this->assertNull($match); + } + + /** + * A different file set invalidates the match. + * + * @return void + * @throws Exception + */ + public function testFindCompletedEquivalentDifferentFilesetNoMatch(): void { + $s = $this->findCompletedSetup(); + $this->makeCompletedTask($s["taskWrapper"], $s["crackerBinary"], $s["crackerBinaryType"], "#HL# -a 0 dict.txt", [$s["file"]]); + $otherFile = $this->createFile($s["accessGroup"]); + + $match = TaskUtils::findCompletedEquivalent( + $s["hashlist"]->getId(), + "#HL# -a 0 dict.txt", + [$otherFile->getId()], + $s["crackerBinary"]->getId(), + $s["crackerBinaryType"]->getId() + ); + + $this->assertNull($match); + } + + /** + * A different attack command invalidates the match. + * + * @return void + * @throws Exception + */ + public function testFindCompletedEquivalentDifferentAttackCmdNoMatch(): void { + $s = $this->findCompletedSetup(); + $this->makeCompletedTask($s["taskWrapper"], $s["crackerBinary"], $s["crackerBinaryType"], "#HL# -a 0 dict.txt", [$s["file"]]); + + $match = TaskUtils::findCompletedEquivalent( + $s["hashlist"]->getId(), + "#HL# -a 3 ?d?d?d?d", + [$s["file"]->getId()], + $s["crackerBinary"]->getId(), + $s["crackerBinaryType"]->getId() + ); + + $this->assertNull($match); + } + + /** + * An identical completed task on a different hashlist is not a match. + * + * @return void + * @throws Exception + */ + public function testFindCompletedEquivalentDifferentHashlistNoMatch(): void { + $s = $this->findCompletedSetup(); + $this->makeCompletedTask($s["taskWrapper"], $s["crackerBinary"], $s["crackerBinaryType"], "#HL# -a 0 dict.txt", [$s["file"]]); + $otherHashlist = $this->createHashlist($s["accessGroup"], $s["hashType"]); + + $match = TaskUtils::findCompletedEquivalent( + $otherHashlist->getId(), + "#HL# -a 0 dict.txt", + [$s["file"]->getId()], + $s["crackerBinary"]->getId(), + $s["crackerBinaryType"]->getId() + ); + + $this->assertNull($match); + } } diff --git a/src/inc/apiv2/common/AbstractBaseAPI.php b/src/inc/apiv2/common/AbstractBaseAPI.php index ad512b51d..fdf45521f 100644 --- a/src/inc/apiv2/common/AbstractBaseAPI.php +++ b/src/inc/apiv2/common/AbstractBaseAPI.php @@ -1636,7 +1636,7 @@ static function createJsonResponse(array $data = [], array $links = [], array $i /** * Get single Resource */ - protected static function getOneResource(object $apiClass, object $object, Request $request, Response $response, int $statusCode = 200): Response { + protected static function getOneResource(object $apiClass, object $object, Request $request, Response $response, int $statusCode = 200, array $extraMeta = []): Response { $apiClass->preCommon($request); $validExpandables = $apiClass->getExpandables(); $expands = $apiClass->makeExpandables($request, $validExpandables); @@ -1670,7 +1670,7 @@ protected static function getOneResource(object $apiClass, object $object, Reque $linksSelf = $request->getUri()->getPath() . ((!empty($linksQuery)) ? '?' . $linksQuery : ''); $links = ["self" => $linksSelf]; - $metaData = []; + $metaData = $extraMeta; if ($apiClass->permissionErrors !== null) { $metaData["Include errors"] = $apiClass->permissionErrors; } diff --git a/src/inc/apiv2/common/AbstractHelperAPI.php b/src/inc/apiv2/common/AbstractHelperAPI.php index b42a7823c..ea3e538c1 100644 --- a/src/inc/apiv2/common/AbstractHelperAPI.php +++ b/src/inc/apiv2/common/AbstractHelperAPI.php @@ -28,6 +28,16 @@ abstract public static function getResponse(): array|string|null; public function getParamsSwagger(): array { return []; } + + /** + * Extra top-level JSON:API "meta" members to include alongside an object response. + * Overridden by helpers that return a resource object but also need to surface + * additional information (e.g. createSupertask returning the TaskWrapper plus a + * list of skipped pretasks). Default is empty, so existing helpers are unaffected. + */ + protected function getExtraMeta(): array { + return []; + } /** * Chunk API endpoint specific call to abort chunk @@ -72,7 +82,7 @@ public function processPost(Request $request, Response $response, array $args): /* Successful executed action of create */ if (is_object($newObject)) { $apiClass = new ($this->container->get('classMapper')->get($newObject::class))($this->container); - return self::getOneResource($apiClass, $newObject, $request, $response); + return self::getOneResource($apiClass, $newObject, $request, $response, 200, $this->getExtraMeta()); /* A meta response of a helper function */ } elseif (is_array($newObject)) { diff --git a/src/inc/apiv2/helper/CreateSupertaskHelperAPI.php b/src/inc/apiv2/helper/CreateSupertaskHelperAPI.php index 738f6b8e3..3bf9178e5 100644 --- a/src/inc/apiv2/helper/CreateSupertaskHelperAPI.php +++ b/src/inc/apiv2/helper/CreateSupertaskHelperAPI.php @@ -2,75 +2,98 @@ namespace Hashtopolis\inc\apiv2\helper; -use Hashtopolis\dba\Factory; -use Hashtopolis\dba\OrderFilter; -use Hashtopolis\dba\QueryFilter; - use Hashtopolis\dba\models\CrackerBinary; use Hashtopolis\dba\models\Hashlist; use Hashtopolis\dba\models\Supertask; use Hashtopolis\dba\models\Task; use Hashtopolis\dba\models\TaskWrapper; use Hashtopolis\inc\apiv2\common\AbstractHelperAPI; -use Hashtopolis\inc\defines\DTaskTypes; use Hashtopolis\inc\HTException; use Hashtopolis\inc\utils\SupertaskUtils; class CreateSupertaskHelperAPI extends AbstractHelperAPI { + /** @var bool whether the caller opted into skipping already-completed pretasks */ + private bool $skipCompletedRequested = false; + /** @var array pretasks skipped on the last run */ + private array $skippedPretasks = []; + public static function getBaseUri(): string { return "/api/v2/helper/createSupertask"; } - + public static function getAvailableMethods(): array { return ['POST']; } - + public function getRequiredPermissions(string $method): array { return [TaskWrapper::PERM_CREATE, Task::PERM_CREATE, Supertask::PERM_READ, Hashlist::PERM_READ, CrackerBinary::PERM_READ]; } - + /** * supertaskTemplateId is the the Id of the supertasktemplate of which you want to create a supertask of. * hashlistId is the Id of the hashlist that has to be used for the supertask. * crackerVersionId is the Id of the crackerversion that is used for the created supertask. + * skipCompleted (optional, default false) skips any pretask whose equivalent attack has already + * been fully exhausted against the hashlist instead of re-instantiating it. */ public function getFormFields(): array { return [ "supertaskTemplateId" => ["type" => "int"], Hashlist::HASHLIST_ID => ["type" => "int"], "crackerVersionId" => ["type" => "int"], + "skipCompleted" => ["type" => "bool", "null" => true], ]; } - + public static function getResponse(): string { return "TaskWrapper"; } - + + /** + * When skipCompleted was requested, expose the skipped pretasks under the top-level + * "meta" member alongside the returned TaskWrapper resource. When it was not requested, + * nothing is added, keeping the response identical to the previous behavior. + */ + protected function getExtraMeta(): array { + if (!$this->skipCompletedRequested) { + return []; + } + return ["skippedPretasks" => $this->skippedPretasks]; + } + /** - * Endpoint to create a supertask from a supertask template + * Endpoint to create a supertask from a supertask template. + * + * When skipCompleted is true, pretasks whose equivalent attack has already been fully + * exhausted against the target hashlist are skipped. The skipped pretasks are reported + * under the top-level "meta.skippedPretasks" member (each entry has pretaskId and + * matchingTaskId). If every pretask is skipped, no TaskWrapper is created and a meta-only + * response with "taskWrapperId": null is returned. * @throws HTException */ public function actionPost($data): object|array|null { $supertaskTemplate = self::getSupertask($data["supertaskTemplateId"]); $hashlist = self::getHashlist($data[Hashlist::HASHLIST_ID]); $crackerBinary = self::getCrackerBinary($data["crackerVersionId"]); - - SupertaskUtils::runSupertask( + + $this->skipCompletedRequested = (bool)($data["skipCompleted"] ?? false); + + $result = SupertaskUtils::runSupertask( $supertaskTemplate->getId(), $hashlist->getId(), - $crackerBinary->getId() + $crackerBinary->getId(), + $this->skipCompletedRequested ); - - /* Quick to retrieve newly created TaskWrapper */ - $qFs = [ - new QueryFilter(TaskWrapper::HASHLIST_ID, $hashlist->getId(), "="), - new QueryFilter(TaskWrapper::TASK_TYPE, DTaskTypes::SUPERTASK, "=") - ]; - $oF = new OrderFilter(TaskWrapper::TASK_WRAPPER_ID, "DESC"); - - $objects = self::getModelFactory(TaskWrapper::class)->filter([Factory::FILTER => $qFs, Factory::ORDER => $oF]); - assert(count($objects) > 0); - - return $objects[0]; + $this->skippedPretasks = $result["skippedPretasks"]; + + /* Every pretask was already completed against this hashlist: no TaskWrapper was created. */ + if ($result["taskWrapper"] === null) { + return [ + "taskWrapperId" => null, + "skippedPretasks" => $this->skippedPretasks, + ]; + } + + return $result["taskWrapper"]; } } diff --git a/src/inc/handlers/SupertaskHandler.php b/src/inc/handlers/SupertaskHandler.php index 34152ca34..27d0e2afe 100644 --- a/src/inc/handlers/SupertaskHandler.php +++ b/src/inc/handlers/SupertaskHandler.php @@ -27,7 +27,7 @@ public function handle($action) { break; case DSupertaskAction::APPLY_SUPERTASK: AccessControl::getInstance()->checkPermission(DSupertaskAction::APPLY_SUPERTASK_PERM); - SupertaskUtils::runSupertask($_POST['supertask'], $_POST['hashlist'], $_POST['crackerBinaryVersionId']); + SupertaskUtils::runSupertask($_POST['supertask'], $_POST['hashlist'], $_POST['crackerBinaryVersionId'], isset($_POST['skipCompleted'])); header("Location: tasks.php"); die(); case DSupertaskAction::IMPORT_SUPERTASK: diff --git a/src/inc/utils/SupertaskUtils.php b/src/inc/utils/SupertaskUtils.php index baad0c265..910ac4219 100644 --- a/src/inc/utils/SupertaskUtils.php +++ b/src/inc/utils/SupertaskUtils.php @@ -247,9 +247,15 @@ public static function getSupertask($supertaskId) { * @param int $supertaskId * @param int $hashlistId * @param int $crackerId + * @param bool $skipCompleted when true, pretasks whose equivalent attack has already been fully + * exhausted against the hashlist are skipped instead of being + * re-instantiated (see TaskUtils::findCompletedEquivalent). Default + * false preserves the previous behavior for all existing callers. + * @return array{taskWrapper: TaskWrapper|null, skippedPretasks: array} + * the created TaskWrapper (null when every pretask was skipped) and the list of skipped pretasks * @throws HTException */ - public static function runSupertask($supertaskId, $hashlistId, $crackerId) { + public static function runSupertask($supertaskId, $hashlistId, $crackerId, $skipCompleted = false) { $supertask = Factory::getSupertaskFactory()->get($supertaskId); if ($supertask == null) { throw new HTException("Invalid supertask ID!"); @@ -270,30 +276,62 @@ public static function runSupertask($supertaskId, $hashlistId, $crackerId) { $joined = Factory::getPretaskFactory()->filter([Factory::FILTER => $qF, Factory::JOIN => $jF]); /** @var $pretasks Pretask[] */ $pretasks = $joined[Factory::getPretaskFactory()->getModelName()]; - + + // Resolve the effective task spec for each pretask, and (when requested) skip the ones whose + // equivalent attack has already been fully exhausted on this hashlist. The skip check only + // reads, so it runs before opening the transaction. + $skippedPretasks = []; + $toRun = []; + foreach ($pretasks as $pretask) { + $crackerBinaryId = $cracker->getId(); + if ($cracker->getCrackerBinaryTypeId() != $pretask->getCrackerBinaryTypeId()) { + $crackerBinaryId = CrackerBinaryUtils::getNewestVersion($pretask->getCrackerBinaryTypeId())->getId(); + } + $attackCmd = $pretask->getAttackCmd(); + if ($hashlist->getHexSalt() == 1 && strpos($attackCmd, "--hex-salt") === false) { + $attackCmd = "--hex-salt " . $attackCmd; + } + if ($skipCompleted) { + $match = TaskUtils::findCompletedEquivalent( + $hashlist->getId(), + $attackCmd, + TaskUtils::getFileIdsOfPretask($pretask), + $crackerBinaryId, + $cracker->getCrackerBinaryTypeId() + ); + if ($match !== null) { + $skippedPretasks[] = ["pretaskId" => $pretask->getId(), "matchingTaskId" => $match->getId()]; + continue; + } + } + $toRun[] = ["pretask" => $pretask, "attackCmd" => $attackCmd, "crackerBinaryId" => $crackerBinaryId]; + } + + // Every pretask was already completed: do not create an empty wrapper. + if (count($toRun) == 0 && $skipCompleted) { + return ["taskWrapper" => null, "skippedPretasks" => $skippedPretasks]; + } + Factory::getAgentFactory()->getDB()->beginTransaction(); - + $wrapperPriority = 0; $wrapperMaxAgents = 0; - foreach ($pretasks as $pretask) { + foreach ($toRun as $item) { + $pretask = $item["pretask"]; if ($wrapperPriority == 0 || $wrapperPriority > $pretask->getPriority()) { $wrapperPriority = $pretask->getPriority(); } } - + $taskWrapper = new TaskWrapper(null, $wrapperPriority, $wrapperMaxAgents, DTaskTypes::SUPERTASK, $hashlist->getId(), $hashlist->getAccessGroupId(), $supertask->getSupertaskName(), 0, 0); $taskWrapper = Factory::getTaskWrapperFactory()->save($taskWrapper); - - foreach ($pretasks as $pretask) { - $crackerBinaryId = $cracker->getId(); - if ($cracker->getCrackerBinaryTypeId() != $pretask->getCrackerBinaryTypeId()) { - $crackerBinaryId = CrackerBinaryUtils::getNewestVersion($pretask->getCrackerBinaryTypeId())->getId(); - } - + + foreach ($toRun as $item) { + $pretask = $item["pretask"]; $task = new Task( null, $pretask->getTaskName(), - $pretask->getAttackCmd(), + $item["attackCmd"], $pretask->getChunkTime(), $pretask->getStatusTimer(), 0, @@ -305,7 +343,7 @@ public static function runSupertask($supertaskId, $hashlistId, $crackerId) { $pretask->getIsCpuTask(), $pretask->getUseNewBench(), 0, - $crackerBinaryId, + $item["crackerBinaryId"], $cracker->getCrackerBinaryTypeId(), $taskWrapper->getId(), 0, @@ -316,14 +354,12 @@ public static function runSupertask($supertaskId, $hashlistId, $crackerId) { 0, '' ); - if ($hashlist->getHexSalt() == 1 && strpos($task->getAttackCmd(), "--hex-salt") === false) { - $task->setAttackCmd("--hex-salt " . $task->getAttackCmd()); - } $task = Factory::getTaskFactory()->save($task); TaskUtils::copyPretaskFiles($pretask, $task); } - + Factory::getAgentFactory()->getDB()->commit(); + return ["taskWrapper" => $taskWrapper, "skippedPretasks" => $skippedPretasks]; } /** diff --git a/src/inc/utils/TaskUtils.php b/src/inc/utils/TaskUtils.php index 19b8ea501..473404d30 100644 --- a/src/inc/utils/TaskUtils.php +++ b/src/inc/utils/TaskUtils.php @@ -1272,7 +1272,107 @@ public static function getFilesOfPretask($pretask) { /** @var $files File[] */ return $joined[Factory::getFileFactory()->getModelName()]; } - + + /** + * @param Task $task + * @return int[] the file ids attached to the task via FileTask + */ + public static function getFileIdsOfTask($task) { + $qF = new QueryFilter(FileTask::TASK_ID, $task->getId(), "="); + $fileTasks = Factory::getFileTaskFactory()->filter([Factory::FILTER => $qF]); + $fileIds = []; + foreach ($fileTasks as $fileTask) { + $fileIds[] = $fileTask->getFileId(); + } + return $fileIds; + } + + /** + * @param Pretask $pretask + * @return int[] the file ids attached to the pretask via FilePretask + */ + public static function getFileIdsOfPretask($pretask) { + $qF = new QueryFilter(FilePretask::PRETASK_ID, $pretask->getId(), "="); + $filePretasks = Factory::getFilePretaskFactory()->filter([Factory::FILTER => $qF]); + $fileIds = []; + foreach ($filePretasks as $filePretask) { + $fileIds[] = $filePretask->getFileId(); + } + return $fileIds; + } + + /** + * Normalizes an attack command for duplicate comparison by collapsing runs of + * whitespace into single spaces and trimming. Flag ordering is intentionally + * NOT normalized: a different flag order is treated as a different attack and + * will not be skipped (conservative). + * + * @param string|null $attackCmd + * @return string + */ + public static function normalizeAttackCmd($attackCmd) { + return trim(preg_replace('/\s+/', ' ', $attackCmd ?? '')); + } + + /** + * Finds an existing, fully-exhausted Task on the given hashlist that is an exact + * duplicate of the supplied attack specification. Used to optionally skip + * re-running pretasks that have already been completed against the target + * hashlist when applying a supertask (or a single pretask). + * + * A Task is considered a match when ALL of the following hold: + * - it belongs to a TaskWrapper on $hashlistId + * - normalized attackCmd equality (see normalizeAttackCmd()) + * - identical set of fileIds (via FileTask) compared to $fileIds + * - crackerBinaryId AND crackerBinaryTypeId equality + * - it is fully exhausted: keyspace > 0 AND keyspaceProgress >= keyspace + * + * Archived tasks count as matches (an archived-but-exhausted task already did + * its work). Partial tasks (keyspace 0, or progress < keyspace) never match, + * since the remaining keyspace is still valuable work. + * + * @param int $hashlistId + * @param string $attackCmd effective attack command (after any --hex-salt prefixing) + * @param int[] $fileIds file ids that would be attached to the new task + * @param int $crackerBinaryId resolved cracker binary id that would be used + * @param int $crackerBinaryTypeId resolved cracker binary type id that would be used + * @return Task|null the matching completed Task, or null if none exists + */ + public static function findCompletedEquivalent($hashlistId, $attackCmd, $fileIds, $crackerBinaryId, $crackerBinaryTypeId) { + $qF = new QueryFilter(TaskWrapper::HASHLIST_ID, $hashlistId, "="); + $wrappers = Factory::getTaskWrapperFactory()->filter([Factory::FILTER => $qF]); + if (count($wrappers) == 0) { + return null; + } + + $normalizedCmd = self::normalizeAttackCmd($attackCmd); + $wantedFileIds = array_map('intval', $fileIds); + sort($wantedFileIds); + + $cF = new ContainFilter(Task::TASK_WRAPPER_ID, Util::arrayOfIds($wrappers)); + $tasks = Factory::getTaskFactory()->filter([Factory::FILTER => $cF]); + foreach ($tasks as $task) { + // must be fully exhausted; partials still have valuable work left + if ($task->getKeyspace() <= 0 || $task->getKeyspaceProgress() < $task->getKeyspace()) { + continue; + } + // a hashcat version change invalidates the dedup (conservative) + if ($task->getCrackerBinaryId() != $crackerBinaryId || $task->getCrackerBinaryTypeId() != $crackerBinaryTypeId) { + continue; + } + if (self::normalizeAttackCmd($task->getAttackCmd()) !== $normalizedCmd) { + continue; + } + $taskFileIds = array_map('intval', self::getFileIdsOfTask($task)); + sort($taskFileIds); + if ($taskFileIds !== $wantedFileIds) { + continue; + } + return $task; + } + return null; + } + /** * @param int $supertaskId * @param User $user diff --git a/src/templates/hashlists/detail/supertasks.template.html b/src/templates/hashlists/detail/supertasks.template.html index 3a6117757..d8fc413cf 100644 --- a/src/templates/hashlists/detail/supertasks.template.html +++ b/src/templates/hashlists/detail/supertasks.template.html @@ -63,6 +63,9 @@

Create supertask:

ddl.options.add(opt); } + diff --git a/src/templates/supertasks/new.template.html b/src/templates/supertasks/new.template.html index 7e21db028..6069f55cb 100755 --- a/src/templates/supertasks/new.template.html +++ b/src/templates/supertasks/new.template.html @@ -82,6 +82,12 @@

Use Supertask

+ + Skip pretasks already completed: + + + +