1
\$\begingroup\$

SDel is an open-source command-line utility for secure file deletion. (For me personally, it is also an attempt to refresh my knowledge of modern C++ after a long period of using C.)

SDel Alpha requires a C++14 standard-compliant toolchain and the Boost Libraries to be installed for Boost.IOStreams, Boost.System and Boost.Filesystem.

Note that the two help printing functions print_help() and print_help2() are generated by the gen_print_help utility from print_help.txt and print_regex_help.txt respectively.

Aside from code style concerns, I am also worried that using memory-mapped files is not suitable for repeatedly overwriting files with data (i.e. the point of "secure deletion"), especially when no flush() function is available.

Posted below is the source file sdel.cpp from Revision 1:

//
// Copyright (c) 2016 Andrei Bondor
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//

//
// TODO: switch from `boost::filesystem` to `std::filesystem` when C++17
//  support becomes available
//

#include <algorithm>
#include <cassert>
#include <cstddef>
#include <cstdlib>
#include <fstream>
#include <functional>
#include <iostream>
#include <limits>
#include <new>
#include <random>
#include <regex>
#include <set>
#include <stdexcept>
#include <string>

#include <boost/filesystem.hpp>
#include <boost/iostreams/device/mapped_file.hpp>

using namespace std::literals::string_literals;

namespace fs = boost::filesystem;
namespace io = boost::iostreams;

using filename_list = std::set<std::string>;
using path_list     = std::set<fs::path>;

///
/// @brief Program version number.
/// @details The version can be thought of as a specially written integer.
///  Thus, for example, the version following "1.0.2.9" is "1.0.3.0".
///  The version shall never be something like "1.0.3210.4".
///
#define SDEL_VERSION    "1.0.0.0"

