use std::{ fs::File, io::{IsTerminal, Stdin, Write}, path::Path, process::{exit, Command, Stdio}, time::UNIX_EPOCH }; mod parser; use parser::Parser; struct Config { ssh_destination: String, xochitl_path: String, // TODO: remove maybe? 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) => { println!("ERROR read from stdin encountered error: {}", err); None } _ => match buf.strip_suffix('\n') { None => Some(buf), Some(stripped) => Some(stripped.to_string()) } } } 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) } fn get_config_value(stdin: &mut Stdin, key: &str) -> Option { println!("GETCONFIG {}", key); get_value(stdin) } 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"); } } "EXPORTSUPPORTED" => { println!("EXPORTSUPPORTED-SUCCESS"); }, _ => { println!("UNSUPPORTED-REQUEST"); eprintln!("got unsupported verb while getting config: {}", line); } } } // can't print here, pipe has already been closed most of the time //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_current_time() -> u128 { std::time::SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() } 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_on_device(config: &Config, key: &str) -> bool { 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 { code.success() } else { false } } fn check_key_present<'a>(config: &Config, parser: &mut Parser) { let key = parser.next_word(); eprintln!("{key}"); parser.assert_end(); 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(); match check { Ok(code) if code.success() => println!("CHECKPRESENT-SUCCESS {}", key), Ok(_) => println!("CHECKPRESENT-FAILURE {}", key), _ => 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, exportname: Option<&str>) -> Option<()> { let title = match exportname { Some(name) => name.to_string(), None => 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": "{time}", "lastOpenedPage": 0, "parent": "", "pinned": false, "type": "DocumentType", "visibleName": "{title}" }}"#, time=get_current_time(), 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, parser: &mut Parser, exportname: Option<&str>) { let key = parser.next_word(); let file = parser.remainder(); parser.assert_end(); let uuid = key_to_uuid(config, &key); let path = format!("{}/{}", config.tmpdir, uuid); if setup_filestructure(config, &path, &uuid, &file, exportname).is_none() { println!("TRANSFER-FAILURE STORE {} could not set up xochitl file structure", key); return } // the spec does not say what should happen if we store an already-present key, // but the test suite demands it will not fail. // for simplicity, we delete & re-store the key // TODO: check for hash-equivalence instead? if check_key_on_device(config, &key) { let _ = Command::new("ssh") .arg(&config.ssh_destination) .arg(format!("rm -r {dir}/{uuid}*", dir=config.xochitl_path, uuid=uuid)) .status(); } // TODO: check if anything with that uuid is present already, otherwise destructive // (thus essentially a stripped-down checkpresent) let check = Command::new("scp") .arg("-r") .arg(format!("{}/{}.pdf", path, uuid)) .arg(format!("{}/{}.content", path, uuid)) .arg(format!("{}/{}.metadata", path, uuid)) .arg(format!("{0}:{1}", config.ssh_destination, config.xochitl_path)) .status(); match check { Ok(code) if code.success() => println!("TRANSFER-SUCCESS STORE {}", key), Ok(_) => println!("TRANSFER-FAILURE STORE {} scp failed: {check:?}", key), _ => 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" }; match cleanup { Ok(code) if !code.success() => println!("{} could not clean up tmp path {}: `rm' returned code {}", maybe_info, path, code), Err(_) => println!("{} could not clean up tmp path {}: could not run `rm'", maybe_info, path), _ => () } } fn retrieve_key<'a>(config: &Config, parser: &mut Parser) { let key = parser.next_word(); let file = parser.remainder(); 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(); match check { Ok(code) if code.success() => println!("TRANSFER-SUCCESS RETRIEVE {}", key), Ok(_) => println!("TRANSFER-FAILURE RETRIEVE {} {} {}", key, config.xochitl_path, uuid), Err(_) => println!("TRANSFER-FAILURE RETRIEVE {} ssh failed", key) } } fn remove_key<'a>(config: &Config, parser: &mut Parser) { let key = parser.next_word(); parser.assert_end(); let uuid = key_to_uuid(config, key); // trivially done if we don't have the key at all // (calling this "success" is explicitely allowed by the spec) if !check_key_on_device(config, key) { println!("REMOVE-SUCCESS {}", key); return } let cmd = Command::new("ssh") .arg(&config.ssh_destination) .arg(format!("rm -r {dir}/{uuid}*", dir=config.xochitl_path, uuid=uuid)) .status(); match cmd { Ok(code) if code.success() => println!("REMOVE-SUCCESS {}", key), Ok(_) => println!("REMOVE-FAILURE {} command failed", key), Err(_) => println!("REMOVE-FAILURE {} ssh failed", key) } } fn whereis(config: &Config, parser: &mut Parser) { let key = parser.next_word(); parser.assert_end(); 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 is_available(config: &Config) { let cmd = Command::new("ssh") .arg("-o ConnectTimeout=1") .arg(&config.ssh_destination) .arg(format!("ls {}", config.xochitl_path)) .status(); match cmd { Ok(code) if code.success() => println!("AVAILABILITY LOCAL"), _ => println!("AVAILABILITY UNAVAILABLE") } } 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 parser = Parser::new(&line); match parser.next_word() { "CHECKPRESENT" => check_key_present(&config, &mut parser), "TRANSFER" => match parser.next_word() { "STORE" => store_key(&config, &mut parser, None), "RETRIEVE" => retrieve_key(&config, &mut parser), _ => println!("UNSUPPORTED-REQUEST") } "REMOVE" => remove_key(&config, &mut parser), "WHEREIS" => whereis(&config, &mut parser), "GETAVAILABILITY" => is_available(&config), "EXPORT" => { // the 'path' is only used as a name let filepath = parser.remainder(); let name = Path::new(&filepath).file_stem().unwrap().to_string_lossy(); let line = get_line(&mut stdin) .expect("no command after EXPORT"); let mut parser = Parser::new(&line); match parser.next_word() { "TRANSFEREXPORT" => match parser.next_word() { "STORE" => store_key(&config, &mut parser, Some(&name)), "RETRIEVE" => retrieve_key(&config, &mut parser), _ => println!("UNSUPPORTED-REQUEST") } "CHECKPRESENTEXPORT" => check_key_present(&config, &mut parser), "REMOVEEXPORT" => remove_key(&config, &mut parser), // REMOVEEXPORTDIRECTORY is left unimplemented // (no directories on the reMarkable device) // TODO implement RENAMEEXPORT? // (would require editing json from under xochitl) _ => println!("UNSUPPORTED-REQUEST") } } _ => println!("UNSUPPORTED-REQUEST") } } }