Seregon/zftpd

Zero-copy FTP/HTTP Daemon compatible with all POSIX systems

C/11.0 KB/No license
src/ftp_path.c
zftpd / src / ftp_path.c
1/*
2MIT License
3 
4Copyright (c) 2026 Seregon
5 
6Permission is hereby granted, free of charge, to any person obtaining a copy
7of this software and associated documentation files (the "Software"), to deal
8in the Software without restriction, including without limitation the rights
9to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10copies of the Software, and to permit persons to whom the Software is
11furnished to do so, subject to the following conditions:
12 
13The above copyright notice and this permission notice shall be included in all
14copies or substantial portions of the Software.
15 
16THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22SOFTWARE.
23*/
24 
25/**
26 * @file ftp_path.c
27 * @brief Secure path validation and normalization implementation
28 *
29 * @author SeregonWar
30 * @version 1.0.0
31 * @date 2026-02-13
32 *
33 */
34 
35#include "ftp_path.h"
36#include "pal_fileio.h"
37#include <string.h>
38#include <ctype.h>
39#include <stdlib.h>
40#include <stdio.h>
41 
42/* Maximum number of path components (for stack allocation) */
43#define MAX_PATH_COMPONENTS 128U
44 
45/*===========================================================================*
46 * PATH NORMALIZATION
47 *===========================================================================*/
48 
49/**
50 * @brief Normalize path to canonical form
51 *
52 * DESIGN RATIONALE:
53 * - Stack-based processing avoids dynamic allocation
54 * - Single-pass algorithm is efficient (O(n))
55 * - Handles all edge cases: .., ., //, trailing slashes
56 */
57ftp_error_t ftp_path_normalize(const char *path,
58 char *output,
59 size_t size)
60{
61 /* Validate parameters */
62 if ((path == NULL) || (output == NULL)) {
63 return FTP_ERR_INVALID_PARAM;
64 }
65
66 if (size == 0U) {
67 return FTP_ERR_INVALID_PARAM;
68 }
69
70 /* Check path length */
71 size_t path_len = strlen(path);
72 if (path_len >= FTP_PATH_MAX) {
73 return FTP_ERR_PATH_TOO_LONG;
74 }
75
76 if (path_len == 0U) {
77 /* Empty path -> root */
78 if (size < 2U) {
79 return FTP_ERR_PATH_TOO_LONG;
80 }
81 output[0] = '/';
82 output[1] = '\0';
83 return FTP_OK;
84 }
85
86 /* Component stack for path processing */
87 const char *components[MAX_PATH_COMPONENTS];
88 size_t depth = 0U;
89
90 /* Working buffer for tokenization */
91 char temp[FTP_PATH_MAX];
92 if (path_len >= sizeof(temp)) {
93 return FTP_ERR_PATH_TOO_LONG;
94 }
95
96 /* Copy path to working buffer */
97 memcpy(temp, path, path_len + 1U);
98
99 /* Split path into components and process */
100 size_t scan = 0U;
101 while (1) {
102 while (temp[scan] == '/') {
103 temp[scan] = '\0';
104 scan++;
105 }
106 if (temp[scan] == '\0') {
107 break;
108 }
109 
110 size_t start = scan;
111 while ((temp[scan] != '\0') && (temp[scan] != '/')) {
112 scan++;
113 }
114 
115 if (temp[scan] == '/') {
116 temp[scan] = '\0';
117 scan++;
118 }
119 
120 const char *token = &temp[start];
121 
122 if (strcmp(token, ".") == 0) {
123 /* Current directory reference: skip */
124 } else if (strcmp(token, "..") == 0) {
125 /* Parent directory: pop stack if not at root */
126 if (depth > 0U) {
127 depth--;
128 }
129 } else if (strlen(token) > 0U) {
130 /* Regular component: push to stack */
131 if (depth >= MAX_PATH_COMPONENTS) {
132 return FTP_ERR_PATH_TOO_LONG; /* Path too deep */
133 }
134 components[depth] = token;
135 depth++;
136 }
137 }
138
139 /* Reconstruct normalized path */
140 if (depth == 0U) {
141 /* Root directory */
142 if (size < 2U) {
143 return FTP_ERR_PATH_TOO_LONG;
144 }
145 output[0] = '/';
146 output[1] = '\0';
147 return FTP_OK;
148 }
149
150 /* Build path from components */
151 size_t offset = 0U;
152 for (size_t idx = 0U; idx < depth; idx++) {
153 size_t comp_len = strlen(components[idx]);
154
155 /* Check if buffer has space: '/' + component + '\0' */
156 if ((offset + comp_len + 2U) > size) {
157 return FTP_ERR_PATH_TOO_LONG;
158 }
159
160 /* Add separator */
161 output[offset] = '/';
162 offset++;
163
164 /* Add component */
165 memcpy(output + offset, components[idx], comp_len);
166 offset += comp_len;
167 }
168
169 /* Null-terminate */
170 output[offset] = '\0';
171
172 return FTP_OK;
173}
174 
175/*===========================================================================*
176 * PATH RESOLUTION
177 *===========================================================================*/
178 
179/**
180 * @brief Resolve path relative to session CWD
181 */
182ftp_error_t ftp_path_resolve(const ftp_session_t *session,
183 const char *path,
184 char *output,
185 size_t size)
186{
187 /* Validate parameters */
188 if ((session == NULL) || (path == NULL) || (output == NULL)) {
189 return FTP_ERR_INVALID_PARAM;
190 }
191
192 if (size < FTP_PATH_MAX) {
193 return FTP_ERR_INVALID_PARAM;
194 }
195
196 /* Check path length */
197 size_t path_len = strlen(path);
198 if (path_len >= FTP_PATH_MAX) {
199 return FTP_ERR_PATH_TOO_LONG;
200 }
201
202 char temp[FTP_PATH_MAX];
203
204 if ((path_len > 0U) && (path[0] == '/')) {
205 /* Absolute path: use as-is */
206 if (path_len >= sizeof(temp)) {
207 return FTP_ERR_PATH_TOO_LONG;
208 }
209 memcpy(temp, path, path_len + 1U);
210 } else {
211 /* Relative path: prepend CWD */
212 size_t cwd_len = strlen(session->cwd);
213
214 /* Check combined length */
215 if ((cwd_len + path_len + 2U) >= sizeof(temp)) {
216 return FTP_ERR_PATH_TOO_LONG;
217 }
218
219 /* Build combined path: CWD + '/' + path */
220 memcpy(temp, session->cwd, cwd_len);
221 temp[cwd_len] = '/';
222 memcpy(temp + cwd_len + 1U, path, path_len + 1U);
223 }
224
225 /* Normalize the path */
226 ftp_error_t err = ftp_path_normalize(temp, output, size);
227 if (err != FTP_OK) {
228 return err;
229 }
230 
231 if (ftp_path_is_within_root(output, session->root_path) != 1) {
232 return FTP_ERR_PATH_INVALID;
233 }
234 
235 if (pal_path_exists(output) == 1) {
236 char real_buf[FTP_PATH_MAX];
237 if (realpath(output, real_buf) != NULL) {
238 size_t n = strlen(real_buf);
239 if ((n + 1U) <= size) {
240 memcpy(output, real_buf, n + 1U);
241 if (ftp_path_is_within_root(output, session->root_path) != 1) {
242 return FTP_ERR_PATH_INVALID;
243 }
244 }
245 }
246 return FTP_OK;
247 }
248 
249 char dir_buf[FTP_PATH_MAX];
250 char base_buf[FTP_PATH_MAX];
251 if (ftp_path_dirname(output, dir_buf, sizeof(dir_buf)) != FTP_OK) {
252 return FTP_OK;
253 }
254 if (ftp_path_basename(output, base_buf, sizeof(base_buf)) != FTP_OK) {
255 return FTP_OK;
256 }
257 
258 if (pal_path_exists(dir_buf) != 1) {
259 return FTP_OK;
260 }
261 
262 char dir_real[FTP_PATH_MAX];
263 if (realpath(dir_buf, dir_real) == NULL) {
264 return FTP_OK;
265 }
266 
267 char joined[FTP_PATH_MAX];
268 int nn = snprintf(joined, sizeof(joined), "%s/%s", dir_real, base_buf);
269 if ((nn < 0) || ((size_t)nn >= sizeof(joined))) {
270 return FTP_ERR_PATH_TOO_LONG;
271 }
272 
273 err = ftp_path_normalize(joined, output, size);
274 if (err != FTP_OK) {
275 return err;
276 }
277 if (ftp_path_is_within_root(output, session->root_path) != 1) {
278 return FTP_ERR_PATH_INVALID;
279 }
280 
281 return FTP_OK;
282}
283 
284/*===========================================================================*
285 * PATH SECURITY CHECKS
286 *===========================================================================*/
287 
288/**
289 * @brief Check if path is within root directory
290 */
291int ftp_path_is_within_root(const char *path, const char *root)
292{
293 /* Validate parameters */
294 if ((path == NULL) || (root == NULL)) {
295 return FTP_ERR_INVALID_PARAM;
296 }
297
298 /* Both must be absolute paths */
299 if ((path[0] != '/') || (root[0] != '/')) {
300 return 0; /* Not absolute */
301 }
302
303 size_t root_len = strlen(root);
304 size_t path_len = strlen(path);
305
306 /* Special case: root is "/" (allows everything) */
307 if ((root_len == 1U) && (root[0] == '/')) {
308 return 1;
309 }
310
311 /* Path must be at least as long as root */
312 if (path_len < root_len) {
313 return 0;
314 }
315
316 /* Check if path starts with root */
317 if (strncmp(path, root, root_len) != 0) {
318 return 0;
319 }
320
321 /* Ensure proper boundary (prevent "/home" matching "/homeother") */
322 if (path_len > root_len) {
323 if (path[root_len] != '/') {
324 return 0;
325 }
326 }
327
328 return 1; /* Path is within root */
329}
330 
331/**
332 * @brief Validate path safety
333 */
334int ftp_path_is_safe(const char *path)
335{
336 if (path == NULL) {
337 return 0;
338 }
339
340 size_t len = strlen(path);
341
342 /* Check length */
343 if (len >= FTP_PATH_MAX) {
344 return 0;
345 }
346
347 /* Check for null bytes (string injection) */
348 if (memchr(path, '\0', len) != (path + len)) {
349 return 0; /* Embedded null */
350 }
351
352 /* Validate characters */
353 for (size_t i = 0U; i < len; i++) {
354 unsigned char c = (unsigned char)path[i];
355
356 /* Allow: alphanumeric, slash, dot, dash, underscore */
357 if (!isalnum(c) && (c != '/') && (c != '.') &&
358 (c != '-') && (c != '_') && (c != ' ')) {
359 /* Potentially dangerous character */
360 return 0;
361 }
362 }
363
364 return 1; /* Safe */
365}
366 
367/*===========================================================================*
368 * PATH MANIPULATION
369 *===========================================================================*/
370 
371/**
372 * @brief Extract basename from path
373 */
374ftp_error_t ftp_path_basename(const char *path,
375 char *basename,
376 size_t size)
377{
378 /* Validate parameters */
379 if ((path == NULL) || (basename == NULL)) {
380 return FTP_ERR_INVALID_PARAM;
381 }
382
383 if (size == 0U) {
384 return FTP_ERR_INVALID_PARAM;
385 }
386
387 /* Find last slash */
388 const char *last_slash = strrchr(path, '/');
389 const char *name;
390
391 if (last_slash == NULL) {
392 /* No slash: entire path is basename */
393 name = path;
394 } else {
395 /* Basename is after last slash */
396 name = last_slash + 1;
397 }
398
399 /* Copy basename */
400 size_t name_len = strlen(name);
401 if ((name_len + 1U) > size) {
402 return FTP_ERR_PATH_TOO_LONG;
403 }
404
405 memcpy(basename, name, name_len + 1U);
406
407 return FTP_OK;
408}
409 
410/**
411 * @brief Extract directory name from path
412 */
413ftp_error_t ftp_path_dirname(const char *path,
414 char *dirname,
415 size_t size)
416{
417 /* Validate parameters */
418 if ((path == NULL) || (dirname == NULL)) {
419 return FTP_ERR_INVALID_PARAM;
420 }
421
422 if (size == 0U) {
423 return FTP_ERR_INVALID_PARAM;
424 }
425
426 /* Find last slash */
427 const char *last_slash = strrchr(path, '/');
428
429 if (last_slash == NULL) {
430 /* No slash: current directory */
431 if (size < 2U) {
432 return FTP_ERR_PATH_TOO_LONG;
433 }
434 dirname[0] = '.';
435 dirname[1] = '\0';
436 return FTP_OK;
437 }
438
439 if (last_slash == path) {
440 /* Root directory */
441 if (size < 2U) {
442 return FTP_ERR_PATH_TOO_LONG;
443 }
444 dirname[0] = '/';
445 dirname[1] = '\0';
446 return FTP_OK;
447 }
448
449 /* Copy directory part */
450 size_t dir_len = (size_t)(last_slash - path);
451 if ((dir_len + 1U) > size) {
452 return FTP_ERR_PATH_TOO_LONG;
453 }
454
455 memcpy(dirname, path, dir_len);
456 dirname[dir_len] = '\0';
457
458 return FTP_OK;
459}
460 
461/**
462 * @brief Join two path components
463 */
464ftp_error_t ftp_path_join(const char *base,
465 const char *append,
466 char *output,
467 size_t size)
468{
469 /* Validate parameters */
470 if ((base == NULL) || (append == NULL) || (output == NULL)) {
471 return FTP_ERR_INVALID_PARAM;
472 }
473
474 if (size < FTP_PATH_MAX) {
475 return FTP_ERR_INVALID_PARAM;
476 }
477
478 size_t base_len = strlen(base);
479 size_t append_len = strlen(append);
480
481 /* Check combined length (base + '/' + append + '\0') */
482 if ((base_len + append_len + 2U) >= size) {
483 return FTP_ERR_PATH_TOO_LONG;
484 }
485
486 char temp[FTP_PATH_MAX];
487
488 /* Copy base */
489 memcpy(temp, base, base_len);
490
491 /* Add separator if needed */
492 if ((base_len > 0U) && (base[base_len - 1U] != '/')) {
493 temp[base_len] = '/';
494 base_len++;
495 }
496
497 /* Append second component */
498 memcpy(temp + base_len, append, append_len + 1U);
499
500 /* Normalize the joined path */
501 return ftp_path_normalize(temp, output, size);
502}
503