From 6e2afa5c3b8bb17bdc7c1a5be896aed177449f59 Mon Sep 17 00:00:00 2001
From: Fugi <me@fugi.dev>
Date: Mon, 6 Nov 2023 20:05:51 +0100
Subject: [PATCH] sftpman: add module

---
 modules/misc/news.nix                         |   7 ++
 modules/modules.nix                           |   1 +
 modules/programs/sftpman.nix                  | 118 ++++++++++++++++++
 tests/default.nix                             |   1 +
 .../programs/sftpman/assert-on-no-sshkey.nix  |  21 ++++
 tests/modules/programs/sftpman/default.nix    |   4 +
 .../programs/sftpman/example-settings.nix     |  44 +++++++
 .../programs/sftpman/expected-mount1.json     |  13 ++
 .../programs/sftpman/expected-mount2.json     |  11 ++
 .../programs/sftpman/expected-mount3.json     |  11 ++
 10 files changed, 231 insertions(+)
 create mode 100644 modules/programs/sftpman.nix
 create mode 100644 tests/modules/programs/sftpman/assert-on-no-sshkey.nix
 create mode 100644 tests/modules/programs/sftpman/default.nix
 create mode 100644 tests/modules/programs/sftpman/example-settings.nix
 create mode 100644 tests/modules/programs/sftpman/expected-mount1.json
 create mode 100644 tests/modules/programs/sftpman/expected-mount2.json
 create mode 100644 tests/modules/programs/sftpman/expected-mount3.json

diff --git a/modules/misc/news.nix b/modules/misc/news.nix
index 7854e5da9..c13b0b8f1 100644
--- a/modules/misc/news.nix
+++ b/modules/misc/news.nix
@@ -1356,6 +1356,13 @@ in
           A new module is available: 'services.osmscout-server'.
         '';
       }
+
+      {
+        time = "2023-12-28T13:01:15+00:00";
+        message = ''
+          A new module is available: 'programs.sftpman'.
+        '';
+      }
     ];
   };
 }
diff --git a/modules/modules.nix b/modules/modules.nix
index 993c77adf..96a9b0b89 100644
--- a/modules/modules.nix
+++ b/modules/modules.nix
@@ -198,6 +198,7 @@ let
     ./programs/scmpuff.nix
     ./programs/script-directory.nix
     ./programs/senpai.nix
+    ./programs/sftpman.nix
     ./programs/sioyek.nix
     ./programs/skim.nix
     ./programs/sm64ex.nix