namespace
{

///
/// @brief Prints basic help information.
/// @note This function was generated by the `gen_print_help` utility and as
///  such it should not be edited directly.
///
void print_help() noexcept
{
    std::cout <<
        "\n ==================================================================="
        "===========\n Secure Deletion Utility                                 "
        "       version " SDEL_VERSION "\n ===================================="
        "==========================================\n\n The syntax is:\n\n    s"
        "del [option <arg> [...]] path1 [path2 [...]]\n    sdel --regex|-R rege"
        "x1 [regex2 [...]]\n\n The following options are supported:\n\n    LONG"
        " NAME       SHORT   ARGUMENT        MEANING\n    ---------------------"
        "------------------------------------------------------\n    --help    "
        "      -h      (none)          print this help information\n    --help2"
        "         -h2     (none)          print help for ECMAScript regex\n    "
        "--version       -V      (none)          print version information\n   "
        " --verbose       -v      (none)          print debug information\n    "
        "--random        -r      (none)          write random data instead of z"
        "eros\n    --regex         -R      (none)          expect ECMAScript re"
        "gex filenames\n    --passes        -p      number > 0      how many ti"
        "mes to overwrite files\n    ------------------------------------------"
        "---------------------------------\n\n Examples:\n\n    sdel ugly.jpg\n"
        "    sdel -v garbage_folder/\n    sdel --random --passes 5 naughty.mov"
        "\n    sdel -r -p 10 horny/*.mp4\n    sdel --regex \"[^a-dA-D].+_2001-0"
        "2-\\d\\d\\.jpe?g\"\n";
}

///
/// @brief Prints usage help for the ECMAScript regex syntax.
/// @note This function was generated by the `gen_print_help` utility and as
///  such it should not be edited directly.
///
void print_help2() noexcept
{
    std::cout <<
        "\n ==================================================================="
        "===========\n Secure Deletion Utility                                 "
        "       version " SDEL_VERSION "\n ===================================="
        "==========================================\n\n Short intro\n ---------"
        "--\n ECMAScript regex mode is enabled by using the \"--regex\" or \"-R"
        "\" options.\n Without using the above options, SDel expects traditiona"
        "l wildcard filenames.\n Wildcards are the asterisk (*) and the questio"
        "n mark (?) symbols, meaning:\n\n    * = any number of any character\n "
        "       Example: *.txt matches \"readme.txt\", \"tutorial.txt\", etc.\n"
        "\n    ? = exactly one of any character\n        Example: ???.txt match"
        "es \"abc.txt\" but not \"a.txt\"\n\n    Mixed example:\n    *_photo??."
        "jpg matches \"samantha_photo01.jpg\" but not \"joe_photograph.jpg\"\n"
        "\n In cases where the above functionality is not fine-grained enough, "
        "regex mode\n can be enabled, and SDel treats all filenames as regexes."
        " (So be careful what\n you type!)\n\n ECMAScript regex syntax\n ------"
        "-----------------\n This section is a quick-and-dirty tutorial, and no"
        "t an in-depth explanation\n of all that ECMAScript regex syntax is and"
        " can do. (Additional links will be\n found at the end of this help mes"
        "sage.)\n\n Note that regex filenames only work for files in the curren"
        "t directory.\n\n Note also that you must quote regexes to prevente you"
        "r shell from\n expanding them (you can use double or single quotes):\n"
        "\n  sdel -R \"regex\"\n\n ============================================"
        "==================================\n\n    SYNTAX              MEANING"
        "\n    ----------------------------------------------------------------"
        "-----------\n    .                   any character except newline\n   "
        " ---------------------------------------------------------------------"
        "------\n\n As such if you have dots (.) in your filename, you must esc"
        "ape them with the\n backslash character (\\) in your regex.\n\n Exampl"
        "e:\n\n    .txt                matches \"atxt\"\n    .\\.txt           "
        "   matches \"a.txt\"\n\n Note that other characters must also be escap"
        "ed, if they're part of the\n filename you're searching for:\n\n    ^ $"
        " \\ . * + ? ( ) [ ] { } |\n\n ========================================"
        "======================================\n\n    SYNTAX              MEAN"
        "ING\n    -------------------------------------------------------------"
        "--------------\n    *                   the previous char, group, or c"
        "lass, 0 or more times\n    +                   same as above, but at l"
        "east once: 1 or more times\n    {N}                 same as above, but"
        " exactly N times\n    {MIN,MAX}           same as above, but at least "
        "MIN, at most MAX times\n    ------------------------------------------"
        "---------------------------------\n\n Example:\n\n    .*\\.txt        "
        "     matches \".txt\", \"a.txt\", \"readme.txt\"\n    .+\\.txt        "
        "     matches \"a.txt\", \"readme.txt\"\n    .{5}\\.txt           match"
        "es \"abcde.txt\" but NOT \"abc.txt\"\n\n ============================="
        "=================================================\n\n    SYNTAX       "
        "       MEANING\n    --------------------------------------------------"
        "-------------------------\n    \\d                  digit (0, 1, 2, .."
        "., 9)\n    \\D                  NOT digit\n    \\s                  wh"
        "itespace (space, tab, newline)\n    \\S                  NOT whitespac"
        "e\n    \\w                  word (alphanumerical + underscore: a-z, A-"
        "Z, 0-9, _)\n    \\W                  NOT word\n    -------------------"
        "--------------------------------------------------------\n\n Example:"
        "\n\n    \\d+\\.jpg            matches \"0123.jpg\", \"4.jpg\" but NOT "
        "\"ab50.jpg\"\n    \\w+_\\D+.png         matches \"ab923_whx.png\" but "
        "NOT \"ab923_100.png\"\n\n ============================================"
        "==================================\n\n    SYNTAX              MEANING"
        "\n    ----------------------------------------------------------------"
        "-----------\n    (group)             group (all the characters inside "
        "the parentheses)\n    ------------------------------------------------"
        "---------------------------\n\n Example:\n\n    (Abc)*\\.txt         m"
        "atches \"AbcAbcAbc.txt\"\n\n ========================================="
        "=====================================\n\n    SYNTAX              MEANI"
        "NG\n    --------------------------------------------------------------"
        "-------------\n    [class]             class (any of the characters in"
        "side the brackets)\n    [^class]            NOT class (negated class)"
        "\n    ----------------------------------------------------------------"
        "-----------\n\n Example:\n\n    [abc]\\.txt          matches \"a.txt\""
        ", \"b.txt\", \"c.txt\" but NOT \"d.txt\"\n    [0-5]+              matc"
        "hes \"001\", \"53\" but NOT \"93\"\n    [^a-d]+             matches \""
        "egz\", \"gh\", \"zw\" but NOT \"ba\"\n\n ============================="
        "=================================================\n\n More regex info "
        "online\n ----------------------\n The following websites explain the E"
        "CMAScript regex syntax in more detail:\n\n    http://www.cplusplus.com"
        "/reference/regex/ECMAScript/\n    http://en.cppreference.com/w/cpp/reg"
        "ex/ecmascript\n    http://ecma-international.org/ecma-262/5.1/#sec-15."
        "10\n";
}

///
/// @brief Prints version information.
/// @note This function uses the following string macros if they are defined:
///  `SDEL_VERSION`                     e.g. "1.0.0.6"
///  `SDEL_BUILD_TYPE`                  e.g. "32-bit (i686)"
/// @remarks `SDEL_VERSION` is mandatory.
///
void print_version() noexcept
{
    std::cout << "SDel " << SDEL_VERSION;
#ifdef SDEL_BUILD_TYPE
    if (SDEL_BUILD_TYPE != ""s)
        std::cout << ' ' << SDEL_BUILD_TYPE;
#endif
    std::cout << std::endl;
}

///
/// @brief Overwrites a file with data for a given number of passes.
/// @pre `fs::is_regular_file(fpath) == true`
/// @param [in] fpath               File path of file to be overwritten.
/// @param [in] passes              How many times to repeat the overwriting.
/// @param [in] random              Whether or not to use random data instead
///                                 of zeros.
///
void overwrite_file(const fs::path &fpath, std::size_t passes, bool random)
try
{
    assert(fs::is_regular_file(fpath) == true);

    io::mapped_file_sink file(fpath.string());

    if (!file.is_open())
        throw std::runtime_error("could not open " + fpath.string() +
            " for writing");

    char * const begin  = file.data();
    char * const end    = file.data() + file.size();

    for (std::size_t p=0; p < passes; ++p)
    {
        if (random) // fill stream with random data
        {
            std::random_device rd;

            std::uniform_int_distribution<char> rand_char(
                std::numeric_limits<char>::min(),
                std::numeric_limits<char>::max());

            std::generate(begin, end, [&rd, &rand_char]() -> char
                {
                    return rand_char(rd);
                });
        }
        else // fill stream with zeros
        {
            std::fill(begin, end, char(0));
        }
    }
}
catch (const std::runtime_error &e)
{
    std::cerr << "`std::runtime_error` exception in `" << __func__ << "`: ";
    std::cerr << e.what() << std::endl;
    throw;
}
catch (...)
{
    std::cerr << "unknown exception in `" << __func__ << '`' << std::endl;
    throw;
}

///
/// @brief Helper structure to hold the options and filenames given by the user
///  from the command-line.
///
struct program_options
{
public:

