Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added CLI args and set inject home directory #43

Merged
merged 13 commits into from
Oct 23, 2018
9 changes: 4 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,15 @@ clean:
find . \( -name '*.tgz' -o -name dropin.cache \) -delete
find . | grep -E "(__pycache__)" | xargs rm -rf

TESTS ?= tests
TESTOPTS ?= -v
test: clean
xvfb-run python -m pytest
xvfb-run python -m pytest -v --cov-config .coveragerc --cov-report html --cov-report term-missing --cov=securedrop_client --cov-fail-under 100 $(TESTOPTS) $(TESTS)

pyflakes:
find . \( -name _build -o -name var -o -path ./docs -o -path \) -type d -prune -o -name '*.py' -print0 | $(XARGS) pyflakes

pycodestyle:
find . \( -name _build -o -name var \) -type d -prune -o -name '*.py' -print0 | $(XARGS) -n 1 pycodestyle --repeat --exclude=build/*,docs/*,.vscode/* --ignore=E731,E402,W504

coverage: clean
xvfb-run python -m pytest --cov-config .coveragerc --cov-report term-missing --cov=securedrop_client tests/

check: clean pycodestyle pyflakes coverage
check: clean pycodestyle pyflakes test
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ pipenv install --dev
pipenv shell
```

## Run the client

You can run the client with an ephemeral data directory:

```
./run.sh
```

If you want to persist data across restarts, you will need to run the client with:

```
./run.sh --sdc-home /path/to/my/dir/
```

## Run tests

```
Expand Down
6 changes: 0 additions & 6 deletions run.py

This file was deleted.

29 changes: 29 additions & 0 deletions run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -e

while [ -n "$1" ]; do
param="$1"
value="$2"
case $param in
--sdc-home)
SDC_HOME="$value"
shift
;;
*)
break
esac
shift
done

SDC_HOME=${SDC_HOME:-$(mktemp -d)}

echo "Running app with home directory: $SDC_HOME"

# create the database for local testing

python - << EOF
from securedrop_client.models import Base, make_engine
Base.metadata.create_all(make_engine("$SDC_HOME"))
EOF

exec python -m securedrop_client --sdc-home "$SDC_HOME" $@
3 changes: 3 additions & 0 deletions securedrop_client/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .app import run

run()
71 changes: 55 additions & 16 deletions securedrop_client/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import os
import signal
import sys
from argparse import ArgumentParser
from sqlalchemy.orm import sessionmaker
from PyQt5.QtWidgets import QApplication
from PyQt5.QtCore import Qt, QTimer
Expand All @@ -29,14 +30,18 @@
from securedrop_client.logic import Client
from securedrop_client.gui.main import Window
from securedrop_client.resources import load_icon, load_css
from securedrop_client.models import engine
from securedrop_client.models import make_engine
from securedrop_client.utils import safe_mkdir


LOG_DIR = os.path.join(str(pathlib.Path.home()), '.securedrop_client')
LOG_FILE = os.path.join(LOG_DIR, 'securedrop_client.log')
DEFAULT_SDC_HOME = '~/.securedrop_client'
ENCODING = 'utf-8'


def init(sdc_home: str) -> None:
safe_mkdir(sdc_home)


def excepthook(*exc_args):
"""
This function is called in the event of a catastrophic failure.
Expand All @@ -47,18 +52,19 @@ def excepthook(*exc_args):
sys.exit(1)


def configure_logging():
def configure_logging(sdc_home: str) -> None:
"""
All logging related settings are set up by this function.
"""
if not os.path.exists(LOG_DIR):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why remove the log directory?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was hoping to keep the directory logic all in one place so I pulled that out for now. I can readd it but...

I guess this brings up a broader question which is how strict do we want to be on data directory permissions? Like I think every dir needs to have 00 for the final two bytes, and we should bail otherwise. I know it's Qubes already, but if the directories don't need global rwx, then it shouldn't be set.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's keep the logs dir to prevent clutter

agreed on permissions

os.makedirs(LOG_DIR)
safe_mkdir(sdc_home, 'logs')
log_file = os.path.join(sdc_home, 'logs', 'client.log')

# set logging format
log_fmt = ('%(asctime)s - %(name)s:%(lineno)d(%(funcName)s) '
'%(levelname)s: %(message)s')
formatter = logging.Formatter(log_fmt)
# define log handlers such as for rotating log files
handler = TimedRotatingFileHandler(LOG_FILE, when='midnight',
handler = TimedRotatingFileHandler(log_file, when='midnight',
backupCount=5, delay=0,
encoding=ENCODING)
handler.setFormatter(formatter)
Expand All @@ -71,7 +77,35 @@ def configure_logging():
sys.excepthook = excepthook


def run():
def configure_signal_handlers(app) -> None:
def signal_handler(*nargs) -> None:
app.quit()

for sig in [signal.SIGINT, signal.SIGTERM]:
signal.signal(sig, signal_handler)


def expand_to_absolute(value: str) -> str:
'''
Helper that expands a path to the absolute path so users can provide
arguments in the form ``~/my/dir/``.
'''
return os.path.abspath(os.path.expanduser(value))


def arg_parser() -> ArgumentParser:
parser = ArgumentParser('securedrop-client',
description='SecureDrop Journalist GUI')
parser.add_argument(
'-H', '--sdc-home',
default=DEFAULT_SDC_HOME,
type=expand_to_absolute,
help=('SecureDrop Client home directory for storing files and state. '
'(Default {})'.format(DEFAULT_SDC_HOME)))
return parser


def start_app(args, qt_args) -> None:
"""
Create all the top-level assets for the application, set things up and
run the application. Specific tasks include:
Expand All @@ -84,10 +118,11 @@ def run():
- configure the client (logic) object.
- ensure the application is setup in the default safe starting state.
"""
configure_logging()
init(args.sdc_home)
configure_logging(args.sdc_home)
logging.info('Starting SecureDrop Client {}'.format(__version__))

app = QApplication(sys.argv)
app = QApplication(qt_args)
app.setApplicationName('SecureDrop Client')
app.setDesktopFileName('org.freedomofthepress.securedrop.client')
app.setApplicationVersion(__version__)
Expand All @@ -97,19 +132,23 @@ def run():
app.setWindowIcon(load_icon(gui.icon))
app.setStyleSheet(load_css('sdclient.css'))

engine = make_engine(args.sdc_home)
Session = sessionmaker(bind=engine)
session = Session()

client = Client("http://localhost:8081/", gui, session)
client = Client("http://localhost:8081/", gui, session, args.sdc_home)
client.setup()

def signal_handler(*nargs) -> None:
app.quit()

for sig in [signal.SIGINT, signal.SIGTERM]:
signal.signal(sig, signal_handler)
configure_signal_handlers(app)
timer = QTimer()
timer.start(500)
timer.timeout.connect(lambda: None)

sys.exit(app.exec_())


def run() -> None:
args, qt_args = arg_parser().parse_known_args()
# reinsert the program's name
qt_args.insert(0, 'securedrop-client')
start_app(args, qt_args)
8 changes: 6 additions & 2 deletions securedrop_client/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import sdclientapi
import arrow
from securedrop_client import storage
from securedrop_client.utils import check_dir_permissions
from PyQt5.QtCore import QObject, QThread, pyqtSignal, QTimer


Expand Down Expand Up @@ -83,19 +84,22 @@ class Client(QObject):

finish_api_call = pyqtSignal() # Acknowledges reciept of an API call.

def __init__(self, hostname, gui, session):
def __init__(self, hostname, gui, session, home: str) -> None:
"""
The hostname, gui and session objects are used to coordinate with the
various other layers of the application: the location of the SecureDrop
proxy, the user interface and SqlAlchemy local storage respectively.
"""

check_dir_permissions(home)

super().__init__()
self.hostname = hostname # Location of the SecureDrop server.
self.gui = gui # Reference to the UI window.
self.api = None # Reference to the API for secure drop proxy.
self.session = session # Reference to the SqlAlchemy session.
self.api_thread = None # Currently active API call thread.
self.sync_flag = os.path.join(os.path.expanduser('~'), '.sdsync')
self.sync_flag = os.path.join(home, 'sync_flag')

def setup(self):
"""
Expand Down
12 changes: 4 additions & 8 deletions securedrop_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, backref

Base = declarative_base()

# TODO: Store this in config file, see issue #2
DB_PATH = os.path.abspath('svs.sqlite')

engine = create_engine('sqlite:///{}'.format(DB_PATH))
Base = declarative_base()
def make_engine(home: str):
db_path = os.path.join(home, 'svs.sqlite')
return create_engine('sqlite:///{}'.format(db_path))


class Source(Base):
Expand Down Expand Up @@ -104,7 +104,3 @@ def __init__(self, username):

def __repr__(self):
return "<Journalist: {}>".format(self.username)


# Populate the database.
Base.metadata.create_all(engine)
47 changes: 47 additions & 0 deletions securedrop_client/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import os


def safe_mkdir(sdc_home: str, relative_path: str=None) -> None:
'''
Safely create directories while checking permissions along the way.
'''
check_dir_permissions(sdc_home)

if not relative_path:
return

full_path = os.path.join(sdc_home, relative_path)
if not full_path == os.path.abspath(full_path):
raise ValueError('Path is not absolute: {}'.format(full_path))

path_components = split_path(relative_path)

path_so_far = sdc_home
for component in path_components:
path_so_far = os.path.join(path_so_far, component)
check_dir_permissions(path_so_far)
os.makedirs(path_so_far, 0o0700, exist_ok=True)


def check_dir_permissions(dir_path: str) -> None:
'''
Check that a directory has ``700`` as the final 3 bytes. Raises a
``RuntimeError`` otherwise.
'''
if os.path.exists(dir_path):
stat_res = os.stat(dir_path).st_mode
masked = stat_res & 0o777
if masked & 0o077:
raise RuntimeError('Unsafe permissions ({}) on {}'
.format(oct(stat_res), dir_path))


def split_path(path: str) -> list:
out = []

while path:
path, tail = os.path.split(path)
out.append(tail)

out.reverse()
return out
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import os
import pytest


@pytest.fixture(scope='function')
def safe_tmpdir(tmpdir):
os.chmod(str(tmpdir), 0o0700)
return tmpdir
Loading