git-shell-enforce-directory 1.0.1

Restrict git-shell into a directory
#[macro_use]
extern crate clap;
use clap::Arg;
use std::{env, fs, path, process};
extern crate regex;
use regex::Regex;
extern crate env_logger;
extern crate exec;
#[macro_use]
extern crate log;

macro_rules! fatal {
    ($msg:expr) => ({
        error!($msg);
        process::exit(1)
    });
    ($fmt:expr, $($arg:tt)*) => ({
      error!($fmt, $($arg)*);
      process::exit(1)
    });
}

fn main() {
  include_str!("../Cargo.toml");
  let args = app_from_crate!()
    .arg(
      Arg::with_name("path")
        .help("Path to Git repository")
        .required(true)
        .multiple(true),
    )
    .arg(
      Arg::with_name("v")
        .short("v")
        .help("Sets the log level to debug"),
    )
    .arg(
      Arg::with_name("recursive")
        .short("r")
        .long("recursive")
        .help("Allow any subdirectory of the provided paths"),
    )
    .arg(
      Arg::with_name("read-only")
        .long("read-only")
        .help("Disable write operations"),
    )
    .get_matches();

  let level = match args.occurrences_of("v") {
    0 => log::LevelFilter::Info,
    _ => log::LevelFilter::Debug,
  };

  env_logger::Builder::from_default_env()
    .default_format_level(true)
    .default_format_module_path(false)
    .default_format_timestamp(false)
    .filter(Some(""), level)
    .init();

  let recursive = args.is_present("recursive");

  let mut arg_paths = args
    .values_of("path")
    .unwrap()
    .map(|x| fs::canonicalize(x).unwrap());

  debug!(
    "SSH_ORIGINAL_COMMAND: {:#?}",
    env::var("SSH_ORIGINAL_COMMAND")
  );
  let cmd = match env::var("SSH_ORIGINAL_COMMAND") {
    Ok(val) => val,
    Err(_e) => {
      fatal!("SSH_ORIGINAL_COMMAND environment variable isn't set");
    }
  };

  let caps = is_upload_or_receive(&cmd);
  let caps = match caps {
    Some(caps) => caps,
    None => {
      fatal!("Command to run looks dangerous: {:?}", cmd);
    }
  };

  let read_only = args.is_present("read-only");
  debug!("read_only: {}", read_only);
  debug!("command: {}", &caps["command"]);
  if read_only && &caps["command"] == "git-receive-pack" {
    fatal!("No write commands allowed, read-only.");
  }

  let cmd_path = match recursive {
    true => fs::canonicalize(&caps["path"]).unwrap(),
    false => path::PathBuf::from(&caps["path"]),
  };

  debug!("path from SSH_ORIGINAL_COMMAND: {:?}", cmd_path);
  if !arg_paths.any(|arg_path| {
    debug!("path: {:?}", arg_path);
    match recursive {
      true => cmd_path.starts_with(fs::canonicalize(arg_path).unwrap()),
      false => cmd_path == arg_path,
    }
  }) {
    fatal!("Path {:?} not allowed", cmd_path);
  }

  let git_shell = "git-shell";
  let err = exec::Command::new(git_shell).arg("-c").arg(&cmd).exec();
  fatal!("{}: {:?}", err, git_shell);
}

fn is_upload_or_receive(cmd: &str) -> Option<regex::Captures> {
  let re = Regex::new(r"^(?P<command>git-(?:receive|upload)-pack) '(?P<path>.+)'$").unwrap();
  let caps = re.captures(cmd);
  debug!("caps: {:#?}", caps);
  caps
}

#[test]
fn correct_command_works() {
  let caps = is_upload_or_receive("git-upload-pack '/ok/path/'").unwrap();
  assert_eq!(&caps["command"], "git-upload-pack");
  assert_eq!(&caps["path"], "/ok/path/");
}

#[test]
fn malicious_command_fails() {
  let caps = is_upload_or_receive("git-upload-archive '/danger/zone/'");
  assert!(caps.is_none());
}

#[test]
fn newline_command_fails() {
  let caps = is_upload_or_receive("git-upload-pack '/danger/zone/'\n");
  assert!(caps.is_none());
}