Skip to content

Commit 45e83c2

Browse files
authored
Merge pull request #107 from rage/hmac
added support for hmac verification in the python plugin
2 parents 864026f + 5ffd953 commit 45e83c2

File tree

4 files changed

+143
-13
lines changed

4 files changed

+143
-13
lines changed

plugins/python3/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ serde_json = "1"
1414
thiserror = "1"
1515
walkdir = "2"
1616
dunce = "1"
17+
rand = "0.8"
18+
hmac = "0.10"
19+
sha2 = "0.9"
20+
hex = "0.4"
1721

1822
[dev-dependencies]
1923
simple_logger = "1"

plugins/python3/src/error.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ pub enum PythonError {
1919
found: String,
2020
minimum_required: String,
2121
},
22+
#[error("Failed to decode the test runner's HMAC as hex")]
23+
UnexpectedHmac,
24+
#[error("Failed to verify the test results")]
25+
InvalidHmac,
2226

2327
#[error("File IO error")]
2428
FileIo(#[from] FileIo),

plugins/python3/src/plugin.rs

Lines changed: 97 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
use crate::error::PythonError;
44
use crate::policy::Python3StudentFilePolicy;
55
use crate::python_test_result::PythonTestResult;
6+
use hmac::{Hmac, Mac, NewMac};
67
use lazy_static::lazy_static;
8+
use rand::Rng;
9+
use sha2::Sha256;
710
use std::collections::{HashMap, HashSet};
811
use std::env;
912
use std::io::BufReader;
@@ -102,6 +105,7 @@ impl Python3Plugin {
102105
path: &Path,
103106
extra_args: &[&str],
104107
timeout: Option<Duration>,
108+
stdin: Option<String>,
105109
) -> Result<Output, PythonError> {
106110
let minimum_python_version = TmcProjectYml::from(path)?
107111
.minimum_python_version
@@ -139,6 +143,12 @@ impl Python3Plugin {
139143

140144
let command = Self::get_local_python_command();
141145
let command = command.with(|e| e.args(&common_args).args(extra_args).cwd(path));
146+
let command = if let Some(stdin) = stdin {
147+
command.set_stdin_data(stdin)
148+
} else {
149+
command
150+
};
151+
142152
let output = if let Some(timeout) = timeout {
143153
command.output_with_timeout(timeout)?
144154
} else {
@@ -166,14 +176,25 @@ impl Python3Plugin {
166176
}
167177

168178
/// Parse test result file
169-
fn parse_test_result(
179+
fn parse_and_verify_test_result(
170180
test_results_json: &Path,
171181
logs: HashMap<String, String>,
182+
hmac_data: Option<(String, String)>,
172183
) -> Result<RunResult, PythonError> {
173-
let results_file = file_util::open_file(&test_results_json)?;
174-
let test_results: Vec<PythonTestResult> =
175-
serde_json::from_reader(BufReader::new(results_file))
176-
.map_err(|e| PythonError::Deserialize(test_results_json.to_path_buf(), e))?;
184+
let results = file_util::read_file_to_string(&test_results_json)?;
185+
186+
// verify test results
187+
if let Some((hmac_secret, test_runner_hmac_hex)) = hmac_data {
188+
let mut mac = Hmac::<Sha256>::new_varkey(hmac_secret.as_bytes())
189+
.expect("HMAC can take key of any size");
190+
mac.update(results.as_bytes());
191+
let bytes =
192+
hex::decode(&test_runner_hmac_hex).map_err(|_| PythonError::UnexpectedHmac)?;
193+
mac.verify(&bytes).map_err(|_| PythonError::InvalidHmac)?;
194+
}
195+
196+
let test_results: Vec<PythonTestResult> = serde_json::from_str(&results)
197+
.map_err(|e| PythonError::Deserialize(test_results_json.to_path_buf(), e))?;
177198

178199
let mut status = RunStatus::Passed;
179200
let mut failed_points = HashSet::new();
@@ -209,7 +230,8 @@ impl LanguagePlugin for Python3Plugin {
209230
file_util::remove_file(&available_points_json)?;
210231
}
211232

212-
let run_result = Self::run_tmc_command(exercise_directory, &["available_points"], None);
233+
let run_result =
234+
Self::run_tmc_command(exercise_directory, &["available_points"], None, None);
213235
if let Err(error) = run_result {
214236
log::error!("Failed to scan exercise. {}", error);
215237
}
@@ -233,7 +255,24 @@ impl LanguagePlugin for Python3Plugin {
233255
file_util::remove_file(&test_results_json)?;
234256
}
235257

236-
let output = Self::run_tmc_command(exercise_directory, &[], timeout);
258+
let (output, random_string) = if exercise_directory.join("tmc/hmac_writer.py").exists() {
259+
// has hmac writer
260+
let random_string: String = rand::thread_rng()
261+
.sample_iter(rand::distributions::Alphanumeric)
262+
.take(32)
263+
.map(char::from)
264+
.collect();
265+
let output = Self::run_tmc_command(
266+
exercise_directory,
267+
&["--wait-for-secret"],
268+
timeout,
269+
Some(random_string.clone()),
270+
);
271+
(output, Some(random_string))
272+
} else {
273+
let output = Self::run_tmc_command(exercise_directory, &[], timeout, None);
274+
(output, None)
275+
};
237276

238277
match output {
239278
Ok(output) => {
@@ -246,7 +285,17 @@ impl LanguagePlugin for Python3Plugin {
246285
"stderr".to_string(),
247286
String::from_utf8_lossy(&output.stderr).into_owned(),
248287
);
249-
let parse_res = Self::parse_test_result(&test_results_json, logs);
288+
289+
let hmac_data = if let Some(random_string) = random_string {
290+
let hmac_result_path = exercise_directory.join(".tmc_test_results.hmac.sha256");
291+
let test_runner_hmac = file_util::read_file_to_string(hmac_result_path)?;
292+
Some((random_string, test_runner_hmac))
293+
} else {
294+
None
295+
};
296+
297+
let parse_res =
298+
Self::parse_and_verify_test_result(&test_results_json, logs, hmac_data);
250299
// remove file regardless of parse success
251300
if test_results_json.exists() {
252301
file_util::remove_file(&test_results_json)?;
@@ -366,7 +415,10 @@ impl LanguagePlugin for Python3Plugin {
366415
#[cfg(test)]
367416
mod test {
368417
use super::*;
369-
use std::path::{Path, PathBuf};
418+
use std::{
419+
io::Write,
420+
path::{Path, PathBuf},
421+
};
370422
use tmc_langs_framework::zip::ZipArchive;
371423
use tmc_langs_framework::{domain::RunStatus, plugin::LanguagePlugin};
372424

@@ -752,4 +804,40 @@ class TestClass(unittest.TestCase):
752804
}
753805
assert!(got_point);
754806
}
807+
808+
#[test]
809+
fn verifies_test_results_success() {
810+
init();
811+
812+
let mut temp = tempfile::NamedTempFile::new().unwrap();
813+
temp.write_all(br#"[{"name": "test.test_hello_world.HelloWorld.test_first", "status": "passed", "message": "", "passed": true, "points": ["p01-01.1"], "backtrace": []}]"#).unwrap();
814+
815+
let hmac_secret = "047QzQx8RAYLR3lf0UfB75WX5EFnx7AV".to_string();
816+
let test_runner_hmac =
817+
"b379817c66cc7b1610d03ac263f02fa11f7b0153e6aeff3262ecc0598bf0be21".to_string();
818+
Python3Plugin::parse_and_verify_test_result(
819+
temp.path(),
820+
HashMap::new(),
821+
Some((hmac_secret, test_runner_hmac)),
822+
)
823+
.unwrap();
824+
}
825+
826+
#[test]
827+
fn verifies_test_results_failure() {
828+
init();
829+
830+
let mut temp = tempfile::NamedTempFile::new().unwrap();
831+
temp.write_all(br#"[{"name": "test.test_hello_world.HelloWorld.test_first", "status": "passed", "message": "", "passed": true, "points": ["p01-01.1"], "backtrace": []}]"#).unwrap();
832+
833+
let hmac_secret = "047QzQx8RAYLR3lf0UfB75WX5EFnx7AV".to_string();
834+
let test_runner_hmac =
835+
"b379817c66cc7b1610d03ac263f02fa11f7b0153e6aeff3262ecc0598bf0be22".to_string();
836+
let res = Python3Plugin::parse_and_verify_test_result(
837+
temp.path(),
838+
HashMap::new(),
839+
Some((hmac_secret, test_runner_hmac)),
840+
);
841+
assert!(res.is_err());
842+
}
755843
}

tmc-langs-framework/src/command.rs

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
11
//! Custom wrapper for Command that supports timeouts and contains custom error handling.
22
33
use crate::{error::CommandError, TmcError};
4-
use std::fs::File;
54
use std::io::Read;
65
use std::time::Duration;
76
use std::{ffi::OsStr, thread::JoinHandle};
7+
use std::{fs::File, io::Write};
88
use subprocess::{Exec, ExitStatus, PopenError, Redirection};
99

1010
/// Wrapper around subprocess::Exec
1111
#[must_use]
1212
pub struct TmcCommand {
1313
exec: Exec,
14+
stdin: Option<String>,
1415
}
1516

1617
impl TmcCommand {
1718
/// Creates a new command
1819
pub fn new(cmd: impl AsRef<OsStr>) -> Self {
1920
Self {
2021
exec: Exec::cmd(cmd).env("LANG", "en_US.UTF-8"),
22+
stdin: None,
2123
}
2224
}
2325

@@ -28,23 +30,36 @@ impl TmcCommand {
2830
.stdout(Redirection::Pipe)
2931
.stderr(Redirection::Pipe)
3032
.env("LANG", "en_US.UTF-8"),
33+
stdin: None,
3134
}
3235
}
3336

3437
/// Allows modification of the internal command without providing access to it.
3538
pub fn with(self, f: impl FnOnce(Exec) -> Exec) -> Self {
36-
Self { exec: f(self.exec) }
39+
Self {
40+
exec: f(self.exec),
41+
..self
42+
}
43+
}
44+
45+
pub fn set_stdin_data(self, data: String) -> Self {
46+
Self {
47+
exec: self.exec.stdin(Redirection::Pipe),
48+
stdin: Some(data),
49+
}
3750
}
3851

3952
// executes the given command and collects its output
4053
fn execute(self, timeout: Option<Duration>, checked: bool) -> Result<Output, TmcError> {
4154
let cmd = self.exec.to_cmdline_lossy();
4255
log::info!("executing {}", cmd);
4356

44-
let Self { exec } = self;
57+
let Self { exec, stdin } = self;
4558

4659
// starts executing the command
4760
let mut popen = exec.popen().map_err(|e| popen_to_tmc_err(cmd.clone(), e))?;
61+
log::debug!("here {:?} {:?}", popen.stdin, stdin);
62+
let stdin_handle = spawn_writer(popen.stdin.take(), stdin);
4863
let stdout_handle = spawn_reader(popen.stdout.take());
4964
let stderr_handle = spawn_reader(popen.stderr.take());
5065

@@ -81,6 +96,9 @@ impl TmcCommand {
8196
};
8297

8398
log::info!("finished executing {}", cmd);
99+
stdin_handle
100+
.join()
101+
.expect("the thread should not be able to panic");
84102
let stdout = stdout_handle
85103
.join()
86104
.expect("the thread should not be able to panic");
@@ -144,12 +162,28 @@ impl TmcCommand {
144162
}
145163
}
146164

165+
// it's assumed the thread will never panic
166+
fn spawn_writer(file: Option<File>, data: Option<String>) -> JoinHandle<()> {
167+
std::thread::spawn(move || {
168+
if let Some(mut file) = file {
169+
if let Some(data) = data {
170+
log::debug!("writing data");
171+
if let Err(err) = file.write_all(data.as_bytes()) {
172+
log::error!("failed to write data in writer thread: {}", err);
173+
}
174+
}
175+
}
176+
})
177+
}
178+
147179
// it's assumed the thread will never panic
148180
fn spawn_reader(file: Option<File>) -> JoinHandle<Vec<u8>> {
149181
std::thread::spawn(move || {
150182
if let Some(mut file) = file {
151183
let mut buf = vec![];
152-
let _ = file.read_to_end(&mut buf);
184+
if let Err(err) = file.read_to_end(&mut buf) {
185+
log::error!("failed to read data in reader thread: {}", err);
186+
}
153187
buf
154188
} else {
155189
vec![]

0 commit comments

Comments
 (0)