Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.67% covered (success)
96.67%
58 / 60
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
SigningAttemptGuard
96.67% covered (success)
96.67%
58 / 60
60.00% covered (warning)
60.00%
3 / 5
22
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 beginAttempt
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 shouldRejectPriorAttempt
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
10
 completeAttempt
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service;
10
11use OCA\Libresign\Db\SignRequest;
12use OCA\Libresign\Db\SignRequestMapper;
13use OCA\Libresign\Service\SignRequest\Error\SignRequestErrorReporter;
14use OCP\BackgroundJob\IJobList;
15use OCP\Lock\ILockingProvider;
16use Psr\Log\LoggerInterface;
17
18/**
19 * Durable execution receipt: prevent a recovered job from signing twice.
20 * A crash after the signing engine starts is ambiguous until manually reconciled.
21 */
22class SigningAttemptGuard {
23    private const string METADATA_KEY = 'async_signing_attempt';
24
25    public function __construct(
26        private SignRequestMapper $signRequestMapper,
27        private ILockingProvider $lockingProvider,
28        private IJobList $jobList,
29        private SignRequestErrorReporter $errorReporter,
30        private LoggerInterface $logger,
31    ) {
32    }
33
34    /** Return false for an already completed or unsafe-to-repeat operation. */
35    public function execute(array $argument, string $jobId, callable $run): bool {
36        $id = $argument['signRequestId'] ?? null;
37        if (!is_int($id) || $id <= 0) {
38            // Malformed arguments belong to the coordinator's error handler.
39            return $run() !== false;
40        }
41        if (!$this->beginAttempt($id, $jobId, $argument)) {
42            return false;
43        }
44
45        // Do not hold a lock during PDF signing. A crash deliberately leaves
46        // a running receipt so the operation cannot be repeated blindly.
47        $completed = $run() !== false;
48        $this->completeAttempt($id, $jobId, $completed);
49        return $completed;
50    }
51
52    private function beginAttempt(int $id, string $jobId, array $argument): bool {
53        $key = 'libresign:sign-attempt:' . $id;
54        $this->lockingProvider->acquireLock($key, ILockingProvider::LOCK_EXCLUSIVE);
55        try {
56            $request = $this->signRequestMapper->getByIdUncached($id);
57            if ($request->getSignedHash() || $request->getSigned() !== null
58                || $this->shouldRejectPriorAttempt($request, $jobId, $argument)) {
59                return false;
60            }
61            $metadata = $request->getMetadata() ?? [];
62            $metadata[self::METADATA_KEY] = [
63                'jobId' => $jobId,
64                'phase' => 'running',
65                'startedAt' => time(),
66            ];
67            $request->setMetadata($metadata);
68            $this->signRequestMapper->update($request);
69            return true;
70        } finally {
71            $this->lockingProvider->releaseLock($key, ILockingProvider::LOCK_EXCLUSIVE);
72        }
73    }
74
75    private function shouldRejectPriorAttempt(SignRequest $request, string $jobId, array $argument): bool {
76        $metadata = $request->getMetadata() ?? [];
77        $previous = $metadata[self::METADATA_KEY] ?? null;
78        if (!is_array($previous)) {
79            return false;
80        }
81        $phase = $previous['phase'] ?? null;
82        if ($phase === 'finished') {
83            return true;
84        }
85        if ($phase !== 'running' && $phase !== 'uncertain') {
86            return false;
87        }
88        $previousJobId = $previous['jobId'] ?? null;
89        if ($phase === 'running' && is_string($previousJobId) && ctype_digit($previousJobId)
90            && $previousJobId !== $jobId && $this->jobList->getDetailsById($previousJobId) !== null) {
91            // Another queued job still owns the running attempt.
92            return true;
93        }
94        $metadata[self::METADATA_KEY]['phase'] = 'uncertain';
95        $request->setMetadata($metadata);
96        $this->signRequestMapper->update($request);
97        $this->logger->warning('Interrupted signing attempt needs review', ['signRequestId' => $request->getId()]);
98        $this->errorReporter->error('An earlier signing attempt did not finish safely', [
99            'signRequestId' => $request->getId(),
100            'signRequestUuid' => $request->getUuid(),
101            'fileId' => $argument['fileId'] ?? null,
102            'exception' => new \RuntimeException('The previous signing attempt may have finished. The document needs review before another signature.'),
103        ]);
104        return true;
105    }
106
107    private function completeAttempt(int $id, string $jobId, bool $completed): void {
108        $key = 'libresign:sign-attempt:' . $id;
109        $this->lockingProvider->acquireLock($key, ILockingProvider::LOCK_EXCLUSIVE);
110        try {
111            $request = $this->signRequestMapper->getByIdUncached($id);
112            $metadata = $request->getMetadata() ?? [];
113            if (($metadata[self::METADATA_KEY]['jobId'] ?? null) !== $jobId) {
114                return;
115            }
116            $metadata[self::METADATA_KEY]['phase'] = $completed ? 'finished' : 'uncertain';
117            $metadata[self::METADATA_KEY]['finishedAt'] = time();
118            $request->setMetadata($metadata);
119            $this->signRequestMapper->update($request);
120        } finally {
121            $this->lockingProvider->releaseLock($key, ILockingProvider::LOCK_EXCLUSIVE);
122        }
123    }
124}