Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
35.85% covered (danger)
35.85%
38 / 106
10.00% covered (danger)
10.00%
1 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ListeningPidResolver
35.85% covered (danger)
35.85%
38 / 106
10.00% covered (danger)
10.00%
1 / 10
656.26
0.00% covered (danger)
0.00%
0 / 1
 findListeningPids
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 findListeningPidsUsingSs
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 findListeningPidsUsingLsof
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 findListeningPidsUsingProc
76.19% covered (warning)
76.19%
16 / 21
0.00% covered (danger)
0.00%
0 / 1
13.94
 getProcFdPaths
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 readProcFdTarget
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getListeningSocketInodesByPort
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 readListeningInodesFromProcTable
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
156
 commandIsAvailable
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 createProcess
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
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\Process;
10
11use OCA\Libresign\Vendor\Symfony\Component\Process\Process;
12
13class ListeningPidResolver {
14    /**
15     * @return int[]
16     */
17    public function findListeningPids(int $port): array {
18        if ($port <= 0) {
19            throw new \RuntimeException('Invalid port to inspect process listeners.');
20        }
21
22        $usedStrategy = false;
23        $pids = [];
24
25        $ss = $this->findListeningPidsUsingSs($port);
26        if ($ss !== null) {
27            $usedStrategy = true;
28            $pids = array_merge($pids, $ss);
29        }
30
31        $lsof = $this->findListeningPidsUsingLsof($port);
32        if ($lsof !== null) {
33            $usedStrategy = true;
34            $pids = array_merge($pids, $lsof);
35        }
36
37        $proc = $this->findListeningPidsUsingProc($port);
38        if ($proc !== null) {
39            $usedStrategy = true;
40            $pids = array_merge($pids, $proc);
41        }
42
43        if (!$usedStrategy) {
44            throw new \RuntimeException('Unable to inspect listening process PIDs: no strategy available.');
45        }
46
47        return array_values(array_filter(
48            array_unique(array_map(intval(...), $pids)),
49            static fn (int $pid): bool => $pid > 0,
50        ));
51    }
52
53    /**
54     * @return int[]|null null means strategy unavailable
55     */
56    protected function findListeningPidsUsingSs(int $port): ?array {
57        if (!$this->commandIsAvailable('ss')) {
58            return null;
59        }
60
61        $process = $this->createProcess(['ss', '-ltnp', 'sport = :' . $port]);
62        $process->run();
63        if (!$process->isSuccessful()) {
64            return [];
65        }
66
67        $output = $process->getOutput();
68        preg_match_all('/pid=(\d+)/', $output, $matches);
69        if (!isset($matches[1]) || !is_array($matches[1])) {
70            return [];
71        }
72
73        return array_map(intval(...), $matches[1]);
74    }
75
76    /**
77     * @return int[]|null null means strategy unavailable
78     */
79    protected function findListeningPidsUsingLsof(int $port): ?array {
80        if (!$this->commandIsAvailable('lsof')) {
81            return null;
82        }
83
84        $process = $this->createProcess(['lsof', '-ti', 'tcp:' . $port, '-sTCP:LISTEN']);
85        $process->run();
86        if (!$process->isSuccessful()) {
87            return [];
88        }
89
90        $lines = preg_split('/\R/', trim($process->getOutput()));
91        if (!is_array($lines)) {
92            return [];
93        }
94
95        return array_map(intval(...), array_filter($lines, static fn (string $line): bool => $line !== ''));
96    }
97
98    /**
99     * @return int[]|null null means strategy unavailable
100     */
101    protected function findListeningPidsUsingProc(int $port): ?array {
102        if (PHP_OS_FAMILY !== 'Linux') {
103            return null;
104        }
105
106        if (!is_readable('/proc/net/tcp') && !is_readable('/proc/net/tcp6')) {
107            return null;
108        }
109
110        $inodesByPort = $this->getListeningSocketInodesByPort($port);
111        if (empty($inodesByPort)) {
112            return [];
113        }
114
115        $fdPaths = $this->getProcFdPaths();
116        if ($fdPaths === []) {
117            return [];
118        }
119
120        $pids = [];
121        foreach ($fdPaths as $fdPath) {
122            $target = $this->readProcFdTarget($fdPath);
123            if (!is_string($target) || !preg_match('/^socket:\\[(\\d+)\\]$/', $target, $matches)) {
124                continue;
125            }
126
127            $inode = $matches[1] ?? '';
128            if ($inode === '' || !isset($inodesByPort[$inode])) {
129                continue;
130            }
131
132            if (preg_match('#^/proc/([0-9]+)/fd/[0-9]+$#', $fdPath, $pidMatches)) {
133                $pids[] = (int)$pidMatches[1];
134            }
135        }
136
137        return $pids;
138    }
139
140    /**
141     * @return list<string>
142     */
143    protected function getProcFdPaths(): array {
144        $fdPaths = glob('/proc/[0-9]*/fd/[0-9]*');
145        if (!is_array($fdPaths)) {
146            return [];
147        }
148
149        return array_values($fdPaths);
150    }
151
152    protected function readProcFdTarget(string $fdPath): ?string {
153        set_error_handler(static function (): bool {
154            return true;
155        });
156
157        try {
158            $target = readlink($fdPath);
159        } finally {
160            restore_error_handler();
161        }
162
163        return is_string($target) ? $target : null;
164    }
165
166    /**
167     * @return array<string, true>
168     */
169    protected function getListeningSocketInodesByPort(int $port): array {
170        $portHex = strtoupper(str_pad(dechex($port), 4, '0', STR_PAD_LEFT));
171        $inodes = [];
172
173        foreach (['/proc/net/tcp', '/proc/net/tcp6'] as $path) {
174            foreach ($this->readListeningInodesFromProcTable($path, $portHex) as $inode) {
175                $inodes[$inode] = true;
176            }
177        }
178
179        return $inodes;
180    }
181
182    /**
183     * @return string[]
184     */
185    protected function readListeningInodesFromProcTable(string $path, string $portHex): array {
186        if (!is_readable($path)) {
187            return [];
188        }
189
190        $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
191        if (!is_array($lines) || count($lines) <= 1) {
192            return [];
193        }
194
195        $inodes = [];
196        foreach (array_slice($lines, 1) as $line) {
197            $columns = preg_split('/\s+/', trim($line));
198            if (!is_array($columns) || count($columns) < 10) {
199                continue;
200            }
201
202            $localAddress = $columns[1];
203            $state = $columns[3];
204            $inode = $columns[9] ?? '';
205
206            if ($state !== '0A' || !is_string($inode) || $inode === '') {
207                continue;
208            }
209
210            $addressParts = explode(':', $localAddress);
211            if (count($addressParts) !== 2) {
212                continue;
213            }
214
215            if (strtoupper($addressParts[1]) !== $portHex) {
216                continue;
217            }
218
219            $inodes[] = $inode;
220        }
221
222        return $inodes;
223    }
224
225    protected function commandIsAvailable(string $command): bool {
226        $process = $this->createProcess(['/bin/sh', '-lc', 'command -v ' . escapeshellarg($command) . ' >/dev/null 2>&1']);
227        $process->run();
228        return $process->isSuccessful();
229    }
230
231    /**
232     * @param string[] $command
233     */
234    protected function createProcess(array $command): Process {
235        return new Process($command);
236    }
237}