    bool help       = false;    ///< Print help information.
    bool help2      = false;    ///< Print regex help information.
    bool version    = false;    ///< Print version information.
    bool verbose    = false;    ///< Print debug information.
    bool random     = false;    ///< Fill files with random data.
    bool regex      = false;    ///< Expect ECMAScript regex syntax.

    /// How many time a file will be overwritten.
    std::size_t     passes = 1;

    /// Raw Filenames of files to be deleted (raw, to be resolved).
    filename_list   rawfnames;
};

///
/// @brief Parses the command-line arguments into program options.
/// @param [in] argc                Number of arguments.
/// @param [in] argv                Argument strings.
/// @returns The program options structure.
///
program_options parse_args(int argc, char *argv[])
try
{
    program_options opts;

    for (int i=1; i < argc; ++i)
    {
        if (argv[i] == "--help"s ||
            argv[i] == "-h"s)
        {
            opts.help = true;
        }
        else
        if (argv[i] == "--help2"s ||
            argv[i] == "-h2"s)
        {
            opts.help2 = true;
        }
        else
        if (argv[i] == "--version"s ||
            argv[i] == "-V"s)
        {
            opts.version = true;
        }
        else
        if (argv[i] == "--verbose"s ||
            argv[i] == "-v"s)
        {
            opts.verbose = true;
        }
        else
        if (argv[i] == "--random"s ||
            argv[i] == "-r"s)
        {
            opts.random = true;
        }
        else
        if (argv[i] == "--regex"s ||
            argv[i] == "-R"s)
        {
            opts.regex = true;
        }
        else
        if (argv[i] == "--passes"s ||
            argv[i] == "-p"s)
        {
            if (++i < argc)
                opts.passes = std::stoul(argv[i]);
        }
        else // consider `argv[i]` to be a raw filename
        {
            opts.rawfnames.insert(argv[i]);
        }
    }

    return opts;
}
catch (const std::invalid_argument &e)
{
    std::cerr << "`std::invalid_argument` exception in `" << __func__ << "`: ";
    std::cerr << e.what() << std::endl;
    throw std::runtime_error("runtime error in `"s + __func__ + '`');
}
catch (const std::out_of_range &e)
{
    std::cerr << "`std::out_of_range` exception in `" << __func__ << "`: ";
    std::cerr << e.what() << std::endl;
    throw std::runtime_error("runtime error in `"s + __func__ + '`');
}
catch (...)
{
    std::cerr << "unknown exception in `" << __func__ << '`' << std::endl;
    throw;
}

///
/// @brief Resolves raw filenames into full paths.
/// @param [in] rawfnames           Raw filenames to be resolved.
/// @param [in] regex               Whether or not to use all raw filenames as
///                                 ECMAScript regexes.
/// @returns List of resolved filenames.
///
path_list resolve_fnames(const filename_list &rawfnames, bool regex)
try
{
    path_list rpaths; // Resolved Paths

    if (regex) // treat raw filenames as regexes
    {
        for (const fs::path &p: fs::directory_iterator("."))
        {
            for (const auto &rfn: rawfnames)
            {
                const std::string   subj(p.filename().string()); // Subject
                const std::regex    patt(rfn); // Pattern

                if (std::regex_match(subj, patt))
                {
                    rpaths.insert(fs::canonical(p));
                    break;
                }
            }
        }
    }
    else // check which raw filenames actually exist
    {
        for (const auto &rfn: rawfnames)
        {
            if (!fs::exists(rfn))
                continue;

            if (fs::is_directory(rfn)) // recurse directories
            {
                // also add the directory, for cleaning up by `fs::remove_all()`
                rpaths.insert(fs::canonical(rfn));

                for (const fs::path &p: fs::recursive_directory_iterator(rfn))
                {
                    if (fs::is_regular_file(p))
                        rpaths.insert(fs::canonical(p));
                }
            }
            else
            if (fs::is_regular_file(rfn))
                rpaths.insert(fs::canonical(rfn));
        }
    }

    return rpaths;
}
catch (const std::bad_alloc &e)
{
    std::cerr << "`std::bad_alloc` exception in `" << __func__ << "`: ";
    std::cerr << e.what() << std::endl;
    throw;
}
catch (const fs::filesystem_error &e)
{
    std::cerr << "`fs::filesystem_error` exception in `" << __func__ << "`: ";
    std::cerr << e.what() << std::endl;
    throw;
}
catch (...)
{
    std::cerr << "unknown exception in `" << __func__ << '`' << std::endl;
    throw;
}

///
/// @brief Prints program options.
/// @param [in] opts                Options to print.
///
void print_options(const program_options &opts) noexcept
{
    std::cout << "\n Program Options\n ---------------\n";
    std::cout << " overwriting mode:   ";
    std::cout << (opts.random ? "random data" : "zeros") << '\n';
    std::cout << " matching mode:      ";
    std::cout << (opts.regex ? "ECMAScript regex" : "plain and wildcards");
    std::cout << "\n\n";

    if (!opts.rawfnames.empty())
    {
        std::cout << ' ' << opts.rawfnames.size();
        std::cout << " filename(s) to be resolved:\n";

        for (const auto &fn: opts.rawfnames)
            std::cout << "   " << fn << '\n';
    }
    else
        std::cout << " no filenames to be resolved\n";

    std::cout << " ---------------" << std::endl;
}

///
/// @brief Prints resolved paths.
/// @param [in] rp                  List of resolved paths.
///
void print_paths(const path_list &rp) noexcept
{
    std::cout << "\n Resolved Paths\n --------------\n";
    std::cout << " total: " << rp.size() << '\n';

    for (const auto &fn: rp)
        std::cout << "   " << fn << '\n';

    std::cout << " --------------" << std::endl;
}

} // unnamed namespace

