Skip to content

Commit

Permalink
Adds Keybase engine
Browse files Browse the repository at this point in the history
Allows syncing files to Keybase.

Folders mounted with Keybase utilize a special file system (kbfs) which,
while doing some pretty cool stuff, either doesn't support some common
operations or requires them to be done via the Keybase CLI. The two
which pertain to Mackup are copying folders recursively (which should be
done with `keybase fs cp`) and stripping ACL's (which is not at all
supported). I tried to document these oddities pretty well, but let me
know if this could be made more clear.

Closes lra#1048
  • Loading branch information
jutonz committed Oct 18, 2017
1 parent a585c46 commit 04952e5
Show file tree
Hide file tree
Showing 9 changed files with 166 additions and 10 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ in it stay put, so that any other computer also running Mackup is unaffected.
- [Copy](https://www.copy.com/)
- [iCloud](http://www.apple.com/icloud/)
- [Box](https://www.box.com)
- [Keybase](https://www.keybase.io)
- Anything able to sync a folder (e.g. [Git](http://git-scm.com/))

See the [README](doc/README.md) file in the doc directory for more info.
Expand Down
10 changes: 10 additions & 0 deletions doc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ where your Copy folder is and store your configuration files in it.
engine = copy
```

### Keybase

```ini
[storage]
engine = keybase
username = yourusername
```

This config will sync files to `/keybase/private/yourusername/Mackup`

### File System

If you want to specify another directory, you can use the `file_system` engine
Expand Down
2 changes: 1 addition & 1 deletion mackup/appsdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def __init__(self):
config = configparser.SafeConfigParser(allow_no_value=True)

# Needed to not lowercase the configuration_files in the ini files
config.optionxform = str
config.optionxform = unicode

if config.read(config_file):
# Get the filename without the directory name
Expand Down
15 changes: 12 additions & 3 deletions mackup/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@
ENGINE_COPY,
ENGINE_ICLOUD,
ENGINE_BOX,
ENGINE_KEYBASE,
ENGINE_FS)

from .utils import (error,
get_dropbox_folder_location,
get_copy_folder_location,
get_google_drive_folder_location,
get_icloud_folder_location,
get_box_folder_location)
get_box_folder_location,
get_keybase_folder_location)
try:
import configparser
except ImportError:
Expand Down Expand Up @@ -65,8 +67,8 @@ def engine(self):
"""
The engine used by the storage.
ENGINE_DROPBOX, ENGINE_GDRIVE, ENGINE_COPY, ENGINE_ICLOUD, ENGINE_BOX
or ENGINE_FS.
ENGINE_DROPBOX, ENGINE_GDRIVE, ENGINE_COPY, ENGINE_ICLOUD, ENGINE_BOX,
ENGINE_KEYBASE, or ENGINE_FS.
Returns:
str
Expand Down Expand Up @@ -188,6 +190,7 @@ def _parse_engine(self):
ENGINE_COPY,
ENGINE_ICLOUD,
ENGINE_BOX,
ENGINE_KEYBASE,
ENGINE_FS]:
raise ConfigError('Unknown storage engine: {}'.format(engine))

Expand All @@ -210,6 +213,12 @@ def _parse_path(self):
path = get_icloud_folder_location()
elif self.engine == ENGINE_BOX:
path = get_box_folder_location()
elif self.engine == ENGINE_KEYBASE:
if not self._parser.has_option('storage', 'username'):
raise ConfigError("Also specify a 'username' key when using"
" 'engine = keybase') ")
username = self._parser.get('storage', 'username')
path = get_keybase_folder_location(username)
elif self.engine == ENGINE_FS:
if self._parser.has_option('storage', 'path'):
cfg_path = self._parser.get('storage', 'path')
Expand Down
1 change: 1 addition & 0 deletions mackup/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@
ENGINE_FS = 'file_system'
ENGINE_GDRIVE = 'google_drive'
ENGINE_ICLOUD = 'icloud'
ENGINE_KEYBASE = 'keybase'
2 changes: 2 additions & 0 deletions mackup/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ def printAppHeader(app_name):
if args['--force']:
utils.FORCE_YES = True

utils.CONFIG = mckp._config

dry_run = args['--dry-run']

verbose = args['--verbose']
Expand Down
47 changes: 43 additions & 4 deletions mackup/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@

from . import constants


# Flag that controls how user confirmation works.
# If True, the user wants to say "yes" to everything.
FORCE_YES = False

CONFIG = None # Overridden once config is read


def confirm(question):
"""
Expand Down Expand Up @@ -56,7 +57,8 @@ def delete(filepath):
filepath (str): Absolute full path to a file. e.g. /path/to/file
"""
# Some files have ACLs, let's remove them recursively
remove_acl(filepath)
if can_remove_acl_with_current_engine():
remove_acl(filepath)

# Some files have immutable attributes, let's remove them recursively
remove_immutable_attribute(filepath)
Expand Down Expand Up @@ -97,12 +99,11 @@ def copy(src, dst):

# We need to copy a single file
if os.path.isfile(src):
# Copy the src file to dst
shutil.copy(src, dst)

# We need to copy a whole folder
elif os.path.isdir(src):
shutil.copytree(src, dst)
_copy_recursive(src, dst)

# What the heck is this ?
else:
Expand All @@ -112,6 +113,22 @@ def copy(src, dst):
chmod(dst)


def _copy_recursive(src, dst):
"""
Helper for copy, above.
Keybase uses a special file system to mount synced directories securely.
This file system does not yet allow for directories to be copied into it
recursively using `cp`, so in those situations we defer to the Keybase CLI
to do the heavy lifting. Note that this only necessary when copying
directories into the store--it's okay to use `cp` when copying files out.
"""
if current_engine() == 'keybase' and '/keybase' in dst:
subprocess.check_output(['keybase', 'fs', 'cp', src, dst])
else:
shutil.copytree(src, dst)


def link(target, link_to):
"""
Create a link to a target file or a folder.
Expand Down Expand Up @@ -269,6 +286,16 @@ def get_box_folder_location():
return box_home


def get_keybase_folder_location(username):
"""
Try to locate the Keybase folder.
Returns:
(str) Full path to the current Keybase folder.
"""
return '/keybase/private/' + username


def get_copy_folder_location():
"""
Try to locate the Copy folder.
Expand Down Expand Up @@ -412,3 +439,15 @@ def can_file_be_synced_on_current_platform(path):
can_be_synced = False

return can_be_synced


def can_remove_acl_with_current_engine():
# Keyabse doesn't support stripping ACL's (chmod prints an ugly error)
return CONFIG and CONFIG.engine != 'keybase'


def current_engine():
if not CONFIG:
return None
else:
return CONFIG.engine
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
nose
flake8
codecov
docopt
docopt
mock
95 changes: 94 additions & 1 deletion tests/utils_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
import tempfile
import unittest
import stat
import subprocess
import random

# from unittest.mock import patch
from mock import Mock, patch

from mackup import utils

Expand Down Expand Up @@ -88,6 +90,27 @@ def test_delete_folder_recursively(self):
assert not os.path.exists(subfolder_path)
assert not os.path.exists(subfilepath)

@patch.object(utils, 'can_remove_acl_with_current_engine', Mock(return_value=False))
@patch.object(utils, 'remove_acl', Mock())
def test_delete_does_not_remove_acl(self):
"""
utils.delete does not strip ACL's if the engine does not support it
"""
# Create a tmp file
tfile = tempfile.NamedTemporaryFile(delete=False)
tfpath = tfile.name
tfile.close()

# Make sure the created file exists
assert os.path.isfile(tfpath)

# Check if mackup can really delete it
utils.delete(tfpath)
assert not os.path.exists(tfpath)

# Make sure we didn't try to strip acl's
utils.remove_acl.assert_not_called()

def test_copy_file(self):
# Create a tmp file
tfile = tempfile.NamedTemporaryFile(delete=False)
Expand Down Expand Up @@ -217,6 +240,50 @@ def test_copy_dir(self):
utils.delete(srcpath)
utils.delete(dstpath)

@patch('subprocess.check_output', Mock(return_value=0))
@patch.object(utils, 'chmod', Mock(return_value=0))
@patch.object(utils, 'CONFIG', Mock())
def test_copy_dir_keybase(self):
"""Use `keybase fs cp` when copying directories into the store"""
# Set engine to Keybase
utils.CONFIG.engine = 'keybase'

# Create a temp folder
srcpath = tempfile.mkdtemp()

# Create a temp file
tfile = tempfile.NamedTemporaryFile(delete=False, dir=srcpath)
srcfile = tfile.name
tfile.close()

# Create a temp folder
dstpath = tempfile.mkdtemp(prefix="keybase")

# Set the destination filename
srcpath_basename = os.path.basename(srcpath)
dstfile = os.path.join(dstpath,
srcpath_basename,
os.path.basename(srcfile))

# Make sure the source file and destination folder exist and the
# destination file doesn't yet exist
assert os.path.isdir(srcpath)
assert os.path.isfile(srcfile)
assert os.path.isdir(dstpath)
assert not os.path.exists(dstfile)

# Trigger copy, and...
utils.copy(srcpath, dstfile)

# ...expect the Keybase CLI to have been invoked. The call was stubbed
# so no files were actually copied.
expected_args = ['keybase', 'fs', 'cp', srcpath, dstfile]
subprocess.check_output.assert_called_with(expected_args)

# Let's clean up
utils.delete(srcpath)
utils.delete(dstpath)

def test_link_file(self):
# Create a tmp file
tfile = tempfile.NamedTemporaryFile(delete=False)
Expand Down Expand Up @@ -335,3 +402,29 @@ def test_can_file_be_synced_on_current_platform(self):
# Try to use the library path on Linux, which shouldn't work
path = os.path.join(os.environ["HOME"], "Library/")
assert not utils.can_file_be_synced_on_current_platform(path)

def test_get_keybase_folder_location(self):
username = str(random.randint(0, 10000))
actual = utils.get_keybase_folder_location(username)
assert actual == '/keybase/private/{}'.format(username)

@patch.object(utils, 'CONFIG', Mock())
def test_can_remove_acl_with_current_engine_with_dropbox(self):
utils.CONFIG.engine = 'dropbox'
assert utils.can_remove_acl_with_current_engine() == True

@patch.object(utils, 'CONFIG', Mock())
def test_can_remove_acl_with_current_engine_with_keybase(self):
utils.CONFIG.engine = 'keybase'
assert utils.can_remove_acl_with_current_engine() == False

@patch.object(utils, 'CONFIG', None)
def test_current_engine_no_config(self):
"""current_engine returns None if no CONFIG is present"""
assert utils.current_engine() is None

@patch.object(utils, 'CONFIG', Mock())
def test_current_engine(self):
engine = str(random.randint(0, 1000))
utils.CONFIG.engine = engine
assert utils.current_engine() is engine

0 comments on commit 04952e5

Please sign in to comment.