diff --git a/docs/config/config-dotfiles.md b/docs/config/config-dotfiles.md index 8cebb1b..82c801b 100644 --- a/docs/config/config-dotfiles.md +++ b/docs/config/config-dotfiles.md @@ -8,7 +8,7 @@ Entry | Description `src` | Dotfile path within the `dotpath` (dotfiles with empty `src` are ignored and considered installed, can use `variables`, make sure to quote) `link` | Defines how this dotfile is installed. Possible values: *nolink*, *absolute*, *relative*, *link_children* (See [Symlinking dotfiles](config-file.md#symlinking-dotfiles)) (defaults to value of `link_dotfile_default`) `actions` | List of action keys that need to be defined in the **actions** entry below (See [actions](config-actions.md)) -`chmod` | Defines the file permissions in octal notation to apply during installation (See [permissions](config-file.md#permissions)) +`chmod` | Defines the file permissions in octal notation to apply during installation or the special keyword `preserve` (See [permissions](config-file.md#permissions)) `cmpignore` | List of patterns to ignore when comparing (enclose in quotes when using wildcards; see [ignore patterns](config-file.md#ignore-patterns)) `ignore_missing_in_dotdrop` | Ignore missing files in dotdrop when comparing and importing (see [Ignore missing](config-file.md#ignore-missing)) `ignoreempty` | If true, an empty template will not be deployed (defaults to the value of `ignoreempty`) diff --git a/docs/config/config-file.md b/docs/config/config-file.md index b87a3fd..099c804 100644 --- a/docs/config/config-file.md +++ b/docs/config/config-file.md @@ -90,8 +90,20 @@ dotfiles: src: dir dst: ~/dir chmod: 744 + f_preserve: + src: preserve + dst: ~/preserve + chmod: preserve ``` +The `chmod` value defines the file permissions in octal notation to apply on dotfiles. If undefined +new files will get the system default permissions (see `umask`, `777-` for directories and +`666-` for files). + +The special keyword `preserve` allows to ensure that if the dotfiles already exists +on the filesystem, it is not altered during `install` and the `chmod` value won't +be changed during `update`. + On `import`, the following rules are applied: * If the `-m`/`--preserve-mode` switch is provided or the config option @@ -107,12 +119,13 @@ On `install`, the following rules are applied: * Otherwise, the permissions of the dotfile in the `dotpath` are applied. * If the global setting `force_chmod` is set to true, dotdrop will not ask for confirmation to apply permissions. +* If `chmod` is `preserve` and the destination exists with a different permission set + than system default, then it is not altered On `update`, the following rule is applied: * If the permissions of the file in the filesystem differ from the dotfile in the `dotpath`, - then the dotfile entry `chmod` is added/updated accordingly. - + then the dotfile entry `chmod` is added/updated accordingly (unless `chmod` value is `preserve`) ## Symlinking dotfiles diff --git a/dotdrop/cfg_yaml.py b/dotdrop/cfg_yaml.py index 8eed556..5e07547 100644 --- a/dotdrop/cfg_yaml.py +++ b/dotdrop/cfg_yaml.py @@ -67,6 +67,9 @@ class CfgYaml: key_dotfile_template = 'template' key_dotfile_chmod = 'chmod' + # chmod value + chmod_ignore = 'preserve' + # profile key_profile_dotfiles = 'dotfiles' key_profile_include = 'include' @@ -378,14 +381,17 @@ class CfgYaml: """return all existing dotfile keys""" return self.dotfiles.keys() - def update_dotfile(self, key, chmod): - """update an existing dotfile""" - if key not in self.dotfiles.keys(): - return False - dotfile = self._yaml_dict[self.key_dotfiles][key] + def _update_dotfile_chmod(self, key, dotfile, chmod): old = None if self.key_dotfile_chmod in dotfile: old = dotfile[self.key_dotfile_chmod] + if old == self.chmod_ignore: + msg = ( + 'ignore chmod change since ' + f'{self.chmod_ignore}' + ) + self._dbg(msg) + return False if old == chmod: return False if self._debug: @@ -397,6 +403,18 @@ class CfgYaml: del dotfile[self.key_dotfile_chmod] else: dotfile[self.key_dotfile_chmod] = str(format(chmod, 'o')) + return True + + def update_dotfile(self, key, chmod): + """ + update an existing dotfile + return true if updated + """ + if key not in self.dotfiles.keys(): + return False + dotfile = self._yaml_dict[self.key_dotfiles][key] + if not self._update_dotfile_chmod(key, dotfile, chmod): + return False self._dirty = True return True @@ -743,62 +761,77 @@ class CfgYaml: new[k] = val return new + def _norm_dotfile_chmod(self, entry): + value = str(entry[self.key_dotfile_chmod]) + if value == self.chmod_ignore: + # is preserve + return + if len(value) < 3: + # bad format + err = f'bad format for chmod: {value}' + self._log.err(err) + raise YamlException(f'config content error: {err}') + + # check is valid value + try: + int(value) + except Exception as exc: + err = f'bad format for chmod: {value}' + self._log.err(err) + err = f'config content error: {err}' + raise YamlException(err) from exc + + # normalize chmod value + for chmodv in list(value): + chmodint = int(chmodv) + if chmodint < 0 or chmodint > 7: + err = f'bad format for chmod: {value}' + self._log.err(err) + raise YamlException( + f'config content error: {err}' + ) + # octal + entry[self.key_dotfile_chmod] = int(value, 8) + def _norm_dotfiles(self, dotfiles): """normalize and check dotfiles entries""" if not dotfiles: return dotfiles new = {} for k, val in dotfiles.items(): - # add 'src' as key' if not present if self.key_dotfile_src not in val: + # add 'src' as key' if not present val[self.key_dotfile_src] = k new[k] = val else: new[k] = val - # fix deprecated trans key + if self.old_key_trans_r in val: - msg = '\"trans\" is deprecated, please use \"trans_read\"' + # fix deprecated trans key + msg = f'{k} \"trans\" is deprecated, please use \"trans_read\"' self._log.warn(msg) val[self.key_trans_r] = val[self.old_key_trans_r] del val[self.old_key_trans_r] new[k] = val + if self.key_dotfile_link not in val: # apply link value if undefined value = self.settings[self.key_settings_link_dotfile_default] val[self.key_dotfile_link] = value - # apply noempty if undefined + if self.key_dotfile_noempty not in val: + # apply noempty if undefined value = self.settings.get(self.key_settings_noempty, False) val[self.key_dotfile_noempty] = value - # apply template if undefined + if self.key_dotfile_template not in val: + # apply template if undefined value = self.settings.get(self.key_settings_template, True) val[self.key_dotfile_template] = value - # validate value of chmod if defined - if self.key_dotfile_chmod in val: - value = str(val[self.key_dotfile_chmod]) - if len(value) < 3: - err = f'bad format for chmod: {value}' - self._log.err(err) - raise YamlException(f'config content error: {err}') - try: - int(value) - except Exception as exc: - err = f'bad format for chmod: {value}' - self._log.err(err) - err = f'config content error: {err}' - raise YamlException(err) from exc - # normalize chmod value - for chmodv in list(value): - chmodint = int(chmodv) - if chmodint < 0 or chmodint > 7: - err = f'bad format for chmod: {value}' - self._log.err(err) - raise YamlException( - f'config content error: {err}' - ) - val[self.key_dotfile_chmod] = int(value, 8) + if self.key_dotfile_chmod in val: + # validate value of chmod if defined + self._norm_dotfile_chmod(val) return new def _add_variables(self, new, shell=False, template=True, prio=False): diff --git a/dotdrop/dotfile.py b/dotdrop/dotfile.py index 88fdff2..0b09f5b 100644 --- a/dotdrop/dotfile.py +++ b/dotdrop/dotfile.py @@ -117,7 +117,10 @@ class Dotfile(DictParser): msg += f', link:\"{self.link}\"' msg += f', template:{self.template}' if self.chmod: - msg += f', chmod:{self.chmod:o}' + if isinstance(self.chmod, int) or len(self.chmod) == 3: + msg += f', chmod:{self.chmod:o}' + else: + msg += f', chmod:\"{self.chmod}\"' return msg def prt(self): @@ -129,7 +132,10 @@ class Dotfile(DictParser): out += f'\n{indent}link: \"{self.link}\"' out += f'\n{indent}template: \"{self.template}\"' if self.chmod: - out += f'\n{indent}chmod: \"{self.chmod:o}\"' + if isinstance(self.chmod, int) or len(self.chmod) == 3: + out += f'\n{indent}chmod: \"{self.chmod:o}\"' + else: + out += f'\n{indent}chmod: \"{self.chmod}\"' out += f'\n{indent}pre-action:' some = self.get_pre_actions() diff --git a/dotdrop/installer.py b/dotdrop/installer.py index 0fb7e6a..ad561ae 100644 --- a/dotdrop/installer.py +++ b/dotdrop/installer.py @@ -14,6 +14,7 @@ from dotdrop.logger import Logger from dotdrop.linktypes import LinkTypes from dotdrop import utils from dotdrop.exceptions import UndefinedException +from dotdrop.cfg_yaml import CfgYaml class Installer: @@ -138,13 +139,15 @@ class Installer: ret, err = self._link_absolute(templater, src, dst, actionexec=actionexec, is_template=is_template, - ignore=ignore) + ignore=ignore, + chmod=chmod) elif linktype == LinkTypes.RELATIVE: # symlink ret, err = self._link_relative(templater, src, dst, actionexec=actionexec, is_template=is_template, - ignore=ignore) + ignore=ignore, + chmod=chmod) elif linktype == LinkTypes.LINK_CHILDREN: # symlink direct children if not isdir: @@ -158,7 +161,16 @@ class Installer: is_template=is_template, ignore=ignore) - self.log.dbg(f'before chmod: {ret} err:{err}') + if self.log.debug and chmod: + cur = utils.get_file_perm(dst) + if chmod == CfgYaml.chmod_ignore: + chmodstr = CfgYaml.chmod_ignore + else: + chmodstr = f'{chmod:o}' + self.log.dbg( + f'before chmod (cur:{cur:o}, new:{chmodstr}): ' + f'installed:{ret} err:{err}' + ) if self.dry: return self._log_install(ret, err) @@ -169,9 +181,15 @@ class Installer: # but not when # - error (not r, err) # - aborted (not r, err) - if os.path.exists(dst) and (ret or (not ret and not err)): + # - special keyword "preserve" + apply_chmod = linktype in [LinkTypes.NOLINK, LinkTypes.LINK_CHILDREN] + apply_chmod = apply_chmod and os.path.exists(dst) + apply_chmod = apply_chmod and (ret or (not ret and not err)) + apply_chmod = apply_chmod and chmod != CfgYaml.chmod_ignore + if apply_chmod: if not chmod: chmod = utils.get_file_perm(src) + self.log.dbg(f'applying chmod {chmod:o} to {dst}') dstperms = utils.get_file_perm(dst) if dstperms != chmod: # apply mode @@ -187,6 +205,8 @@ class Installer: else: ret = False err = 'chmod failed' + else: + self.log.dbg('no chmod applied') return self._log_install(ret, err) @@ -255,7 +275,8 @@ class Installer: def _link_absolute(self, templater, src, dst, actionexec=None, is_template=True, - ignore=None): + ignore=None, + chmod=None): """ install link:absolute|link @@ -269,12 +290,14 @@ class Installer: actionexec=actionexec, is_template=is_template, ignore=ignore, - absolute=True) + absolute=True, + chmod=chmod) def _link_relative(self, templater, src, dst, actionexec=None, is_template=True, - ignore=None): + ignore=None, + chmod=None): """ install link:relative @@ -288,13 +311,18 @@ class Installer: actionexec=actionexec, is_template=is_template, ignore=ignore, - absolute=False) + absolute=False, + chmod=chmod) def _link_dotfile(self, templater, src, dst, actionexec=None, - is_template=True, ignore=None, absolute=True): + is_template=True, ignore=None, absolute=True, + chmod=None): """ symlink + chmod is only used if the dotfile is a template + and needs to be installed to the workdir first + return - True, None : success - False, error_msg : error @@ -302,15 +330,15 @@ class Installer: - False, 'aborted' : user aborted """ if is_template: - self.log.dbg('is a template') - self.log.dbg(f'install to {self.workdir}') + self.log.dbg(f'is a template, installing to {self.workdir}') tmp = utils.pivot_path(dst, self.workdir, striphome=True, logger=self.log) ret, err = self.install(templater, src, tmp, LinkTypes.NOLINK, actionexec=actionexec, is_template=is_template, - ignore=ignore) + ignore=ignore, + chmod=chmod) if not ret and not os.path.exists(tmp): return ret, err src = tmp @@ -467,6 +495,10 @@ class Installer: dstrel = os.path.dirname(dstrel) lnk_src = os.path.relpath(src, dstrel) os.symlink(lnk_src, dst) + self.log.dbg( + f'symlink {dst} to {lnk_src} ' + f'(mode:{utils.get_file_perm(dst):o})' + ) if not self.comparing: self.log.sub(f'linked {dst} to {lnk_src}') return True, None @@ -527,7 +559,10 @@ class Installer: ret, err = self._write(src, dst, content=content, actionexec=actionexec) + if ret and not err: + rights = f'{utils.get_file_perm(src):o}' + self.log.dbg(f'installed file {src} to {dst} ({rights})') if not self.dry and not self.comparing: self.log.sub(f'install {src} to {dst}') return ret, err @@ -587,13 +622,12 @@ class Installer: @classmethod def _write_content_to_file(cls, content, src, dst): """write content to file""" - if content: # write content the file try: with open(dst, 'wb') as file: file.write(content) - shutil.copymode(src, dst) + # shutil.copymode(src, dst) except NotADirectoryError as exc: err = f'opening dest file: {exc}' return False, err @@ -605,7 +639,7 @@ class Installer: # copy file try: shutil.copyfile(src, dst) - shutil.copymode(src, dst) + # shutil.copymode(src, dst) except OSError as exc: return False, str(exc) return True, None @@ -665,7 +699,7 @@ class Installer: if not ret: return False, err - self.log.dbg(f'install file to \"{dst}\"') + self.log.dbg(f'installing file to \"{dst}\"') # re-check in case action created the file if self.safe and not overwrite and \ os.path.lexists(dst) and \ @@ -674,7 +708,10 @@ class Installer: return False, 'aborted' # writing to file - return self._write_content_to_file(content, src, dst) + self.log.dbg(f'before writing to {dst} ({utils.get_file_perm(src):o})') + ret = self._write_content_to_file(content, src, dst) + self.log.dbg(f'written to {dst} ({utils.get_file_perm(src):o})') + return ret ######################################################## # helpers @@ -749,7 +786,13 @@ class Installer: return dst = path.rstrip(os.sep) + self.backup_suffix self.log.log(f'backup {path} to {dst}') - os.rename(path, dst) + # os.rename(path, dst) + # copy to preserve mode on chmod=preserve + # since we expect dotfiles this shouldn't have + # such a big impact but who knows. + shutil.copy2(path, dst) + stat = os.stat(path) + os.chown(dst, stat.st_uid, stat.st_gid) def _exec_pre_actions(self, actionexec): """execute action executor""" diff --git a/dotdrop/utils.py b/dotdrop/utils.py index 0c28b89..6b5bf00 100644 --- a/dotdrop/utils.py +++ b/dotdrop/utils.py @@ -439,7 +439,9 @@ def get_default_file_perms(path, umask): def get_file_perm(path): """return file permission""" - return os.stat(path).st_mode & 0o777 + if not os.path.exists(path): + return 0o777 + return os.stat(path, follow_symlinks=True).st_mode & 0o777 def chmod(path, mode, debug=False):