summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--default.nix8
-rw-r--r--src/main.rs386
-rw-r--r--src/parser.rs38
-rw-r--r--test.nix53
4 files changed, 400 insertions, 85 deletions
diff --git a/default.nix b/default.nix
new file mode 100644
index 0000000..a150f68
--- /dev/null
+++ b/default.nix
@@ -0,0 +1,8 @@
+{ rustPlatform }:
+
+rustPlatform.buildRustPackage rec {
+ name = "git-annex-remote-remarkable2";
+ RUSTC_BOOTSTRAP = 1;
+ src = ./.;
+ cargoLock.lockFile = ./Cargo.lock;
+}
diff --git a/src/main.rs b/src/main.rs
index b500951..e2fede0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,11 +1,19 @@
-use std::{io::{Stdin, IsTerminal}, process::{exit, Command, Stdio}};
+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<String>,
- extensions: Extensions
+ extensions: Extensions,
+ tmpdir: String,
+ uuid: String,
}
#[derive(Default, Clone)]
@@ -19,10 +27,13 @@ fn get_line(stdin: &mut Stdin) -> Option<String> {
match stdin.read_line(&mut buf) {
Ok(0) => None,
Err(err) => {
- eprintln!("read encountered error: {}", err);
+ println!("ERROR read from stdin encountered error: {}", err);
None
}
- _ => Some(buf)
+ _ => match buf.strip_suffix('\n') {
+ None => Some(buf),
+ Some(stripped) => Some(stripped.to_string())
+ }
}
}
@@ -39,10 +50,15 @@ fn get_value(stdin: &mut Stdin) -> Option<String> {
return None;
}
}
- eprintln!("did not receive value when expected");
+ println!("ERROR did not receive value when expected");
exit(1);
}
+fn get_uuid(stdin: &mut Stdin) -> Option<String> {
+ println!("GETUUID");
+ get_value(stdin)
+}
+
fn get_config_value(stdin: &mut Stdin, key: &str) -> Option<String> {
println!("GETCONFIG {}", key);
get_value(stdin)
@@ -50,13 +66,14 @@ fn get_config_value(stdin: &mut Stdin, key: &str) -> Option<String> {
fn get_config(stdin: &mut Stdin, extensions: &Extensions) -> Config {
Config {
- ssh_destination: get_config_value(stdin, "ssh_destination").unwrap()
- .strip_suffix("\n").unwrap().to_string(),
+ ssh_destination: get_config_value(stdin, "ssh_destination").unwrap(),
xochitl_path: get_config_value(stdin, "xochitl_directory")
- .map(|s| s.strip_suffix("\n").unwrap().to_string())
.unwrap_or_else(|| ".local/share/remarkable/xochitl".to_owned()),
ssh_key_path: get_config_value(stdin, "ssh_key"),
- extensions: extensions.to_owned()
+ tmpdir: get_config_value(stdin, "tmpdir")
+ .unwrap_or_else(|| "/tmp/xochitl".to_owned()),
+ extensions: extensions.to_owned(),
+ uuid: get_uuid(stdin).unwrap(),
}
}
@@ -90,7 +107,7 @@ fn startup(stdin: &mut Stdin) -> Config {
"INITREMOTE" => {
let config = get_config(stdin, &extensions);
let check = Command::new("ssh")
- .arg(config.ssh_destination)
+ .arg(&config.ssh_destination)
.arg("ls")
.arg(config.xochitl_path)
.stdout(Stdio::null())
@@ -105,22 +122,26 @@ fn startup(stdin: &mut Stdin) -> Config {
println!("INITREMOTE-FAILURE failed to spawn ssh check command");
}
}
+ "EXPORTSUPPORTED" => {
+ println!("EXPORTSUPPORTED-SUCCESS");
+ },
_ => {
println!("UNSUPPORTED-REQUEST");
eprintln!("got unsupported verb while getting config: {}", line);
}
}
}
- println!("DEBUG done early?");
+ // can't print here, pipe has already been closed most of the time
+ //println!("DEBUG done early?");
exit(0);
}
-fn key_to_uuid(key: &str) -> String {
+fn key_to_uuid(config: &Config, key: &str) -> String {
let maybe = Command::new("uuidgen")
.arg("-s")
.arg("-n")
- .arg("435bb27b-6704-416f-bc5b-3e2d02d0cf8f")
+ .arg(&config.uuid)
.arg("-N")
.arg(key)
.output();
@@ -134,97 +155,271 @@ fn key_to_uuid(key: &str) -> String {
}
}
-fn check_key_present<'a>(config: &Config, mut words: impl Iterator<Item=&'a str>) {
- let key = words.next().unwrap();
- assert_no_args_left(words);
- let uuid = key_to_uuid(key);
+fn get_current_time() -> u128 {
+ std::time::SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis()
+}
+
+fn get_exifdata(filepath: &str, key: &str) -> Option<String> {
+ 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 {
- if code.success() {
- println!("CHECKPRESENT-SUCCESS {}", key);
- } else {
- println!("CHECKPRESENT-FAILURE {}", key); // TODO: check if remote present or not
- }
+ code.success()
} else {
- println!("CHECKPRESENT-UNKNOWN {} ssh failed", key);
+ false
}
}
-fn store_key<'a>(config: &Config, mut words: impl Iterator<Item=&'a str>) {
- let key = words.next().unwrap();
- let file = words.next().unwrap();
- assert_no_args_left(words);
- let uuid = key_to_uuid(key);
- let check = Command::new("scp")
- .arg(file)
- .arg(format!("{}:{}/{}.pdf", config.ssh_destination, config.xochitl_path, uuid))
+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();
- 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
+
+ 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)
}
- } else {
- println!("TRANSFER-FAILURE STORE {} ssh failed", key);
}
+ buf
}
-fn retrieve_key<'a>(config: &Config, mut words: impl Iterator<Item=&'a str>) {
- let key = words.next().unwrap();
- let file = words.next().unwrap();
- assert_no_args_left(words);
- let uuid = key_to_uuid(key);
+#[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();
- 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);
+
+ 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, mut words: impl Iterator<Item=&'a str>) {
- let key = words.next().unwrap();
- assert_no_args_left(words);
- let uuid = key_to_uuid(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();
- 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);
+
+ 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<'a>(config: &Config, mut words: impl Iterator<Item=&'a str>) {
- let key = words.next().unwrap();
- assert_no_args_left(words);
- let uuid = key_to_uuid(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 assert_no_args_left<'a>(mut words: impl Iterator<Item=&'a str>) {
- if words.next().is_some() {
- println!("UNSUPPORTED-REQUEST");
- println!("ERROR");
- exit(1);
+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")
}
}
@@ -239,21 +434,42 @@ fn main() {
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),
+
+ 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, words),
- "WHEREIS" => whereis(&config, words),
- "GETAVAILABILITY" => {
- println!("ERROR");
- unimplemented!()
- // check routing table if usb network device is present?
+ "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")
}
diff --git a/src/parser.rs b/src/parser.rs
new file mode 100644
index 0000000..13cd22b
--- /dev/null
+++ b/src/parser.rs
@@ -0,0 +1,38 @@
+
+pub struct Parser<'a> {
+ buf: &'a str,
+ offset: usize
+}
+
+impl<'a> Parser<'a> {
+ pub fn new(buf: &'a str) -> Self {
+ Parser{buf, offset: 0}
+ }
+
+ pub fn next_word(&mut self) -> &'a str {
+ match self.buf[self.offset..].split_once(' ') {
+ Some((a,_)) => {
+ self.offset += a.len() + 1;
+ a
+ },
+ None => {
+ let res = &self.buf[self.offset..];
+ self.offset += self.buf.len() - self.offset;
+ res
+ }
+ }
+ }
+
+ pub fn remainder(&mut self) -> String {
+ let res = self.buf[self.offset..].to_owned();
+ self.offset = self.buf.len();
+ res
+ }
+
+ pub fn assert_end(&self) {
+ if self.buf.len() != self.offset {
+ println!("ERROR: failed parsing");
+ panic!()
+ }
+ }
+}
diff --git a/test.nix b/test.nix
new file mode 100644
index 0000000..b708c5e
--- /dev/null
+++ b/test.nix
@@ -0,0 +1,53 @@
+{ nixpkgs ? import <nixpkgs> {} }:
+
+let inherit (import <nixpkgs/nixos/tests/ssh-keys.nix> nixpkgs)
+ snakeOilPrivateKey snakeOilPublicKey snakeOilEd25519PrivateKey snakeOilEd25519PublicKey;
+in
+
+nixpkgs.nixosTest {
+ name = "git-annex-specialremote-ramarkable2";
+
+ nodes.annex = {
+
+ services.openssh = {
+ enable = true;
+ settings.PermitEmptyPasswords = true;
+ settings.PermitRootLogin = "yes";
+ };
+
+ environment.systemPackages = with nixpkgs; [
+ openssh
+ (callPackage ./default.nix {})
+ gitFull
+ git-annex
+ ];
+
+ users.users.root = {
+ openssh.authorizedKeys.keys = [ snakeOilEd25519PublicKey ];
+ };
+
+ };
+
+ testScript = ''
+ machine.start()
+
+ machine.wait_for_open_port(22)
+
+ machine.succeed("mkdir -p /root/.ssh")
+ machine.succeed("echo ${snakeOilEd25519PublicKey} > /root/.ssh/id_ed25519.pub")
+ machine.succeed("echo StrictHostKeyChecking no > /root/.ssh/config")
+ machine.succeed("cp ${snakeOilEd25519PrivateKey} /root/.ssh/id_ed25519")
+ machine.succeed("chmod 600 /root/.ssh/id_ed25519")
+
+ machine.succeed("ssh localhost echo meow")
+
+ machine.succeed("mkdir -p /root/.local/share/remarkable/xochitl")
+
+ machine.succeed("mkdir -p repo && cd repo")
+ machine.succeed("git init")
+ machine.succeed("git annex init")
+ machine.succeed("git annex initremote localhost type=external externaltype=remarkable2 ssh_destination=localhost encryption=none exporttree=yes")
+
+ machine.succeed("git annex testremote -d --fast localhost")
+ '';
+}