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/4111/head
Taloth Saldono 4 years ago committed by Taloth
parent 850552bf17
commit d88bb7f855

@ -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;

@ -15,6 +15,7 @@ import Measure from 'Components/Measure';
import Modal from 'Components/Modal/Modal'; import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody'; import ModalBody from 'Components/Modal/ModalBody';
import Scroller from 'Components/Scroller/Scroller'; import Scroller from 'Components/Scroller/Scroller';
import TextInput from './TextInput';
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue'; import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import HintedSelectInputOption from './HintedSelectInputOption'; import HintedSelectInputOption from './HintedSelectInputOption';
import styles from './EnhancedSelectInput.css'; import styles from './EnhancedSelectInput.css';
@ -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,

@ -23,6 +23,7 @@ import TagInputConnector from './TagInputConnector';
import TagSelectInputConnector from './TagSelectInputConnector'; import TagSelectInputConnector from './TagSelectInputConnector';
import TextTagInputConnector from './TextTagInputConnector'; import TextTagInputConnector from './TextTagInputConnector';
import TextInput from './TextInput'; import TextInput from './TextInput';
import UMaskInput from './UMaskInput';
import FormInputHelpText from './FormInputHelpText'; import FormInputHelpText from './FormInputHelpText';
import styles from './FormInputGroup.css'; import styles from './FormInputGroup.css';
@ -88,6 +89,9 @@ function getComponent(type) {
case inputTypes.TAG_SELECT: case inputTypes.TAG_SELECT:
return TagSelectInputConnector; return TagSelectInputConnector;
case inputTypes.UMASK:
return UMaskInput;
default: default:
return TextInput; return TextInput;
} }
@ -195,7 +199,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 styles from './UMaskInput.css';
import EnhancedSelectInput from './EnhancedSelectInput';
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;

@ -19,6 +19,7 @@ export const TAG = 'tag';
export const TEXT = 'text'; export const TEXT = 'text';
export const TEXT_TAG = 'textTag'; export const TEXT_TAG = 'textTag';
export const TAG_SELECT = 'tagSelect'; export const TAG_SELECT = 'tagSelect';
export const UMASK = 'umask';
export const all = [ export const all = [
AUTO_COMPLETE, AUTO_COMPLETE,
@ -41,5 +42,6 @@ export const all = [
TAG, TAG,
TEXT, TEXT,
TEXT_TAG, TEXT_TAG,
TAG_SELECT TAG_SELECT,
UMASK
]; ];

@ -357,7 +357,7 @@ class MediaManagement extends Component {
</FieldSet> </FieldSet>
{ {
advancedSettings && isMono && advancedSettings &&
<FieldSet <FieldSet
legend="Permissions" legend="Permissions"
> >
@ -382,17 +382,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 sonarr 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 Sonarr', helpTextWarning="This only works if the user running sonarr is the owner of the file. It's better to ensure the download client uses the same group as sonarr."
'The same mode is applied to series/season 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,10 +8,10 @@ namespace NzbDrone.Api.Config
{ {
public class MediaManagementConfigModule : NzbDroneConfigModule<MediaManagementConfigResource> public class MediaManagementConfigModule : NzbDroneConfigModule<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.FileChmod).SetValidator(fileChmodValidator).When(c => !string.IsNullOrEmpty(c.FileChmod) && PlatformInfo.IsMono); SharedValidator.RuleFor(c => c.ChmodFolder).SetValidator(folderChmodValidator).When(c => !string.IsNullOrEmpty(c.ChmodFolder) && PlatformInfo.IsMono);
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));
} }

@ -15,7 +15,8 @@ namespace NzbDrone.Api.Config
public FileDateType FileDate { get; set; } public FileDateType FileDate { 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 bool CopyUsingHardlinks { get; set; } public bool CopyUsingHardlinks { get; set; }
@ -38,7 +39,8 @@ namespace NzbDrone.Api.Config
FileDate = model.FileDate, FileDate = model.FileDate,
SetPermissionsLinux = model.SetPermissionsLinux, SetPermissionsLinux = model.SetPermissionsLinux,
FileChmod = model.FileChmod, ChmodFolder = model.ChmodFolder,
ChownGroup = model.ChownGroup,
SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting, SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting,
CopyUsingHardlinks = model.CopyUsingHardlinks, CopyUsingHardlinks = model.CopyUsingHardlinks,

@ -32,7 +32,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);
@ -509,7 +509,7 @@ namespace NzbDrone.Common.Disk
} }
} }
public virtual bool IsValidFilePermissionMask(string mask) public virtual bool IsValidFolderPermissionMask(string mask)
{ {
throw new NotSupportedException(); throw new NotSupportedException();
} }

