Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.02% covered (warning)
73.02%
46 / 63
25.00% covered (danger)
25.00%
2 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
LocalWorkerStateStore
73.02% covered (warning)
73.02%
46 / 63
25.00% covered (danger)
25.00%
2 / 8
66.37
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
 withExclusiveLock
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 openLock
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
6.44
 ensurePrivateDirectory
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
8.30
 readState
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
12.52
 writeState
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 suffix
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 directory
20.00% covered (danger)
20.00%
2 / 10
0.00% covered (danger)
0.00%
0 / 1
7.61
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\Worker;
10
11/** Local, atomic state storage. It never supervises or signals PIDs. */
12class LocalWorkerStateStore {
13    public function __construct(
14        private ?string $directory = null,
15    ) {
16    }
17
18    /** @param callable(): bool $operation */
19    public function withExclusiveLock(callable $operation, string $pool = WorkerConfiguration::SIGNING): bool {
20        $handle = $this->openLock($pool);
21        try {
22            $wouldBlock = 0;
23            if (!flock($handle, LOCK_EX | LOCK_NB, $wouldBlock)) {
24                if ($wouldBlock !== 0) {
25                    return true; // Another request is deciding whether to launch.
26                }
27                throw new \RuntimeException('Cannot acquire local worker launch lock');
28            }
29            return $operation();
30        } finally {
31            // Closing the descriptor releases the flock even on exceptions.
32            fclose($handle);
33        }
34    }
35
36    /** @return resource */
37    private function openLock(string $pool) {
38        $this->ensurePrivateDirectory();
39        $path = $this->directory() . '/lock' . $this->suffix($pool);
40        if (is_link($path)) {
41            throw new \RuntimeException('Unsafe local worker lock');
42        }
43        $handle = @fopen($path, 'c+');
44        if ($handle === false) {
45            throw new \RuntimeException('Cannot open local worker lock');
46        }
47        $stat = fstat($handle);
48        if (!is_file($path) || (function_exists('posix_geteuid') && ($stat['uid'] ?? null) !== posix_geteuid())) {
49            fclose($handle);
50            throw new \RuntimeException('Unsafe local worker lock ownership');
51        }
52        @chmod($path, 0600);
53        return $handle;
54    }
55
56    private function ensurePrivateDirectory(): void {
57        $directory = $this->directory();
58        if (!is_dir($directory) && !@mkdir($directory, 0700) && !is_dir($directory)) {
59            throw new \RuntimeException('Cannot create local worker registry');
60        }
61        if (is_link($directory) || ((int)fileperms($directory) & 0077) !== 0
62            || (function_exists('posix_geteuid') && fileowner($directory) !== posix_geteuid())) {
63            throw new \RuntimeException('Unsafe local worker registry permissions');
64        }
65    }
66
67    /** @return array<string, mixed> */
68    public function readState(string $pool = WorkerConfiguration::SIGNING): array {
69        $path = $this->directory() . '/workers' . $this->suffix($pool) . '.json';
70        if (!is_file($path)) {
71            return [];
72        }
73        try {
74            $state = json_decode((string)file_get_contents($path), true, flags: JSON_THROW_ON_ERROR);
75        } catch (\JsonException $e) {
76            throw new \RuntimeException('Invalid local worker state', previous: $e);
77        }
78        if (!is_array($state) || !is_array($state['workers'] ?? null) || !array_is_list($state['workers'])) {
79            throw new \RuntimeException('Invalid local worker state');
80        }
81        foreach ($state['workers'] as $worker) {
82            if (!is_array($worker) || !is_int($worker['pid'] ?? null) || $worker['pid'] <= 0
83                || !is_string($worker['start'] ?? null) || $worker['start'] === '') {
84                // Treat corruption as a launch failure, not as an empty pool that
85                // might exceed the configured concurrency limit.
86                throw new \RuntimeException('Invalid local worker process record');
87            }
88        }
89        return $state;
90    }
91
92    /** @param array<string, mixed> $state */
93    public function writeState(array $state, string $pool = WorkerConfiguration::SIGNING): void {
94        $file = $this->directory() . '/workers' . $this->suffix($pool) . '.json';
95        $temp = tempnam($this->directory(), 'workers-');
96        if ($temp === false) {
97            throw new \RuntimeException('Cannot allocate local worker state');
98        }
99        try {
100            if (file_put_contents($temp, json_encode($state, JSON_THROW_ON_ERROR)) === false || !rename($temp, $file)) {
101                throw new \RuntimeException('Cannot write local worker state');
102            }
103        } finally {
104            @unlink($temp);
105        }
106    }
107
108    private function suffix(string $pool): string {
109        return match ($pool) {
110            WorkerConfiguration::SIGNING => '',
111            WorkerConfiguration::NOTIFICATION => '-notification',
112            default => throw new \InvalidArgumentException('Unknown local worker pool'),
113        };
114    }
115
116    private function directory(): string {
117        if ($this->directory !== null) {
118            return $this->directory;
119        }
120        $scope = implode(':', [
121            (string)\OC::$SERVERROOT,
122            function_exists('posix_geteuid') ? posix_geteuid() : 'php',
123            gethostname(),
124            (string)@readlink('/proc/self/ns/pid'),
125            (string)@file_get_contents('/proc/sys/kernel/random/boot_id'),
126        ]);
127        return rtrim(sys_get_temp_dir(), '/') . '/libresign-workers-' . substr(hash('sha256', $scope), 0, 24);
128    }
129}