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/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 ""
@@ -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 ""