@ -11,7 +11,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);
@ -55,6 +55,6 @@ namespace NzbDrone.Common.Disk
List<FileInfo> GetFileInfos(string path); List<FileInfo> GetFileInfos(string path);
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);
} }
} }

@ -252,11 +252,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 int FirstDayOfWeek public int FirstDayOfWeek

@ -43,7 +43,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; }

@ -0,0 +1,56 @@
using System;
using System.Data;
using FluentMigrator;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(147)]
public class swap_filechmod_for_folderchmod : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
// Reverts part of migration 140, note that the v1 of migration140 also removed chowngroup
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();
}
}
}
}
}
}

@ -194,13 +194,10 @@ namespace NzbDrone.Core.MediaFiles
try try
{ {
var permissions = _configService.FileChmod; _diskProvider.SetPermissions(path, _configService.ChmodFolder, _configService.ChownGroup);
_diskProvider.SetPermissions(path, permissions);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.Warn(ex, "Unable to apply permissions to: " + path); _logger.Warn(ex, "Unable to apply permissions to: " + path);
_logger.Debug(ex, ex.Message); _logger.Debug(ex, ex.Message);
} }

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

@ -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;
@ -17,7 +17,7 @@ namespace NzbDrone.Core.Validation
{ {
if (context.PropertyValue == null) return false; if (context.PropertyValue == null) 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,51 +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");
} }
[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();
} }
} }
} }

@ -66,50 +66,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 (Directory.Exists(path)) if (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;
} }

@ -135,7 +135,7 @@ namespace NzbDrone.Update.UpdateEngine
{ {
// Old MacOS App stores Sonarr binaries in MacOS together with shell script // Old MacOS App stores Sonarr binaries in MacOS together with shell script
// Make shim executable // Make shim executable
_diskProvider.SetPermissions(shimPath, "0755"); _diskProvider.SetPermissions(shimPath, "755", null);
} }
} }
} }

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

@ -8,11 +8,11 @@ namespace Sonarr.Api.V3.Config
{ {
public class MediaManagementConfigModule : SonarrConfigModule<MediaManagementConfigResource> public class MediaManagementConfigModule : SonarrConfigModule<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) && PlatformInfo.IsMono); SharedValidator.RuleFor(c => c.ChmodFolder).SetValidator(folderChmodValidator).When(c => !string.IsNullOrEmpty(c.ChmodFolder) && PlatformInfo.IsMono);
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);
} }

@ -18,7 +18,8 @@ namespace Sonarr.Api.V3.Config
public RescanAfterRefreshType RescanAfterRefresh { get; set; } public RescanAfterRefreshType RescanAfterRefresh { 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 EpisodeTitleRequiredType EpisodeTitleRequired { get; set; } public EpisodeTitleRequiredType EpisodeTitleRequired { get; set; }
public bool SkipFreeSpaceCheckWhenImporting { get; set; } public bool SkipFreeSpaceCheckWhenImporting { get; set; }
@ -45,7 +46,8 @@ namespace Sonarr.Api.V3.Config
RescanAfterRefresh = model.RescanAfterRefresh, RescanAfterRefresh = model.RescanAfterRefresh,
SetPermissionsLinux = model.SetPermissionsLinux, SetPermissionsLinux = model.SetPermissionsLinux,
FileChmod = model.FileChmod, ChmodFolder = model.ChmodFolder,
ChownGroup = model.ChownGroup,
EpisodeTitleRequired = model.EpisodeTitleRequired, EpisodeTitleRequired = model.EpisodeTitleRequired,
SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting, SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting,

Loading…
Cancel
Save