///
/// @brief Enters the program.
/// @param [in] argc                Number of arguments.
/// @param [in] argv                Argument strings.
/// @returns Whether or not the operation was successful.
/// @retval EXIT_SUCCESS            Operation success.
/// @retval EXIT_FAILURE            Operation failure.
///
int main(int argc, char *argv[])
try
{
    const program_options opts = parse_args(argc, argv);

    if (argc <= 1 || opts.help)
    {
        print_help();
        return EXIT_SUCCESS;
    }

    if (opts.help2)
    {
        print_help2();
        return EXIT_SUCCESS;
    }

    if (opts.version)
    {
        print_version();
        return EXIT_SUCCESS;
    }

    // Resolved Paths
    const path_list rpaths = resolve_fnames(opts.rawfnames, opts.regex);

    if (opts.verbose)
    {
        print_options(opts);
        print_paths(rpaths);
    }

    std::function<void (const fs::path &)> verbose_maybe =
        [&opts](const fs::path &p) -> void
        {
            if (!opts.verbose)
                return;

            std::cout << "   " << p.string() << " (" << fs::file_size(p);
            std::cout << " bytes)" << std::endl;
        };

    path_list empty_dirs; // Empty Directories

    if (opts.verbose && !rpaths.empty())
        std::cout << "overwriting and deleting files\n";

    for (const fs::path &p: rpaths)
    {
        if (fs::is_directory(p))
        {
            empty_dirs.insert(p);
            continue;
        }

        verbose_maybe(p);
        overwrite_file(p, opts.passes, opts.random);
        fs::remove(p);
    }

    if (opts.verbose && !empty_dirs.empty())
        std::cout << "cleanup of empty directories\n";

    for (const fs::path &p: empty_dirs)
    {
        if (opts.verbose)
            std::cout << "   " << p.string() << std::endl;

        fs::remove_all(p);
    }

    return EXIT_SUCCESS;
}
catch (const std::bad_alloc &e)
{
    std::cerr << "`std::bad_alloc` exception in `" << __func__ << "`: ";
    std::cerr << e.what() << std::endl;
    throw;
}
catch (const fs::filesystem_error &e)
{
    std::cerr << "`fs::filesystem_error` exception in `" << __func__ << "`: ";
    std::cerr << e.what() << std::endl;
    return EXIT_FAILURE;
}
catch (const std::runtime_error &e)
{
    std::cerr << "`std::runtime_error` exception in `" << __func__ << "`: ";
    std::cerr << e.what() << std::endl;
    return EXIT_FAILURE;
}
catch (...)
{
    std::cerr << "unknown exception in `" << __func__ << '`' << std::endl;
    return EXIT_FAILURE;
}
\$\endgroup\$
1
\$\begingroup\$

