diff --git a/src/app.tsx b/src/app.tsx index d27090ca..18dbd26a 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -36,7 +36,7 @@ import { useInit, usePageLocation } from "hooks"; import { superuser } from "superuser"; import { FilesContext, usePath } from "./common.ts"; -import type { FolderFileInfo } from "./common.ts"; +import type { ClipboardInfo, FolderFileInfo } from "./common.ts"; import { FilesBreadcrumbs } from "./files-breadcrumbs.tsx"; import { FilesFolderView } from "./files-folder-view.tsx"; import { FilesFooterDetail } from "./files-footer-detail.tsx"; @@ -61,7 +61,7 @@ export const Application = () => { const [files, setFiles] = useState([]); const [selected, setSelected] = useState([]); const [showHidden, setShowHidden] = useState(localStorage.getItem("files:showHiddenFiles") === "true"); - const [clipboard, setClipboard] = useState([]); + const [clipboard, setClipboard] = useState({ path: "/", files: [] }); const [alerts, setAlerts] = useState([]); const [cwdInfo, setCwdInfo] = useState(null); diff --git a/src/common.ts b/src/common.ts index b6148255..c4869a8c 100644 --- a/src/common.ts +++ b/src/common.ts @@ -16,7 +16,6 @@ * You should have received a copy of the GNU Lesser General Public License * along with Cockpit; If not, see . */ - import React, { useContext } from "react"; import type { AlertVariant } from "@patternfly/react-core/dist/esm/components/Alert"; @@ -33,6 +32,11 @@ export interface FolderFileInfo extends FileInfo { category: { class: string } | null, } +export interface ClipboardInfo { + path: string, + files: FolderFileInfo[]; +} + interface FilesContextType { addAlert: (title: string, variant: AlertVariant, key: string, detail?: string | React.ReactNode, actionLinks?: React.ReactNode) => void, @@ -139,3 +143,9 @@ export function checkFilename(candidate: string, entries: Record. + */ + +import React, { useState } from 'react'; + +import { AlertVariant } from "@patternfly/react-core/dist/esm/components/Alert"; +import { Button } from "@patternfly/react-core/dist/esm/components/Button"; +import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form"; +import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect"; +import { Modal, ModalVariant } from "@patternfly/react-core/dist/esm/components/Modal"; +import { Text, TextContent } from "@patternfly/react-core/dist/esm/components/Text"; + +import cockpit from 'cockpit'; +import { FileInfo } from 'cockpit/fsinfo.ts'; +import type { Dialogs, DialogResult } from 'dialogs'; +import { useEvent, useInit } from 'hooks.ts'; +import { superuser } from 'superuser'; + +import { useFilesContext } from '../common.ts'; +import type { ClipboardInfo, FolderFileInfo } from '../common.ts'; +import { get_owner_candidates } from '../ownership.tsx'; + +const _ = cockpit.gettext; + +async function pasteAsOwner(clipboard: ClipboardInfo, + dstPath: string, + ownerStr: string, + addAlert: (title: string, variant: AlertVariant, key: string, detail?: string) => void) { + try { + await cockpit.spawn([ + "cp", + "-a", + "--recursive", + ...clipboard.files.map(file => clipboard.path + "/" + file.name), + dstPath + ], { superuser: "require" }); + + // "original" is a special value, valid values are formatted as 'user:group' + if (ownerStr !== "original") { + await cockpit.spawn([ + "chown", + "--recursive", + ownerStr, + ...clipboard.files.map(file => dstPath + "/" + file.name), + ], { superuser: "require" }); + } + } catch (err) { + const e = err as cockpit.BasicError; + addAlert(_("Pasting failed"), AlertVariant.danger, `${new Date().getTime()}`, e.message); + + // cleanup potentially copied files in case of "chown" fail + try { + await cockpit.spawn([ + "rm", + "-rf", + ...clipboard.files.map(file => dstPath + "/" + file.name) + ], { superuser: "try" }); + } catch (ex) { + console.warn(`Failed to clean up copied files in ${dstPath}`, ex); + } + } +} + +function ownershipTypes(files: FolderFileInfo[]) { + const owners = new Set(); + + for (const file of files) { + const user = file.user; + const group = file.group; + + if (user === group) { + owners.add(String(user)); + } else { + owners.add(`${user}:${group}`); + } + } + + return [...owners]; +} + +function makeCandidatesMap(currentUser: cockpit.UserInfo, cwdInfo: FileInfo, clipboard: ClipboardInfo) { + // keys in Map() are ordered in the same order as they were inserted + // and has no duplicates. Only the first insertion. + const map: Map = new Map(); + const candidates = get_owner_candidates(currentUser, cwdInfo); + + // also add current ownership if it is same for all files (shallow check) + const firstFile = clipboard.files[0]; + const firstOwnerStr = `${firstFile.user}:${firstFile.group}`; + const uniqueOwners = ownershipTypes(clipboard.files); + if (uniqueOwners.length === 1) { + candidates.add(firstOwnerStr); + } + + candidates.forEach(owner => { + const split = owner.split(':'); + let key = owner; + if (split.length === 2 && split[0] === split[1]) { + key = split[0]; + } + if (uniqueOwners.length === 1 && owner === firstOwnerStr) { + key = `${key} ${_("(original owner)")}`; + } + + map.set(key, owner); + }); + + // add option to keep the same permissions on mixed permissions files + if (uniqueOwners.length > 1) { + const dots = uniqueOwners.length > 2 ? ", ..." : ""; + map.set(`${_("keep original owners")} (${uniqueOwners.slice(0, 2).join(", ")}${dots})`, "original"); + } + + return map; +} + +const CopyPasteAsOwnerModal = ({ + clipboard, + dialogResult, + path, +} : { + clipboard: ClipboardInfo, + dialogResult: DialogResult, + path: string, +}) => { + // @ts-expect-error superuser.js is not typed + useEvent(superuser, "changed"); + const [selectedOwner, setSelectedOwner] = useState(); + const { cwdInfo, addAlert } = useFilesContext(); + const [candidatesMap, setCandidatesMap] = useState | undefined>(); + + useInit(async () => { + const userInfo = await cockpit.user(); + let map: Map = new Map(); + + if (superuser.allowed && userInfo && cwdInfo) { + map = makeCandidatesMap(userInfo, cwdInfo, clipboard); + if (selectedOwner === undefined) { + setSelectedOwner(map.keys().next().value); + } + } + + setCandidatesMap(map); + }); + + if (selectedOwner === undefined || candidatesMap === undefined) { + return; + } + + const selectedVal = candidatesMap.get(selectedOwner); + cockpit.assert(selectedVal !== undefined, "New file ownership undefined"); + + const modalFooter = ( + <> + + + + ); + + return ( + dialogResult.resolve()} + variant={ModalVariant.small} + footer={modalFooter} + > +
+ + + {/*eslint-disable-line*/ _("Files being pasted have a different owner. By default, ownership will be changed to match the destination directory.")} + + + + setSelectedOwner(val)} + > + {[...candidatesMap.keys()].map(user => + )} + + +
+
+ ); +}; + +export function show_copy_paste_as_owner(dialogs: Dialogs, clipboard: ClipboardInfo, path: string) { + dialogs.run(CopyPasteAsOwnerModal, { clipboard, path }); +} diff --git a/src/files-card-body.tsx b/src/files-card-body.tsx index 571c01c6..2f95fdc5 100644 --- a/src/files-card-body.tsx +++ b/src/files-card-body.tsx @@ -36,7 +36,7 @@ import { useDialogs } from "dialogs"; import * as timeformat from "timeformat"; import { get_permissions, permissionShortStr, useFilesContext } from "./common.ts"; -import type { FolderFileInfo } from "./common.ts"; +import type { ClipboardInfo, FolderFileInfo } from "./common.ts"; import { confirm_delete } from "./dialogs/delete.tsx"; import { show_create_directory_dialog } from "./dialogs/mkdir.tsx"; import { show_rename_dialog } from "./dialogs/rename.tsx"; @@ -101,7 +101,7 @@ function compare(sortBy: Sort): (a: FolderFileInfo, b: FolderFileInfo) => number const ContextMenuItems = ({ path, selected, setSelected, clipboard, setClipboard } : { path: string, selected: FolderFileInfo[], setSelected: React.Dispatch>, - clipboard: string[], setClipboard: React.Dispatch>, + clipboard: ClipboardInfo, setClipboard: React.Dispatch>, }) => { const dialogs = useDialogs(); const { addAlert, cwdInfo } = useFilesContext(); @@ -152,7 +152,7 @@ export const FilesCardBody = ({ selected: FolderFileInfo[], setSelected: React.Dispatch>, sortBy: Sort, setSortBy: React.Dispatch>, loadingFiles: boolean, - clipboard: string[], setClipboard: React.Dispatch>, + clipboard: ClipboardInfo, setClipboard: React.Dispatch>, showHidden: boolean, setShowHidden: React.Dispatch>, }) => { @@ -365,15 +365,16 @@ export const FilesCardBody = ({ // Keep standard text editing behavior by excluding input fields if (e.ctrlKey && !e.shiftKey && !e.altKey && !(e.target instanceof HTMLInputElement)) { e.preventDefault(); - setClipboard(selected.map(s => path + s.name)); + setClipboard({ path, files: selected }); } break; case "v": // Keep standard text editing behavior by excluding input fields - if (e.ctrlKey && !e.shiftKey && !e.altKey && !(e.target instanceof HTMLInputElement)) { + if (e.ctrlKey && !e.shiftKey && !e.altKey && clipboard.files.length > 0 && + !(e.target instanceof HTMLInputElement)) { e.preventDefault(); - pasteFromClipboard(clipboard, cwdInfo, path, addAlert); + pasteFromClipboard(clipboard, cwdInfo, path, dialogs, addAlert); } break; diff --git a/src/files-folder-view.tsx b/src/files-folder-view.tsx index c116b67f..78d5471b 100644 --- a/src/files-folder-view.tsx +++ b/src/files-folder-view.tsx @@ -26,7 +26,7 @@ import { debounce } from "throttle-debounce"; import cockpit from "cockpit"; import { EmptyStatePanel } from "cockpit-components-empty-state"; -import type { FolderFileInfo } from "./common.ts"; +import type { FolderFileInfo, ClipboardInfo } from "./common.ts"; import { FilesCardBody } from "./files-card-body.tsx"; import { as_sort, FilesCardHeader } from "./header.tsx"; @@ -63,7 +63,7 @@ export const FilesFolderView = ({ showHidden: boolean, setShowHidden: React.Dispatch>, selected: FolderFileInfo[], setSelected: React.Dispatch>, - clipboard: string[], setClipboard: React.Dispatch>, + clipboard: ClipboardInfo, setClipboard: React.Dispatch>, }) => { const dropzoneRef = useRef(null); const [currentFilter, setCurrentFilter] = useState(""); diff --git a/src/header.tsx b/src/header.tsx index 01a656a3..31c6736d 100644 --- a/src/header.tsx +++ b/src/header.tsx @@ -33,7 +33,7 @@ import { KebabDropdown } from "cockpit-components-dropdown"; import { useDialogs } from "dialogs"; import { useFilesContext } from "./common.ts"; -import type { FolderFileInfo } from "./common.ts"; +import type { FolderFileInfo, ClipboardInfo } from "./common.ts"; import { showKeyboardShortcuts } from "./dialogs/keyboardShortcutsHelp.tsx"; import { get_menu_items } from "./menu.tsx"; import { UploadButton } from "./upload-button.tsx"; @@ -147,7 +147,7 @@ export const FilesCardHeader = ({ sortBy: Sort, setSortBy: React.Dispatch> showHidden: boolean, setShowHidden: React.Dispatch>, selected: FolderFileInfo[], setSelected: React.Dispatch>, - clipboard: string[], setClipboard: React.Dispatch> + clipboard: ClipboardInfo, setClipboard: React.Dispatch> path: string, }) => { const { addAlert, cwdInfo } = useFilesContext(); diff --git a/src/menu.tsx b/src/menu.tsx index b2e16218..eabeb371 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -25,8 +25,11 @@ import cockpit from "cockpit"; import type { FileInfo } from "cockpit/fsinfo"; import { basename, dirname } from "cockpit-path"; import type { Dialogs } from 'dialogs'; +import { superuser } from 'superuser'; -import type { FolderFileInfo } from "./common.ts"; +import { debug } from "./common.ts"; +import type { ClipboardInfo, FolderFileInfo } from "./common.ts"; +import { show_copy_paste_as_owner } from "./dialogs/copyPasteOwnership.tsx"; import { show_create_file_dialog } from './dialogs/create-file.tsx'; import { confirm_delete } from './dialogs/delete.tsx'; import { edit_file, MAX_EDITOR_FILE_SIZE } from './dialogs/editor.tsx'; @@ -46,31 +49,47 @@ type MenuItem = { type: "divider" } | { className?: string; }; -export function pasteFromClipboard( - clipboard: string[], +export async function pasteFromClipboard( + clipboard: ClipboardInfo, cwdInfo: FileInfo | null, path: string, + dialogs: Dialogs, addAlert: (title: string, variant: AlertVariant, key: string, detail?: string) => void, ) { - const existingFiles = clipboard.filter(sourcePath => cwdInfo?.entries?.[basename(sourcePath)]); + const existingFiles = clipboard.files.filter(sourcePath => cwdInfo?.entries?.[sourcePath.name]); + if (existingFiles.length > 0) { addAlert(_("Pasting failed"), AlertVariant.danger, "paste-error", cockpit.format(_("\"$0\" exists, not overwriting with paste."), - existingFiles.map(basename).join(", "))); + existingFiles.map(file => file.name).join(", "))); return; } - cockpit.spawn([ - "cp", - "-R", - ...clipboard, - path - ]).catch(err => addAlert(err.message, AlertVariant.danger, `${new Date().getTime()}`)); + + try { + // check for write access to destination directory + await cockpit.spawn(["test", "-w", path]); + const filePaths = clipboard.files.map(file => `${clipboard.path}/${file.name}`); + await cockpit.spawn([ + "cp", + "-R", + ...filePaths, + path + ]); + } catch (e) { + const err = e as cockpit.BasicError; + debug("Failed to copy as admin: ", err); + if (superuser.allowed) { + show_copy_paste_as_owner(dialogs, clipboard, path); + } else { + addAlert(err.message, AlertVariant.danger, `${new Date().getTime()}`); + } + } } export function get_menu_items( path: string, selected: FolderFileInfo[], setSelected: React.Dispatch>, - clipboard: string[], setClipboard: React.Dispatch>, + clipboard: ClipboardInfo, setClipboard: React.Dispatch>, cwdInfo: FileInfo | null, addAlert: (title: string, variant: AlertVariant, key: string, detail?: string) => void, dialogs: Dialogs, @@ -86,8 +105,8 @@ export function get_menu_items( { id: "paste-item", title: _("Paste"), - isDisabled: clipboard.length === 0, - onClick: () => pasteFromClipboard(clipboard, cwdInfo, path, addAlert), + isDisabled: clipboard.files.length === 0, + onClick: () => pasteFromClipboard(clipboard, cwdInfo, path, dialogs, addAlert), }, { type: "divider" }, { @@ -138,7 +157,7 @@ export function get_menu_items( { id: "copy-item", title: _("Copy"), - onClick: () => setClipboard([path + item.name]) + onClick: () => setClipboard({ path, files: [item] }) }, { type: "divider" }, { @@ -183,7 +202,7 @@ export function get_menu_items( { id: "copy-item", title: _("Copy"), - onClick: () => setClipboard(selected.map(s => path + s.name)), + onClick: () => setClipboard({ path, files: selected }), }, { id: "delete-item", diff --git a/src/ownership.tsx b/src/ownership.tsx index 5e55365d..853fd78a 100644 --- a/src/ownership.tsx +++ b/src/ownership.tsx @@ -39,5 +39,5 @@ export function get_owner_candidates(user: cockpit.UserInfo, info: FileInfo) { candidates.add(`${user.name || user.id}:${setgid || user.gid}`); } - return [...candidates]; + return candidates; } diff --git a/src/upload-button.tsx b/src/upload-button.tsx index fd769690..9e5be642 100644 --- a/src/upload-button.tsx +++ b/src/upload-button.tsx @@ -186,7 +186,7 @@ export const UploadButton = ({ const user = await cockpit.user(); if (superuser.allowed && cwdInfo) { const candidates = get_owner_candidates(user, cwdInfo); - owner = candidates[0]; + owner = [...candidates][0]; } const resetInput = () => { diff --git a/test/check-application b/test/check-application index f37292f0..278d20e5 100755 --- a/test/check-application +++ b/test/check-application @@ -1970,6 +1970,7 @@ class TestFiles(testlib.MachineCase): m = self.machine self.enter_files() + b.click("button[aria-label='Display as a list']") # Copy/paste file m.execute("runuser -u admin mkdir /home/admin/newdir") @@ -1989,6 +1990,8 @@ class TestFiles(testlib.MachineCase): # original file still exists b.wait_visible("[data-item='newfile']") + b.click("button[aria-label='Display as a grid']") + # Copy/paste directory m.execute("runuser -u admin mkdir /home/admin/copyDir") m.execute("runuser -u admin mkdir /home/admin/newdir/loaded") @@ -2065,6 +2068,211 @@ class TestFiles(testlib.MachineCase): b.wait_in_text("h4.pf-v5-c-alert__title", "Pasting failed") b.wait_in_text(".pf-v5-c-alert__description", "\"newfile\" exists") self.assertEqual(m.execute("head -n 1 /home/admin/kbdCopy/newfile"), "keybindings good\n") + b.click(".pf-v5-c-alert__action button") + + # Copy & paste as superuser + # Switch to list so owner is visible + b.click("button[aria-label='Display as a list']") + b.go("/files#/?path=/home/admin") + self.assert_last_breadcrumb("admin") + m.execute("useradd -m foouser") + m.write("/home/admin/newfile", "test copy", owner="admin:admin") + + b.click("[data-item='newfile']") + b.click("#dropdown-menu") + b.click("#copy-item") + + b.go("files#/?path=/home/foouser") + self.assert_last_breadcrumb("foouser") + # Paste file as destination directory owner + b.click("#dropdown-menu") + b.click("#paste-item") + b.wait_visible("#paste-owner-modal") + b.click("#paste-owner-modal button.pf-m-primary") + b.wait_in_text("[data-item='newfile'] .item-owner", "foouser") + self.assertEqual(m.execute("cat /home/foouser/newfile"), "test copy") + + # Copy foouser file into admin directory as admin + m.write("/home/foouser/foouserfile", "footext", owner="foouser:foouser") + b.wait_in_text("[data-item='foouserfile'] .item-owner", "foouser") + b.click("[data-item='foouserfile']") + b.click("#dropdown-menu") + b.click("#copy-item") + b.go("files#/?path=/home/admin") + self.assert_last_breadcrumb("admin") + b.click("#dropdown-menu") + b.click("#paste-item") + b.wait_visible("#paste-owner-modal") + b.click("#paste-owner-modal button.pf-m-primary") + b.wait_in_text("[data-item='foouserfile'] .item-owner", "admin") + self.assertEqual(m.execute("cat /home/admin/foouserfile"), "footext") + + # Paste as admin into foouser directory + m.write("/home/admin/adminfile", "admintext", owner="admin:admin") + b.wait_in_text("[data-item='adminfile'] .item-owner", "admin") + b.click("[data-item='adminfile']") + b.click("#dropdown-menu") + b.click("#copy-item") + b.go("files#/?path=/home/foouser") + self.assert_last_breadcrumb("foouser") + b.click("#dropdown-menu") + b.click("#paste-item") + b.wait_visible("#paste-owner-modal") + b.select_from_dropdown("#paste-owner-select", "admin (original owner)") + b.click("#paste-owner-modal button.pf-m-primary") + b.wait_in_text("[data-item='adminfile'] .item-owner", "admin") + self.assertEqual(m.execute("cat /home/foouser/adminfile"), "admintext") + + # Copying a directory should change the owner recursively + b.go("files#/?path=/home/admin") + self.assert_last_breadcrumb("admin") + b.click("[data-item='kbdCopy']") + b.click("#dropdown-menu") + b.click("#copy-item") + b.go("files#/?path=/home/foouser") + self.assert_last_breadcrumb("foouser") + b.click("#dropdown-menu") + b.click("#paste-item") + b.wait_visible("#paste-owner-modal") + b.select_from_dropdown("#paste-owner-select", "root") + b.click("#paste-owner-modal button.pf-m-primary") + b.wait_in_text("[data-item='kbdCopy'] .item-owner", "root") + b.go("files#/?path=/home/foouser/kbdCopy") + self.assert_last_breadcrumb("kbdCopy") + b.wait_in_text("[data-item='loaded'] .item-owner", "root") + b.wait_in_text("[data-item='newfile'] .item-owner", "root") + + # Copy file with different user, group + b.go("files#/?path=/home/admin") + self.assert_last_breadcrumb("admin") + m.write("/home/admin/adminfoofile", "adminfoo", owner="admin:foouser") + b.click("[data-item='adminfoofile']") + b.click("#dropdown-menu") + b.click("#copy-item") + b.go("files#/?path=/home/foouser") + self.assert_last_breadcrumb("foouser") + b.click("#dropdown-menu") + b.click("#paste-item") + b.wait_visible("#paste-owner-modal") + b.select_from_dropdown("#paste-owner-select", "admin:foouser (original owner)") + b.click("#paste-owner-modal button.pf-m-primary") + b.wait_in_text("[data-item='adminfoofile'] .item-owner", "admin:foouser") + self.assertEqual(m.execute("cat /home/foouser/adminfoofile"), "adminfoo") + + b.go("files#/?path=/home/admin") + self.assert_last_breadcrumb("admin") + b.click("[data-item='newdir']") + b.click("#dropdown-menu") + b.click("#copy-item") + b.go("files#/?path=/home/foouser") + self.assert_last_breadcrumb("foouser") + b.click("#dropdown-menu") + b.click("#paste-item") + b.wait_visible("#paste-owner-modal") + b.click("button.pf-m-link") + b.wait_not_present("#paste-owner-modal") + b.wait_not_present("[data-item='newdir']") + + # Test where copy fails + # Add +i attribute on the directory which makes it immutable + m.execute(""" + runuser -u foouser mkdir /home/foouser/copyfail + chattr +i /home/foouser/copyfail + """) + self.addCleanup(m.execute, "chattr -i /home/foouser/copyfail") + b.go("files#/?path=/home/admin") + self.assert_last_breadcrumb("admin") + b.click("[data-item='newfile']") + b.click("#dropdown-menu") + b.click("#copy-item") + b.go("files#/?path=/home/foouser/copyfail") + self.assert_last_breadcrumb("copyfail") + b.click("#dropdown-menu") + b.click("#paste-item") + b.click("#paste-owner-modal button.pf-m-primary") + b.wait_in_text("h4.pf-v5-c-alert__title", "Pasting failed") + # check that alert has a sensible error message + self.assertIn("/home/foouser/copyfail/newfile", b.text(".pf-v5-c-alert__description")) + self.assertIn("Operation not permitted", b.text(".pf-v5-c-alert__description")) + b.wait_not_present("[data-item='copyfail']") + b.click(".pf-v5-c-alert__action button") + + # Multiple ownerships are kept when selecting "keep original owners" + m.execute("runuser -u foouser mkdir /home/foouser/copydiff") + b.go("files#/?path=/home/admin") + self.assert_last_breadcrumb("admin") + b.mouse("[data-item='adminfile']", "click") + b.mouse("[data-item='adminfoofile']", "click", ctrlKey=True) + b.click("#dropdown-menu") + b.click("#copy-item") + b.go("files#/?path=/home/foouser/copydiff") + self.assert_last_breadcrumb("copydiff") + b.click("#dropdown-menu") + b.click("#paste-item") + b.wait_visible("#paste-owner-modal") + b.select_from_dropdown("#paste-owner-select", "keep original owners (admin, admin:foouser)") + b.click("#paste-owner-modal button.pf-m-primary") + b.wait_in_text("[data-item='adminfile'] .item-owner", "admin") + b.wait_in_text("[data-item='adminfoofile'] .item-owner", "admin:foouser") + self.assertEqual(m.execute("cat /home/foouser/copydiff/adminfile"), "admintext") + self.assertEqual(m.execute("cat /home/foouser/copydiff/adminfoofile"), "adminfoo") + + # Multiple directory trees with multiple ownerships + # Ownership is not changed when selecting "keep original owners" + m.execute(""" + mkdir /home/admin/dir1 /home/admin/dir2 + echo 'hello' > /home/admin/dir1/hello.txt + echo 'goodbye' > /home/admin/dir1/goodbye.txt + echo 'hi' > /home/admin/dir2/hi.txt + echo 'bye' > /home/admin/dir2/bye.txt + chown -R admin:admin /home/admin/dir1 + chown -R foouser:foouser /home/admin/dir2 + """) + b.go("files#/?path=/home/admin") + self.assert_last_breadcrumb("admin") + b.wait_in_text("[data-item='dir1'] .item-owner", "admin") + b.wait_in_text("[data-item='dir2'] .item-owner", "foouser") + b.mouse("[data-item='dir1']", "click") + b.mouse("[data-item='dir2']", "click", ctrlKey=True) + b.click("#dropdown-menu") + b.click("#copy-item") + b.go("files#/?path=/home/foouser") + self.assert_last_breadcrumb("foouser") + b.click("#dropdown-menu") + b.click("#paste-item") + b.wait_visible("#paste-owner-modal") + b.select_from_dropdown("#paste-owner-select", "keep original owners (admin, foouser)") + b.click("#paste-owner-modal button.pf-m-primary") + b.wait_in_text("[data-item='dir1'] .item-owner", "admin") + b.wait_in_text("[data-item='dir2'] .item-owner", "foouser") + self.assertEqual(m.execute("cat /home/foouser/dir1/hello.txt"), "hello\n") + self.assertEqual(m.execute("cat /home/foouser/dir1/goodbye.txt"), "goodbye\n") + self.assertEqual(m.execute("cat /home/foouser/dir2/hi.txt"), "hi\n") + self.assertEqual(m.execute("cat /home/foouser/dir2/bye.txt"), "bye\n") + self.assert_owner("/home/foouser/dir1/hello.txt", "admin:admin") + self.assert_owner("/home/foouser/dir1/goodbye.txt", "admin:admin") + self.assert_owner("/home/foouser/dir2/hi.txt", "foouser:foouser") + self.assert_owner("/home/foouser/dir2/bye.txt", "foouser:foouser") + + # Multiple directories with mixed ownership, chown recursively to same owner after pasting + m.execute("rm -rf /home/foouser/dir1 /home/foouser/dir2") + b.wait_not_present("[data-item='dir1']") + b.wait_not_present("[data-item='dir12]") + b.click("#dropdown-menu") + b.click("#paste-item") + b.wait_visible("#paste-owner-modal") + b.select_from_dropdown("#paste-owner-select", "foouser") + b.click("#paste-owner-modal button.pf-m-primary") + b.wait_in_text("[data-item='dir1'] .item-owner", "foouser") + b.wait_in_text("[data-item='dir2'] .item-owner", "foouser") + self.assertEqual(m.execute("cat /home/foouser/dir1/hello.txt"), "hello\n") + self.assertEqual(m.execute("cat /home/foouser/dir1/goodbye.txt"), "goodbye\n") + self.assertEqual(m.execute("cat /home/foouser/dir2/hi.txt"), "hi\n") + self.assertEqual(m.execute("cat /home/foouser/dir2/bye.txt"), "bye\n") + self.assert_owner("/home/foouser/dir1/hello.txt", "foouser:foouser") + self.assert_owner("/home/foouser/dir1/goodbye.txt", "foouser:foouser") + self.assert_owner("/home/foouser/dir2/hi.txt", "foouser:foouser") + self.assert_owner("/home/foouser/dir2/bye.txt", "foouser:foouser") @testlib.skipBrowser(".upload_files() doesn't work on Firefox", "firefox") def testUpload(self) -> None: