New: Displaying folder-based permissions in UI rather than file-based permissions and with selectable sane presets

Fixed: Preserve setgid when applying unix permissions
pull/1807/head
Taloth Saldono 4 years ago committed by Qstick
parent 40df88e37c
commit e2a0b63256

@ -5,6 +5,10 @@
align-items: center; align-items: center;
} }
.editableContainer {
width: 100%;
}
.hasError { .hasError {
composes: hasError from '~Components/Form/Input.css'; composes: hasError from '~Components/Form/Input.css';
} }
@ -22,6 +26,16 @@
margin-left: 12px; margin-left: 12px;
} }
.dropdownArrowContainerEditable {
position: absolute;
top: 0;
right: 0;
padding-right: 17px;
width: 30%;
height: 35px;
text-align: right;
}
.dropdownArrowContainerDisabled { .dropdownArrowContainerDisabled {
composes: dropdownArrowContainer; composes: dropdownArrowContainer;

@ -17,6 +17,7 @@ import getUniqueElememtId from 'Utilities/getUniqueElementId';
import { isMobile as isMobileUtil } from 'Utilities/mobile'; import { isMobile as isMobileUtil } from 'Utilities/mobile';
import HintedSelectInputOption from './HintedSelectInputOption'; import HintedSelectInputOption from './HintedSelectInputOption';
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue'; import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import TextInput from './TextInput';
import styles from './EnhancedSelectInput.css'; import styles from './EnhancedSelectInput.css';
function isArrowKey(keyCode) { function isArrowKey(keyCode) {
@ -169,11 +170,21 @@ class EnhancedSelectInput extends Component {
} }
} }
onFocus = () => {
if (this.state.isOpen) {
this._removeListener();
this.setState({ isOpen: false });
}
}
onBlur = () => { onBlur = () => {
// Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox) if (!this.props.isEditable) {
const origIndex = getSelectedIndex(this.props); // Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox)
if (origIndex !== this.state.selectedIndex) { const origIndex = getSelectedIndex(this.props);
this.setState({ selectedIndex: origIndex });
if (origIndex !== this.state.selectedIndex) {
this.setState({ selectedIndex: origIndex });
}
} }
} }
@ -297,16 +308,19 @@ class EnhancedSelectInput extends Component {
const { const {
className, className,
disabledClassName, disabledClassName,
name,
value, value,
values, values,
isDisabled, isDisabled,
isEditable,
isFetching, isFetching,
hasError, hasError,
hasWarning, hasWarning,
valueOptions, valueOptions,
selectedValueOptions, selectedValueOptions,
selectedValueComponent: SelectedValueComponent, selectedValueComponent: SelectedValueComponent,
optionComponent: OptionComponent optionComponent: OptionComponent,
onChange
} = this.props; } = this.props;
const { const {
@ -332,52 +346,94 @@ class EnhancedSelectInput extends Component {
whitelist={['width']} whitelist={['width']}
onMeasure={this.onMeasure} onMeasure={this.onMeasure}
> >
<Link {
className={classNames( isEditable ?
className, <div
hasError && styles.hasError, className={styles.editableContainer}
hasWarning && styles.hasWarning, >
isDisabled && disabledClassName <TextInput
)} className={className}
isDisabled={isDisabled} name={name}
onBlur={this.onBlur} value={value}
onKeyDown={this.onKeyDown} readOnly={isDisabled}
onPress={this.onPress} hasError={hasError}
> hasWarning={hasWarning}
<SelectedValueComponent onFocus={this.onFocus}
value={value} onBlur={this.onBlur}
values={values} onChange={onChange}
{...selectedValueOptions} />
{...selectedOption} <Link
isDisabled={isDisabled} className={classNames(
isMultiSelect={isMultiSelect} styles.dropdownArrowContainerEditable,
> isDisabled ?
{selectedOption ? selectedOption.value : null} styles.dropdownArrowContainerDisabled :
</SelectedValueComponent> styles.dropdownArrowContainer)
}
<div onPress={this.onPress}
className={isDisabled ? >
styles.dropdownArrowContainerDisabled : {
styles.dropdownArrowContainer isFetching &&
} <LoadingIndicator
> className={styles.loading}
size={20}
{ />
isFetching && }
<LoadingIndicator
className={styles.loading} {
size={20} !isFetching &&
/> <Icon
} name={icons.CARET_DOWN}
/>
{ }
!isFetching && </Link>
<Icon </div> :
name={icons.CARET_DOWN} <Link
/> className={classNames(
} className,
</div> hasError && styles.hasError,
</Link> hasWarning && styles.hasWarning,
isDisabled && disabledClassName
)}
isDisabled={isDisabled}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
onPress={this.onPress}
>
<SelectedValueComponent
value={value}
values={values}
{...selectedValueOptions}
{...selectedOption}
isDisabled={isDisabled}
isMultiSelect={isMultiSelect}
>
{selectedOption ? selectedOption.value : null}
</SelectedValueComponent>
<div
className={isDisabled ?
styles.dropdownArrowContainerDisabled :
styles.dropdownArrowContainer
}
>
{
isFetching &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
{
!isFetching &&
<Icon
name={icons.CARET_DOWN}
/>
}
</div>
</Link>
}
</Measure> </Measure>
</div> </div>
)} )}
@ -502,6 +558,7 @@ EnhancedSelectInput.propTypes = {
values: PropTypes.arrayOf(PropTypes.object).isRequired, values: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool.isRequired, isDisabled: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired, isFetching: PropTypes.bool.isRequired,
isEditable: PropTypes.bool.isRequired,
hasError: PropTypes.bool, hasError: PropTypes.bool,
hasWarning: PropTypes.bool, hasWarning: PropTypes.bool,
valueOptions: PropTypes.object.isRequired, valueOptions: PropTypes.object.isRequired,
@ -517,6 +574,7 @@ EnhancedSelectInput.defaultProps = {
disabledClassName: styles.isDisabled, disabledClassName: styles.isDisabled,
isDisabled: false, isDisabled: false,
isFetching: false, isFetching: false,
isEditable: false,
valueOptions: {}, valueOptions: {},
selectedValueOptions: {}, selectedValueOptions: {},
selectedValueComponent: HintedSelectInputSelectedValue, selectedValueComponent: HintedSelectInputSelectedValue,

@ -25,6 +25,7 @@ import SeriesTypeSelectInput from './SeriesTypeSelectInput';
import TagInputConnector from './TagInputConnector'; import TagInputConnector from './TagInputConnector';
import TextInput from './TextInput'; import TextInput from './TextInput';
import TextTagInputConnector from './TextTagInputConnector'; import TextTagInputConnector from './TextTagInputConnector';
import UMaskInput from './UMaskInput';
import styles from './FormInputGroup.css'; import styles from './FormInputGroup.css';
function getComponent(type) { function getComponent(type) {
@ -92,6 +93,9 @@ function getComponent(type) {
case inputTypes.TEXT_TAG: case inputTypes.TEXT_TAG:
return TextTagInputConnector; return TextTagInputConnector;
case inputTypes.UMASK:
return UMaskInput;
default: default:
return TextInput; return TextInput;
} }
@ -199,7 +203,7 @@ function FormInputGroup(props) {
} }
{ {
!checkInput && helpTextWarning && (!checkInput || helpText) && helpTextWarning &&
<FormInputHelpText <FormInputHelpText
text={helpTextWarning} text={helpTextWarning}
isWarning={true} isWarning={true}

@ -0,0 +1,53 @@
.inputWrapper {
display: flex;
}
.inputFolder {
composes: input from '~Components/Form/Input.css';
max-width: 100px;
}
.inputUnitWrapper {
position: relative;
width: 100%;
}
.inputUnit {
composes: inputUnit from '~Components/Form/FormInputGroup.css';
right: 40px;
font-family: $monoSpaceFontFamily;
}
.unit {
font-family: $monoSpaceFontFamily;
}
.details {
margin-top: 5px;
margin-left: 17px;
line-height: 20px;
> div {
display: flex;
label {
flex: 0 0 50px;
}
.value {
width: 50px;
text-align: right;
}
.unit {
width: 90px;
text-align: right;
}
}
}
.readOnly {
background-color: #eee;
}

@ -0,0 +1,133 @@
/* eslint-disable no-bitwise */
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import EnhancedSelectInput from './EnhancedSelectInput';
import styles from './UMaskInput.css';
const umaskOptions = [
{
key: '755',
value: '755 - Owner write, Everyone else read',
hint: 'drwxr-xr-x'
},
{
key: '775',
value: '775 - Owner & Group write, Other read',
hint: 'drwxrwxr-x'
},
{
key: '770',
value: '770 - Owner & Group write',
hint: 'drwxrwx---'
},
{
key: '750',
value: '750 - Owner write, Group read',
hint: 'drwxr-x---'
},
{
key: '777',
value: '777 - Everyone write',
hint: 'drwxrwxrwx'
}
];
function formatPermissions(permissions) {
const hasSticky = permissions & 0o1000;
const hasSetGID = permissions & 0o2000;
const hasSetUID = permissions & 0o4000;
let result = '';
for (let i = 0; i < 9; i++) {
const bit = (permissions & (1 << i)) !== 0;
let digit = bit ? 'xwr'[i % 3] : '-';
if (i === 6 && hasSetUID) {
digit = bit ? 's' : 'S';
} else if (i === 3 && hasSetGID) {
digit = bit ? 's' : 'S';
} else if (i === 0 && hasSticky) {
digit = bit ? 't' : 'T';
}
result = digit + result;
}
return result;
}
class UMaskInput extends Component {
//
// Render
render() {
const {
name,
value,
onChange
} = this.props;
const valueNum = parseInt(value, 8);
const umaskNum = 0o777 & ~valueNum;
const umask = umaskNum.toString(8).padStart(4, '0');
const folderNum = 0o777 & ~umaskNum;
const folder = folderNum.toString(8).padStart(3, '0');
const fileNum = 0o666 & ~umaskNum;
const file = fileNum.toString(8).padStart(3, '0');
const unit = formatPermissions(folderNum);
const values = umaskOptions.map((v) => {
return { ...v, hint: <span className={styles.unit}>{v.hint}</span> };
});
return (
<div>
<div className={styles.inputWrapper}>
<div className={styles.inputUnitWrapper}>
<EnhancedSelectInput
name={name}
value={value}
values={values}
isEditable={true}
onChange={onChange}
/>
<div className={styles.inputUnit}>
d{unit}
</div>
</div>
</div>
<div className={styles.details}>
<div>
<label>UMask</label>
<div className={styles.value}>{umask}</div>
</div>
<div>
<label>Folder</label>
<div className={styles.value}>{folder}</div>
<div className={styles.unit}>d{formatPermissions(folderNum)}</div>
</div>
<div>
<label>File</label>
<div className={styles.value}>{file}</div>
<div className={styles.unit}>{formatPermissions(fileNum)}</div>
</div>
</div>
</div>
);
}
}
UMaskInput.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onFocus: PropTypes.func,
onBlur: PropTypes.func
};
export default UMaskInput;

@ -20,6 +20,7 @@ export const DYNAMIC_SELECT = 'dynamicSelect';
export const TAG = 'tag'; export const TAG = 'tag';
export const TEXT = 'text'; export const TEXT = 'text';
export const TEXT_TAG = 'textTag'; export const TEXT_TAG = 'textTag';
export const UMASK = 'umask';
export const all = [ export const all = [
AUTO_COMPLETE, AUTO_COMPLETE,
@ -43,5 +44,6 @@ export const all = [
SERIES_TYPE_SELECT, SERIES_TYPE_SELECT,
TAG, TAG,
TEXT, TEXT,
TEXT_TAG TEXT_TAG,
UMASK
]; ];

@ -381,17 +381,32 @@ class MediaManagement extends Component {
advancedSettings={advancedSettings} advancedSettings={advancedSettings}
isAdvanced={true} isAdvanced={true}
> >
<FormLabel>File chmod mode</FormLabel> <FormLabel>chmod Folder</FormLabel>
<FormInputGroup
type={inputTypes.UMASK}
name="chmodFolder"
helpText="Octal, applied during import/rename to media folders and files (without execute bits)"
helpTextWarning="This only works if the user running Lidarr is the owner of the file. It's better to ensure the download client sets the permissions properly."
onChange={onInputChange}
{...settings.chmodFolder}
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>chown Group</FormLabel>
<FormInputGroup <FormInputGroup
type={inputTypes.TEXT} type={inputTypes.TEXT}
name="fileChmod" name="chownGroup"
helpTexts={[ helpText="Group name or gid. Use gid for remote file systems."
'Octal, applied to media files when imported/renamed by Lidarr', helpTextWarning="This only works if the user running Lidarr is the owner of the file. It's better to ensure the download client uses the same group as Lidarr."
'The same mode is applied to movie/sub folders with the execute bit added, e.g., 0644 becomes 0755' values={fileDateOptions}
]}
onChange={onInputChange} onChange={onInputChange}
{...settings.fileChmod} {...settings.chownGroup}
/> />
</FormGroup> </FormGroup>
</FieldSet> </FieldSet>

@ -8,11 +8,11 @@ namespace Lidarr.Api.V1.Config
{ {
public class MediaManagementConfigModule : LidarrConfigModule<MediaManagementConfigResource> public class MediaManagementConfigModule : LidarrConfigModule<MediaManagementConfigResource>
{ {
public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator, FileChmodValidator fileChmodValidator) public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator, FolderChmodValidator folderChmodValidator)
: base(configService) : base(configService)
{ {
SharedValidator.RuleFor(c => c.RecycleBinCleanupDays).GreaterThanOrEqualTo(0); SharedValidator.RuleFor(c => c.RecycleBinCleanupDays).GreaterThanOrEqualTo(0);
SharedValidator.RuleFor(c => c.FileChmod).SetValidator(fileChmodValidator).When(c => !string.IsNullOrEmpty(c.FileChmod) && (OsInfo.IsLinux || OsInfo.IsOsx)); SharedValidator.RuleFor(c => c.ChmodFolder).SetValidator(folderChmodValidator).When(c => !string.IsNullOrEmpty(c.ChmodFolder) && (OsInfo.IsLinux || OsInfo.IsOsx));
SharedValidator.RuleFor(c => c.RecycleBin).IsValidPath().SetValidator(pathExistsValidator).When(c => !string.IsNullOrWhiteSpace(c.RecycleBin)); SharedValidator.RuleFor(c => c.RecycleBin).IsValidPath().SetValidator(pathExistsValidator).When(c => !string.IsNullOrWhiteSpace(c.RecycleBin));
SharedValidator.RuleFor(c => c.MinimumFreeSpaceWhenImporting).GreaterThanOrEqualTo(100); SharedValidator.RuleFor(c => c.MinimumFreeSpaceWhenImporting).GreaterThanOrEqualTo(100);
} }

@ -19,7 +19,8 @@ namespace Lidarr.Api.V1.Config
public AllowFingerprinting AllowFingerprinting { get; set; } public AllowFingerprinting AllowFingerprinting { get; set; }
public bool SetPermissionsLinux { get; set; } public bool SetPermissionsLinux { get; set; }
public string FileChmod { get; set; } public string ChmodFolder { get; set; }
public string ChownGroup { get; set; }
public bool SkipFreeSpaceCheckWhenImporting { get; set; } public bool SkipFreeSpaceCheckWhenImporting { get; set; }
public int MinimumFreeSpaceWhenImporting { get; set; } public int MinimumFreeSpaceWhenImporting { get; set; }
@ -46,7 +47,8 @@ namespace Lidarr.Api.V1.Config
AllowFingerprinting = model.AllowFingerprinting, AllowFingerprinting = model.AllowFingerprinting,
SetPermissionsLinux = model.SetPermissionsLinux, SetPermissionsLinux = model.SetPermissionsLinux,
FileChmod = model.FileChmod, ChmodFolder = model.ChmodFolder,
ChownGroup = model.ChownGroup,
SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting, SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting,
MinimumFreeSpaceWhenImporting = model.MinimumFreeSpaceWhenImporting, MinimumFreeSpaceWhenImporting = model.MinimumFreeSpaceWhenImporting,

@ -37,7 +37,7 @@ namespace NzbDrone.Common.Disk
public abstract long? GetAvailableSpace(string path); public abstract long? GetAvailableSpace(string path);
public abstract void InheritFolderPermissions(string filename); public abstract void InheritFolderPermissions(string filename);
public abstract void SetEveryonePermissions(string filename); public abstract void SetEveryonePermissions(string filename);
public abstract void SetPermissions(string path, string mask); public abstract void SetPermissions(string path, string mask, string group);
public abstract void CopyPermissions(string sourcePath, string targetPath); public abstract void CopyPermissions(string sourcePath, string targetPath);
public abstract long? GetTotalSize(string path); public abstract long? GetTotalSize(string path);
@ -534,7 +534,7 @@ namespace NzbDrone.Common.Disk
} }
} }
public virtual bool IsValidFilePermissionMask(string mask) public virtual bool IsValidFolderPermissionMask(string mask)
{ {
throw new NotSupportedException(); throw new NotSupportedException();
} }

@ -12,7 +12,7 @@ namespace NzbDrone.Common.Disk
long? GetAvailableSpace(string path); long? GetAvailableSpace(string path);
void InheritFolderPermissions(string filename); void InheritFolderPermissions(string filename);
void SetEveryonePermissions(string filename); void SetEveryonePermissions(string filename);
void SetPermissions(string path, string mask); void SetPermissions(string path, string mask, string group);
void CopyPermissions(string sourcePath, string targetPath); void CopyPermissions(string sourcePath, string targetPath);
long? GetTotalSize(string path); long? GetTotalSize(string path);
DateTime FolderGetCreationTime(string path); DateTime FolderGetCreationTime(string path);
@ -58,6 +58,6 @@ namespace NzbDrone.Common.Disk
List<IFileInfo> GetFileInfos(string path, SearchOption searchOption = SearchOption.TopDirectoryOnly); List<IFileInfo> GetFileInfos(string path, SearchOption searchOption = SearchOption.TopDirectoryOnly);
void RemoveEmptySubfolders(string path); void RemoveEmptySubfolders(string path);
void SaveStream(Stream stream, string path); void SaveStream(Stream stream, string path);
bool IsValidFilePermissionMask(string mask); bool IsValidFolderPermissionMask(string mask);
} }
} }

@ -255,11 +255,18 @@ namespace NzbDrone.Core.Configuration
set { SetValue("SetPermissionsLinux", value); } set { SetValue("SetPermissionsLinux", value); }
} }
public string FileChmod public string ChmodFolder
{ {
get { return GetValue("FileChmod", "0644"); } get { return GetValue("ChmodFolder", "755"); }
set { SetValue("FileChmod", value); } set { SetValue("ChmodFolder", value); }
}
public string ChownGroup
{
get { return GetValue("ChownGroup", ""); }
set { SetValue("ChownGroup", value); }
} }
public string MetadataSource public string MetadataSource

@ -42,7 +42,8 @@ namespace NzbDrone.Core.Configuration
//Permissions (Media Management) //Permissions (Media Management)
bool SetPermissionsLinux { get; set; } bool SetPermissionsLinux { get; set; }
string FileChmod { get; set; } string ChmodFolder { get; set; }
string ChownGroup { get; set; }
//Indexers //Indexers
int Retention { get; set; } int Retention { get; set; }

@ -1,4 +1,7 @@
using System;
using System.Data;
using FluentMigrator; using FluentMigrator;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore.Migration.Framework; using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration namespace NzbDrone.Core.Datastore.Migration
@ -9,6 +12,45 @@ namespace NzbDrone.Core.Datastore.Migration
protected override void MainDbUpgrade() protected override void MainDbUpgrade()
{ {
Execute.Sql("DELETE FROM config WHERE Key IN ('folderchmod', 'chownuser')"); Execute.Sql("DELETE FROM config WHERE Key IN ('folderchmod', 'chownuser')");
Execute.WithConnection(ConvertFileChmodToFolderChmod);
}
private void ConvertFileChmodToFolderChmod(IDbConnection conn, IDbTransaction tran)
{
using (IDbCommand getFileChmodCmd = conn.CreateCommand())
{
getFileChmodCmd.Transaction = tran;
getFileChmodCmd.CommandText = @"SELECT Value FROM Config WHERE Key = 'filechmod'";
var fileChmod = getFileChmodCmd.ExecuteScalar() as string;
if (fileChmod != null)
{
if (fileChmod.IsNotNullOrWhiteSpace())
{
// Convert without using mono libraries. We take the 'r' bits and shifting them to the 'x' position, preserving everything else.
var fileChmodNum = Convert.ToInt32(fileChmod, 8);
var folderChmodNum = fileChmodNum | ((fileChmodNum & 0x124) >> 2);
var folderChmod = Convert.ToString(folderChmodNum, 8).PadLeft(3, '0');
using (IDbCommand insertCmd = conn.CreateCommand())
{
insertCmd.Transaction = tran;
insertCmd.CommandText = "INSERT INTO Config (Key, Value) VALUES ('chmodfolder', ?)";
insertCmd.AddParameter(folderChmod);
insertCmd.ExecuteNonQuery();
}
}
using (IDbCommand deleteCmd = conn.CreateCommand())
{
deleteCmd.Transaction = tran;
deleteCmd.CommandText = "DELETE FROM Config WHERE Key = 'filechmod'";
deleteCmd.ExecuteNonQuery();
}
}
}
} }
} }
} }

@ -54,7 +54,7 @@ namespace NzbDrone.Core.MediaFiles
} }
else else
{ {
SetMonoPermissions(path, _configService.FileChmod); SetMonoPermissions(path);
} }
} }
@ -62,7 +62,7 @@ namespace NzbDrone.Core.MediaFiles
{ {
if (OsInfo.IsNotWindows) if (OsInfo.IsNotWindows)
{ {
SetMonoPermissions(path, _configService.FileChmod); SetMonoPermissions(path);
} }
} }
@ -75,7 +75,7 @@ namespace NzbDrone.Core.MediaFiles
} }
} }
private void SetMonoPermissions(string path, string permissions) private void SetMonoPermissions(string path)
{ {
if (!_configService.SetPermissionsLinux) if (!_configService.SetPermissionsLinux)
{ {
@ -84,7 +84,7 @@ namespace NzbDrone.Core.MediaFiles
try try
{ {
_diskProvider.SetPermissions(path, permissions); _diskProvider.SetPermissions(path, _configService.ChmodFolder, _configService.ChownGroup);
} }
catch (Exception ex) catch (Exception ex)
{ {

@ -149,7 +149,7 @@ namespace NzbDrone.Core.Update
// Set executable flag on update app // Set executable flag on update app
if (OsInfo.IsOsx || (OsInfo.IsLinux && PlatformInfo.IsNetCore)) if (OsInfo.IsOsx || (OsInfo.IsLinux && PlatformInfo.IsNetCore))
{ {
_diskProvider.SetPermissions(_appFolderInfo.GetUpdateClientExePath(updatePackage.Runtime), "0755"); _diskProvider.SetPermissions(_appFolderInfo.GetUpdateClientExePath(updatePackage.Runtime), "0755", null);
} }
_logger.Info("Starting update client {0}", _appFolderInfo.GetUpdateClientExePath(updatePackage.Runtime)); _logger.Info("Starting update client {0}", _appFolderInfo.GetUpdateClientExePath(updatePackage.Runtime));

@ -3,11 +3,11 @@ using NzbDrone.Common.Disk;
namespace NzbDrone.Core.Validation namespace NzbDrone.Core.Validation
{ {
public class FileChmodValidator : PropertyValidator public class FolderChmodValidator : PropertyValidator
{ {
private readonly IDiskProvider _diskProvider; private readonly IDiskProvider _diskProvider;
public FileChmodValidator(IDiskProvider diskProvider) public FolderChmodValidator(IDiskProvider diskProvider)
: base("Must contain a valid Unix permissions octal") : base("Must contain a valid Unix permissions octal")
{ {
_diskProvider = diskProvider; _diskProvider = diskProvider;
@ -20,7 +20,7 @@ namespace NzbDrone.Core.Validation
return false; return false;
} }
return _diskProvider.IsValidFilePermissionMask(context.PropertyValue.ToString()); return _diskProvider.IsValidFolderPermissionMask(context.PropertyValue.ToString());
} }
} }
} }

@ -170,15 +170,15 @@ namespace NzbDrone.Mono.Test.DiskProviderTests
Syscall.stat(tempFile, out var fileStat); Syscall.stat(tempFile, out var fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0444"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0444");
Subject.SetPermissions(tempFile, "644"); Subject.SetPermissions(tempFile, "755", null);
Syscall.stat(tempFile, out fileStat); Syscall.stat(tempFile, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0644"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0644");
Subject.SetPermissions(tempFile, "0644"); Subject.SetPermissions(tempFile, "0755", null);
Syscall.stat(tempFile, out fileStat); Syscall.stat(tempFile, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0644"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0644");
Subject.SetPermissions(tempFile, "1664"); Subject.SetPermissions(tempFile, "1775", null);
Syscall.stat(tempFile, out fileStat); Syscall.stat(tempFile, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("1664"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("1664");
} }
@ -195,56 +195,49 @@ namespace NzbDrone.Mono.Test.DiskProviderTests
Syscall.stat(tempPath, out var fileStat); Syscall.stat(tempPath, out var fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0555"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0555");
Subject.SetPermissions(tempPath, "644"); Subject.SetPermissions(tempPath, "755", null);
Syscall.stat(tempPath, out fileStat); Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0755"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0755");
Subject.SetPermissions(tempPath, "0644"); Subject.SetPermissions(tempPath, "0755", null);
Syscall.stat(tempPath, out fileStat); Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0755"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0755");
Subject.SetPermissions(tempPath, "1664"); Subject.SetPermissions(tempPath, "1775", null);
Syscall.stat(tempPath, out fileStat); Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("1775"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("1775");
Subject.SetPermissions(tempPath, "775"); Subject.SetPermissions(tempPath, "775", null);
Syscall.stat(tempPath, out fileStat); Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0775"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0775");
Subject.SetPermissions(tempPath, "640"); Subject.SetPermissions(tempPath, "750", null);
Syscall.stat(tempPath, out fileStat); Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0750"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0750");
Subject.SetPermissions(tempPath, "0041"); Subject.SetPermissions(tempPath, "0051", null);
Syscall.stat(tempPath, out fileStat); Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0051"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0051");
// reinstate sane permissions so fokder can be cleaned up
Subject.SetPermissions(tempPath, "775");
Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0775");
} }
[Test] [Test]
public void IsValidFilePermissionMask_should_return_correct() public void IsValidFolderPermissionMask_should_return_correct()
{ {
// Files may not be executable
Subject.IsValidFilePermissionMask("0777").Should().BeFalse();
Subject.IsValidFilePermissionMask("0544").Should().BeFalse();
Subject.IsValidFilePermissionMask("0454").Should().BeFalse();
Subject.IsValidFilePermissionMask("0445").Should().BeFalse();
// No special bits should be set // No special bits should be set
Subject.IsValidFilePermissionMask("1644").Should().BeFalse(); Subject.IsValidFolderPermissionMask("1755").Should().BeFalse();
Subject.IsValidFilePermissionMask("2644").Should().BeFalse(); Subject.IsValidFolderPermissionMask("2755").Should().BeFalse();
Subject.IsValidFilePermissionMask("4644").Should().BeFalse(); Subject.IsValidFolderPermissionMask("4755").Should().BeFalse();
Subject.IsValidFilePermissionMask("7644").Should().BeFalse(); Subject.IsValidFolderPermissionMask("7755").Should().BeFalse();
// Files should be readable and writeable by owner // Folder should be readable and writeable by owner
Subject.IsValidFilePermissionMask("0400").Should().BeFalse(); Subject.IsValidFolderPermissionMask("0000").Should().BeFalse();
Subject.IsValidFilePermissionMask("0000").Should().BeFalse(); Subject.IsValidFolderPermissionMask("0100").Should().BeFalse();
Subject.IsValidFilePermissionMask("0200").Should().BeFalse(); Subject.IsValidFolderPermissionMask("0200").Should().BeFalse();
Subject.IsValidFilePermissionMask("0600").Should().BeTrue(); Subject.IsValidFolderPermissionMask("0300").Should().BeFalse();
Subject.IsValidFolderPermissionMask("0400").Should().BeFalse();
Subject.IsValidFolderPermissionMask("0500").Should().BeFalse();
Subject.IsValidFolderPermissionMask("0600").Should().BeFalse();
Subject.IsValidFolderPermissionMask("0700").Should().BeTrue();
} }
} }
} }