I know this is just a toy program, but since you mention your concerns about security: yeah, I'd agree with your concerns about mmap. If you really want similar security guarantees (such as they are) to srm, I strongly recommend just reading the srm source code and seeing how they do it. I suspect you have to go waaay lower-level than boost::filesystem to deal properly with things like journaling filesystems.


/// @details The version can be thought of as a specially written integer.
///  Thus, for example, the version following "1.0.2.9" is "1.0.3.0".
///  The version shall never be something like "1.0.3210.4".
///
#define SDEL_VERSION    "1.0.0.0"

I recommend following the guidelines on https://semver.org, rather than making up your own idiosyncratic version-number format. Also, if you really do want the version number to be an integer with funny formatting, I recommend actually making it an integer — and then getting rid of the funny formatting — and then you'll find that it's just a mildly idiosyncratic way of writing semver.org's PATCH number. Your code will semantically remain at 0.0.z forever.

Anyway, if you do want to invent your own version-numbering scheme, a throwaway comment in the middle of a secure-deletion program is not the right place to document it. ;)


void print_help() noexcept is practically unreadable. Use semantically meaningful line-breaks, so that you can read and edit the help text later! Or else I don't know what the gen_print_help utility is.

Also, it's not semantically noexcept, given that writing to std::cout can throw. You should only use noexcept on functions that are actually nothrow; and my personal advice is to use it very sparingly — only clutter your code with the noexcept keyword if you know that the code will be pessimized without it. For example, use noexcept on your move constructors, move assignment operators (if possible), and swap functions; but don't just slap it on everything willy-nilly, because most of the time it's just visual clutter for no measurable benefit. (Some smart people would disagree with this advice, though.)


