From: Lady <redacted>
Date: Tue, 18 Mar 2025 04:31:32 +0000 (-0400)
Subject: Add request parsing and related tests
X-Git-Url: https://git.ladys.computer/CGirls/commitdiff_plain/f6f2fd79a596ecedaadd8b605b7de9d8c662151c?ds=inline

Add request parsing and related tests

This commit adds a function for processing a “path info” string, for
example one received through C·G·I (as the `PATH_INFO´ environment
variable), into a structure which represents its semantics,
`cgirls_req´. It also adds a function for reserializing this structure
into a canonical form. The program `cgirls-test-pathinfo´ is used with
the existing test infrastructure to ensure that strings are processed
correctly.

There is a flaw in this design (which I realized after making the
original commit, but before writing this updated message), in that an
empty identifier string is represented as `..´, which in a URL already
has a different, and very normative, meaning of “parent directory”.
This flaw will need to be fixed in a later commit.

Probably some more tests could be added here; in particular only a few
verbs and extensions are being tested right now and ideally they all
would be.
---

diff --git a/cgirls-test-pathinfo.c b/cgirls-test-pathinfo.c
new file mode 100644
index 0000000..160797c
--- /dev/null
+++ b/cgirls-test-pathinfo.c
@@ -0,0 +1,72 @@
+// SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+// SPDX-License-Identifier: GPL-2.0-only
+
+#include "aa.h"
+#include "request.h"
+
+// Read in lines from standard input, parse them as path info, and then
+// reserialize them to standard output.
+int cmd_main(int argc, [[maybe_unused]] const char* argv[argc+1]) {
+	char* lineptr[1] = { nullptr };
+	size_t linelen[1] = { 0 };
+	while (true) {
+		ssize_t readlen = getline(lineptr, linelen, stdin);
+		if (!feof(stdin) && readlen > 0) {
+			char* line = nullptr;
+			char* reline = nullptr;
+			bool empty = false;
+			if (readlen == 1) {
+				line = "";
+				empty = true;
+			} else {
+				line = strndup(lineptr[0], readlen - 1);
+				if (!line) {
+					free(lineptr[0]);
+					fprintf(stderr, "Error: Failed to allocate string for line.\n");
+					return EXIT_FAILURE;
+				}
+			}
+			cgirls_req req = cgirls_path2req(line);
+			if (!empty) {
+				free(line);
+			}
+			line = cgirls_req2path(req);
+			cgirls_freereq(req);
+			if (!line) {
+				free(lineptr[0]);
+				fprintf(stderr, "Error: Failed to allocate string for path.\n");
+				return EXIT_FAILURE;
+			}
+			req = cgirls_path2req(line);
+			reline = cgirls_req2path(req);
+			cgirls_freereq(req);
+			if (!reline) {
+				free(lineptr[0]);
+				free(line);
+				fprintf(stderr, "Error: Failed to allocate another string for path.\n");
+				return EXIT_FAILURE;
+			}
+			if (strcmp(line, reline) != 0) {
+				free(lineptr[0]);
+				fprintf(stderr, "Error: Path normalization was not idempotent for path <%s> (got <%s>).\n", line, reline);
+				free(line);
+				free(reline);
+				return EXIT_FAILURE;
+			}
+			free(reline);
+			fprintf(stdout, "%s\n", line);
+			free(line);
+		} else if (!feof(stdin)) {
+			free(lineptr[0]);
+			fprintf(stderr, "Error: Got an error from trying to read from file.\n");
+			return EXIT_FAILURE;
+		} else if (readlen > 0) {
+			free(lineptr[0]);
+			fprintf(stderr, "Error: Final line in file was not blank.\n");
+			return EXIT_FAILURE;
+		} else {
+			free(lineptr[0]);
+			return EXIT_SUCCESS;
+		}
+	}
+}
diff --git a/expect/pathinfo/01-emptyproj b/expect/pathinfo/01-emptyproj
new file mode 100644
index 0000000..1556201
--- /dev/null
+++ b/expect/pathinfo/01-emptyproj
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+
diff --git a/expect/pathinfo/02-blush b/expect/pathinfo/02-blush
new file mode 100644
index 0000000..0cd0ae8
--- /dev/null
+++ b/expect/pathinfo/02-blush
@@ -0,0 +1,4 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+
+p/unknown/../d
diff --git a/expect/pathinfo/03-noverb b/expect/pathinfo/03-noverb
new file mode 100644
index 0000000..5e7ea13
--- /dev/null
+++ b/expect/pathinfo/03-noverb
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+p/unknown/b..i/j
diff --git a/expect/pathinfo/04-badverbwext b/expect/pathinfo/04-badverbwext
new file mode 100644
index 0000000..40a59d6
--- /dev/null
+++ b/expect/pathinfo/04-badverbwext
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+p/unknown.txt
diff --git a/expect/pathinfo/05-noverbokext b/expect/pathinfo/05-noverbokext
new file mode 100644
index 0000000..59372b6
--- /dev/null
+++ b/expect/pathinfo/05-noverbokext
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+p/unknown
diff --git a/expect/pathinfo/06-dotid b/expect/pathinfo/06-dotid
new file mode 100644
index 0000000..3db7ef0
--- /dev/null
+++ b/expect/pathinfo/06-dotid
@@ -0,0 +1,4 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+p/show
+p/show/../d
diff --git a/expect/pathinfo/07-nobase b/expect/pathinfo/07-nobase
new file mode 100644
index 0000000..3bf4cc2
--- /dev/null
+++ b/expect/pathinfo/07-nobase
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+p/show/i
diff --git a/expect/pathinfo/08-notarget b/expect/pathinfo/08-notarget
new file mode 100644
index 0000000..aa33f15
--- /dev/null
+++ b/expect/pathinfo/08-notarget
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+p/show/b..
diff --git a/expect/pathinfo/09-canonical b/expect/pathinfo/09-canonical
new file mode 100644
index 0000000..1872535
--- /dev/null
+++ b/expect/pathinfo/09-canonical
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+p/log.txt/b..i/s
diff --git a/expect/pathinfo/10-blushypath b/expect/pathinfo/10-blushypath
new file mode 100644
index 0000000..38db5b2
--- /dev/null
+++ b/expect/pathinfo/10-blushypath
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+p/log/b..i/m/n/o
diff --git a/request.c b/request.c
new file mode 100644
index 0000000..ca58f0b
--- /dev/null
+++ b/request.c
@@ -0,0 +1,375 @@
+// SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+// SPDX-License-Identifier: GPL-2.0-only
+
+#include "aa.h"
+#include "request.h"
+
+void cgirls_freereq (cgirls_req req) {
+	free(req.cgirls_project);
+	free(req.cgirls_id);
+	if (req.cgirls_subpath) {
+		size_t i = 0;
+		char* c = req.cgirls_subpath[i];
+		while (c) {
+			free(c);
+			c = req.cgirls_subpath[++i];
+		}
+		free(req.cgirls_subpath);
+	}
+	free(req.cgirls_baseid);
+	free(req.cgirls_status.cgirls_message);
+}
+
+cgirls_req cgirls_path2req(char const*const pathinfo) {
+	assert(pathinfo != nullptr);
+
+	// Initialize the result.
+	cgirls_req req = {
+		.cgirls_action = cgirls_vb_index,
+		.cgirls_type = cgirls_mediatype_any,
+		.cgirls_project = nullptr,
+		.cgirls_id = nullptr,
+		.cgirls_subpath = nullptr,
+		.cgirls_baseid = nullptr,
+		.cgirls_status = {
+			.cgirls_code = 200,
+			.cgirls_message = nullptr,
+		},
+	};
+
+	// `sont´ stores the start of the next term; `eopi´ stores the end of
+	// the `pathinfo´ string, excluding any extension.
+	char const* sont = pathinfo;
+	char const*const eopi = strchr(pathinfo, 0);
+
+	// The portion of the pathinfo which precedes the first slash gives
+	// the project of the request. If there is no first slash, the
+	// project extends to the end of the string. An empty string is
+	// equivalent to having no project.
+	char const* eopj = strchr(sont, '/');
+	if (!eopj) {
+		eopj = eopi;
+	}
+	if (eopj > sont) {
+		req.cgirls_project = strndup(sont, eopj - sont);
+	}
+	if (eopj < eopi) {
+		sont = eopj + 1;
+	} else {
+		sont = eopi;
+	}
+
+  // The portion of the pathinfo which follows the first slash but
+  // precedes the second gives the verb of the request. If there is no
+  // second slash, the verb extends to the end of the string. If the
+  // verb is not present, or is the empty string, it is treated as
+  // `"index"´, unless the second slash is present, in which case it is
+  // treated as `"unknown"´.
+  //
+  // Verbs may be suffixed with one of a small number of extensions to
+  // request a specific type of response.
+  //
+  // Only a few verbs are recognized (corresponding to the `cgirls_vb´
+  // constants). If a verb is present, but unrecognized, it is assigned
+  // the special value `cgirls_vb_unknown´, which should generally be
+  // interpreted as an error.
+	char const* eovb = strchr(sont, '/');
+	if (!eovb) {
+		eovb = eopi;
+	}
+	char const*const eove = eovb;
+	char* verb = nullptr;
+	if (eovb - sont > 4) {
+		// If the verb is at least 5 characters, extract the extension if
+		// present (it will be the last 4), and then set the end of the
+		// verb to the start of the extension.
+		char const* exts = eovb - 4;
+		do {
+			// This “loop” encapsulates extension checking for readability.
+			// If an extension matches, `eovb´ is re·assigned to point to the
+			// beginning of the extension. Otherwise, the loop exits early
+			// and `eovb´ keeps pointing at the end of the string.
+			if (strncmp(exts, ".txt", 4) == 0) {
+				req.cgirls_type = cgirls_mediatype_txt;
+			} else if (strncmp(exts, ".htm", 4) == 0) {
+				req.cgirls_type = cgirls_mediatype_htm;
+			} else if (strncmp(exts, ".xml", 4) == 0) {
+				req.cgirls_type = cgirls_mediatype_xml;
+			} else if (strncmp(exts, ".rdf", 4) == 0) {
+				req.cgirls_type = cgirls_mediatype_rdf;
+			} else {
+				break; // do not re·assign `eovb´
+			}
+			eovb = exts;
+		} while (false);
+	}
+	if (eovb > sont) {
+		verb = strndup(sont, eovb - sont);
+	}
+	if (eove < eopi) {
+		sont = eove + 1;
+	} else {
+		sont = eopi;
+	}
+	if (verb) {
+		if (strcmp(verb, "branches") == 0) {
+			req.cgirls_action = cgirls_vb_branches;
+		} else if (strcmp(verb, "tags") == 0) {
+			req.cgirls_action = cgirls_vb_tags;
+		} else if (strcmp(verb, "show") == 0) {
+			req.cgirls_action = cgirls_vb_show;
+		} else if (strcmp(verb, "raw") == 0) {
+			req.cgirls_action = cgirls_vb_raw;
+		} else if (strcmp(verb, "blame") == 0) {
+			req.cgirls_action = cgirls_vb_blame;
+		} else if (strcmp(verb, "log") == 0) {
+			req.cgirls_action = cgirls_vb_log;
+		} else if (strcmp(verb, "shortlog") == 0) {
+			req.cgirls_action = cgirls_vb_shortlog;
+		} else if (strcmp(verb, "atom") == 0) {
+			req.cgirls_action = cgirls_vb_atom;
+		} else if (strcmp(verb, "patch") == 0) {
+			req.cgirls_action = cgirls_vb_patch;
+		} else if (strcmp(verb, "index") != 0) {
+			req.cgirls_action = cgirls_vb_unknown;
+		}
+		free(verb);
+	} else if (eovb < eopi) {
+		req.cgirls_action = cgirls_vb_unknown;
+	}
+
+  // The portion of the pathinfo which follows the second slash but
+  // precedes the third identifies the identifiers for the request. If
+  // there is no third slash, the identifiers extend to the end of the
+  // string. A single identifier may be given, or two identifiers may
+  // be given separated by two periods. An empty string is equivalent
+  // to no identifier.
+	char const* eoid = strchr(sont, '/');
+	if (!eoid) {
+		eoid = eopi;
+	}
+	char* idid = nullptr;
+	if (eoid > sont) {
+		idid = strndup(sont, eoid - sont);
+	}
+	if (eoid < eopi) {
+		sont = eoid + 1;
+	} else {
+		sont = eopi;
+	}
+	if (idid) {
+		// If the identifier string contains two successive dots, the base
+		// and target identifiers must be extracted and the original
+		// identifier string freed. Otherwise, the identifier string is the
+		// target identifier, and there is no base.
+		char const*const dots = strstr(idid, "..");
+		if (dots) {
+			char const*const eods = dots + 2;
+			char const*const eoii = strchr(idid, 0);
+			if (dots > idid) {
+				req.cgirls_baseid = strndup(idid, dots - idid);
+			}
+			if (eods < eoii) {
+				req.cgirls_id = strndup(eods, eoii - eods);
+			}
+			free(idid);
+		} else {
+			req.cgirls_id = idid;
+		}
+	}
+
+	// The portion of the pathinfo which follows the third slash is the
+	// subpath of the request. An empty sting is equivalent to having no
+	// subpath. Trailing and successive slashes are dropped.
+	char const* soct = sont;
+	char const* psep = nullptr;
+	size_t npth = 0;
+	while (eopi > soct) {
+		// Count the number of segments in the pathinfo so that the correct
+		// amount of space can be allocated.
+		psep = strchr(soct, '/');
+		if (!psep) {
+			psep = eopi;
+		}
+		if (psep > soct) {
+			++npth;
+		}
+		if (eopi > psep) {
+			soct = psep + 1;
+		} else {
+			soct = eopi;
+		}
+	}
+	req.cgirls_subpath = calloc(npth + 1, sizeof(char*));
+	if (!req.cgirls_subpath) {
+		return req;
+	}
+	size_t pthi = 0;
+	while (eopi > sont) {
+		// Add the segments to the newly allocated array.
+		psep = strchr(sont, '/');
+		if (!psep) {
+			psep = eopi;
+		}
+		if (psep > sont) {
+			req.cgirls_subpath[pthi++] = strndup(sont, psep - sont);
+		}
+		if (eopi > psep) {
+			sont = psep + 1;
+		} else {
+			sont = eopi;
+		}
+	}
+	assert(pthi == npth);
+	req.cgirls_subpath[pthi] = nullptr;
+
+	// Return the result.
+	return req;
+}
+
+char* cgirls_req2path(cgirls_req req) {
+	char* action = "unknown";
+	char* extnsn = "";
+	size_t length = 8; // length of `action´ plus 1, to start
+
+	// Get the length of the various parts, saving the verb and the
+	// extension. This length includes a trailing slash, but in practice
+	// this will be replaced by the final null byte.
+	switch (req.cgirls_action) {
+		case cgirls_vb_index:
+			action = "index";
+			length = 6;
+			break;
+		case cgirls_vb_branches:
+			action = "branches";
+			length = 9;
+			break;
+		case cgirls_vb_tags:
+			action = "tags";
+			length = 5;
+			break;
+		case cgirls_vb_show:
+			action = "show";
+			length = 5;
+			break;
+		case cgirls_vb_raw:
+			action = "raw";
+			length = 4;
+			break;
+		case cgirls_vb_blame:
+			action = "blame";
+			length = 6;
+			break;
+		case cgirls_vb_log:
+			action = "log";
+			length = 4;
+			break;
+		case cgirls_vb_shortlog:
+			action = "shortlog";
+			length = 9;
+			break;
+		case cgirls_vb_atom:
+			action = "atom";
+			length = 5;
+			break;
+		case cgirls_vb_patch:
+			action = "patch";
+			length = 6;
+			break;
+		default:
+			break;
+	}
+	switch (req.cgirls_type) {
+		case cgirls_mediatype_txt:
+			extnsn = ".txt";
+			break;
+		case cgirls_mediatype_htm:
+			extnsn = ".htm";
+			break;
+		case cgirls_mediatype_xml:
+			extnsn = ".xml";
+			break;
+		case cgirls_mediatype_rdf:
+			extnsn = ".rdf";
+			break;
+		default:
+			break;
+	}
+	if (req.cgirls_project) {
+		length += strlen(req.cgirls_project) + 1;
+		if (req.cgirls_type != cgirls_mediatype_any) {
+			length += 4;
+		}
+		if (req.cgirls_baseid || req.cgirls_id) {
+			if (req.cgirls_baseid) {
+				length += strlen(req.cgirls_baseid) + 2;
+			}
+			if (req.cgirls_id) {
+				length += strlen(req.cgirls_id);
+			}
+			length += 1;
+		} else if (req.cgirls_subpath && req.cgirls_subpath[0]) {
+			length += 3;
+		}
+		if (req.cgirls_subpath) {
+			size_t i = 0;
+			char* c = req.cgirls_subpath[i];
+			while (c) {
+				length += strlen(c) + 1;
+				c = req.cgirls_subpath[++i];
+			}
+		}
+	} else {
+		// If there is no project, then the action must be removed, and the
+		// length is just that of the trailing slash.
+		length = 1;
+	}
+	// Create and compose the final path.
+	char* result = calloc(length, sizeof(char*));
+	if (!result) {
+		return nullptr;
+	}
+	char* cursor = result;
+	if (req.cgirls_project) {
+		cursor = stpcpy(cursor, req.cgirls_project);
+		(cursor++)[0] = '/';
+		cursor = stpcpy(cursor, action);
+		if (req.cgirls_type != cgirls_mediatype_any) {
+			cursor = stpcpy(cursor, extnsn);
+		}
+		(cursor++)[0] = '/';
+		if (req.cgirls_baseid || req.cgirls_id) {
+			if (req.cgirls_baseid) {
+				cursor = stpcpy(cursor, req.cgirls_baseid);
+				cursor[0] = '.';
+				cursor[1] = '.';
+				cursor += 2;
+			}
+			if (req.cgirls_id) {
+				cursor = stpcpy(cursor, req.cgirls_id);
+			}
+			(cursor++)[0] = '/';
+		} else if (req.cgirls_subpath && req.cgirls_subpath[0]) {
+			cursor = stpcpy(cursor, "../");
+		}
+		if (req.cgirls_subpath) {
+			size_t i = 0;
+			char* c = req.cgirls_subpath[i];
+			while (c) {
+				cursor = stpcpy(cursor, c);
+				c = req.cgirls_subpath[++i];
+				(cursor++)[0] = '/';
+			}
+		}
+	} else {
+		(cursor++)[0] = '/';
+	}
+
+	// At this point, `cursor´ points one ⹐past⹑ the last element of the
+	// array (this is allowed in C), and the last element is a slash.
+	// Rewind and set it to the null byte, and assert that everything was
+	// done correctly.
+	(--cursor)[0] = 0;
+	assert((cursor + 1) - result == length);
+	return result;
+}
diff --git a/request.h b/request.h
new file mode 100644
index 0000000..2ab70e0
--- /dev/null
+++ b/request.h
@@ -0,0 +1,90 @@
+// SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+// SPDX-License-Identifier: GPL-2.0-only
+
+#ifndef CGIRLS_REQUEST_H
+#define CGIRLS_REQUEST_H
+
+enum cgirls_mediatype : unsigned char {
+	// Unspecified media type
+	cgirls_mediatype_any = 0x00,
+	// Text media types
+	cgirls_mediatype_txt = 0x10,
+	cgirls_mediatype_htm = 0x11,
+	// X·M·L media types
+	cgirls_mediatype_xml = 0x20,
+	cgirls_mediatype_rdf = 0x21,
+};
+typedef enum cgirls_mediatype cgirls_mediatype;
+
+enum cgirls_vb : unsigned char {
+	// Actions in general
+	cgirls_vb_index = 0x00,
+	// Actions on projects
+	cgirls_vb_branches = 0x10,
+	cgirls_vb_tags = 0x11,
+	// Actions on single objects
+	cgirls_vb_show = 0x20,
+	cgirls_vb_raw = 0x21,
+	cgirls_vb_blame = 0x22,
+	// Actions on ranges of objects
+	cgirls_vb_log = 0x30,
+	cgirls_vb_shortlog = 0x31,
+	cgirls_vb_atom = 0x32,
+	cgirls_vb_patch = 0x33,
+	// Unknown verb
+	cgirls_vb_unknown = 0xFF,
+};
+typedef enum cgirls_vb cgirls_vb;
+
+typedef struct cgirls_req_status cgirls_req_status;
+struct cgirls_req_status {
+	unsigned short cgirls_code;
+	char* cgirls_message; // if `cgirls_code´ is not ok
+};
+
+typedef struct cgirls_req cgirls_req;
+struct cgirls_req {
+	cgirls_vb cgirls_action;
+	cgirls_mediatype cgirls_type;
+	char* cgirls_project;
+	char* cgirls_id;
+	char** cgirls_subpath;
+	char* cgirls_baseid;
+	cgirls_req_status cgirls_status;
+};
+
+/*
+Frees up any dynamically‐allocated memory which was allocated by
+`cgirls_path2req´.
+*/
+void cgirls_freereq (cgirls_req req);
+
+/*
+Converts the provided “path info” string into a `cgirls_req´ struct
+and returns the result.
+
+This struct contains dynamically‐allocated strings which must be freed
+by calling `cgirls_freereq´.
+
+Maximally, a “path info” string has the following form :—
+
+	{project}/{action}/{baseid}..{id}/{subpath}
+
+—: (where subpath can contain additional slashes, and action may
+optionally include one of a small number of supported extensions).
+`baseid´ is optional; if omitted, the dots preceding `id´ are also
+dropped. For all other components, all preceding components must be
+provided if a given component is provided.
+*/
+cgirls_req cgirls_path2req(char const*const pathinfo);
+
+/*
+Returns the canonical “path info” string which represents the provided
+`cgirls_req´.
+
+Note that if `cgirls_req.cgirls_project´ is the null pointer, the
+canonical “path info” string is always the empty string.
+*/
+char* cgirls_req2path(cgirls_req);
+
+#endif /* CGIRLS_REQUEST_H */
diff --git a/sh/test.sh b/sh/test.sh
index fc0935b..1d9b5e2 100755
--- a/sh/test.sh
+++ b/sh/test.sh
@@ -80,9 +80,9 @@ do :
 						else :
 							printf '%27b \n' '\0033[1;93;41;m[NG]\0033[0m'
 							printf '\033[7m \033[0m   \033[2m%b\033[0m \n' 'Result did not match expectation.' 'Got:'
-							printf '\033[30;107m%-49s\033[0m\n' "${TEST_RESULT}"
+							printf '%s\n' "${TEST_RESULT}" | sed 's/'"'"'/'"'"'"'"'"'"'"'"'/g;s/^/'"'"'/;s/$/'"'"'/' | xargs -E '' printf '\033[30;107m%-49s\033[0m\n'
 							printf '\033[7m \033[0m   \033[2m%b\033[0m \n' 'Expected:'
-							printf '\033[30;107m%-49s\033[0m\n' "${TEST_EXPECTATION}"
+							printf '%s\n' "${TEST_EXPECTATION}" | sed 's/'"'"'/'"'"'"'"'"'"'"'"'/g;s/^/'"'"'/;s/$/'"'"'/' | xargs -E '' printf '\033[30;107m%-49s\033[0m\n'
 							TEST_EXIT_STATUS=$((${TEST_EXIT_STATUS} | 1))
 						fi
 					fi
diff --git a/test/pathinfo/01-emptyproj b/test/pathinfo/01-emptyproj
new file mode 100644
index 0000000..37442f4
--- /dev/null
+++ b/test/pathinfo/01-emptyproj
@@ -0,0 +1,4 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+
+/show
diff --git a/test/pathinfo/02-blush b/test/pathinfo/02-blush
new file mode 100644
index 0000000..63e9bfe
--- /dev/null
+++ b/test/pathinfo/02-blush
@@ -0,0 +1,4 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+////////////
+p//////////d
diff --git a/test/pathinfo/03-noverb b/test/pathinfo/03-noverb
new file mode 100644
index 0000000..316d7f9
--- /dev/null
+++ b/test/pathinfo/03-noverb
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+p//b..i//j
diff --git a/test/pathinfo/04-badverbwext b/test/pathinfo/04-badverbwext
new file mode 100644
index 0000000..b5cbee1
--- /dev/null
+++ b/test/pathinfo/04-badverbwext
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+p/na.txt
diff --git a/test/pathinfo/05-noverbokext b/test/pathinfo/05-noverbokext
new file mode 100644
index 0000000..2b4866f
--- /dev/null
+++ b/test/pathinfo/05-noverbokext
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+p/.txt
diff --git a/test/pathinfo/06-dotid b/test/pathinfo/06-dotid
new file mode 100644
index 0000000..2f46dfe
--- /dev/null
+++ b/test/pathinfo/06-dotid
@@ -0,0 +1,4 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+p/show/../
+p/show/../d
diff --git a/test/pathinfo/07-nobase b/test/pathinfo/07-nobase
new file mode 100644
index 0000000..c3a5e31
--- /dev/null
+++ b/test/pathinfo/07-nobase
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+p/show/..i/
diff --git a/test/pathinfo/08-notarget b/test/pathinfo/08-notarget
new file mode 100644
index 0000000..aa33f15
--- /dev/null
+++ b/test/pathinfo/08-notarget
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+p/show/b..
diff --git a/test/pathinfo/09-canonical b/test/pathinfo/09-canonical
new file mode 100644
index 0000000..1872535
--- /dev/null
+++ b/test/pathinfo/09-canonical
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+p/log.txt/b..i/s
diff --git a/test/pathinfo/10-blushypath b/test/pathinfo/10-blushypath
new file mode 100644
index 0000000..85d519e
--- /dev/null
+++ b/test/pathinfo/10-blushypath
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2025 Lady <https://www.ladys.computer/about/#lady>
+# SPDX-License-Identifier: CC0-1.0
+p/log/b..i//m//n///o////