diff --git a/files.js b/files.js
index 87e64c62428..d839c5252c9 100644
--- a/files.js
+++ b/files.js
@@ -78,6 +78,8 @@ const info = {
"base1/test-user.js",
"base1/test-websocket.js",
+ "lib/test-path.ts",
+
"kdump/test-config-client.js",
"networkmanager/test-utils.js",
diff --git a/pkg/lib/path.ts b/pkg/lib/path.ts
new file mode 100644
index 00000000000..6f7ab35ef53
--- /dev/null
+++ b/pkg/lib/path.ts
@@ -0,0 +1,46 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2024 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+function drop_slashes(path : string): string {
+ // Drop all trailing slashes, but never drop the first character.
+ let pos = path.length;
+ while (pos > 1 && path[pos - 1] == "/")
+ pos -= 1;
+ return pos == path.length ? path : path.substr(0, pos);
+}
+
+export function dirname(path : string): string {
+ const norm = drop_slashes(path);
+ const pos = norm.lastIndexOf("/");
+ if (pos < 0)
+ return ".";
+ else if (pos == 0)
+ return "/";
+ else
+ return drop_slashes(norm.substr(0, pos));
+}
+
+export function basename(path : string): string {
+ const norm = drop_slashes(path);
+ const pos = norm.lastIndexOf("/");
+ if (pos < 0)
+ return norm;
+ else
+ return norm.substr(pos + 1);
+}
diff --git a/pkg/lib/test-path.ts b/pkg/lib/test-path.ts
new file mode 100644
index 00000000000..afc6e0d72db
--- /dev/null
+++ b/pkg/lib/test-path.ts
@@ -0,0 +1,57 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2024 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import { dirname, basename } from "./path";
+import QUnit from "qunit-tests";
+
+QUnit.test("dirname", function (assert) {
+ const checks = [
+ ["foo", "."],
+ ["/", "/"],
+ ["foo/bar", "foo"],
+ ["/foo", "/"],
+ ["foo///", "."],
+ ["/foo///", "/"],
+ ["////", "/"],
+ ["//foo///", "/"],
+ ["///foo///bar///", "///foo"],
+ ];
+
+ assert.expect(checks.length);
+ for (let i = 0; i < checks.length; i++) {
+ assert.strictEqual(dirname(checks[i][0]), checks[i][1],
+ "dirname(" + checks[i][0] + ") = " + checks[i][1]);
+ }
+});
+
+QUnit.test("basename", function (assert) {
+ const checks = [
+ ["foo", "foo"],
+ ["bar/foo/", "foo"],
+ ["//bar//foo///", "foo"],
+ ];
+
+ assert.expect(checks.length);
+ for (let i = 0; i < checks.length; i++) {
+ assert.strictEqual(basename(checks[i][0]), checks[i][1],
+ "basename(" + checks[i][0] + ") = " + checks[i][1]);
+ }
+});
+
+QUnit.start();
diff --git a/pkg/sosreport/sosreport.jsx b/pkg/sosreport/sosreport.jsx
index 64fb050f7cc..044e7051357 100644
--- a/pkg/sosreport/sosreport.jsx
+++ b/pkg/sosreport/sosreport.jsx
@@ -40,6 +40,7 @@ import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
import { ListingTable } from "cockpit-components-table.jsx";
+import { basename as path_basename } from "path";
import cockpit from "cockpit";
import { superuser } from "superuser";
@@ -74,7 +75,7 @@ function sosLister() {
}
function parse_report_name(path) {
- const basename = path.replace(/.*\//, "");
+ const basename = path_basename(path);
const archive_rx = /^(secured-)?sosreport-(.*)\.tar\.[^.]+(\.gpg)?$/;
const m = basename.match(archive_rx);
if (m) {
@@ -197,7 +198,7 @@ function sosCreate(args, setProgress, setError, setErrorDetail) {
}
function sosDownload(path) {
- const basename = path.replace(/.*\//, "");
+ const basename = path_basename(path);
const query = window.btoa(JSON.stringify({
payload: "fsread1",
binary: "raw",
diff --git a/pkg/storaged/btrfs/subvolume.jsx b/pkg/storaged/btrfs/subvolume.jsx
index 9fad2f92eff..b373732626b 100644
--- a/pkg/storaged/btrfs/subvolume.jsx
+++ b/pkg/storaged/btrfs/subvolume.jsx
@@ -24,6 +24,8 @@ import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.
import { Card, CardBody, CardHeader, CardTitle } from "@patternfly/react-core/dist/esm/components/Card/index.js";
import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+import { dirname } from "path";
+
import {
PageTable, StorageCard, StorageDescription, ChildrenTable,
new_card, new_page, navigate_away_from_card, register_crossref, get_crossrefs,
@@ -249,14 +251,6 @@ function subvolume_delete(volume, subvol, mount_point_in_parent, card) {
});
}
-function dirname(path) {
- const i = path.lastIndexOf("/");
- if (i < 0)
- return null;
- else
- return path.substr(0, i);
-}
-
export function make_btrfs_subvolume_pages(parent, volume) {
let subvols = client.uuids_btrfs_subvols[volume.data.uuid];
if (!subvols) {
@@ -297,7 +291,7 @@ export function make_btrfs_subvolume_pages(parent, volume) {
let dn = pn;
while (true) {
dn = dirname(dn);
- if (!dn) {
+ if (dn == "." || dn == "/") {
subvols_by_pathname[pn].parent = 5;
break;
} else if (subvols_by_pathname[dn]) {