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