Don't use function-try-blocks on regular functions such as overwrite_file. Use normal try-blocks inside the function body instead. The one place function-try-blocks are useful is on constructors, where they catch exceptions thrown from the member-initializer-list as well; everywhere else you're using them is just unnecessary confusion for people who don't know all the obscure corners of the language.


assert(fs::is_regular_file(fpath) == true);

This is a needlessly complicated way of writing

assert(fs::is_regular_file(fpath));

If I understand correctly, the catch-blocks in parse_args are hit only if the user invokes your program with an invalid -p option, e.g. -p five. Your error messages are pretty unhelpful in this case. Consider adding some actual error-handling logic and a friendly message, e.g. "The argument to -p must be numeric."

Also, you should check that the argument to -p is non-negative (maybe even positive?); and you should not silently accept -p-without-any-argument as a synonym for no--p-at-all.


Regardless of your concerns about mmapped files, std::fill in a loop isn't going to overwrite the file multiple times, not even in memory. Check the assembly output with -O3; I think you'll find that the compiler has hoisted the if (random) test outside the loop and duplicated the loop body, and then replaced for (size_t i=0; i < passes; ++i) std::fill(begin, end, 0); with the equivalent of memset(begin, 0, end-begin);.


        std::generate(begin, end, [&rd, &rand_char]() -> char
            {
                return rand_char(rd);
            });

is a complicated way of writing

        std::generate(begin, end, [&]() { return rand_char(rd); });

    else
    if (argv[i] == "--help2"s ||
        argv[i] == "-h2"s)

Your else if style is highly idiosyncratic. I've seen people disagree about cuddling:

} else if (foo) {  // my preference

} 
else if (foo)   // some others' preference
{

but I've never before seen this kind of "extreme uncuddling"!

} 
else
if (foo)   // some others' preference
{

Also, notice that the s suffixes here are impeding readability, and constructing all those temporary strings might even hurt performance (not that argument-parsing is going to be a hotspot). Personally I would write this as

const std::string arg = argv[i];
if (arg == "--help" || arg == "-h") {
    // ...
} else if (arg == "--help2" || arg == "-h2") {
    // ...

IMHO your interface's use of both -r and -R, and both -v and -V, is unfortunate. There's a whole alphabet available; maybe you could come up with some synonyms with different initial letters? Alternatively, just removing the short options would eliminate the potential for dangerous typos. Plenty of tools these days have only GNU-style long options — we can afford such luxuries now that most of us have retired our teletypes. :)

\$\endgroup\$
  • \$\begingroup\$ Regarding noexcept, thanks for reminding me about std::ios::exceptions() though I cannot fathom who would want to enable them for std::cout. Maybe something like noexcept (std::cout.exceptions() == 0) could be used, noting that std::ios_base::goodbit is defined as zero in the C++11 standard. \$\endgroup\$ – user7023624 Dec 7 '16 at 8:27
  • \$\begingroup\$ I didn't even know about std::ios::exceptions() and was just thinking — apparently incorrectly — that pretty much any non-trivial C++ code risks throwing std::bad_alloc! Good to know that I was technically correct anyway. ;) \$\endgroup\$ – Quuxplusone Dec 7 '16 at 23:18

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.