@ -77,50 +77,66 @@ namespace NzbDrone.Mono.Disk
{ {
} }
public override void SetPermissions(string path, string mask) public override void SetPermissions(string path, string mask, string group)
{ {
_logger.Debug("Setting permissions: {0} on {1}", mask, path); _logger.Debug("Setting permissions: {0} on {1}", mask, path);
var permissions = NativeConvert.FromOctalPermissionString(mask); var permissions = NativeConvert.FromOctalPermissionString(mask);
if (_fileSystem.Directory.Exists(path)) if (_fileSystem.File.Exists(path))
{ {
permissions = GetFolderPermissions(permissions); permissions = GetFilePermissions(permissions);
} }
// Preserve non-access permissions
if (Syscall.stat(path, out var curStat) < 0)
{
var error = Stdlib.GetLastError();
throw new LinuxPermissionsException("Error getting current permissions: " + error);
}
permissions |= curStat.st_mode & ~FilePermissions.ACCESSPERMS;
if (Syscall.chmod(path, permissions) < 0) if (Syscall.chmod(path, permissions) < 0)
{ {
var error = Stdlib.GetLastError(); var error = Stdlib.GetLastError();
throw new LinuxPermissionsException("Error setting permissions: " + error); throw new LinuxPermissionsException("Error setting permissions: " + error);
} }
var groupId = GetGroupId(group);
if (Syscall.chown(path, unchecked((uint)-1), groupId) < 0)
{
var error = Stdlib.GetLastError();
throw new LinuxPermissionsException("Error setting group: " + error);
}
} }
private static FilePermissions GetFolderPermissions(FilePermissions permissions) private static FilePermissions GetFilePermissions(FilePermissions permissions)
{ {
permissions |= (FilePermissions)((int)(permissions & (FilePermissions.S_IRUSR | FilePermissions.S_IRGRP | FilePermissions.S_IROTH)) >> 2); permissions &= ~(FilePermissions.S_IXUSR | FilePermissions.S_IXGRP | FilePermissions.S_IXOTH);
return permissions; return permissions;
} }
public override bool IsValidFilePermissionMask(string mask) public override bool IsValidFolderPermissionMask(string mask)
{ {
try try
{ {
var permissions = NativeConvert.FromOctalPermissionString(mask); var permissions = NativeConvert.FromOctalPermissionString(mask);
if ((permissions & (FilePermissions.S_ISUID | FilePermissions.S_ISGID | FilePermissions.S_ISVTX)) != 0) if ((permissions & ~FilePermissions.ACCESSPERMS) != 0)
{
return false;
}
if ((permissions & (FilePermissions.S_IXUSR | FilePermissions.S_IXGRP | FilePermissions.S_IXOTH)) != 0)
{ {
// Only allow access permissions
return false; return false;
} }
if ((permissions & (FilePermissions.S_IRUSR | FilePermissions.S_IWUSR)) != (FilePermissions.S_IRUSR | FilePermissions.S_IWUSR)) if ((permissions & FilePermissions.S_IRWXU) != FilePermissions.S_IRWXU)
{ {
// We expect at least full owner permissions (700)
return false; return false;
} }

@ -128,7 +128,7 @@ namespace NzbDrone.Update.UpdateEngine
// Set executable flag on Lidarr app // Set executable flag on Lidarr app
if (OsInfo.IsOsx || (OsInfo.IsLinux && PlatformInfo.IsNetCore)) if (OsInfo.IsOsx || (OsInfo.IsLinux && PlatformInfo.IsNetCore))
{ {
_diskProvider.SetPermissions(Path.Combine(installationFolder, "Lidarr"), "0755"); _diskProvider.SetPermissions(Path.Combine(installationFolder, "Lidarr"), "0755", null);
} }
} }
catch (Exception e) catch (Exception e)

@ -102,7 +102,7 @@ namespace NzbDrone.Windows.Disk
} }
} }
public override void SetPermissions(string path, string mask) public override void SetPermissions(string path, string mask, string group)
{ {
} }

Loading…
Cancel
Save