🏡 index : quine-global/quine-anaconda-webui.git

/*
 * Copyright (C) 2023 Red Hat, Inc.
 * SPDX-License-Identifier: LGPL-2.1-or-later
 */

import cockpit from "cockpit";

import { checkIfArraysAreEqual } from "./utils.js";

export const requestsFromDbus = (requests) => {
    return requests.map(request => Object.entries(request).reduce((acc, [key, value]) => ({ ...acc, [key]: value.v }), {}));
};

export const requestsToDbus = (requests) => {
    return requests.map(row => {
        return {
            "device-spec": cockpit.variant("s", row["device-spec"] || ""),
            "format-options": cockpit.variant("s", row["format-options"] || ""),
            "format-type": cockpit.variant("s", row["format-type"] || ""),
            "mount-options": cockpit.variant("s", row["mount-options"] || ""),
            "mount-point": cockpit.variant("s", row["mount-point"] || ""),
            reformat: cockpit.variant("b", !!row.reformat),
        };
    });
};

/* Get the list of IDs of all the ancestors of the given device
 * (excluding the device itself)
 * @param {Object} deviceData - The device data object
 * @param {string} device - The ID of the device
 * @returns {Array}
 */
export const getDeviceAncestors = (deviceData, device) => {
    const ancestors = [];
    const deviceParents = deviceData[device]?.parents?.v || [];

    deviceParents.forEach(parent => {
        ancestors.push(parent);
        ancestors.push(...getDeviceAncestors(deviceData, parent));
    });

    return ancestors;
};

/* Get the list of IDs of all the descendants of the given device
 * (excluding the device itself)
 * @param {string} device - The ID of the device
 * @param {Object} deviceData - The device data object
 * @returns {Array}
 */
export const getDeviceChildren = ({ device, deviceData }) => {
    const deviceChildren = deviceData[device]?.children?.v || [];

    return deviceChildren.reduce((acc, child) => {
        return [...acc, child, ...getDeviceChildren({ device: child, deviceData })];
    }, []);
};

/* Check if a device is encrypted (LUKS format or encrypted Stratis pool)
 * @param {Object} device - The device data object
 * @returns {boolean}
 */
export const isDeviceEncrypted = (device) => {
    if (!device) {
        return false;
    }

    return (
        device.formatData?.type?.v === "luks" ||
        (device.type?.v === "stratis pool" && device.attrs?.v?.encrypted === "True")
    );
};

/* Check if an encrypted device has its encryption key set
 * @param {Object} device - The device data object
 * @returns {boolean}
 */
export const deviceHasEncryptionKey = (device) => {
    if (!device) {
        return false;
    }

    if (device.formatData?.type?.v === "luks") {
        return device.formatData.attrs?.v?.has_key === "True";
    }
    if (device.type?.v === "stratis pool" && device.attrs?.v?.encrypted === "True") {
        return device.attrs?.v?.has_key === "True";
    }
    return false;
};

/* Get the list of IDs of all locked encrypted devices
 * @param {Array} selectedDisks - The list of selected disks
 * @param {Object} deviceData - The device data object
 * @returns {Array}
 */
export const getLockedEncryptedDevices = (selectedDisks, deviceData) => {
    const relevantDevs = selectedDisks.flatMap(disk => (
        [disk, ...getDeviceChildren({ device: disk, deviceData })]
    ));

    return Object.keys(deviceData).filter(d => {
        return (
            relevantDevs.includes(d) &&
            isDeviceEncrypted(deviceData[d]) &&
            !deviceHasEncryptionKey(deviceData[d])
        );
    });
};

/* Check if the requests array contains duplicate entries
 * @param {Array} requests - The list of requests from a partitioning
 * @param {string} fieldName - The ID of the field to check for duplicates, ex: "mount-point"
 * @returns {boolean}
 */
export const hasDuplicateFields = (requests, fieldName) => {
    let _requests = requests;
    if (fieldName === "mount-point") {
        /* Swap devices have empty mount points and multiple swap devices are allowed
         * so we need to remove these before checking for duplicates
         */
        _requests = requests.filter(r => r["format-type"] !== "swap");
    }
    const items = _requests.map(r => r[fieldName]);

    return new Set(items).size !== items.length;
};

/* Check if the requests array contains duplicate entries for a given field value
 * @param {Array} requests - The list of requests from a partitioning
 * @param {string} fieldName - The name of the field to check for duplicates, ex: "mount-point"
 * @param {string} fieldValue - The value of the field to check for duplicates, ex: "/boot"
 * @returns {boolean}
 */
export const isDuplicateRequestField = (requests, fieldName, fieldValue) => {
    return requests.filter((request) => request[fieldName] === fieldValue).length > 1;
};

export const getDeviceByPath = (deviceData, path) => {
    const devices = Object.keys(deviceData).filter(d => deviceData[d].path?.v === path || deviceData[d].links?.v.includes(path));
    // If multiple devices can have the same path, pick the ancestor device
    return devices.find(device => !getDeviceAncestors(deviceData, device).some(ancestor => devices.includes(ancestor)));
};

export const getDeviceByName = (deviceData, name) => {
    return Object.keys(deviceData).find(d => deviceData[d].name?.v === name);
};

/* Check if a device is encrypted or resides on an encrypted device
 * @param {Object} deviceData - The device data object
 * @param {string} device - The ID of the device
 * @returns {boolean}
 * */
export const isOnEncryptedDevice = (deviceData, device) => {
    if (deviceData[device].type.v === "luks/dm-crypt") {
        return true;
    }

    if (isDeviceEncrypted(deviceData[device])) {
        return true;
    }

    const parent = deviceData[device].parents.v?.[0];

    if (parent) {
        return isOnEncryptedDevice(deviceData, parent);
    } else {
        return false;
    }
};

/* Get the parent partitions IDs of a given device
 * @param {Object} deviceData - The device data object
 * @param {string} device - The ID of the device
 * @returns {Array}
 * */
export const getParentPartitions = (deviceData, device) => {
    if (deviceData[device].type.v === "partition") {
        return [device];
    }

    return deviceData[device].parents.v.map(parent => getParentPartitions(deviceData, parent)).flat(Infinity);
};

/* Get the parent disk ID of a given device
 * @param {Object} deviceData - The device data object
 * @param {string} device - The ID of the device
 * @returns {string}
 * */
export const getDeviceDisk = (deviceData, device) => {
    const parents = getDeviceAncestors(deviceData, device);
    const parentDisk = parents.find(parent => deviceData[parent].formatData.type.v === "disklabel");

    return parentDisk;
};

/* Check if a device has parents of a given type
 * @param {Object} deviceData - The device data object
 * @param {string} device - The ID of the device
 * @param {string} type - The type of the device to check for
 * @returns {boolean}
 * */
export const checkDeviceOnStorageType = (deviceData, device, type) => {
    if (deviceData[device].type.v === type) {
        return true;
    }

    return deviceData[device].parents.v?.some(parent => checkDeviceOnStorageType(deviceData, parent, type));
};

export const getDeviceTypeInfo = (device) => {
    const deviceType = device.type.v;
    const deviceFormatType = device.formatData.type.v;

    if (deviceType === "partition") {
        return deviceFormatType;
    }
    return deviceType;
};

/* Match the units to their respective sizes */
export const unitMultiplier = {
    B: 1,
    KB: 1000,
    MB: 1000000,
    // eslint-disable-next-line sort-keys
    GB: 1000000000,
    TB: 1000000000000,
};

export const bootloaderTypes = ["efi", "biosboot", "appleboot", "prepboot"];

const _ = cockpit.gettext;

/* Blivet reports plain FAT without ESP flags as "vfat", not "efi". */
const VFAT_FORMAT_TYPE = "vfat";

/**
 * Check whether a device satisfies the format constraint for a mount point.
 *
 * The backend's required-filesystem-type field actually carries blivet format
 * types (efi, biosboot, ext4, ...). Bootloader format types such as "efi" are
 * not filesystem types; use constraint.description (from GetFormatTypeData)
 * when presenting them to the user.
 *
 * @returns {[boolean, string]} [invalid, errorMessage]
 */
export const getMountPointFormatConstraintError = ({ constraint, device, devices, mountPoint }) => {
    const requiredFormatType = constraint?.["required-filesystem-type"]?.v;
    const deviceFormatType = devices[device]?.formatData?.type?.v;

    if (!requiredFormatType || !deviceFormatType || deviceFormatType === requiredFormatType) {
        return [false, ""];
    }

    const formatLabel = constraint.description || requiredFormatType;

    if (requiredFormatType === "efi" && deviceFormatType === VFAT_FORMAT_TYPE) {
        return [true,
            cockpit.format(
                _("'$0' must be on an EFI System Partition. The selected partition uses a FAT filesystem, but is not marked as an ESP. Set the partition type to EFI System and enable the esp flag."),
                mountPoint
            )];
    }

    if (bootloaderTypes.includes(requiredFormatType)) {
        return [true, cockpit.format(_("'$0' must be assigned to $1"), mountPoint, formatLabel)];
    }

    return [true, cockpit.format(_("'$0' must be on a device formatted to '$1'"), mountPoint, formatLabel)];
};

/* Filter requests to remove devices that should not be sent to the backend.
 * Keeps:
 * - Requests with reformat set to true or with specified mount points
 *
 * Note: Devices whose parent is reformatted are already filtered by the frontend
 * (via isChildReformatValid in isReformatInvalid), so we don't need to check here.
 *
 * Note: The same filtering happens in TUI as well.
 * (https://github.com/rhinstaller/anaconda/blob/98a9e05674dcf48b3545d6a4f5977e733c813acf/pyanaconda/ui/tui/spokes/storage.py#L717-L725)
 *
 * @param {Array} requests - Array of request objects (DBus variant format or plain)
 * @returns {Array} Filtered requests (same format as input)
 */
