dotsync-cli 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dotsync/__init__.py +0 -0
- dotsync/__main__.py +1080 -0
- dotsync/args.py +149 -0
- dotsync/calc_ops.py +298 -0
- dotsync/checks.py +41 -0
- dotsync/enums.py +16 -0
- dotsync/file_ops.py +122 -0
- dotsync/flists.py +90 -0
- dotsync/git.py +161 -0
- dotsync/info.py +11 -0
- dotsync/plugin.py +48 -0
- dotsync/plugins/__init__.py +0 -0
- dotsync/plugins/encrypt.py +230 -0
- dotsync/plugins/plain.py +48 -0
- dotsync_cli-1.0.2.dist-info/METADATA +267 -0
- dotsync_cli-1.0.2.dist-info/RECORD +19 -0
- dotsync_cli-1.0.2.dist-info/WHEEL +4 -0
- dotsync_cli-1.0.2.dist-info/entry_points.txt +2 -0
- dotsync_cli-1.0.2.dist-info/licenses/LICENSE +36 -0
dotsync/flists.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import dotsync.info as info
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Filelist:
|
|
9
|
+
def __init__(self, fname):
|
|
10
|
+
self.groups = {}
|
|
11
|
+
self.files = {}
|
|
12
|
+
|
|
13
|
+
logging.debug(f'parsing filelist in {fname}')
|
|
14
|
+
|
|
15
|
+
with open(fname, 'r') as f:
|
|
16
|
+
for line in f.readlines():
|
|
17
|
+
line = line.strip()
|
|
18
|
+
|
|
19
|
+
if not line or line.startswith('#'):
|
|
20
|
+
continue
|
|
21
|
+
|
|
22
|
+
# group
|
|
23
|
+
if '=' in line:
|
|
24
|
+
group, categories = line.split('=')
|
|
25
|
+
categories = categories.split(',')
|
|
26
|
+
if group == info.hostname:
|
|
27
|
+
categories.append(info.hostname)
|
|
28
|
+
self.groups[group] = categories
|
|
29
|
+
# file
|
|
30
|
+
else:
|
|
31
|
+
split = re.split('[:|]', line)
|
|
32
|
+
|
|
33
|
+
path, categories, plugin = split[0], ['common'], 'plain'
|
|
34
|
+
if len(split) >= 2:
|
|
35
|
+
if ':' in line:
|
|
36
|
+
categories = split[1].split(',')
|
|
37
|
+
else:
|
|
38
|
+
plugin = split[1]
|
|
39
|
+
if len(split) >= 3:
|
|
40
|
+
plugin = split[2]
|
|
41
|
+
|
|
42
|
+
if path not in self.files:
|
|
43
|
+
self.files[path] = []
|
|
44
|
+
self.files[path].append({
|
|
45
|
+
'categories': categories,
|
|
46
|
+
'plugin': plugin
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
def activate(self, categories):
|
|
50
|
+
# expand groups
|
|
51
|
+
categories = [self.groups.get(c, [c]) for c in categories]
|
|
52
|
+
# flatten category list
|
|
53
|
+
categories = [c for cat in categories for c in cat]
|
|
54
|
+
|
|
55
|
+
files = {}
|
|
56
|
+
for path in self.files:
|
|
57
|
+
for group in self.files[path]:
|
|
58
|
+
cat_list = group['categories']
|
|
59
|
+
if set(categories) & set(cat_list):
|
|
60
|
+
if path in files:
|
|
61
|
+
logging.error('multiple category lists active for '
|
|
62
|
+
f'{path}: {files[path]["categories"]} '
|
|
63
|
+
f'and {cat_list}')
|
|
64
|
+
raise RuntimeError
|
|
65
|
+
else:
|
|
66
|
+
files[path] = group
|
|
67
|
+
|
|
68
|
+
return files
|
|
69
|
+
|
|
70
|
+
# generates a list of all the filenames in each plugin for later use when
|
|
71
|
+
# cleaning the repo
|
|
72
|
+
def manifest(self):
|
|
73
|
+
manifest = {}
|
|
74
|
+
|
|
75
|
+
for path in self.files:
|
|
76
|
+
for instance in self.files[path]:
|
|
77
|
+
plugin = instance['plugin']
|
|
78
|
+
for category in instance['categories']:
|
|
79
|
+
if category in self.groups:
|
|
80
|
+
categories = self.groups[category]
|
|
81
|
+
else:
|
|
82
|
+
categories = [category]
|
|
83
|
+
|
|
84
|
+
if plugin not in manifest:
|
|
85
|
+
manifest[plugin] = []
|
|
86
|
+
|
|
87
|
+
for category in categories:
|
|
88
|
+
manifest[plugin].append(os.path.join(category, path))
|
|
89
|
+
|
|
90
|
+
return manifest
|
dotsync/git.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import shlex
|
|
4
|
+
import logging
|
|
5
|
+
import enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FileState(enum.Enum):
|
|
9
|
+
MODIFIED = 'M'
|
|
10
|
+
ADDED = 'A'
|
|
11
|
+
DELETED = 'D'
|
|
12
|
+
RENAMED = 'R'
|
|
13
|
+
COPIED = 'C'
|
|
14
|
+
UPDATED = 'U'
|
|
15
|
+
UNTRACKED = '?'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Git:
|
|
19
|
+
def __init__(self, repo_dir):
|
|
20
|
+
if not os.path.isdir(repo_dir):
|
|
21
|
+
raise FileNotFoundError
|
|
22
|
+
|
|
23
|
+
self.repo_dir = repo_dir
|
|
24
|
+
|
|
25
|
+
def run(self, cmd):
|
|
26
|
+
if not type(cmd) is list:
|
|
27
|
+
cmd = shlex.split(cmd)
|
|
28
|
+
logging.info(f'running git command {cmd}')
|
|
29
|
+
try:
|
|
30
|
+
proc = subprocess.run(cmd, cwd=self.repo_dir,
|
|
31
|
+
stdout=subprocess.PIPE, check=True)
|
|
32
|
+
except subprocess.CalledProcessError as e:
|
|
33
|
+
logging.error(e.stdout.decode())
|
|
34
|
+
logging.error(f'git command {cmd} failed with exit code '
|
|
35
|
+
f'{e.returncode}\n')
|
|
36
|
+
raise
|
|
37
|
+
logging.debug(f'git command {cmd} succeeded')
|
|
38
|
+
return proc.stdout.decode()
|
|
39
|
+
|
|
40
|
+
def init(self):
|
|
41
|
+
self.run('git init')
|
|
42
|
+
|
|
43
|
+
def reset(self, fname=None):
|
|
44
|
+
self.run('git reset' if fname is None else f'git reset {fname}')
|
|
45
|
+
|
|
46
|
+
def add(self, fname=None):
|
|
47
|
+
self.run('git add --all' if fname is None else f'git add {fname}')
|
|
48
|
+
|
|
49
|
+
def commit(self, message=None):
|
|
50
|
+
if message is None:
|
|
51
|
+
message = self.gen_commit_message()
|
|
52
|
+
return self.run(['git', 'commit', '-m', message])
|
|
53
|
+
|
|
54
|
+
def status(self, staged=True):
|
|
55
|
+
out = self.run('git status --porcelain').strip()
|
|
56
|
+
status = []
|
|
57
|
+
|
|
58
|
+
# Handle empty output
|
|
59
|
+
if not out:
|
|
60
|
+
return status
|
|
61
|
+
|
|
62
|
+
for line in out.split('\n'):
|
|
63
|
+
# Skip empty lines
|
|
64
|
+
if not line.strip():
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# git status --porcelain format: XY filename
|
|
68
|
+
# X = staged state, Y = working tree state
|
|
69
|
+
# Both can be: M=modified, A=added, D=deleted, R=renamed, C=copied, U=updated, ?=untracked
|
|
70
|
+
# Space means no change in that area
|
|
71
|
+
if len(line) < 3:
|
|
72
|
+
logging.warning(f'Unexpected git status line format: {line}')
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
state_chars = line[:2]
|
|
76
|
+
path = line[3:].strip() if len(line) > 3 else ''
|
|
77
|
+
|
|
78
|
+
# Handle special cases that don't follow XY format
|
|
79
|
+
if state_chars in ['!!', '##']:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
# Extract stage and work states
|
|
83
|
+
stage_char = state_chars[0]
|
|
84
|
+
work_char = state_chars[1]
|
|
85
|
+
|
|
86
|
+
# Choose which state to use based on staged parameter
|
|
87
|
+
# If preferred state is space, try the other one
|
|
88
|
+
if staged:
|
|
89
|
+
state_char = stage_char if stage_char != ' ' else work_char
|
|
90
|
+
else:
|
|
91
|
+
state_char = work_char if work_char != ' ' else stage_char
|
|
92
|
+
|
|
93
|
+
# Skip if both states are spaces (no change)
|
|
94
|
+
if state_char == ' ':
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
# Only process if we have a valid path
|
|
98
|
+
if not path:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
file_state = FileState(state_char)
|
|
103
|
+
except ValueError:
|
|
104
|
+
# Handle unknown states gracefully (might be submodule states, etc.)
|
|
105
|
+
logging.debug(f'Unknown file state: {state_char} in line: {line}, skipping')
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
status.append((file_state, path))
|
|
109
|
+
|
|
110
|
+
return sorted(status, key=lambda s: s[1])
|
|
111
|
+
|
|
112
|
+
def has_changes(self):
|
|
113
|
+
return bool(self.run('git status -s --porcelain').strip())
|
|
114
|
+
|
|
115
|
+
def gen_commit_message(self, ignore=[]):
|
|
116
|
+
mods = []
|
|
117
|
+
for stat in self.status():
|
|
118
|
+
state, path = stat
|
|
119
|
+
# skip all untracked files since they will not be committed
|
|
120
|
+
if state == FileState.UNTRACKED:
|
|
121
|
+
continue
|
|
122
|
+
if any((path.startswith(p) for p in ignore)):
|
|
123
|
+
logging.debug(f'ignoring {path} from commit message')
|
|
124
|
+
continue
|
|
125
|
+
mods.append(f'{state.name.lower()} {path}')
|
|
126
|
+
|
|
127
|
+
# Return empty string if no modifications after filtering
|
|
128
|
+
if not mods:
|
|
129
|
+
return ''
|
|
130
|
+
|
|
131
|
+
return ', '.join(mods).capitalize()
|
|
132
|
+
|
|
133
|
+
def commits(self):
|
|
134
|
+
return self.run('git log -1 --pretty=%s').strip().split('\n')
|
|
135
|
+
|
|
136
|
+
def last_commit(self):
|
|
137
|
+
return self.commits()[-1]
|
|
138
|
+
|
|
139
|
+
def has_remote(self):
|
|
140
|
+
return bool(self.run('git remote').strip())
|
|
141
|
+
|
|
142
|
+
def push(self):
|
|
143
|
+
self.run('git push')
|
|
144
|
+
|
|
145
|
+
def diff(self, ignore=[]):
|
|
146
|
+
if not self.has_changes():
|
|
147
|
+
return ['no changes']
|
|
148
|
+
|
|
149
|
+
self.add()
|
|
150
|
+
status = self.status()
|
|
151
|
+
self.reset()
|
|
152
|
+
|
|
153
|
+
diff = []
|
|
154
|
+
|
|
155
|
+
for path in status:
|
|
156
|
+
# ignore the paths specified in ignore
|
|
157
|
+
if any((path[1].startswith(i) for i in ignore)):
|
|
158
|
+
continue
|
|
159
|
+
diff.append(f'{path[0].name.lower()} {path[1]}')
|
|
160
|
+
|
|
161
|
+
return diff
|
dotsync/info.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from os.path import expanduser
|
|
2
|
+
import socket
|
|
3
|
+
|
|
4
|
+
__version__ = '1.0.2'
|
|
5
|
+
__author__ = 'Harvey'
|
|
6
|
+
__author_email__ = 'harvey.wanghy@gmail.com'
|
|
7
|
+
__url__ = 'https://github.com/HarveyGG/dotsync'
|
|
8
|
+
__license__ = 'MIT License (Non-Commercial Use Only)'
|
|
9
|
+
|
|
10
|
+
home = expanduser('~')
|
|
11
|
+
hostname = socket.gethostname()
|
dotsync/plugin.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Plugin:
|
|
5
|
+
def __init__(self, data_dir, repo_dir=None):
|
|
6
|
+
self.data_dir = data_dir
|
|
7
|
+
self.repo_dir = '/' if repo_dir is None else repo_dir
|
|
8
|
+
|
|
9
|
+
if not os.path.isdir(self.data_dir):
|
|
10
|
+
os.makedirs(self.data_dir)
|
|
11
|
+
|
|
12
|
+
self.setup_data()
|
|
13
|
+
|
|
14
|
+
# does plugin-specific setting up of data located in the data_dir
|
|
15
|
+
def setup_data(self):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
# cleans up plugin's data by removing entries that is no longer in the
|
|
19
|
+
# given manifest
|
|
20
|
+
def clean_data(self, manifest):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
# takes a source (outside the repo) and applies its operation and store the
|
|
24
|
+
# resulting file in dest (inside the repo). This operation should not
|
|
25
|
+
# remove the source file
|
|
26
|
+
def apply(self, source, dest):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
# takes a source (inside the repo) and removes its operation and stores the
|
|
30
|
+
# result in dest (outside the repo)
|
|
31
|
+
def remove(self, source, dest):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
# takes a path to a repo_file and an ext_file and compares them, should
|
|
35
|
+
# return true if they are the same file
|
|
36
|
+
def samefile(self, repo_file, ext_file):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
# takes a callable (one of the plugin's ops) and returns a string
|
|
40
|
+
# describing the op
|
|
41
|
+
def strify(self, op):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
# takes a path inside the repo and strips the repo dir as a prefix
|
|
45
|
+
def strip_repo(self, path):
|
|
46
|
+
if os.path.isabs(path):
|
|
47
|
+
return os.path.relpath(path, self.repo_dir)
|
|
48
|
+
return path
|
|
File without changes
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import shlex
|
|
3
|
+
import logging
|
|
4
|
+
import json
|
|
5
|
+
import getpass
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
from dotsync.plugin import Plugin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GPG:
|
|
14
|
+
def __init__(self, password):
|
|
15
|
+
self.password = password
|
|
16
|
+
|
|
17
|
+
def run(self, cmd):
|
|
18
|
+
if not type(cmd) is list:
|
|
19
|
+
cmd = shlex.split(cmd)
|
|
20
|
+
|
|
21
|
+
# these are needed to read the password from stdin and to not ask
|
|
22
|
+
# questions
|
|
23
|
+
pre = ['--passphrase-fd', '0', '--pinentry-mode', 'loopback',
|
|
24
|
+
'--batch', '--yes']
|
|
25
|
+
# insert pre into the gpg command string
|
|
26
|
+
cmd = cmd[:1] + pre + cmd[1:]
|
|
27
|
+
|
|
28
|
+
logging.debug(f'running gpg command {cmd}')
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
proc = subprocess.run(cmd, input=self.password.encode(),
|
|
32
|
+
stdout=subprocess.PIPE,
|
|
33
|
+
stderr=subprocess.PIPE, check=True)
|
|
34
|
+
except subprocess.CalledProcessError as e:
|
|
35
|
+
logging.error(e.stderr.decode())
|
|
36
|
+
logging.error(f'gpg command {cmd} failed with exit code '
|
|
37
|
+
f'{e.returncode}\n')
|
|
38
|
+
raise
|
|
39
|
+
|
|
40
|
+
logging.debug(f'gpg command {cmd} succeeded')
|
|
41
|
+
return proc.stdout.decode()
|
|
42
|
+
|
|
43
|
+
def encrypt(self, input_file, output_file):
|
|
44
|
+
self.run(f'gpg --armor --output {shlex.quote(output_file)} '
|
|
45
|
+
f'--symmetric {shlex.quote(input_file)}')
|
|
46
|
+
|
|
47
|
+
def decrypt(self, input_file, output_file):
|
|
48
|
+
self.run(f'gpg --output {shlex.quote(output_file)} '
|
|
49
|
+
f'--decrypt {shlex.quote(input_file)}')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# calculates the sha256 hash of the file at fpath
|
|
53
|
+
def hash_file(path):
|
|
54
|
+
h = hashlib.sha256()
|
|
55
|
+
|
|
56
|
+
with open(path, 'rb') as f:
|
|
57
|
+
while True:
|
|
58
|
+
chunk = f.read(h.block_size)
|
|
59
|
+
if not chunk:
|
|
60
|
+
break
|
|
61
|
+
h.update(chunk)
|
|
62
|
+
|
|
63
|
+
return h.hexdigest()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# hash password using suitable key-stretching algorithm
|
|
67
|
+
# salt needs to be >16 bits from a suitable cryptographically secure random
|
|
68
|
+
# source, but can be stored in plaintext
|
|
69
|
+
def key_stretch(password, salt):
|
|
70
|
+
if type(password) is not bytes:
|
|
71
|
+
password = password.encode()
|
|
72
|
+
if type(salt) is not bytes:
|
|
73
|
+
salt = bytes.fromhex(salt)
|
|
74
|
+
key = hashlib.pbkdf2_hmac(hash_name='sha256', password=password, salt=salt,
|
|
75
|
+
iterations=100000)
|
|
76
|
+
return key.hex()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class EncryptPlugin(Plugin):
|
|
80
|
+
def __init__(self, data_dir, *args, **kwargs):
|
|
81
|
+
self.gpg = None
|
|
82
|
+
self.hashes_path = os.path.join(data_dir, 'hashes')
|
|
83
|
+
self.modes_path = os.path.join(data_dir, 'modes')
|
|
84
|
+
self.pword_path = os.path.join(data_dir, 'passwd')
|
|
85
|
+
super().__init__(*args, data_dir=data_dir, **kwargs)
|
|
86
|
+
|
|
87
|
+
# reads the stored hashes
|
|
88
|
+
def setup_data(self):
|
|
89
|
+
if os.path.exists(self.hashes_path):
|
|
90
|
+
with open(self.hashes_path, 'r') as f:
|
|
91
|
+
self.hashes = json.load(f)
|
|
92
|
+
else:
|
|
93
|
+
self.hashes = {}
|
|
94
|
+
|
|
95
|
+
if os.path.exists(self.modes_path):
|
|
96
|
+
with open(self.modes_path, 'r') as f:
|
|
97
|
+
self.modes = json.load(f)
|
|
98
|
+
else:
|
|
99
|
+
self.modes = {}
|
|
100
|
+
|
|
101
|
+
# removes file entries in modes and hashes that are no longer in the
|
|
102
|
+
# manifest
|
|
103
|
+
def clean_data(self, manifest):
|
|
104
|
+
for data in [self.hashes, self.modes]:
|
|
105
|
+
diff = set(data) - set(manifest)
|
|
106
|
+
for key in diff:
|
|
107
|
+
data.pop(key)
|
|
108
|
+
self.save_data()
|
|
109
|
+
|
|
110
|
+
# saves the current hashes and modes to the data dir
|
|
111
|
+
def save_data(self):
|
|
112
|
+
with open(self.hashes_path, 'w') as f:
|
|
113
|
+
json.dump(self.hashes, f)
|
|
114
|
+
with open(self.modes_path, 'w') as f:
|
|
115
|
+
json.dump(self.modes, f)
|
|
116
|
+
|
|
117
|
+
# sets the password in the plugin's data dir. do not use directly, use
|
|
118
|
+
# change_password instead
|
|
119
|
+
def save_password(self, password):
|
|
120
|
+
# get salt from crypto-safe random source
|
|
121
|
+
salt = os.urandom(32)
|
|
122
|
+
# calculate password hash
|
|
123
|
+
key = key_stretch(password.encode(), salt)
|
|
124
|
+
|
|
125
|
+
# save salt and hash
|
|
126
|
+
with open(self.pword_path, 'w') as f:
|
|
127
|
+
d = {'pword': key, 'salt': salt.hex()}
|
|
128
|
+
json.dump(d, f)
|
|
129
|
+
|
|
130
|
+
# takes a password and checks if the correct password was entered
|
|
131
|
+
def verify_password(self, password):
|
|
132
|
+
with open(self.pword_path, 'r') as f:
|
|
133
|
+
d = json.load(f)
|
|
134
|
+
return key_stretch(password, d['salt']) == d['pword']
|
|
135
|
+
|
|
136
|
+
# asks the user for a new password and re-encrypts all the files with the
|
|
137
|
+
# new password. if repo is None no attempt is made to re-encrypt files
|
|
138
|
+
def change_password(self, repo=None):
|
|
139
|
+
while True:
|
|
140
|
+
p1 = getpass.getpass(prompt='Enter new password: ')
|
|
141
|
+
p2 = getpass.getpass(prompt='Re-enter new password: ')
|
|
142
|
+
|
|
143
|
+
if p1 != p2:
|
|
144
|
+
print('Entered passwords do not match, please try again')
|
|
145
|
+
else:
|
|
146
|
+
break
|
|
147
|
+
|
|
148
|
+
new_pword = p1
|
|
149
|
+
new_gpg = GPG(new_pword)
|
|
150
|
+
|
|
151
|
+
if repo is not None:
|
|
152
|
+
self.init_password()
|
|
153
|
+
|
|
154
|
+
for root, dirs, files in os.walk(repo):
|
|
155
|
+
for fname in files:
|
|
156
|
+
fname = os.path.join(root, fname)
|
|
157
|
+
logging.info(f'changing passphrase for '
|
|
158
|
+
f'{os.path.relpath(fname, repo)}')
|
|
159
|
+
|
|
160
|
+
# make a secure temporary file
|
|
161
|
+
fs, sfname = tempfile.mkstemp()
|
|
162
|
+
# close the file-handle since we won't be using it (just
|
|
163
|
+
# there for gpg to write to)
|
|
164
|
+
os.close(fs)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
# decrypt with old passphrase and re-encrypt with new
|
|
168
|
+
# passphrase
|
|
169
|
+
self.gpg.decrypt(fname, sfname)
|
|
170
|
+
new_gpg.encrypt(sfname, fname)
|
|
171
|
+
except: # noqa: E722
|
|
172
|
+
raise
|
|
173
|
+
finally:
|
|
174
|
+
os.remove(sfname)
|
|
175
|
+
|
|
176
|
+
self.gpg = new_gpg
|
|
177
|
+
self.save_password(new_pword)
|
|
178
|
+
return new_pword
|
|
179
|
+
|
|
180
|
+
# gets the password from the user if needed
|
|
181
|
+
def init_password(self):
|
|
182
|
+
if self.gpg is not None:
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
if not os.path.exists(self.pword_path):
|
|
186
|
+
print('No encryption password was found for this repo. To '
|
|
187
|
+
'continue please set an encryption password\n')
|
|
188
|
+
password = self.change_password()
|
|
189
|
+
else:
|
|
190
|
+
while True:
|
|
191
|
+
password = getpass.getpass(prompt='Encryption password: ')
|
|
192
|
+
if self.verify_password(password):
|
|
193
|
+
break
|
|
194
|
+
print('Incorrect password entered, please try again')
|
|
195
|
+
|
|
196
|
+
self.gpg = GPG(password)
|
|
197
|
+
|
|
198
|
+
# encrypts a file from outside the repo and stores it inside the repo
|
|
199
|
+
def apply(self, source, dest):
|
|
200
|
+
self.init_password()
|
|
201
|
+
self.gpg.encrypt(source, dest)
|
|
202
|
+
|
|
203
|
+
# calculate and store file hash
|
|
204
|
+
self.hashes[self.strip_repo(dest)] = hash_file(source)
|
|
205
|
+
# store file mode data (metadata)
|
|
206
|
+
self.modes[self.strip_repo(dest)] = os.stat(source).st_mode & 0o777
|
|
207
|
+
|
|
208
|
+
self.save_data()
|
|
209
|
+
|
|
210
|
+
# decrypts source and saves it in dest
|
|
211
|
+
def remove(self, source, dest):
|
|
212
|
+
self.init_password()
|
|
213
|
+
self.gpg.decrypt(source, dest)
|
|
214
|
+
os.chmod(dest, self.modes[self.strip_repo(source)])
|
|
215
|
+
|
|
216
|
+
# compares the ext_file to repo_file and returns true if they are the same.
|
|
217
|
+
# does this by looking at the repo_file's hash and calculating the hash of
|
|
218
|
+
# the ext_file
|
|
219
|
+
def samefile(self, repo_file, ext_file):
|
|
220
|
+
ext_hash = hash_file(ext_file)
|
|
221
|
+
repo_file = self.strip_repo(repo_file)
|
|
222
|
+
return self.hashes.get(repo_file, None) == ext_hash
|
|
223
|
+
|
|
224
|
+
def strify(self, op):
|
|
225
|
+
if op == self.apply:
|
|
226
|
+
return "ENCRYPT"
|
|
227
|
+
elif op == self.remove:
|
|
228
|
+
return "DECRYPT"
|
|
229
|
+
else:
|
|
230
|
+
return ""
|
dotsync/plugins/plain.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import filecmp
|
|
4
|
+
|
|
5
|
+
from dotsync.plugin import Plugin
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PlainPlugin(Plugin):
|
|
9
|
+
def __init__(self, *args, **kwargs):
|
|
10
|
+
self.hard = kwargs.pop('hard', False)
|
|
11
|
+
super().__init__(*args, **kwargs)
|
|
12
|
+
|
|
13
|
+
def setup_data(self):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
# copies file from outside the repo to the repo
|
|
17
|
+
def apply(self, source, dest):
|
|
18
|
+
shutil.copy2(source, dest)
|
|
19
|
+
|
|
20
|
+
# if not in hard mode, creates a symlink in dest (outside the repo) that
|
|
21
|
+
# points to source (inside the repo)
|
|
22
|
+
# if in hard mode, copies the file from the repo to the dest.
|
|
23
|
+
def remove(self, source, dest):
|
|
24
|
+
if self.hard:
|
|
25
|
+
shutil.copy2(source, dest)
|
|
26
|
+
else:
|
|
27
|
+
os.symlink(source, dest)
|
|
28
|
+
|
|
29
|
+
# if not in hard mode, checks if symlink points to file in repo
|
|
30
|
+
# if in hard mode, a bit-by-bit comparison is made to compare the files
|
|
31
|
+
def samefile(self, repo_file, ext_file):
|
|
32
|
+
if self.hard:
|
|
33
|
+
if os.path.islink(ext_file):
|
|
34
|
+
return False
|
|
35
|
+
if not os.path.exists(repo_file):
|
|
36
|
+
return False
|
|
37
|
+
return filecmp.cmp(repo_file, ext_file, shallow=False)
|
|
38
|
+
else:
|
|
39
|
+
# not using os.samefile since it resolves repo_file as well which
|
|
40
|
+
# is not what we want
|
|
41
|
+
return os.path.realpath(ext_file) == os.path.abspath(repo_file)
|
|
42
|
+
|
|
43
|
+
def strify(self, op):
|
|
44
|
+
if op == self.apply:
|
|
45
|
+
return "COPY"
|
|
46
|
+
elif op == self.remove:
|
|
47
|
+
return "COPY" if self.hard else "LINK"
|
|
48
|
+
return ""
|