use std::{io::{Stdin, IsTerminal, Write}, process::{exit, Command, Stdio}, fs::File}; struct Config { ssh_destination: String, xochitl_path: String, ssh_key_path: Option, extensions: Extensions, tmpdir: String, uuid: String } #[derive(Default, Clone)] struct Extensions { unavailable_response: bool, info: bool } fn get_line(stdin: &mut Stdin) -> Option { let mut buf = String::new(); match stdin.read_line(&mut buf) { Ok(0) => None, Err(err) => { eprintln!("read encountered error: {}", err); None } _ => Some(buf) } } fn get_value(stdin: &mut Stdin) -> Option { if let Some(line) = get_line(stdin) { if let Some(value) = line.strip_prefix("VALUE ") { if value.split_ascii_whitespace().count() == 0 { return None } else { return Some(value.to_owned()); } } if line == "VALUE" { return None; } } println!("ERROR did not receive value when expected"); exit(1); } fn get_uuid(stdin: &mut Stdin) -> Option { println!("GETUUID"); get_value(stdin) .map(|s| s.strip_suffix("\n").unwrap().to_string()) } fn get_config_value(stdin: &mut Stdin, key: &str) -> Option { println!("GETCONFIG {}", key); get_value(stdin) .map(|s| s.strip_suffix("\n").unwrap().to_string()) } fn get_config(stdin: &mut Stdin, extensions: &Extensions) -> Config { Config { ssh_destination: get_config_value(stdin, "ssh_destination").unwrap(), xochitl_path: get_config_value(stdin, "xochitl_directory") .unwrap_or_else(|| ".local/share/remarkable/xochitl".to_owned()), ssh_key_path: get_config_value(stdin, "ssh_key"), tmpdir: get_config_value(stdin, "tmpdir") .unwrap_or_else(|| "/tmp/xochitl".to_owned()), extensions: extensions.to_owned(), uuid: get_uuid(stdin).unwrap() } } fn startup(stdin: &mut Stdin) -> Config { let mut extensions = Extensions::default(); while let Some(line) = get_line(stdin) { let mut words = line.split_ascii_whitespace(); let verb = words.next().expect("missing command verb"); match verb { "EXTENSIONS" => { let supported: Vec<_> = words.collect(); extensions.unavailable_response = supported.contains(&"UNAVAILABLERESPONSE"); extensions.info = supported.contains(&"INFO"); println!( "EXTENSIONS {} {}", if extensions.unavailable_response { "UNAVAILABLERESPONSE" } else { "" }, if extensions.info { "INFO" } else { "" } ) } "PREPARE" => { let config = get_config(stdin, &extensions); println!("PREPARE-SUCCESS"); return config; }, "LISTCONFIGS" => { println!("CONFIG ssh_destination ssh destination under which the reMarkable tablet can be reached."); println!("CONFIG ssh_key the path to an ssh key to use (optional)."); println!("CONFIG xochitl_directory the directory on the remarkable device in which data is stored (default .local/share/remarkable/xochitl)."); println!("CONFIGEND") }, "INITREMOTE" => { let config = get_config(stdin, &extensions); let check = Command::new("ssh") .arg(config.ssh_destination) .arg("ls") .arg(config.xochitl_path) .stdout(Stdio::null()) .status(); if let Ok(status) = check { if status.success() { println!("INITREMOTE-SUCCESS"); } else { println!("INITREMOTE-FAILURE ssh check returned {}", status); } } else { println!("INITREMOTE-FAILURE failed to spawn ssh check command"); } } _ => { println!("UNSUPPORTED-REQUEST"); eprintln!("got unsupported verb while getting config: {}", line); } } } println!("DEBUG done early?"); exit(0); } fn key_to_uuid(config: &Config, key: &str) -> String { let maybe = Command::new("uuidgen") .arg("-s") .arg("-n") .arg(&config.uuid) .arg("-N") .arg(key) .output(); match maybe { Ok(uuid) => String::from_utf8_lossy(&uuid.stdout) .split_ascii_whitespace().next().unwrap().to_owned(), Err(e) => { println!("DEBUG conversion to uuid failed: {}", e); exit(1) } } } fn get_exifdata(filepath: &str, key: &str) -> Option { let maybe = Command::new("exiftool") .arg(filepath) .arg("-b") .arg(format!("-{}", key)) .output(); match maybe { Ok(out) => { let value = String::from_utf8_lossy(&out.stdout); if value.split_ascii_whitespace().next().is_some() { Some(value.to_string()) } else { println!("DEBUG could not get exif data for key {}: empty value returned", key); return None } }, Err(e) => { println!("DEBUG could not get exif data for key {}: {}", key, e); return None } } } fn check_key_present<'a>(config: &Config, mut words: impl Iterator) { let key = words.next().unwrap(); assert_no_args_left(words); let uuid = key_to_uuid(config, key); let check = Command::new("ssh") .arg(&config.ssh_destination) .arg(format!("test -f {dir}/{uuid}.pdf && test ! -f {dir}/{uuid}.tombstone", dir=config.xochitl_path, uuid=uuid)) .status(); if let Ok(code) = check { if code.success() { println!("CHECKPRESENT-SUCCESS {}", key); } else { println!("CHECKPRESENT-FAILURE {}", key); // TODO: check if remote present or not } } else { println!("CHECKPRESENT-UNKNOWN {} ssh failed", key); } } // see https://www.ietf.org/rfc/rfc4627.txt fn json_string_escape(str: &str) -> String { let mut buf = String::with_capacity(str.len()); for c in str.chars() { match c { '"' => buf.push_str("\\\""), '\\' => buf.push_str("\\\\"), // technically (maybe?) overshoots? the rfc seems to think only 0x00 // to 0x1f are control characters _ if c.is_control() => buf.push_str(&format!("\\u{:04x}", c as u32)), _ => buf.push(c) } } buf } #[test] fn test_json() { assert_eq!(json_string_escape("abcd"), "abcd"); assert_eq!(json_string_escape("ab\"d"), "ab\\\"d"); assert_eq!(json_string_escape("ab\nd"), "ab\\u000ad"); } fn setup_filestructure(config: &Config, path: &str, uuid: &str, file: &str) -> Option<()> { let title = match get_exifdata(file, "Title") { Some(title) => title, None => { println!("DEBUG could not get title from exif data, using uuid instead"); uuid.to_owned() // TODO: this can't easily access the actual file name, since the file we // get is actually the version in git annex's store, which is content-addressed, // and special remotes aren't really supposed to know anything about the file // they store. // // As a workaround, maybe iterate through the repo's file listing until a // matching symlink is found, than use that one's file name? } }; if config.extensions.info { println!("INFO using title {:?}", title); } Command::new("mkdir") .arg("-p") .arg(&path) .status().ok()?; Command::new("ln") .arg("-s") .arg("-r") .arg(file) .arg(format!("{}/{}.pdf", path, uuid)) .status().ok()?; let mut metadata = File::create(format!("{}/{}.metadata", path, uuid)).ok()?; metadata.write_all(format!(r#"{{ "createdTime": "{time}", "lastModified": "{time}", "lastOpened": "0", "lastOpenedPage": 0, "parent": "", "pinned": false, "type": "DocumentType", "visibleName": "{title}" }}"#, time=10, title=json_string_escape(&title)).as_bytes()).ok()?; let mut content = File::create(format!("{}/{}.content", path, uuid)).ok()?; content.write_all(format!(r#"{{ "coverPageNumber": 0, "documentMetadata": {{}}, "extraMetadata": {{}}, "fileType": "pdf", "fontName": "", "pageTags": [], "tags": [], "textAlignment": "justify", "textScale": 1, "zoomMode": "bestFit" }}"#).as_bytes()).ok()?; Some(()) } fn store_key<'a>(config: &Config, mut words: impl Iterator) { let key = words.next().unwrap(); let file = words.next().unwrap(); assert_no_args_left(words); let uuid = key_to_uuid(config, key); let path = format!("{}/{}", config.tmpdir, uuid); if setup_filestructure(config, &path, &uuid, file).is_none() { println!("TRANSFER-FAILURE STORE {} could not set up xochitl file structure", key); return } // TODO: check if anything with that uuid is present already, otherwise destructive let check = Command::new("scp") .arg("-r") .arg(format!("{}/{}.pdf", path, uuid)) .arg(format!("{}/{}.content", path, uuid)) .arg(format!("{}/{}.metadata", path, uuid)) .arg(format!("{}:{}", config.ssh_destination, config.xochitl_path)) .status(); if let Ok(code) = check { if code.success() { println!("TRANSFER-SUCCESS STORE {}", key); } else { println!("TRANSFER-FAILURE STORE {}", key); // TODO: check if remote present or not } } else { println!("TRANSFER-FAILURE STORE {} ssh failed", key); } let cleanup = Command::new("rm") .arg("-r") .arg(&path) .status(); let maybe_info = if config.extensions.info { "INFO" } else { "DEBUG" }; if let Ok(code) = cleanup { if !code.success() { println!("{} could not clean up tmp path {}: `rm' returned code {}", maybe_info, path, code); } } else { println!("{} could not clean up tmp path {}: could not run `rm'", maybe_info, path); } } fn retrieve_key<'a>(config: &Config, mut words: impl Iterator) { let key = words.next().unwrap(); let file = words.next().unwrap(); assert_no_args_left(words); let uuid = key_to_uuid(config, key); let check = Command::new("scp") .arg(format!("{}:{}/{}.pdf", config.ssh_destination, config.xochitl_path, uuid)) .arg(file) .status(); if let Ok(code) = check { if code.success() { println!("TRANSFER-SUCCESS RETRIEVE {}", key); } else { println!("TRANSFER-FAILURE RETRIEVE {}", key); // TODO: check if remote present or not } } else { println!("TRANSFER-FAILURE RETRIEVE {} ssh failed", key); } } fn remove_key<'a>(config: &Config, mut words: impl Iterator) { let key = words.next().unwrap(); assert_no_args_left(words); let uuid = key_to_uuid(config, key); let cmd = Command::new("ssh") .arg(&config.ssh_destination) .arg(format!("rm -r {dir}/{uuid}*", dir=config.xochitl_path, uuid=uuid)) .status(); if let Ok(code) = cmd { if code.success() { println!("REMOVE-SUCCESS {}", key); } else { println!("REMOVE-FAILURE {} command failed", key); // TODO: check if remote present or not } } else { println!("REMOVE-FAILURE {} ssh failed", key); } } fn whereis<'a>(config: &Config, mut words: impl Iterator) { let key = words.next().unwrap(); assert_no_args_left(words); let uuid = key_to_uuid(config, key); println!("WHEREIS-SUCCESS {ssh}:{dir}/{uuid}.pdf", ssh=config.ssh_destination, dir=config.xochitl_path, uuid=uuid); } fn assert_no_args_left<'a>(mut words: impl Iterator) { if words.next().is_some() { println!("UNSUPPORTED-REQUEST"); println!("ERROR"); exit(1); } } fn main() { let mut stdin = std::io::stdin(); if stdin.is_terminal() { eprintln!("warning: this program is not meant to be invoked by hand."); } println!("VERSION 2"); let config = startup(&mut stdin); while let Some(line) = get_line(&mut stdin) { let mut words = line.split_ascii_whitespace(); let verb = words.next().expect("missing command verb"); match verb { "CHECKPRESENT" => check_key_present(&config, words), "TRANSFER" => match words.next().expect("missing STORE/RETRIEVE") { "STORE" => store_key(&config, words), "RETRIEVE" => retrieve_key(&config, words), _ => println!("UNSUPPORTED-REQUEST") } "REMOVE" => remove_key(&config, words), "WHEREIS" => whereis(&config, words), "GETAVAILABILITY" => { println!("ERROR"); unimplemented!() // check routing table if usb network device is present? } _ => println!("UNSUPPORTED-REQUEST") } } }