mirror of
https://github.com/wez/wezterm.git
synced 2024-11-23 06:54:45 +03:00
ssh: can now pass e2e tests using libssh backend
This commit is contained in:
parent
390fcc56ca
commit
8be442e39b
5
Cargo.lock
generated
5
Cargo.lock
generated
@ -2413,7 +2413,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "libssh-rs"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/wez/libssh-rs.git?rev=02256a841e3859e8fc4c3c92e849245ac31e17f0#02256a841e3859e8fc4c3c92e849245ac31e17f0"
|
||||
source = "git+https://github.com/wez/libssh-rs.git?rev=a260815969fa3588996ac9eb25fe09f55ac52219#a260815969fa3588996ac9eb25fe09f55ac52219"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"libssh-rs-sys",
|
||||
@ -2423,7 +2423,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "libssh-rs-sys"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/wez/libssh-rs.git?rev=02256a841e3859e8fc4c3c92e849245ac31e17f0#02256a841e3859e8fc4c3c92e849245ac31e17f0"
|
||||
source = "git+https://github.com/wez/libssh-rs.git?rev=a260815969fa3588996ac9eb25fe09f55ac52219#a260815969fa3588996ac9eb25fe09f55ac52219"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libz-sys",
|
||||
@ -5479,6 +5479,7 @@ dependencies = [
|
||||
"filenamegen",
|
||||
"indoc",
|
||||
"k9",
|
||||
"libc",
|
||||
"libssh-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
|
@ -21,12 +21,13 @@ camino = "1.0"
|
||||
dirs-next = "2.0"
|
||||
filedescriptor = { version="0.8", path = "../filedescriptor" }
|
||||
filenamegen = "0.2"
|
||||
libc = "0.2"
|
||||
log = "0.4"
|
||||
portable-pty = { version="0.5", path = "../pty" }
|
||||
regex = "1"
|
||||
smol = "1.2"
|
||||
ssh2 = {version="0.9.3", features=["openssl-on-win32"]}
|
||||
libssh-rs = {git="https://github.com/wez/libssh-rs.git", rev="02256a841e3859e8fc4c3c92e849245ac31e17f0", features=["vendored", "vendored-openssl"]}
|
||||
libssh-rs = {git="https://github.com/wez/libssh-rs.git", rev="a260815969fa3588996ac9eb25fe09f55ac52219", features=["vendored", "vendored-openssl"]}
|
||||
#libssh-rs = {path="../../libssh-rs/libssh-rs", features=["vendored", "vendored-openssl"]}
|
||||
thiserror = "1.0"
|
||||
|
||||
|
@ -235,7 +235,7 @@ impl crate::sessioninner::SessionInner {
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("unhandled auth case");
|
||||
anyhow::bail!("unhandled auth case; methods={:?}", auth_methods);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,35 +1,43 @@
|
||||
use crate::sftp::types::Metadata;
|
||||
use crate::sftp::{SftpChannelError, SftpChannelResult};
|
||||
use libssh_rs as libssh;
|
||||
use std::io::Write;
|
||||
|
||||
pub(crate) enum FileWrap {
|
||||
Ssh2(ssh2::File),
|
||||
LibSsh(libssh::SftpFile),
|
||||
}
|
||||
|
||||
impl FileWrap {
|
||||
pub fn reader(&mut self) -> impl std::io::Read + '_ {
|
||||
pub fn reader(&mut self) -> Box<dyn std::io::Read + '_> {
|
||||
match self {
|
||||
Self::Ssh2(file) => file,
|
||||
Self::Ssh2(file) => Box::new(file),
|
||||
Self::LibSsh(file) => Box::new(file),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn writer(&mut self) -> impl std::io::Write + '_ {
|
||||
pub fn writer(&mut self) -> Box<dyn std::io::Write + '_> {
|
||||
match self {
|
||||
Self::Ssh2(file) => file,
|
||||
Self::Ssh2(file) => Box::new(file),
|
||||
Self::LibSsh(file) => Box::new(file),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_metadata(&mut self, metadata: Metadata) -> SftpChannelResult<()> {
|
||||
match self {
|
||||
Self::Ssh2(file) => file
|
||||
.setstat(metadata.into())
|
||||
.map_err(SftpChannelError::from),
|
||||
Self::Ssh2(file) => Ok(file.setstat(metadata.into())?),
|
||||
Self::LibSsh(_file) => Err(libssh::Error::fatal(
|
||||
"FileWrap::set_metadata not implemented for libssh::SftpFile",
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn metadata(&mut self) -> SftpChannelResult<Metadata> {
|
||||
match self {
|
||||
Self::Ssh2(file) => file
|
||||
.stat()
|
||||
Self::Ssh2(file) => Ok(file.stat().map(Metadata::from)?),
|
||||
Self::LibSsh(file) => file
|
||||
.metadata()
|
||||
.map(Metadata::from)
|
||||
.map_err(SftpChannelError::from),
|
||||
}
|
||||
@ -38,6 +46,7 @@ impl FileWrap {
|
||||
pub fn fsync(&mut self) -> SftpChannelResult<()> {
|
||||
match self {
|
||||
Self::Ssh2(file) => file.fsync().map_err(SftpChannelError::from),
|
||||
Self::LibSsh(file) => Ok(file.flush()?),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -114,6 +114,15 @@ impl SessionInner {
|
||||
sess.set_option(libssh::SshOption::Hostname(hostname.clone()))?;
|
||||
sess.set_option(libssh::SshOption::User(Some(user)))?;
|
||||
sess.set_option(libssh::SshOption::Port(port))?;
|
||||
if let Some(files) = self.config.get("identityfile") {
|
||||
for file in files.split_whitespace() {
|
||||
sess.set_option(libssh::SshOption::AddIdentity(file.to_string()))?;
|
||||
}
|
||||
}
|
||||
if let Some(kh) = self.config.get("userknownhostsfile") {
|
||||
sess.set_option(libssh::SshOption::KnownHosts(Some(kh.to_string())))?;
|
||||
}
|
||||
|
||||
sess.options_parse_config(None)?; // FIXME: overridden config path?
|
||||
sess.connect()?;
|
||||
|
||||
@ -948,7 +957,12 @@ impl SessionInner {
|
||||
}
|
||||
Ok(sess.sftp.as_mut().expect("sftp should have been set above"))
|
||||
}
|
||||
SessionWrap::LibSsh(_) => Err(SftpChannelError::NotImplemented),
|
||||
SessionWrap::LibSsh(sess) => {
|
||||
if sess.sftp.is_none() {
|
||||
sess.sftp = Some(SftpWrap::LibSsh(sess.sess.sftp()?));
|
||||
}
|
||||
Ok(sess.sftp.as_mut().expect("sftp should have been set above"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,9 +9,14 @@ pub(crate) struct Ssh2Session {
|
||||
pub sftp: Option<SftpWrap>,
|
||||
}
|
||||
|
||||
pub(crate) struct LibSshSession {
|
||||
pub sess: libssh::Session,
|
||||
pub sftp: Option<SftpWrap>,
|
||||
}
|
||||
|
||||
pub(crate) enum SessionWrap {
|
||||
Ssh2(Ssh2Session),
|
||||
LibSsh(libssh::Session),
|
||||
LibSsh(LibSshSession),
|
||||
}
|
||||
|
||||
impl SessionWrap {
|
||||
@ -20,13 +25,13 @@ impl SessionWrap {
|
||||
}
|
||||
|
||||
pub fn with_libssh(sess: libssh::Session) -> Self {
|
||||
Self::LibSsh(sess)
|
||||
Self::LibSsh(LibSshSession { sess, sftp: None })
|
||||
}
|
||||
|
||||
pub fn set_blocking(&mut self, blocking: bool) {
|
||||
match self {
|
||||
Self::Ssh2(sess) => sess.sess.set_blocking(blocking),
|
||||
Self::LibSsh(sess) => sess.set_blocking(blocking),
|
||||
Self::LibSsh(sess) => sess.sess.set_blocking(blocking),
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,7 +44,7 @@ impl SessionWrap {
|
||||
BlockDirections::Both => POLLIN | POLLOUT,
|
||||
},
|
||||
Self::LibSsh(sess) => {
|
||||
let (read, write) = sess.get_poll_state();
|
||||
let (read, write) = sess.sess.get_poll_state();
|
||||
match (read, write) {
|
||||
(false, false) => 0,
|
||||
(true, false) => POLLIN,
|
||||
@ -53,7 +58,7 @@ impl SessionWrap {
|
||||
pub fn as_socket_descriptor(&self) -> SocketDescriptor {
|
||||
match self {
|
||||
Self::Ssh2(sess) => sess.sess.as_socket_descriptor(),
|
||||
Self::LibSsh(sess) => sess.as_socket_descriptor(),
|
||||
Self::LibSsh(sess) => sess.sess.as_socket_descriptor(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,7 +69,7 @@ impl SessionWrap {
|
||||
Ok(ChannelWrap::Ssh2(channel))
|
||||
}
|
||||
Self::LibSsh(sess) => {
|
||||
let channel = sess.new_channel()?;
|
||||
let channel = sess.sess.new_channel()?;
|
||||
channel.open_session()?;
|
||||
Ok(ChannelWrap::LibSsh(channel))
|
||||
}
|
||||
|
@ -362,6 +362,16 @@ impl From<libssh::FileType> for FileType {
|
||||
}
|
||||
}
|
||||
|
||||
fn sys_time_to_unix(t: SystemTime) -> u64 {
|
||||
t.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.expect("UNIX_EPOCH < SystemTime")
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn unix_to_sys(u: u64) -> SystemTime {
|
||||
SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(u)
|
||||
}
|
||||
|
||||
impl From<libssh::Metadata> for Metadata {
|
||||
fn from(stat: libssh::Metadata) -> Self {
|
||||
Self {
|
||||
@ -373,16 +383,33 @@ impl From<libssh::Metadata> for Metadata {
|
||||
size: stat.len(),
|
||||
uid: stat.uid(),
|
||||
gid: stat.gid(),
|
||||
accessed: stat.accessed().map(|t| {
|
||||
t.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.expect("UNIX_EPOCH < SystemTime")
|
||||
.as_secs()
|
||||
}),
|
||||
modified: stat.modified().map(|t| {
|
||||
t.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.expect("UNIX_EPOCH < SystemTime")
|
||||
.as_secs()
|
||||
}),
|
||||
accessed: stat.accessed().map(sys_time_to_unix),
|
||||
modified: stat.modified().map(sys_time_to_unix),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<libssh::SetAttributes> for Metadata {
|
||||
fn into(self) -> libssh::SetAttributes {
|
||||
let size = self.size;
|
||||
let uid_gid = match (self.uid, self.gid) {
|
||||
(Some(uid), Some(gid)) => Some((uid, gid)),
|
||||
_ => None,
|
||||
};
|
||||
let permissions = self.permissions.map(FilePermissions::to_unix_mode);
|
||||
let atime_mtime = match (self.accessed, self.modified) {
|
||||
(Some(a), Some(m)) => {
|
||||
let a = unix_to_sys(a);
|
||||
let m = unix_to_sys(m);
|
||||
Some((a, m))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
libssh::SetAttributes {
|
||||
size,
|
||||
uid_gid,
|
||||
permissions,
|
||||
atime_mtime,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,21 @@
|
||||
use crate::dirwrap::DirWrap;
|
||||
use crate::filewrap::FileWrap;
|
||||
use crate::sftp::types::{Metadata, OpenOptions, RenameOptions};
|
||||
use crate::sftp::types::{Metadata, OpenOptions, RenameOptions, WriteMode};
|
||||
use crate::sftp::{SftpChannelError, SftpChannelResult};
|
||||
use camino::{Utf8Path, Utf8PathBuf};
|
||||
use std::convert::TryFrom;
|
||||
use libc::{O_APPEND, O_RDONLY, O_RDWR, O_WRONLY};
|
||||
use libssh_rs as libssh;
|
||||
use std::convert::{TryFrom, TryInto};
|
||||
|
||||
pub(crate) enum SftpWrap {
|
||||
Ssh2(ssh2::Sftp),
|
||||
LibSsh(libssh::Sftp),
|
||||
}
|
||||
|
||||
fn pathconv(path: std::path::PathBuf) -> SftpChannelResult<Utf8PathBuf> {
|
||||
Ok(Utf8PathBuf::try_from(path).map_err(|x| {
|
||||
SftpChannelError::from(std::io::Error::new(std::io::ErrorKind::InvalidData, x))
|
||||
})?)
|
||||
}
|
||||
|
||||
impl SftpWrap {
|
||||
@ -17,75 +26,64 @@ impl SftpWrap {
|
||||
let mode = opts.mode;
|
||||
let open_type: ssh2::OpenType = opts.ty.into();
|
||||
|
||||
let file = sftp
|
||||
.open_mode(filename.as_std_path(), flags, mode, open_type)
|
||||
.map_err(SftpChannelError::from)?;
|
||||
let file = sftp.open_mode(filename.as_std_path(), flags, mode, open_type)?;
|
||||
Ok(FileWrap::Ssh2(file))
|
||||
}
|
||||
Self::LibSsh(sftp) => {
|
||||
let accesstype = match (opts.write, opts.read) {
|
||||
(Some(WriteMode::Append), true) => O_RDWR | O_APPEND,
|
||||
(Some(WriteMode::Append), false) => O_WRONLY | O_APPEND,
|
||||
(Some(WriteMode::Write), false) => O_WRONLY,
|
||||
(Some(WriteMode::Write), true) => O_RDWR,
|
||||
(None, true) => O_RDONLY,
|
||||
(None, false) => 0,
|
||||
};
|
||||
let file =
|
||||
sftp.open(filename.as_str(), accesstype, opts.mode.try_into().unwrap())?;
|
||||
Ok(FileWrap::LibSsh(file))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn symlink(&self, path: &Utf8Path, target: &Utf8Path) -> SftpChannelResult<()> {
|
||||
match self {
|
||||
Self::Ssh2(sftp) => sftp
|
||||
.symlink(path.as_std_path(), target.as_std_path())
|
||||
.map_err(SftpChannelError::from),
|
||||
Self::Ssh2(sftp) => Ok(sftp.symlink(path.as_std_path(), target.as_std_path())?),
|
||||
Self::LibSsh(sftp) => Ok(sftp.symlink(path.as_str(), target.as_str())?),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_link(&self, filename: &Utf8Path) -> SftpChannelResult<Utf8PathBuf> {
|
||||
match self {
|
||||
Self::Ssh2(sftp) => sftp
|
||||
.readlink(filename.as_std_path())
|
||||
.map_err(SftpChannelError::from)
|
||||
.and_then(|path| {
|
||||
Utf8PathBuf::try_from(path).map_err(|x| {
|
||||
SftpChannelError::from(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
x,
|
||||
))
|
||||
})
|
||||
}),
|
||||
Self::Ssh2(sftp) => Ok(pathconv(sftp.readlink(filename.as_std_path())?)?),
|
||||
Self::LibSsh(sftp) => Ok(sftp.read_link(filename.as_str())?.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn canonicalize(&self, filename: &Utf8Path) -> SftpChannelResult<Utf8PathBuf> {
|
||||
match self {
|
||||
Self::Ssh2(sftp) => sftp
|
||||
.realpath(filename.as_std_path())
|
||||
.map_err(SftpChannelError::from)
|
||||
.and_then(|path| {
|
||||
Utf8PathBuf::try_from(path).map_err(|x| {
|
||||
SftpChannelError::from(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
x,
|
||||
))
|
||||
})
|
||||
}),
|
||||
Self::Ssh2(sftp) => Ok(pathconv(sftp.realpath(filename.as_std_path())?)?),
|
||||
Self::LibSsh(sftp) => Ok(sftp.canonicalize(filename.as_str())?.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unlink(&self, filename: &Utf8Path) -> SftpChannelResult<()> {
|
||||
match self {
|
||||
Self::Ssh2(sftp) => sftp
|
||||
.unlink(filename.as_std_path())
|
||||
.map_err(SftpChannelError::from),
|
||||
Self::Ssh2(sftp) => Ok(sftp.unlink(filename.as_std_path())?),
|
||||
Self::LibSsh(sftp) => Ok(sftp.remove_file(filename.as_str())?),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_dir(&self, filename: &Utf8Path) -> SftpChannelResult<()> {
|
||||
match self {
|
||||
Self::Ssh2(sftp) => sftp
|
||||
.rmdir(filename.as_std_path())
|
||||
.map_err(SftpChannelError::from),
|
||||
Self::Ssh2(sftp) => Ok(sftp.rmdir(filename.as_std_path())?),
|
||||
Self::LibSsh(sftp) => Ok(sftp.remove_dir(filename.as_str())?),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_dir(&self, filename: &Utf8Path, mode: i32) -> SftpChannelResult<()> {
|
||||
match self {
|
||||
Self::Ssh2(sftp) => sftp
|
||||
.mkdir(filename.as_std_path(), mode)
|
||||
.map_err(SftpChannelError::from),
|
||||
Self::Ssh2(sftp) => Ok(sftp.mkdir(filename.as_std_path(), mode)?),
|
||||
Self::LibSsh(sftp) => Ok(sftp.create_dir(filename.as_str(), mode.try_into().unwrap())?),
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,68 +94,73 @@ impl SftpWrap {
|
||||
opts: RenameOptions,
|
||||
) -> SftpChannelResult<()> {
|
||||
match self {
|
||||
Self::Ssh2(sftp) => sftp
|
||||
.rename(src.as_std_path(), dest.as_std_path(), Some(opts.into()))
|
||||
.map_err(SftpChannelError::from),
|
||||
Self::Ssh2(sftp) => {
|
||||
Ok(sftp.rename(src.as_std_path(), dest.as_std_path(), Some(opts.into()))?)
|
||||
}
|
||||
Self::LibSsh(sftp) => Ok(sftp.rename(src.as_str(), dest.as_str())?),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn symlink_metadata(&self, filename: &Utf8Path) -> SftpChannelResult<Metadata> {
|
||||
match self {
|
||||
Self::Ssh2(sftp) => sftp
|
||||
.lstat(filename.as_std_path())
|
||||
.map(Metadata::from)
|
||||
.map_err(SftpChannelError::from),
|
||||
Self::Ssh2(sftp) => Ok(sftp.lstat(filename.as_std_path()).map(Metadata::from)?),
|
||||
Self::LibSsh(sftp) => Ok(sftp
|
||||
.symlink_metadata(filename.as_str())
|
||||
.map(Metadata::from)?),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn metadata(&self, filename: &Utf8Path) -> SftpChannelResult<Metadata> {
|
||||
match self {
|
||||
Self::Ssh2(sftp) => sftp
|
||||
.stat(filename.as_std_path())
|
||||
.map(Metadata::from)
|
||||
.map_err(SftpChannelError::from),
|
||||
Self::Ssh2(sftp) => Ok(sftp.stat(filename.as_std_path()).map(Metadata::from)?),
|
||||
Self::LibSsh(sftp) => Ok(sftp.metadata(filename.as_str()).map(Metadata::from)?),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_metadata(&self, filename: &Utf8Path, metadata: Metadata) -> SftpChannelResult<()> {
|
||||
match self {
|
||||
Self::Ssh2(sftp) => sftp
|
||||
.setstat(filename.as_std_path(), metadata.into())
|
||||
.map_err(SftpChannelError::from),
|
||||
Self::Ssh2(sftp) => Ok(sftp.setstat(filename.as_std_path(), metadata.into())?),
|
||||
Self::LibSsh(sftp) => {
|
||||
let attr: libssh::SetAttributes = metadata.into();
|
||||
Ok(sftp.set_metadata(filename.as_str(), &attr)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_dir(&self, filename: &Utf8Path) -> SftpChannelResult<DirWrap> {
|
||||
match self {
|
||||
Self::Ssh2(sftp) => sftp
|
||||
.opendir(filename.as_std_path())
|
||||
.map_err(SftpChannelError::from)
|
||||
.map(DirWrap::Ssh2),
|
||||
Self::Ssh2(sftp) => Ok(sftp.opendir(filename.as_std_path()).map(DirWrap::Ssh2)?),
|
||||
Self::LibSsh(sftp) => Ok(sftp.open_dir(filename.as_str()).map(DirWrap::LibSsh)?),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_dir(&self, filename: &Utf8Path) -> SftpChannelResult<Vec<(Utf8PathBuf, Metadata)>> {
|
||||
match self {
|
||||
Self::Ssh2(sftp) => sftp
|
||||
.readdir(filename.as_std_path())
|
||||
.map_err(SftpChannelError::from)
|
||||
.and_then(|entries| {
|
||||
let mut mapped_entries = Vec::new();
|
||||
for (path, stat) in entries {
|
||||
match Utf8PathBuf::try_from(path) {
|
||||
Ok(path) => mapped_entries.push((path, Metadata::from(stat))),
|
||||
Err(x) => {
|
||||
return Err(SftpChannelError::from(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
x,
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Ssh2(sftp) => {
|
||||
let entries = sftp.readdir(filename.as_std_path())?;
|
||||
let mut mapped_entries = vec![];
|
||||
for (path, stat) in entries {
|
||||
let path = pathconv(path)?;
|
||||
mapped_entries.push((path, Metadata::from(stat)));
|
||||
}
|
||||
|
||||
Ok(mapped_entries)
|
||||
}),
|
||||
Ok(mapped_entries)
|
||||
}
|
||||
Self::LibSsh(sftp) => {
|
||||
let entries = sftp.read_dir(filename.as_str())?;
|
||||
let mut mapped_entries = vec![];
|
||||
for metadata in entries {
|
||||
let path = metadata
|
||||
.name()
|
||||
.expect("name to be present in read dir results");
|
||||
if path == "." || path == ".." {
|
||||
continue;
|
||||
}
|
||||
mapped_entries.push((filename.join(path), metadata.into()));
|
||||
}
|
||||
|
||||
Ok(mapped_entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user