Adds SFTP as a third remote ISO-library protocol alongside SMB and NFS. Pure-Rust russh + russh-sftp on the ring crypto backend — no kernel mount, no subprocess, no OpenSSL, no new C deps. Like NFS (and unlike SMB), SFTP-sourced ISOs support HTTP Range requests because SFTP opens a seekable file handle. - iso-store: SftpShareManager (connect/auth/READDIR/seekable stream), IsoSource::Sftp, password OR SSH-key auth, trust-on-first-use host-key pinning, 0600 credential sidecar with a restart-safe derived path. - http-api: /api/sftp-shares routes, Range-aware ISO dispatch arm, status/metrics counts, /api/docs entry, `sftp` terminal commands. - webui: "SFTP (SSH)" protocol option with a password/key auth toggle, host-key fingerprint display, dashboard tile, updated copy. SCP was deliberately rejected: sequential-only (no Range) and its crates wrap libssh2 (C + OpenSSL), which would break the static-musl build. russh is pinned to =0.55.0: russh 0.61 needs the stable RustCrypto generation (pkcs8 0.11), which is API-incompatible with the release- candidate crates bergshamra-crypto pins (pkcs8 =0.11.0-rc.11). 0.55 is the newest russh on the prior generation (pkcs8 0.7) that coexists. Do not bump past 0.55 until bergshamra adopts stable RustCrypto. 252 tests pass, clippy clean, static musl x86_64 binary (ring already present via rustls + bergshamra, so no new crypto/C deps). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1146 lines
41 KiB
Rust
1146 lines
41 KiB
Rust
//! SFTP share consumer — in-process userspace SSH/SFTP client.
|
|
//!
|
|
//! v0.5.5 adds a third remote-library protocol alongside SMB (v0.4.65)
|
|
//! and NFS (v0.4.67). Like NFS it's a pure-Rust, in-process client —
|
|
//! no subprocess, no kernel mount, no `CAP_SYS_ADMIN` — so it works in
|
|
//! every container the other two work in (Unraid, restricted-SCC
|
|
//! OpenShift).
|
|
//!
|
|
//! ## Why SFTP and not SCP
|
|
//!
|
|
//! The obvious "SSH-based shares" ask is SCP, but SCP is the wrong
|
|
//! protocol for this job twice over: (1) it's a sequential whole-file
|
|
//! stream with no random access, so it could only ever behave like the
|
|
//! SMB path (whole-file, no HTTP Range); and (2) the mature Rust SCP
|
|
//! crates wrap libssh2 — a C library that needs OpenSSL — which would
|
|
//! break the static-musl, OpenSSL-free build. SFTP sidesteps both:
|
|
//! `SSH_FXP_READ` takes an explicit offset (so Range works, like NFS),
|
|
//! and `russh` + `russh-sftp` are pure Rust on the `ring` crypto
|
|
//! backend already in the binary (rustls + bergshamra), adding zero new
|
|
//! C dependencies.
|
|
//!
|
|
//! ## How it works
|
|
//!
|
|
//! 1. Operator submits `{ server, export, username, port, +secret }`
|
|
//! via the Storage tab. The secret is either a password or an SSH
|
|
//! private key (PEM, optionally passphrase-protected) — never both.
|
|
//! 2. We write the secret to a 0600 JSON file under
|
|
//! `<work_dir>/sftp_creds/<id>.cred`. The share metadata
|
|
//! (server/user/export/host-key fingerprint) lives in
|
|
//! `<work_dir>/sftp_shares.json`; the secret never touches that file.
|
|
//! 3. We connect over SSH, authenticate, open the `sftp` subsystem, and
|
|
//! `READDIR` the export directory for `*.iso` files (size comes back
|
|
//! in the same listing — no extra round trip).
|
|
//! 4. Each ISO registers with the `IsoStore` as
|
|
//! `IsoSource::Sftp { share_id, relative_path }`.
|
|
//! 5. On a PXE client request the HTTP handler asks for a byte stream
|
|
//! at an offset; we open the file, `seek`, and pipe bounded chunks
|
|
//! into the response body. HTTP Range → `206 Partial Content`, same
|
|
//! as NFS.
|
|
//!
|
|
//! ## Host-key trust (TOFU)
|
|
//!
|
|
//! On the first successful connect we pin the server's SSH host-key
|
|
//! fingerprint (SHA256) into the share. Every later connect compares
|
|
//! the presented key against the pinned one and refuses the connection
|
|
//! on a mismatch — classic trust-on-first-use, like a fresh
|
|
//! `known_hosts`. A rebuilt server (new host key) surfaces a clear
|
|
//! "host key changed" error; the operator removes and re-adds to trust
|
|
//! the new key. This is strictly more authentication than NFS/SMB do
|
|
//! (neither authenticates the server at all), and it's cheap over SSH.
|
|
//!
|
|
//! ## Same public surface
|
|
//!
|
|
//! `SftpShareManager` mirrors `NfsShareManager` / `SmbShareManager`;
|
|
//! `SftpShare` mirrors `NfsShare` / `SmbShare`; the `{error, stderr,
|
|
//! hint}` error shape is shared so the storage tab renders all three
|
|
//! protocols through one code path.
|
|
|
|
use crate::introspect::{DistroFamily, IntrospectionReport};
|
|
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
|
use bytes::Bytes;
|
|
use openpxe_core::{Error, Result};
|
|
use parking_lot::Mutex;
|
|
use russh::client;
|
|
use russh::keys::{decode_secret_key, HashAlg, PrivateKeyWithHashAlg, PublicKey};
|
|
use russh_sftp::client::SftpSession;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::io::{SeekFrom, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use time::OffsetDateTime;
|
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
|
|
|
/// Default TCP port for SSH (and thus the SFTP subsystem).
|
|
const DEFAULT_SFTP_PORT: u16 = 22;
|
|
|
|
/// How long we wait for the TCP connect + SSH handshake before giving
|
|
/// up. Matches the shape of the NFS/SMB pre-flight timeouts so the UI
|
|
/// banner reads consistently across protocols. The SSH key exchange is
|
|
/// a couple of round trips plus some asymmetric crypto, so we give it a
|
|
/// little more headroom than the bare TCP probe.
|
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
/// Bounded TCP pre-flight so a wrong IP / closed port surfaces a clean
|
|
/// error before we spin up the (heavier) SSH handshake.
|
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
|
|
|
|
/// Read chunk size for streaming. 64 KiB keeps in-flight memory bounded
|
|
/// and matches the NFS path; russh-sftp pipelines reads internally so
|
|
/// the chunk size is about response-body granularity, not throughput.
|
|
const READ_CHUNK_BYTES: usize = 64 * 1024;
|
|
|
|
/// Bound on the in-flight queue between the read-loop task and the HTTP
|
|
/// body stream. 16 * 64 KiB ≈ 1 MiB max buffer per stream.
|
|
const STREAM_BUFFER_DEPTH: usize = 16;
|
|
|
|
/// Which credential the share authenticates with. The secret itself
|
|
/// lives in the 0600 creds file, never here.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SftpAuthKind {
|
|
Password,
|
|
Key,
|
|
}
|
|
|
|
/// One configured SFTP share. The id is derived from server+export so
|
|
/// re-adding the same coordinates is idempotent. No secret is stored on
|
|
/// this struct — it's safe to serialize into the API JSON.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SftpShare {
|
|
pub id: String,
|
|
pub server: String,
|
|
/// Directory on the server holding the ISOs (e.g. "/srv/isos").
|
|
pub export: String,
|
|
/// SSH username.
|
|
pub username: String,
|
|
/// Which credential kind this share uses. The UI echoes it; the
|
|
/// secret lives in the creds file.
|
|
pub auth: SftpAuthKind,
|
|
/// TCP port — 22 unless overridden.
|
|
#[serde(default = "default_port")]
|
|
pub port: u16,
|
|
/// Pinned SSH host-key fingerprint (SHA256, e.g. "SHA256:abc…").
|
|
/// `None` until the first successful connect; once set, a changed
|
|
/// key is refused (TOFU). Public information — safe to surface.
|
|
#[serde(default)]
|
|
pub host_key_fingerprint: Option<String>,
|
|
pub last_error: Option<String>,
|
|
pub last_hint: Option<String>,
|
|
#[serde(with = "time::serde::rfc3339::option")]
|
|
pub last_scan: Option<OffsetDateTime>,
|
|
pub iso_count: u32,
|
|
pub reachable: bool,
|
|
}
|
|
|
|
/// Submission from the UI / API. Exactly one of `password` /
|
|
/// `private_key` should be set.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct SftpAddRequest {
|
|
pub server: String,
|
|
pub export: String,
|
|
#[serde(default)]
|
|
pub username: Option<String>,
|
|
#[serde(default)]
|
|
pub port: Option<u16>,
|
|
#[serde(default)]
|
|
pub password: Option<String>,
|
|
/// PEM-encoded OpenSSH private key, pasted by the operator.
|
|
#[serde(default)]
|
|
pub private_key: Option<String>,
|
|
/// Passphrase for an encrypted private key. Optional.
|
|
#[serde(default)]
|
|
pub passphrase: Option<String>,
|
|
}
|
|
|
|
fn default_port() -> u16 {
|
|
DEFAULT_SFTP_PORT
|
|
}
|
|
|
|
/// The secret material for a share, persisted to a 0600 sidecar JSON
|
|
/// file. Never serialized into the public share JSON or API responses.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
struct SftpCreds {
|
|
#[serde(default)]
|
|
password: Option<String>,
|
|
#[serde(default)]
|
|
private_key: Option<String>,
|
|
#[serde(default)]
|
|
passphrase: Option<String>,
|
|
}
|
|
|
|
/// Structured error surfaced to the API and rendered in the UI. Same
|
|
/// shape as `NfsShareError` / `SmbShareError`.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct SftpShareError {
|
|
pub error: String,
|
|
pub stderr: String,
|
|
pub hint: Option<String>,
|
|
}
|
|
|
|
impl SftpShareError {
|
|
fn from_raw(error: impl Into<String>, stderr: impl Into<String>) -> Self {
|
|
let stderr = stderr.into();
|
|
let error = error.into();
|
|
let hint = hint_for(&stderr).or_else(|| hint_for(&error));
|
|
Self {
|
|
error,
|
|
stderr,
|
|
hint,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct Inner {
|
|
shares: HashMap<String, SftpShare>,
|
|
}
|
|
|
|
/// Manages SFTP shares. Cheap to clone — internal state is
|
|
/// `Arc<Mutex<...>>`.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SftpShareManager {
|
|
creds_root: Arc<PathBuf>,
|
|
state_path: Arc<PathBuf>,
|
|
inner: Arc<Mutex<Inner>>,
|
|
iso_store: IsoStore,
|
|
/// Serializes scan operations on the same manager for predictable
|
|
/// log output; each scan opens its own SSH connection.
|
|
op_lock: Arc<tokio::sync::Mutex<()>>,
|
|
}
|
|
|
|
impl SftpShareManager {
|
|
/// Construct a manager. Secrets live under `<work_dir>/sftp_creds/`
|
|
/// (0600); state persists to `<work_dir>/sftp_shares.json`.
|
|
#[must_use]
|
|
pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self {
|
|
Self {
|
|
creds_root: Arc::new(work_dir.join("sftp_creds")),
|
|
state_path: Arc::new(work_dir.join("sftp_shares.json")),
|
|
inner: Arc::new(Mutex::new(Inner::default())),
|
|
iso_store,
|
|
op_lock: Arc::new(tokio::sync::Mutex::new(())),
|
|
}
|
|
}
|
|
|
|
/// Load persisted state and re-scan every share. Per-share failures
|
|
/// are logged but never propagated — startup must not block on a
|
|
/// single offline server.
|
|
pub async fn load_and_rescan(&self) -> Result<()> {
|
|
tokio::fs::create_dir_all(self.creds_root.as_path()).await?;
|
|
let shares = match tokio::fs::read_to_string(self.state_path.as_path()).await {
|
|
Ok(text) => serde_json::from_str::<Vec<SftpShare>>(&text).unwrap_or_default(),
|
|
Err(_) => Vec::new(),
|
|
};
|
|
for mut s in shares {
|
|
s.last_error = None;
|
|
s.last_hint = None;
|
|
s.reachable = false;
|
|
self.inner.lock().shares.insert(s.id.clone(), s.clone());
|
|
if let Err(e) = self.rescan_inner(&s.id).await {
|
|
tracing::warn!(
|
|
target: "openpxe::sftp",
|
|
id = %s.id, server = %s.server, export = %s.export,
|
|
"rescan on startup failed: {e}"
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Register an SFTP share. Validates, writes the creds file, probes
|
|
/// connectivity by performing a real SSH connect + SFTP READDIR,
|
|
/// pins the host key (TOFU), and registers the resulting ISOs.
|
|
pub async fn add(&self, req: SftpAddRequest) -> std::result::Result<SftpShare, SftpShareError> {
|
|
let server = normalize_server(&req.server);
|
|
let export = req.export.trim().to_string();
|
|
if server.is_empty() {
|
|
return Err(SftpShareError::from_raw("server is required", ""));
|
|
}
|
|
if !export.starts_with('/') {
|
|
return Err(SftpShareError::from_raw(
|
|
"export path must be absolute (start with '/', e.g. /srv/isos)",
|
|
"",
|
|
));
|
|
}
|
|
if server.contains('\0') || export.contains('\0') {
|
|
return Err(SftpShareError::from_raw("NUL bytes are not allowed", ""));
|
|
}
|
|
let username = req.username.unwrap_or_default().trim().to_string();
|
|
if username.is_empty() {
|
|
return Err(SftpShareError::from_raw("username is required", ""));
|
|
}
|
|
|
|
// Exactly one secret. Empty strings count as "not provided".
|
|
let password = req.password.filter(|s| !s.is_empty());
|
|
let private_key = req.private_key.filter(|s| !s.trim().is_empty());
|
|
let (auth, creds) = match (password, private_key) {
|
|
(Some(_), Some(_)) => {
|
|
return Err(SftpShareError::from_raw(
|
|
"provide either a password or a private key, not both",
|
|
"",
|
|
));
|
|
}
|
|
(Some(pw), None) => (
|
|
SftpAuthKind::Password,
|
|
SftpCreds {
|
|
password: Some(pw),
|
|
..Default::default()
|
|
},
|
|
),
|
|
(None, Some(key)) => (
|
|
SftpAuthKind::Key,
|
|
SftpCreds {
|
|
private_key: Some(key),
|
|
passphrase: req.passphrase.filter(|s| !s.is_empty()),
|
|
..Default::default()
|
|
},
|
|
),
|
|
(None, None) => {
|
|
return Err(SftpShareError::from_raw(
|
|
"a password or an SSH private key is required",
|
|
"",
|
|
));
|
|
}
|
|
};
|
|
let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_SFTP_PORT);
|
|
let id = share_id(&server, &export);
|
|
|
|
// Pre-flight TCP probe so a wrong IP / firewall surfaces a clean
|
|
// error instead of a slow SSH-handshake timeout.
|
|
if let Err((err, hint)) = tcp_probe(&server, port).await {
|
|
return Err(SftpShareError {
|
|
error: err,
|
|
stderr: String::new(),
|
|
hint: Some(hint),
|
|
});
|
|
}
|
|
|
|
// Persist the secret (0600) before we register the spec.
|
|
if let Err(e) = self.write_creds(&id, &creds).await {
|
|
return Err(SftpShareError::from_raw(
|
|
format!("could not write credentials file: {e}"),
|
|
"",
|
|
));
|
|
}
|
|
|
|
let spec = SftpShare {
|
|
id: id.clone(),
|
|
server,
|
|
export,
|
|
username,
|
|
auth,
|
|
port,
|
|
host_key_fingerprint: None,
|
|
last_error: None,
|
|
last_hint: None,
|
|
last_scan: None,
|
|
iso_count: 0,
|
|
reachable: false,
|
|
};
|
|
self.inner.lock().shares.insert(id.clone(), spec);
|
|
self.persist_locked();
|
|
|
|
if let Err(e) = self.rescan_inner(&id).await {
|
|
// Clean up the creds file and the half-registered share so a
|
|
// failed add doesn't leave a dead row behind.
|
|
let _ = tokio::fs::remove_file(self.creds_path_for(&id)).await;
|
|
let m = self.get(&id);
|
|
self.inner.lock().shares.remove(&id);
|
|
self.persist_locked();
|
|
return Err(SftpShareError {
|
|
error: m
|
|
.as_ref()
|
|
.and_then(|m| m.last_error.clone())
|
|
.unwrap_or_else(|| e.to_string()),
|
|
stderr: String::new(),
|
|
hint: m.and_then(|m| m.last_hint),
|
|
});
|
|
}
|
|
Ok(self.get(&id).expect("just inserted"))
|
|
}
|
|
|
|
/// Remove a share. Drops every ISO sourced from it and scrubs the
|
|
/// creds file. Idempotent.
|
|
pub async fn remove(&self, id: &str) -> Result<()> {
|
|
let removed = self.inner.lock().shares.remove(id).is_some();
|
|
if removed {
|
|
self.iso_store.drop_external_source(id);
|
|
let _ = tokio::fs::remove_file(self.creds_path_for(id)).await;
|
|
self.persist_locked();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Re-walk a share for new / removed ISOs.
|
|
pub async fn rescan(&self, id: &str) -> Result<u32> {
|
|
self.rescan_inner(id).await
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn list(&self) -> Vec<SftpShare> {
|
|
let g = self.inner.lock();
|
|
let mut v: Vec<_> = g.shares.values().cloned().collect();
|
|
v.sort_by(|a, b| a.id.cmp(&b.id));
|
|
v
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn get(&self, id: &str) -> Option<SftpShare> {
|
|
self.inner.lock().shares.get(id).cloned()
|
|
}
|
|
|
|
/// Open a byte stream reading `filename` out of share `share_id`,
|
|
/// starting at `start_offset` and reading at most `max_len` bytes.
|
|
/// Supports HTTP Range requests because SFTP opens a file handle we
|
|
/// can `seek` into — the same protocol-level advantage NFS has over
|
|
/// the SMB userspace path.
|
|
pub async fn stream_iso(
|
|
&self,
|
|
share_id: &str,
|
|
filename: &str,
|
|
start_offset: u64,
|
|
max_len: Option<u64>,
|
|
) -> Result<SftpStream> {
|
|
let share = self
|
|
.get(share_id)
|
|
.ok_or_else(|| Error::Invalid(format!("no such SFTP share '{share_id}'")))?;
|
|
// Flat path model (matching NFS/SMB): ISOs live at the export
|
|
// root, no nested directories. Reject anything that looks like a
|
|
// path so a crafted name can't escape the export.
|
|
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
|
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
|
|
}
|
|
let creds = self
|
|
.read_creds(share_id)
|
|
.await
|
|
.map_err(|e| Error::Invalid(format!("could not read credentials: {e}")))?;
|
|
|
|
let params = ConnParams::from_share(&share);
|
|
let fname = filename.to_string();
|
|
let (tx, rx) = tokio::sync::mpsc::channel::<std::io::Result<Bytes>>(STREAM_BUFFER_DEPTH);
|
|
|
|
// Spawn a task that owns the SSH connection for the lifetime of
|
|
// the stream — like the NFS path, each stream gets its own
|
|
// connection so there's no sharing across scan/stream.
|
|
let task = tokio::spawn(async move {
|
|
let result =
|
|
stream_loop(¶ms, &creds, &fname, start_offset, max_len, tx.clone()).await;
|
|
if let Err(e) = result {
|
|
let _ = tx.send(Err(std::io::Error::other(e.to_string()))).await;
|
|
}
|
|
});
|
|
|
|
Ok(SftpStream { rx, _task: task })
|
|
}
|
|
|
|
// ── internals ─────────────────────────────────────────────────────
|
|
|
|
fn creds_path_for(&self, id: &str) -> PathBuf {
|
|
self.creds_root.join(format!("{id}.cred"))
|
|
}
|
|
|
|
async fn rescan_inner(&self, id: &str) -> Result<u32> {
|
|
let _g = self.op_lock.lock().await;
|
|
let share = self
|
|
.get(id)
|
|
.ok_or_else(|| Error::Invalid(format!("no such share '{id}'")))?;
|
|
let now = OffsetDateTime::now_utc();
|
|
|
|
let creds = match self.read_creds(id).await {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
let msg = format!("could not read credentials: {e}");
|
|
self.update_status(id, 0, false, Some(msg.clone()), None, now);
|
|
return Err(Error::Invalid(msg));
|
|
}
|
|
};
|
|
|
|
// Drop prior entries so a deleted file disappears from the store
|
|
// on the next scan.
|
|
self.iso_store.drop_external_source(id);
|
|
|
|
let params = ConnParams::from_share(&share);
|
|
let (listing, fingerprint) = match list_isos(¶ms, &creds).await {
|
|
Ok(v) => v,
|
|
Err(err) => {
|
|
let stderr = err.to_string();
|
|
let hint = hint_for(&stderr);
|
|
self.update_status(id, 0, false, Some(stderr.clone()), hint, now);
|
|
return Err(Error::Invalid(stderr));
|
|
}
|
|
};
|
|
|
|
let mut count = 0u32;
|
|
for entry in listing {
|
|
let iso_id = format!("sftp-{}-{}", share.id, slugify_str(&entry.filename));
|
|
// Same as NFS/SMB: no over-the-network introspection yet, so
|
|
// register `Unknown` and let the boot-entry generator fall
|
|
// back to filename-based detection. SFTP *could* do bounded
|
|
// PVD reads (it has random access) — a follow-up can add it.
|
|
let report = IntrospectionReport {
|
|
family: DistroFamily::Unknown,
|
|
volume_label: None,
|
|
kernel_path: None,
|
|
initrd_paths: Vec::new(),
|
|
has_boot_wim: false,
|
|
};
|
|
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
|
let source = IsoSource::Sftp {
|
|
share_id: share.id.clone(),
|
|
relative_path: entry.filename.clone(),
|
|
};
|
|
self.iso_store.register_external(
|
|
iso_id,
|
|
entry.filename,
|
|
entry.size,
|
|
report,
|
|
boot_entries,
|
|
source,
|
|
);
|
|
count += 1;
|
|
}
|
|
|
|
// Pin the host key (TOFU) on first sight; later scans connect
|
|
// with it as the expected key and would have failed above on a
|
|
// mismatch, so this only ever sets or re-confirms.
|
|
self.pin_fingerprint(id, fingerprint);
|
|
self.update_status(id, count, true, None, None, now);
|
|
tracing::info!(
|
|
target: "openpxe::sftp",
|
|
id = %id, server = %share.server, export = %share.export,
|
|
iso_count = count,
|
|
"SFTP share scanned"
|
|
);
|
|
Ok(count)
|
|
}
|
|
|
|
fn pin_fingerprint(&self, id: &str, fingerprint: String) {
|
|
if fingerprint.is_empty() {
|
|
return;
|
|
}
|
|
let mut g = self.inner.lock();
|
|
if let Some(s) = g.shares.get_mut(id) {
|
|
if s.host_key_fingerprint.is_none() {
|
|
s.host_key_fingerprint = Some(fingerprint);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn update_status(
|
|
&self,
|
|
id: &str,
|
|
iso_count: u32,
|
|
reachable: bool,
|
|
err: Option<String>,
|
|
hint: Option<String>,
|
|
ts: OffsetDateTime,
|
|
) {
|
|
if let Some(s) = self.inner.lock().shares.get_mut(id) {
|
|
s.iso_count = iso_count;
|
|
s.reachable = reachable;
|
|
s.last_error = err;
|
|
s.last_hint = hint;
|
|
s.last_scan = Some(ts);
|
|
}
|
|
self.persist_locked();
|
|
}
|
|
|
|
async fn write_creds(&self, id: &str, creds: &SftpCreds) -> std::io::Result<()> {
|
|
tokio::fs::create_dir_all(self.creds_root.as_path()).await?;
|
|
let body = serde_json::to_vec(creds).map_err(std::io::Error::other)?;
|
|
let path = self.creds_path_for(id);
|
|
// Synchronous write to set 0600 atomically with create — std has
|
|
// no async OpenOptions+mode on stable.
|
|
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
let mut f = std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(true)
|
|
.mode(0o600)
|
|
.open(&path)?;
|
|
f.write_all(&body)?;
|
|
Ok(())
|
|
})
|
|
.await
|
|
.map_err(std::io::Error::other)??;
|
|
Ok(())
|
|
}
|
|
|
|
async fn read_creds(&self, id: &str) -> std::io::Result<SftpCreds> {
|
|
let body = tokio::fs::read(self.creds_path_for(id)).await?;
|
|
serde_json::from_slice(&body).map_err(std::io::Error::other)
|
|
}
|
|
|
|
fn persist_locked(&self) {
|
|
let shares: Vec<SftpShare> = self.inner.lock().shares.values().cloned().collect();
|
|
let path = self.state_path.as_path();
|
|
let tmp = path.with_extension("json.tmp");
|
|
let body = match serde_json::to_vec_pretty(&shares) {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
tracing::warn!(target: "openpxe::sftp", "serialize: {e}");
|
|
return;
|
|
}
|
|
};
|
|
if let Some(parent) = path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
if let Err(e) = std::fs::write(&tmp, body) {
|
|
tracing::warn!(target: "openpxe::sftp", "write tmp: {e}");
|
|
return;
|
|
}
|
|
if let Err(e) = std::fs::rename(&tmp, path) {
|
|
tracing::warn!(target: "openpxe::sftp", "rename: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// HTTP body stream for an SFTP-sourced ISO read. Implements
|
|
/// `Stream<Item = io::Result<Bytes>>` so axum can build a response body
|
|
/// via `Body::from_stream`.
|
|
#[derive(Debug)]
|
|
pub struct SftpStream {
|
|
rx: tokio::sync::mpsc::Receiver<std::io::Result<Bytes>>,
|
|
/// Kept alive so the read-loop task (which owns the SSH connection)
|
|
/// isn't cancelled while the client is still consuming. Dropping the
|
|
/// stream cancels the task — the right behaviour on client disconnect.
|
|
_task: tokio::task::JoinHandle<()>,
|
|
}
|
|
|
|
impl futures::Stream for SftpStream {
|
|
type Item = std::io::Result<Bytes>;
|
|
fn poll_next(
|
|
mut self: std::pin::Pin<&mut Self>,
|
|
cx: &mut std::task::Context<'_>,
|
|
) -> std::task::Poll<Option<Self::Item>> {
|
|
self.rx.poll_recv(cx)
|
|
}
|
|
}
|
|
|
|
/// Connection coordinates extracted from a share (no secret).
|
|
#[derive(Debug, Clone)]
|
|
struct ConnParams {
|
|
server: String,
|
|
port: u16,
|
|
username: String,
|
|
export: String,
|
|
/// Pinned host key to enforce, or `None` for trust-on-first-use.
|
|
expected_fingerprint: Option<String>,
|
|
}
|
|
|
|
impl ConnParams {
|
|
fn from_share(s: &SftpShare) -> Self {
|
|
Self {
|
|
server: s.server.clone(),
|
|
port: s.port,
|
|
username: s.username.clone(),
|
|
export: s.export.clone(),
|
|
expected_fingerprint: s.host_key_fingerprint.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct SftpListEntry {
|
|
filename: String,
|
|
size: u64,
|
|
}
|
|
|
|
/// A live SSH session plus its SFTP subsystem. The session `Handle`
|
|
/// must outlive the `SftpSession` — dropping the last `Handle` closes
|
|
/// the SSH connection out from under the subsystem channel.
|
|
struct SftpConn {
|
|
_session: client::Handle<TofuHandler>,
|
|
sftp: SftpSession,
|
|
}
|
|
|
|
/// russh client handler implementing trust-on-first-use host-key
|
|
/// verification. We never construct an `Err` from `check_server_key`;
|
|
/// returning `Ok(false)` makes russh abort the handshake, and the
|
|
/// caller distinguishes "host key mismatch" from other connect failures
|
|
/// by comparing the captured `observed` fingerprint to the expected one.
|
|
struct TofuHandler {
|
|
expected: Option<String>,
|
|
observed: Arc<Mutex<Option<String>>>,
|
|
}
|
|
|
|
impl client::Handler for TofuHandler {
|
|
type Error = russh::Error;
|
|
|
|
async fn check_server_key(
|
|
&mut self,
|
|
server_public_key: &PublicKey,
|
|
) -> std::result::Result<bool, Self::Error> {
|
|
let fp = server_public_key
|
|
.fingerprint(HashAlg::default())
|
|
.to_string();
|
|
*self.observed.lock() = Some(fp.clone());
|
|
match &self.expected {
|
|
Some(exp) if exp != &fp => Ok(false), // mismatch → abort handshake
|
|
_ => Ok(true),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Connect, authenticate, and open the SFTP subsystem. Returns the live
|
|
/// connection plus the observed host-key fingerprint (for TOFU pinning).
|
|
async fn connect(
|
|
p: &ConnParams,
|
|
creds: &SftpCreds,
|
|
) -> std::result::Result<(SftpConn, String), SftpClientError> {
|
|
let observed = Arc::new(Mutex::new(None::<String>));
|
|
let handler = TofuHandler {
|
|
expected: p.expected_fingerprint.clone(),
|
|
observed: observed.clone(),
|
|
};
|
|
let config = Arc::new(client::Config::default());
|
|
|
|
let connect_fut = client::connect(config, (p.server.as_str(), p.port), handler);
|
|
let mut session = match tokio::time::timeout(CONNECT_TIMEOUT, connect_fut).await {
|
|
Ok(Ok(s)) => s,
|
|
Ok(Err(e)) => {
|
|
// Distinguish a host-key mismatch (handshake aborted by our
|
|
// handler) from a generic connect failure.
|
|
if let (Some(exp), Some(got)) = (&p.expected_fingerprint, observed.lock().clone()) {
|
|
if exp != &got {
|
|
return Err(SftpClientError::HostKeyMismatch {
|
|
expected: exp.clone(),
|
|
got,
|
|
});
|
|
}
|
|
}
|
|
return Err(SftpClientError::Connect(e.to_string()));
|
|
}
|
|
Err(_) => return Err(SftpClientError::Timeout(p.server.clone(), p.port)),
|
|
};
|
|
let observed_fp = observed.lock().clone().unwrap_or_default();
|
|
|
|
let authed = if let Some(pw) = creds.password.as_deref() {
|
|
session
|
|
.authenticate_password(p.username.as_str(), pw)
|
|
.await
|
|
.map_err(|e| SftpClientError::Auth(e.to_string()))?
|
|
.success()
|
|
} else if let Some(pem) = creds.private_key.as_deref() {
|
|
let key = decode_secret_key(pem, creds.passphrase.as_deref())
|
|
.map_err(|e| SftpClientError::Auth(format!("private key: {e}")))?;
|
|
// For RSA keys, request rsa-sha2-256 (modern servers reject the
|
|
// legacy ssh-rsa/SHA-1); `new` ignores the hash for other key
|
|
// types (ed25519, ecdsa), so this is safe to pass unconditionally.
|
|
let pk = PrivateKeyWithHashAlg::new(Arc::new(key), Some(HashAlg::Sha256));
|
|
session
|
|
.authenticate_publickey(p.username.as_str(), pk)
|
|
.await
|
|
.map_err(|e| SftpClientError::Auth(e.to_string()))?
|
|
.success()
|
|
} else {
|
|
return Err(SftpClientError::Auth("no credential configured".into()));
|
|
};
|
|
if !authed {
|
|
return Err(SftpClientError::AuthRejected);
|
|
}
|
|
|
|
let channel = session
|
|
.channel_open_session()
|
|
.await
|
|
.map_err(|e| SftpClientError::Connect(e.to_string()))?;
|
|
channel
|
|
.request_subsystem(true, "sftp")
|
|
.await
|
|
.map_err(|e| SftpClientError::Protocol(e.to_string()))?;
|
|
let sftp = SftpSession::new(channel.into_stream())
|
|
.await
|
|
.map_err(|e| SftpClientError::Sftp(e.to_string()))?;
|
|
|
|
Ok((
|
|
SftpConn {
|
|
_session: session,
|
|
sftp,
|
|
},
|
|
observed_fp,
|
|
))
|
|
}
|
|
|
|
/// Connect and READDIR the export for `*.iso` files. Returns the listing
|
|
/// plus the observed host-key fingerprint.
|
|
async fn list_isos(
|
|
p: &ConnParams,
|
|
creds: &SftpCreds,
|
|
) -> std::result::Result<(Vec<SftpListEntry>, String), SftpClientError> {
|
|
let (conn, fingerprint) = connect(p, creds).await?;
|
|
let dir = conn
|
|
.sftp
|
|
.read_dir(p.export.as_str())
|
|
.await
|
|
.map_err(|e| SftpClientError::Sftp(e.to_string()))?;
|
|
|
|
let mut entries = Vec::new();
|
|
for entry in dir {
|
|
let name = entry.file_name();
|
|
if name == "." || name == ".." {
|
|
continue;
|
|
}
|
|
let md = entry.metadata();
|
|
if md.is_dir() {
|
|
continue;
|
|
}
|
|
if !name.to_ascii_lowercase().ends_with(".iso") {
|
|
continue;
|
|
}
|
|
entries.push(SftpListEntry {
|
|
filename: name,
|
|
size: md.len(),
|
|
});
|
|
}
|
|
// `conn` drops here, closing the SSH session.
|
|
Ok((entries, fingerprint))
|
|
}
|
|
|
|
async fn stream_loop(
|
|
p: &ConnParams,
|
|
creds: &SftpCreds,
|
|
filename: &str,
|
|
start_offset: u64,
|
|
max_len: Option<u64>,
|
|
tx: tokio::sync::mpsc::Sender<std::io::Result<Bytes>>,
|
|
) -> std::result::Result<(), SftpClientError> {
|
|
let (conn, _fp) = connect(p, creds).await?;
|
|
let full = format!("{}/{}", p.export.trim_end_matches('/'), filename);
|
|
let mut file = conn
|
|
.sftp
|
|
.open(full)
|
|
.await
|
|
.map_err(|e| SftpClientError::Sftp(e.to_string()))?;
|
|
if start_offset > 0 {
|
|
file.seek(SeekFrom::Start(start_offset))
|
|
.await
|
|
.map_err(|e| SftpClientError::Io(e.to_string()))?;
|
|
}
|
|
|
|
let mut remaining = max_len;
|
|
let mut buf = vec![0u8; READ_CHUNK_BYTES];
|
|
loop {
|
|
if remaining == Some(0) {
|
|
break;
|
|
}
|
|
let want = match remaining {
|
|
Some(r) if (r as usize) < READ_CHUNK_BYTES => r as usize,
|
|
_ => READ_CHUNK_BYTES,
|
|
};
|
|
let n = file
|
|
.read(&mut buf[..want])
|
|
.await
|
|
.map_err(|e| SftpClientError::Io(e.to_string()))?;
|
|
if n == 0 {
|
|
break; // EOF
|
|
}
|
|
let bytes = Bytes::copy_from_slice(&buf[..n]);
|
|
if tx.send(Ok(bytes)).await.is_err() {
|
|
break; // client disconnected
|
|
}
|
|
if let Some(r) = remaining.as_mut() {
|
|
*r = r.saturating_sub(n as u64);
|
|
}
|
|
}
|
|
// `conn` drops here.
|
|
Ok(())
|
|
}
|
|
|
|
/// Pre-flight TCP probe to `server:port`. Mirrors the SMB probe so the
|
|
/// UI banner reads consistently.
|
|
async fn tcp_probe(server: &str, port: u16) -> std::result::Result<(), (String, String)> {
|
|
use tokio::net::TcpStream;
|
|
let addr = format!("{server}:{port}");
|
|
match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(&addr)).await {
|
|
Ok(Ok(_)) => Ok(()),
|
|
Ok(Err(e)) => Err((
|
|
format!("cannot reach SSH port: {addr}: {e}"),
|
|
format!("verify the SSH service is running on {server} and that port {port} is open"),
|
|
)),
|
|
Err(_) => Err((
|
|
format!(
|
|
"cannot reach SSH port: {addr}: timed out after {}s",
|
|
PROBE_TIMEOUT.as_secs()
|
|
),
|
|
format!(
|
|
"no TCP answer from {server}:{port} within {}s — check the IP and any firewall in between",
|
|
PROBE_TIMEOUT.as_secs()
|
|
),
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum SftpClientError {
|
|
Connect(String),
|
|
Timeout(String, u16),
|
|
Auth(String),
|
|
AuthRejected,
|
|
HostKeyMismatch { expected: String, got: String },
|
|
Protocol(String),
|
|
Sftp(String),
|
|
Io(String),
|
|
}
|
|
|
|
impl std::fmt::Display for SftpClientError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Connect(msg) => write!(f, "connect failed: {msg}"),
|
|
Self::Timeout(host, port) => write!(
|
|
f,
|
|
"connect timed out after {}s talking to {host}:{port}",
|
|
CONNECT_TIMEOUT.as_secs()
|
|
),
|
|
Self::Auth(msg) => write!(f, "authentication error: {msg}"),
|
|
Self::AuthRejected => write!(f, "the server rejected the credentials"),
|
|
Self::HostKeyMismatch { expected, got } => {
|
|
write!(f, "SSH host key changed (expected {expected}, got {got})")
|
|
}
|
|
Self::Protocol(msg) => write!(f, "SSH channel error: {msg}"),
|
|
Self::Sftp(msg) => write!(f, "SFTP error: {msg}"),
|
|
Self::Io(msg) => write!(f, "read error: {msg}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for SftpClientError {}
|
|
|
|
/// Translate well-known SFTP/SSH failures into actionable hints.
|
|
fn hint_for(text: &str) -> Option<String> {
|
|
let s = text.to_ascii_lowercase();
|
|
if s.contains("host key changed") || s.contains("host key mismatch") {
|
|
Some(
|
|
"the server's SSH host key is different from the one pinned when \
|
|
this share was added. Either the server was rebuilt / its host \
|
|
key rotated, or this is a man-in-the-middle. If the change is \
|
|
expected, remove and re-add the share to trust the new key."
|
|
.into(),
|
|
)
|
|
} else if s.contains("rejected the credentials")
|
|
|| s.contains("authentication error")
|
|
|| s.contains("auth")
|
|
{
|
|
Some(
|
|
"SSH authentication failed. Double-check the username and the \
|
|
password / private key. For key auth, make sure you pasted the \
|
|
PRIVATE key (the file without .pub), that its passphrase is \
|
|
correct, and that the matching public key is in the server's \
|
|
~/.ssh/authorized_keys for this user."
|
|
.into(),
|
|
)
|
|
} else if s.contains("no such file") || s.contains("does not exist") || s.contains("no such") {
|
|
Some(
|
|
"the export directory or ISO wasn't found on the server. Check \
|
|
the export path (absolute, e.g. /srv/isos) and that the SSH user \
|
|
can list it."
|
|
.into(),
|
|
)
|
|
} else if s.contains("permission denied") {
|
|
Some(
|
|
"authenticated, but the SSH user can't read the export directory \
|
|
or file. Check the directory's permissions on the server."
|
|
.into(),
|
|
)
|
|
} else if s.contains("connection refused") {
|
|
Some(
|
|
"nothing is listening on this SSH port. Verify sshd is running on \
|
|
the server and that the port (default 22) is correct."
|
|
.into(),
|
|
)
|
|
} else if s.contains("timed out") || s.contains("timeout") {
|
|
Some(
|
|
"no answer from the server within the connect timeout. Verify the \
|
|
IP, the port (default 22), and any firewall in between."
|
|
.into(),
|
|
)
|
|
} else if s.contains("no route to host") || s.contains("network is unreachable") {
|
|
Some("the server isn't reachable on this network. Check the IP and routes.".into())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn share_id(server: &str, export: &str) -> String {
|
|
slugify_str(&format!("{server}{export}"))
|
|
}
|
|
|
|
/// Normalize a server input: trim, strip schemes, drop trailing slashes.
|
|
/// Mirrors the NFS/SMB normalizers so paste-from-anywhere works.
|
|
fn normalize_server(raw: &str) -> String {
|
|
let s = raw.trim();
|
|
let s = s
|
|
.strip_prefix("sftp://")
|
|
.or_else(|| s.strip_prefix("ssh://"))
|
|
.unwrap_or(s);
|
|
s.trim_end_matches('/').to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn share_id_is_stable_and_safe() {
|
|
let a = share_id("10.0.0.5", "/srv/isos");
|
|
let b = share_id("10.0.0.5", "/srv/isos");
|
|
assert_eq!(a, b);
|
|
assert!(!a.contains('/'));
|
|
assert!(!a.contains('.'));
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_server_strips_schemes() {
|
|
assert_eq!(normalize_server(" 10.0.0.5 "), "10.0.0.5");
|
|
assert_eq!(normalize_server("sftp://nas.lan/"), "nas.lan");
|
|
assert_eq!(normalize_server("ssh://192.168.1.51"), "192.168.1.51");
|
|
assert_eq!(normalize_server("nas.lan"), "nas.lan");
|
|
}
|
|
|
|
#[test]
|
|
fn hint_for_auth_failure_mentions_credentials() {
|
|
let h = hint_for("authentication error: bad password").unwrap();
|
|
let lc = h.to_lowercase();
|
|
assert!(lc.contains("username") || lc.contains("password") || lc.contains("key"));
|
|
}
|
|
|
|
#[test]
|
|
fn hint_for_host_key_mismatch_warns_about_mitm_and_readd() {
|
|
let h = hint_for("SSH host key changed (expected SHA256:a, got SHA256:b)").unwrap();
|
|
let lc = h.to_lowercase();
|
|
assert!(lc.contains("host key"));
|
|
assert!(lc.contains("re-add") || lc.contains("man-in-the-middle"));
|
|
}
|
|
|
|
#[test]
|
|
fn hint_for_refused_points_at_sshd() {
|
|
let h = hint_for("connect failed: Connection refused").unwrap();
|
|
assert!(h.to_lowercase().contains("sshd") || h.to_lowercase().contains("listening"));
|
|
}
|
|
|
|
#[test]
|
|
fn hint_for_unknown_is_none() {
|
|
assert!(hint_for("some entirely unrelated string").is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_requires_username() {
|
|
let dir = tempdir().unwrap();
|
|
let store = IsoStore::new(dir.path().join("isos"));
|
|
let mgr = SftpShareManager::new(dir.path(), store);
|
|
let err = mgr
|
|
.add(SftpAddRequest {
|
|
server: "10.0.0.5".into(),
|
|
export: "/srv/isos".into(),
|
|
username: None,
|
|
port: None,
|
|
password: Some("pw".into()),
|
|
private_key: None,
|
|
passphrase: None,
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert!(
|
|
err.error.to_lowercase().contains("username"),
|
|
"{}",
|
|
err.error
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_requires_a_secret() {
|
|
let dir = tempdir().unwrap();
|
|
let store = IsoStore::new(dir.path().join("isos"));
|
|
let mgr = SftpShareManager::new(dir.path(), store);
|
|
let err = mgr
|
|
.add(SftpAddRequest {
|
|
server: "10.0.0.5".into(),
|
|
export: "/srv/isos".into(),
|
|
username: Some("root".into()),
|
|
port: None,
|
|
password: None,
|
|
private_key: None,
|
|
passphrase: None,
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
let lc = err.error.to_lowercase();
|
|
assert!(
|
|
lc.contains("password") || lc.contains("private key"),
|
|
"{}",
|
|
err.error
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_rejects_both_secrets() {
|
|
let dir = tempdir().unwrap();
|
|
let store = IsoStore::new(dir.path().join("isos"));
|
|
let mgr = SftpShareManager::new(dir.path(), store);
|
|
let err = mgr
|
|
.add(SftpAddRequest {
|
|
server: "10.0.0.5".into(),
|
|
export: "/srv/isos".into(),
|
|
username: Some("root".into()),
|
|
port: None,
|
|
password: Some("pw".into()),
|
|
private_key: Some("-----BEGIN OPENSSH PRIVATE KEY-----".into()),
|
|
passphrase: None,
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert!(
|
|
err.error.to_lowercase().contains("not both"),
|
|
"{}",
|
|
err.error
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_requires_absolute_export() {
|
|
let dir = tempdir().unwrap();
|
|
let store = IsoStore::new(dir.path().join("isos"));
|
|
let mgr = SftpShareManager::new(dir.path(), store);
|
|
let err = mgr
|
|
.add(SftpAddRequest {
|
|
server: "10.0.0.5".into(),
|
|
export: "relative/path".into(),
|
|
username: Some("root".into()),
|
|
port: None,
|
|
password: Some("pw".into()),
|
|
private_key: None,
|
|
passphrase: None,
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert!(
|
|
err.error.to_lowercase().contains("absolute"),
|
|
"{}",
|
|
err.error
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn creds_round_trip_0600() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let dir = tempdir().unwrap();
|
|
let store = IsoStore::new(dir.path().join("isos"));
|
|
let mgr = SftpShareManager::new(dir.path(), store);
|
|
let creds = SftpCreds {
|
|
password: Some("hunter2".into()),
|
|
private_key: None,
|
|
passphrase: None,
|
|
};
|
|
mgr.write_creds("share1", &creds).await.unwrap();
|
|
let read = mgr.read_creds("share1").await.unwrap();
|
|
assert_eq!(read.password.as_deref(), Some("hunter2"));
|
|
// The secret file must be 0600.
|
|
let meta = std::fs::metadata(mgr.creds_path_for("share1")).unwrap();
|
|
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
|
|
}
|
|
}
|