diff --git a/modules/programs/sftpman.nix b/modules/programs/sftpman.nix
new file mode 100644
index 000000000..c6978f80a
--- /dev/null
+++ b/modules/programs/sftpman.nix
@@ -0,0 +1,118 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.programs.sftpman;
+
+  jsonFormat = pkgs.formats.json { };
+
+  mountOpts = { config, name, ... }: {
+    options = {
+      host = mkOption {
+        type = types.str;
+        description = "The host to connect to.";
+      };
+
+      port = mkOption {
+        type = types.port;
+        default = 22;
+        description = "The port to connect to.";
+      };
+
+      user = mkOption {
+        type = types.str;
+        description = "The username to authenticate with.";
+      };
+
+      mountOptions = mkOption {
+        type = types.listOf types.str;
+        default = [ ];
+        description = "Options to pass to sshfs.";
+      };
+
+      mountPoint = mkOption {
+        type = types.str;
+        description = "The remote path to mount.";
+      };
+
+      authType = mkOption {
+        type = types.enum [
+          "password"
+          "publickey"
+          "hostbased"
+          "keyboard-interactive"
+          "gssapi-with-mic"
+        ];
+        default = "publickey";
+        description = "The authentication method to use.";
+      };
+
+      sshKey = mkOption {
+        type = types.nullOr types.str;
+        default = cfg.defaultSshKey;
+        defaultText =
+          lib.literalExpression "config.programs.sftpman.defaultSshKey";
+        description = ''
+          Path to the SSH key to use for authentication.
+          Only applies if authMethod is `publickey`.
+        '';
+      };
+
+      beforeMount = mkOption {
+        type = types.str;
+        default = "true";
+        description = "Command to run before mounting.";
+      };
+    };
+  };
+in {
+  meta.maintainers = with maintainers; [ fugi ];
+
+  options.programs.sftpman = {
+    enable = mkEnableOption
+      "sftpman, an application that handles sshfs/sftp file systems mounting";
+
+    package = mkPackageOption pkgs "sftpman" { };
+
+    defaultSshKey = mkOption {
+      type = types.nullOr types.str;
+      default = null;
+      description =
+        "Path to the SSH key to be used by default. Can be overridden per host.";
+    };
+
+    mounts = mkOption {
+      type = types.attrsOf (types.submodule mountOpts);
+      default = { };
+      description = ''
+        The sshfs mount configurations written to
+        {file}`$XDG_CONFIG_HOME/sftpman/mounts/`.
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    assertions = [
+      (let
+        hasMissingKey = _: mount:
+          mount.authType == "publickey" && mount.sshKey == null;
+        mountsWithMissingKey = attrNames (filterAttrs hasMissingKey cfg.mounts);
+        mountsWithMissingKeyStr = concatStringsSep ", " mountsWithMissingKey;
+      in {
+        assertion = mountsWithMissingKey == [ ];
+        message = ''
+          sftpman mounts using authentication type "publickey" but missing 'sshKey': ${mountsWithMissingKeyStr}
+        '';
+      })
+    ];
+
+    home.packages = [ cfg.package ];
+
+    xdg.configFile = mapAttrs' (name: value:
+      nameValuePair "sftpman/mounts/${name}.json" {
+        source =
+          jsonFormat.generate "sftpman-${name}.json" (value // { id = name; });
+      }) cfg.mounts;
+  };
+}
diff --git a/tests/default.nix b/tests/default.nix
index 53458adba..c7b7892c3 100644
--- a/tests/default.nix
+++ b/tests/default.nix
@@ -139,6 +139,7 @@ import nmt {
     ./modules/programs/sapling
     ./modules/programs/sbt
     ./modules/programs/scmpuff
+    ./modules/programs/sftpman
     ./modules/programs/sioyek
     ./modules/programs/sm64ex
     ./modules/programs/ssh
diff --git a/tests/modules/programs/sftpman/assert-on-no-sshkey.nix b/tests/modules/programs/sftpman/assert-on-no-sshkey.nix
new file mode 100644
index 000000000..549b68902
--- /dev/null
+++ b/tests/modules/programs/sftpman/assert-on-no-sshkey.nix
@@ -0,0 +1,21 @@
+{
+  config = {
+    programs.sftpman = {
+      enable = true;
+
+      mounts = {
+        mount1 = {
+          host = "host1.example.com";
+          mountPoint = "/path/to/somewhere";
+          user = "root";
+        };
+      };
+    };
+
+    test.stubs.sftpman = { };
+
+    test.asserts.assertions.expected = [''
+      sftpman mounts using authentication type "publickey" but missing 'sshKey': mount1
+    ''];
+  };
+}
diff --git a/tests/modules/programs/sftpman/default.nix b/tests/modules/programs/sftpman/default.nix
new file mode 100644
index 000000000..55e319e16
--- /dev/null
+++ b/tests/modules/programs/sftpman/default.nix
@@ -0,0 +1,4 @@
+{
+  sftpman-example-settings = ./example-settings.nix;
+  sftpman-assert-on-no-sshkey = ./assert-on-no-sshkey.nix;
+}
diff --git a/tests/modules/programs/sftpman/example-settings.nix b/tests/modules/programs/sftpman/example-settings.nix
new file mode 100644
index 000000000..265f35525
--- /dev/null
+++ b/tests/modules/programs/sftpman/example-settings.nix
@@ -0,0 +1,44 @@
+{
+  config = {
+    programs.sftpman = {
+      enable = true;
+      defaultSshKey = "/home/user/.ssh/id_ed25519";
+
+      mounts = {
+        mount1 = {
+          host = "host1.example.com";
+          mountPoint = "/path/to/somewhere";
+          user = "root";
+          mountOptions = [ "idmap=user" ];
+        };
+        mount2 = {
+          host = "host2.example.com";
+          mountPoint = "/another/path";
+          user = "someuser";
+          authType = "password";
+          sshKey = null;
+        };
+        mount3 = {
+          host = "host3.example.com";
+          mountPoint = "/yet/another/path";
+          user = "user";
+          sshKey = "/home/user/.ssh/id_rsa";
+        };
+      };
+    };
+
+    test.stubs.sftpman = { };
+
+    nmt.script = ''
+      assertFileContent \
+        home-files/.config/sftpman/mounts/mount1.json \
+        ${./expected-mount1.json}
+      assertFileContent \
+        home-files/.config/sftpman/mounts/mount2.json \
+        ${./expected-mount2.json}
+      assertFileContent \
+        home-files/.config/sftpman/mounts/mount3.json \
+        ${./expected-mount3.json}
+    '';
+  };
+}
diff --git a/tests/modules/programs/sftpman/expected-mount1.json b/tests/modules/programs/sftpman/expected-mount1.json
new file mode 100644
index 000000000..06124fd26
--- /dev/null
+++ b/tests/modules/programs/sftpman/expected-mount1.json
@@ -0,0 +1,13 @@
+{
+  "authType": "publickey",
+  "beforeMount": "true",
+  "host": "host1.example.com",
+  "id": "mount1",
+  "mountOptions": [
+    "idmap=user"
+  ],
+  "mountPoint": "/path/to/somewhere",
+  "port": 22,
+  "sshKey": "/home/user/.ssh/id_ed25519",
+  "user": "root"
+}
diff --git a/tests/modules/programs/sftpman/expected-mount2.json b/tests/modules/programs/sftpman/expected-mount2.json
new file mode 100644
index 000000000..384a9e93f
--- /dev/null
+++ b/tests/modules/programs/sftpman/expected-mount2.json
@@ -0,0 +1,11 @@
+{
+  "authType": "password",
+  "beforeMount": "true",
+  "host": "host2.example.com",
+  "id": "mount2",
+  "mountOptions": [],
+  "mountPoint": "/another/path",
+  "port": 22,
+  "sshKey": null,
+  "user": "someuser"
+}
diff --git a/tests/modules/programs/sftpman/expected-mount3.json b/tests/modules/programs/sftpman/expected-mount3.json
new file mode 100644
index 000000000..5ee679623
--- /dev/null
+++ b/tests/modules/programs/sftpman/expected-mount3.json
@@ -0,0 +1,11 @@
+{
+  "authType": "publickey",
+  "beforeMount": "true",
+  "host": "host3.example.com",
+  "id": "mount3",
+  "mountOptions": [],
+  "mountPoint": "/yet/another/path",
+  "port": 22,
+  "sshKey": "/home/user/.ssh/id_rsa",
+  "user": "user"
+}