Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
78 / 78
100.00% covered (success)
100.00%
51 / 51
77.50% covered (warning)
77.50%
31 / 40
100.00% covered (success)
100.00%
16 / 16
CRAP
100.00% covered (success)
100.00%
1 / 1
Session
100.00% covered (success)
100.00%
78 / 78
100.00% covered (success)
100.00%
51 / 51
77.50% covered (warning)
77.50%
31 / 40
100.00% covered (success)
100.00%
16 / 16
48.95
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 start
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
11 / 11
55.56% covered (warning)
55.56%
5 / 9
100.00% covered (success)
100.00%
1 / 1
11.30
 destroy
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
8 / 8
37.50% covered (danger)
37.50%
3 / 8
100.00% covered (success)
100.00%
1 / 1
11.10
 name
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 id
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 all
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clear
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 pull
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 set
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 remove
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 active
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 close
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 regenerate
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 assertActive
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Session;
6
7use SessionHandlerInterface;
8
9/** @api */
10class Session
11{
12    private const array DEFAULT_OPTIONS = [
13        'cache_limiter' => 'nocache',
14        'cookie_httponly' => true,
15        'cookie_samesite' => 'Lax',
16        'cookie_secure' => true,
17        'use_only_cookies' => true,
18        'use_strict_mode' => true,
19        'use_trans_sid' => false,
20    ];
21
22    protected readonly array $options;
23
24    /** @psalm-suppress UnusedProperty Used by the $flash property hook. */
25    private ?Flash $flashInstance = null;
26
27    /** @psalm-suppress UnusedProperty Used by the $csrf property hook. */
28    private ?Csrf $csrfInstance = null;
29
30    /** @psalm-suppress UnusedProperty Used by the $uri property hook. */
31    private ?Uri $uriInstance = null;
32
33    /** @psalm-suppress UnusedProperty Used by helper property hooks. */
34    private readonly Contract\Helpers $helpers;
35
36    /** @psalm-suppress PropertyNotSetInConstructor Virtual property backed by a get hook. */
37    public Flash $flash {
38        get => $this->flashInstance ??= $this->helpers->flash($this);
39    }
40
41    /** @psalm-suppress PropertyNotSetInConstructor Virtual property backed by a get hook. */
42    public Csrf $csrf {
43        get => $this->csrfInstance ??= $this->helpers->csrf($this);
44    }
45
46    /** @psalm-suppress PropertyNotSetInConstructor Virtual property backed by a get hook. */
47    public Uri $uri {
48        get => $this->uriInstance ??= $this->helpers->uri($this);
49    }
50
51    public function __construct(
52        array $options = [],
53        protected readonly string $name = '',
54        protected readonly ?SessionHandlerInterface $handler = null,
55        Contract\Helpers $helpers = new Helpers(),
56    ) {
57        $this->options = array_replace(self::DEFAULT_OPTIONS, $options);
58        $this->helpers = $helpers;
59    }
60
61    public function start(): void
62    {
63        if (session_status() !== PHP_SESSION_NONE) {
64            return;
65        }
66
67        if (headers_sent($file, $line)) {
68            // Requires sent headers, which the test suite cannot trigger reliably.
69            // @codeCoverageIgnoreStart
70            throw new RuntimeException(
71                __METHOD__ . 'Session started after headers sent. File: ' . $file . ' line: ' . $line,
72            );
73
74            // @codeCoverageIgnoreEnd
75        }
76
77        if ($this->name) {
78            session_name($this->name);
79        }
80
81        if ($this->handler && !session_set_save_handler($this->handler, true)) {
82            throw new RuntimeException('Session handler setup failed');
83        }
84
85        if (!session_start($this->options)) {
86            // session_start() only returns false after PHP accepts the setup but fails internally;
87            // forcing that path would make the test process depend on unstable runtime state.
88            throw new RuntimeException(__METHOD__ . 'session_start failed.'); // @codeCoverageIgnore
89        }
90    }
91
92    public function destroy(): void
93    {
94        $this->assertActive();
95
96        session_unset();
97
98        $useCookies = ini_get('session.use_cookies');
99        if ($useCookies === '1') {
100            $params = session_get_cookie_params();
101            $name = session_name();
102            if ($name !== false) {
103                /** @psalm-suppress InvalidArrayOffset PHP 8.5 exposes partitioned cookies. */
104                $partitioned = (bool) ($params['partitioned'] ?? false);
105                $options = [
106                    'expires' => time() - 42000,
107                    'path' => (string) $params['path'],
108                    'secure' => (bool) $params['secure'],
109                    'httponly' => (bool) $params['httponly'],
110                    'samesite' => (string) $params['samesite'],
111                    'partitioned' => $partitioned,
112                ];
113
114                $domain = (string) $params['domain'];
115                if ($domain !== '') {
116                    $options['domain'] = $domain;
117                }
118
119                setcookie($name, '', $options);
120            }
121        }
122
123        if (!session_destroy()) {
124            throw new RuntimeException('Session destroy failed');
125        }
126    }
127
128    public function name(): string
129    {
130        $name = session_name();
131        if ($name === false) {
132            throw new RuntimeException('Session name not available');
133        }
134
135        return $name;
136    }
137
138    public function id(): string
139    {
140        $id = session_id();
141        if ($id === false) {
142            throw new RuntimeException('Session id not available');
143        }
144
145        return $id;
146    }
147
148    /** @return array<array-key, mixed> */
149    public function all(): array
150    {
151        $this->assertActive();
152
153        /**
154         * @var array<array-key, mixed> $session
155         * @mago-expect lint:inline-variable-return Keep the explicit type for `$_SESSION`.
156         */
157        $session = $_SESSION;
158
159        return $session;
160    }
161
162    public function clear(): void
163    {
164        $this->assertActive();
165
166        session_unset();
167    }
168
169    /** @param non-empty-string $key */
170    public function get(string $key, mixed $default = null): mixed
171    {
172        $this->assertActive();
173
174        if ($this->has($key)) {
175            return $_SESSION[$key];
176        }
177
178        if (func_num_args() > 1) {
179            return $default;
180        }
181
182        throw new OutOfBoundsException(
183            "The session key '{$key}' does not exist",
184        );
185    }
186
187    /**
188     * @psalm-suppress MixedAssignment
189     * @param non-empty-string $key
190     */
191    public function pull(string $key, mixed $default = null): mixed
192    {
193        $this->assertActive();
194
195        if ($this->has($key)) {
196            $value = $_SESSION[$key];
197            unset($_SESSION[$key]);
198
199            return $value;
200        }
201
202        if (func_num_args() > 1) {
203            return $default;
204        }
205
206        throw new OutOfBoundsException(
207            "The session key '{$key}' does not exist",
208        );
209    }
210
211    /**
212     * @psalm-suppress MixedAssignment
213     * @param non-empty-string $key
214     * */
215    public function set(string $key, mixed $value): void
216    {
217        $this->assertActive();
218
219        $_SESSION[$key] = $value;
220    }
221
222    /** @param non-empty-string $key */
223    public function has(string $key): bool
224    {
225        $this->assertActive();
226
227        return ($_SESSION[$key] ?? null) !== null;
228    }
229
230    /** @param non-empty-string $key */
231    public function remove(string $key): void
232    {
233        $this->assertActive();
234
235        unset($_SESSION[$key]);
236    }
237
238    public function active(): bool
239    {
240        return session_status() === PHP_SESSION_ACTIVE;
241    }
242
243    public function close(): void
244    {
245        $this->assertActive();
246
247        if (!session_write_close()) {
248            throw new RuntimeException('Session close failed');
249        }
250    }
251
252    public function regenerate(): void
253    {
254        $this->assertActive();
255
256        if (!session_regenerate_id(true)) {
257            throw new RuntimeException('Session id regeneration failed');
258        }
259    }
260
261    private function assertActive(): void
262    {
263        if (!$this->active()) {
264            throw new RuntimeException('Session not started');
265        }
266    }
267}