export const filterPartitioningRequests = (requests) => {
    return requests.filter(request => {
        // Handle both DBus variant format and plain format
        const mountPoint = request["mount-point"];
        const hasMountPoint = mountPoint &&
                             mountPoint !== "none";

        // Keep requests with mount points or reformatted
        return request.reformat || hasMountPoint;
    });
};

export const systemMountPoints = ["/", "/usr"];

export const isBootloaderDevice = ({ device, devices }) => {
    const fmt = devices[device].formatData?.type?.v;
    const mntPt = devices[device].formatData?.attrs?.v?.["mount-point"];

    return bootloaderTypes.includes(fmt) && !mntPt;
};

export const getUsableDevicesManualPartitioning = ({ devices, selectedDisks }) => {
    // Calculate usable devices for partitioning by replicating the logic in the backend
    // FIXME: Create a backend API for that
    // https://github.com/rhinstaller/anaconda/blob/f79f019e22c87dc388dbcc637a7a5612a3c223a7/pyanaconda/modules/storage/partitioning/manual/manual_module.py#L127
    const usableDevices = Object.keys(devices).filter(device => {
        const children = devices[device].children.v;
        const ancestors = getDeviceAncestors(devices, device);

        if (
            (children.length > 0 || devices[device].attrs.v.isleaf === "False") &&
                devices[device].type.v !== "btrfs subvolume"
        ) {
            return false;
        }

        // We don't want to allow to use snapshots in mount point assignment.
        if (devices[device].type.v === "btrfs snapshot") {
            return false;
        }

        // Is the device usable?
        if (devices[device].protected.v || devices[device].size.v === 0) {
            return false;
        }

        // All device's parent disks have to be in selected disks.
        // Or the device itself has to be in selected disks.
        return selectedDisks.some(disk => [device, ...ancestors].includes(disk));
    });

    return usableDevices;
};

/* Get disks on which given existing OS is located
 * @param {Object} deviceData - The device data object
 * @param {Object} osData - the existing OS data object
 * @returns {Array}
 * */
export const getOSDisks = (deviceData, osData) => {
    return osData.devices.v.map(deviceId => getDeviceAncestors(deviceData, deviceId)).flat()
            .filter(dev => deviceData[dev]["is-disk"].v)
            .reduce((uniqueDisks, disk) => uniqueDisks.includes(disk) ? uniqueDisks : [...uniqueDisks, disk], []);
};

/* Check if the OS is located completely on the disks
 * @param {Object} deviceData - The device data object
 * @param {Array[string]} selectedDisks - a list of disk names
 * @param {Object} osData - the existing OS data object
 * @returns {boolean}
 * */
export const isCompleteOSOnDisks = (deviceData, selectedDisks, osData) => {
    return getOSDisks(deviceData, osData).every(disk => selectedDisks.includes(disk));
};

/* Check if the there is a reusable Fedora + Windows installation
 * @param {Object} deviceData - The device data object
 * @param {Array[string]} selectedDisks - a list of disk names
 * @param {Array[Object]} existingSystems - a list of OS data objects
 * @returns {boolean}
 * */
export const hasReusableFedoraWithWindowsOS = (deviceData, selectedDisks, existingSystems) => {
    const completeSystems = existingSystems
            .filter(osdata => isCompleteOSOnDisks(deviceData, selectedDisks, osdata));
    const fedoraSystems = completeSystems
            .filter(osdata => osdata["os-name"].v.includes("Fedora"));
    const windowsSystems = completeSystems
            .filter(osdata => osdata["os-name"].v.includes("Windows"));
    return (
        fedoraSystems.length === 1 &&
        windowsSystems.length === 1 &&
        checkIfArraysAreEqual(
            getOSDisks(deviceData, fedoraSystems[0]),
            getOSDisks(deviceData, windowsSystems[0])
        )
    );
};

/**
 * Select default disks for the partitioning (mirrors Anaconda ``select_default_disks`` for
 * the Web UI disk list).
 *
 * If some selected disks are usable, keep those (filtered to usable). Otherwise select a disk
 * only when there is exactly one usable non-ignored candidate.
 */
export const selectDefaultDisks = ({ ignoredDisks, selectedDisks, usableDisks }) => {
    const availableDisks = usableDisks.filter(disk => !ignoredDisks.includes(disk));

    if (selectedDisks.length && selectedDisks.some(disk => usableDisks.includes(disk))) {
        return selectedDisks.filter(disk => usableDisks.includes(disk));
    }
    if (availableDisks.length === 1) {
        return availableDisks;
    }
    return [];
};

/**
 * Selected disks that also appear in the module usable list. SelectedDisks can still name
 * removed disks after a rescan.
 *
 * @param {string[]} selectedDisks
 * @param {string[]} usableDisks
 * @returns {string[]}
 */
export const intersectSelectedDisksWithUsable = (selectedDisks, usableDisks) => {
    if (!usableDisks) {
        return selectedDisks;
    }
    return selectedDisks.filter(disk => usableDisks.includes(disk));
};