Condense the documentation and thin the server's comments
The markdown had accumulated a lot that was stale rather than wrong. PLAN.md still described pi as the llama.cpp harness, a refcounted LlamaServerManager, and a providers-by-hosts cross-product, all of which were superseded or never built; it also carried a second copy of the HTTP table that routes.rs owns. EXPLORER.md and TRANSCRIPT_CACHE.md held implementation checklists for work that has since landed. AGENTS.md restated most of PLAN.md's design instead of being the working-notes layer it says it is. 3225 lines of markdown to 2180, with the stale sections gone rather than reworded. On the server, comments explaining what the code already says are out and the ones recording a constraint, a measurement or an incident are kept but cut to a few lines each: 5504 comment lines to 4586. Four doc comments in session/mod.rs, and one each in process.rs and usage.rs, had drifted onto the item above the one they describe -- functions were reordered without them, so `stop_session`'s doc sat on `set_session_cwd`, `stat_of`'s on `struct Stat`, and `UsageMonitor`'s on `type Cached`. Each is back on its own item. routes.rs's module table also claimed later phases would add `/hosts`, which setups replaced. cargo test (127 passed), clippy --all-targets and fmt are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e3e02d55f7
commit
79682f03a7
24 files changed
+4572
-6821
No files matched your search
+104
-130
@@ -1,71 +1,58 @@
|
||||
//! Reading and changing files on the machine a setup names.
|
||||
//!
|
||||
//! Every operation here is one small POSIX shell script handed to
|
||||
//! `Transport`, exactly the way `setups::discover` and `import::list`
|
||||
//! already ask a machine a question. That is what makes the local and the
|
||||
//! ssh case one implementation: a second one written against `std::fs`
|
||||
//! would be the one that gets tested, and the remote half -- the ordering
|
||||
//! of entries, what a symlink reports, how a permission error reads --
|
||||
//! would drift until it shipped broken. The cost is an `sh` process per
|
||||
//! operation on this machine, which is under a millisecond.
|
||||
//! Every operation here is one small POSIX shell script handed to `Transport`,
|
||||
//! the way `setups::discover` and `import::list` already ask a machine a
|
||||
//! question. That is what makes the local and the ssh case one implementation:
|
||||
//! a second one written against `std::fs` would be the one that gets tested,
|
||||
//! and the remote half -- the ordering of entries, what a symlink reports, how
|
||||
//! a permission error reads -- would drift until it shipped broken. The cost is
|
||||
//! an `sh` process per operation here, which is under a millisecond.
|
||||
//!
|
||||
//! The scripts assume GNU coreutils and findutils (`find -printf`,
|
||||
//! `stat -c`, `sha256sum`, `chmod --reference`), which is what
|
||||
//! `session::import` already assumes and what both machines here run. One
|
||||
//! without them fails with that tool's own message, which names what is
|
||||
//! missing.
|
||||
//! The scripts assume GNU coreutils and findutils, which is what
|
||||
//! `session::import` already assumes. A machine without them fails with that
|
||||
//! tool's own message, which names what is missing.
|
||||
//!
|
||||
//! **The phone names a path, and that is deliberate** -- see PLAN.md's
|
||||
//! Security section. The enrolled token already spawns an agent in any
|
||||
//! directory on any machine a setup names, and that agent reads and writes
|
||||
//! every file its user can; this is a shorter path to authority the token
|
||||
//! already holds. What is *not* given up: no route here accepts a command.
|
||||
//! Listing, reading and writing are the fixed scripts below, and the phone
|
||||
//! chooses only the path and the bytes.
|
||||
//! **The phone names a path, and that is deliberate** -- see PLAN.md's Security
|
||||
//! section. What is *not* given up: no route here accepts a command. Listing,
|
||||
//! reading and writing are the fixed scripts below, and the phone chooses only
|
||||
//! the path and the bytes.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::session::transport::{Input, Launch, Transport};
|
||||
|
||||
/// The most of a file that crosses the tunnel, in bytes.
|
||||
///
|
||||
/// Checked on the far machine before anything reads the file, so a 2 GB
|
||||
/// log costs a `stat` rather than a transfer. A file over it is reported
|
||||
/// as [`FileRead::TooBig`] with its size, because "we did not read this"
|
||||
/// and "this is empty" must not look the same on the phone.
|
||||
/// The most of a file that crosses the tunnel, in bytes. Checked on the far
|
||||
/// machine before anything reads the file, so a 2 GB log costs a `stat` rather
|
||||
/// than a transfer. A file over it is [`FileRead::TooBig`] with its size,
|
||||
/// because "we did not read this" and "this is empty" must not look the same.
|
||||
pub const FILE_LIMIT: u64 = 1024 * 1024;
|
||||
|
||||
/// The prelude every script here starts with: the path arrives as `$1`,
|
||||
/// and this is where a leading `~` becomes that machine's own home.
|
||||
/// The prelude every script here starts with: the path arrives as `$1`, and
|
||||
/// this is where a leading `~` becomes that machine's own home.
|
||||
///
|
||||
/// The path is a **positional argument** and never text spliced into the
|
||||
/// script -- the rule `import::find` follows with `"$1"`, for the reason
|
||||
/// `ssh::quote` exists: a path is attacker-adjacent input in a server
|
||||
/// whose job is running commands, and interpolated it would be syntax
|
||||
/// rather than data.
|
||||
/// The path is a **positional argument** and never text spliced into the script
|
||||
/// -- the rule `import::find` follows, for the reason `ssh::quote` exists: a
|
||||
/// path is attacker-adjacent input in a server whose job is running commands,
|
||||
/// and interpolated it would be syntax rather than data.
|
||||
///
|
||||
/// `~` is the one character that costs something for it. A shell expands a
|
||||
/// tilde in *text*, so a path handed over as an argument arrives with a
|
||||
/// literal one; expanding it here, once, gives it the same meaning
|
||||
/// `ssh::quote_path` and `ssh::expand_home` give it everywhere else, and
|
||||
/// it is the *far* machine's `$HOME` -- the only one that could be right.
|
||||
/// `~user` stays literal here too, and fails with the shell's own message.
|
||||
/// tilde in *text*, so a path handed over as an argument arrives with a literal
|
||||
/// one; expanding it here gives it the same meaning `ssh::quote_path` gives it
|
||||
/// everywhere else, and it is the *far* machine's `$HOME`. `~user` stays
|
||||
/// literal and fails with the shell's own message.
|
||||
///
|
||||
/// Everything below uses `$p` for the path and `$2` for whatever else it
|
||||
/// was given.
|
||||
/// Everything below uses `$p` for the path and `$2` for whatever else.
|
||||
const PATH_PRELUDE: &str = r#"p=$1; case $p in "~") p=$HOME;; "~/"*) p=$HOME/${p#"~/"};; esac; "#;
|
||||
|
||||
/// What a directory turned out to be, and what is in it.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Listing {
|
||||
/// `pwd -P` of the directory that was listed.
|
||||
///
|
||||
/// Answered by the machine rather than worked out here, so the phone
|
||||
/// navigates on a resolved absolute path: the parent of one of these
|
||||
/// is a string operation, and a `~` a session was spawned with is
|
||||
/// shown as what it turned out to be.
|
||||
/// `pwd -P` of the directory that was listed. Answered by the machine
|
||||
/// rather than worked out here, so the phone navigates on a resolved
|
||||
/// absolute path: the parent of one of these is a string operation, and a
|
||||
/// `~` a session was spawned with is shown as what it turned out to be.
|
||||
pub path: String,
|
||||
pub entries: Vec<Entry>,
|
||||
}
|
||||
@@ -74,14 +61,13 @@ pub struct Listing {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
/// What tapping it does, which for a symlink is decided by its
|
||||
/// *target* -- a link to a directory navigates.
|
||||
/// What tapping it does, which for a symlink is decided by its *target* --
|
||||
/// a link to a directory navigates.
|
||||
pub kind: EntryKind,
|
||||
pub size: u64,
|
||||
/// Seconds since the epoch.
|
||||
pub modified: i64,
|
||||
/// Whether the entry itself is a symlink, whatever [`Entry::kind`]
|
||||
/// says its target is.
|
||||
/// Whether the entry itself is a symlink, whatever [`Entry::kind`] says
|
||||
/// its target is.
|
||||
pub link: bool,
|
||||
}
|
||||
|
||||
@@ -90,18 +76,18 @@ pub struct Entry {
|
||||
pub enum EntryKind {
|
||||
Directory,
|
||||
File,
|
||||
/// A socket, a device, a fifo -- and a symlink whose target is missing
|
||||
/// or loops, which `find` reports the same way. Shown, because a
|
||||
/// directory that hid what it held would be lying about being empty.
|
||||
/// A socket, a device, a fifo -- and a symlink whose target is missing or
|
||||
/// loops, which `find` reports the same way. Shown, because a directory
|
||||
/// that hid what it held would be lying about being empty.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// What reading a file produced -- four answers, not content-or-error.
|
||||
///
|
||||
/// A binary file drawn as text and a big file cut off silently are both
|
||||
/// wrong in ways the reader cannot see, and "couldn't read it" must not
|
||||
/// look like "it is empty". A file that is genuinely empty is
|
||||
/// [`FileRead::Text`] with nothing in it, which is what it is.
|
||||
/// A binary file drawn as text and a big file cut off silently are both wrong
|
||||
/// in ways the reader cannot see, and "couldn't read it" must not look like
|
||||
/// "it is empty". A genuinely empty file is [`FileRead::Text`] with nothing in
|
||||
/// it, which is what it is.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum FileRead {
|
||||
@@ -114,8 +100,8 @@ pub enum FileRead {
|
||||
},
|
||||
/// Not UTF-8. Its size is reported; nothing is shown.
|
||||
Binary { size: u64, modified: i64 },
|
||||
/// Over [`FILE_LIMIT`]. Its size is reported, so the reader knows what
|
||||
/// they are looking at rather than only that they cannot have it.
|
||||
/// Over [`FILE_LIMIT`]. Its size is reported, so the reader knows what they
|
||||
/// are looking at rather than only that they cannot have it.
|
||||
TooBig { size: u64, modified: i64 },
|
||||
}
|
||||
|
||||
@@ -129,18 +115,16 @@ pub struct Written {
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// The exit code the write script uses for "this is not the file you
|
||||
/// read", which the route turns into a 409. Distinct from every other
|
||||
/// failure, which is a message from the machine.
|
||||
/// The exit code the write script uses for "this is not the file you read",
|
||||
/// which the route turns into a 409. Distinct from every other failure, which
|
||||
/// is a message from the machine.
|
||||
pub const STALE: i32 = 3;
|
||||
|
||||
/// A path the phone may name: absolute, or home-relative on that machine.
|
||||
///
|
||||
/// Shared with `POST /sessions/{id}/cwd`, which asks the same question for
|
||||
/// the same reason -- a relative path is relative to something nobody
|
||||
/// looking at the screen can see, so it is refused rather than resolved
|
||||
/// against a guess. Returns the path with the whitespace a phone keyboard
|
||||
/// adds taken off.
|
||||
/// Shared with `POST /sessions/{id}/cwd`, which asks the same question for the
|
||||
/// same reason -- a relative path is relative to something nobody looking at
|
||||
/// the screen can see, so it is refused rather than resolved against a guess.
|
||||
pub fn check_path(path: &str) -> Result<String> {
|
||||
let path = path.trim();
|
||||
if path.is_empty() {
|
||||
@@ -160,8 +144,8 @@ fn launch(script: String, path: &str, extra: Option<&str>) -> Launch {
|
||||
let mut args = vec![
|
||||
"-c".to_string(),
|
||||
script,
|
||||
// `$0`, which is what `sh` names itself in a message about the
|
||||
// script; the path is `$1`.
|
||||
// `$0`, which is what `sh` names itself in a message about the script;
|
||||
// the path is `$1`.
|
||||
"sh".to_string(),
|
||||
path.to_string(),
|
||||
];
|
||||
@@ -169,11 +153,10 @@ fn launch(script: String, path: &str, extra: Option<&str>) -> Launch {
|
||||
Launch::new("sh", args, None)
|
||||
}
|
||||
|
||||
/// Everything in `path`, and what `path` resolved to.
|
||||
///
|
||||
/// Entries are separated by `\0` and their fields by `\t`, so a filename
|
||||
/// with a newline or a tab in it survives -- both are legal, and a listing
|
||||
/// that lost one would quietly show the wrong thing.
|
||||
/// Everything in `path`, and what `path` resolved to. Entries are separated by
|
||||
/// `\0` and their fields by `\t`, so a filename with a newline or a tab in it
|
||||
/// survives -- both are legal, and a listing that lost one would quietly show
|
||||
/// the wrong thing.
|
||||
pub async fn list(transport: &Transport, path: &str) -> Result<Listing> {
|
||||
let script = format!(
|
||||
"{PATH_PRELUDE}cd -- \"$p\" && pwd -P && \
|
||||
@@ -193,11 +176,9 @@ pub async fn list(transport: &Transport, path: &str) -> Result<Listing> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The `find` output above, as rows.
|
||||
///
|
||||
/// A record that does not have all five fields is dropped rather than
|
||||
/// guessed at: it can only come from a `find` that printed something else,
|
||||
/// and half a row is worse than no row.
|
||||
/// The `find` output above, as rows. A record without all five fields is
|
||||
/// dropped rather than guessed at: it can only come from a `find` that printed
|
||||
/// something else, and half a row is worse than no row.
|
||||
fn parse_entries(text: &str) -> Vec<Entry> {
|
||||
text.split('\0')
|
||||
.filter(|record| !record.is_empty())
|
||||
@@ -208,8 +189,7 @@ fn parse_entries(text: &str) -> Vec<Entry> {
|
||||
let own = fields.next()?;
|
||||
let target = fields.next()?;
|
||||
let size = fields.next()?.parse().ok()?;
|
||||
// `%T@` is seconds with a fractional part; the phone shows a
|
||||
// date, so the fraction is dropped rather than carried.
|
||||
// `%T@` is seconds with a fractional part; the phone shows a date.
|
||||
let modified = fields.next()?.split('.').next()?.parse().ok()?;
|
||||
let name = fields.next()?;
|
||||
Some(Entry {
|
||||
@@ -229,14 +209,13 @@ fn parse_entries(text: &str) -> Vec<Entry> {
|
||||
|
||||
/// One file's content, or the reason there is none to show.
|
||||
///
|
||||
/// The size is checked on the far machine *before* anything reads the
|
||||
/// file, so a file over [`FILE_LIMIT`] costs a `stat` rather than a
|
||||
/// transfer. `stat -L` and `sha256sum` both follow symlinks, as `cat`
|
||||
/// does, so a link to a file reports the file.
|
||||
/// The size is checked on the far machine *before* anything reads the file, so
|
||||
/// a file over [`FILE_LIMIT`] costs a `stat` rather than a transfer. `stat -L`
|
||||
/// and `sha256sum` both follow symlinks, as `cat` does.
|
||||
pub async fn read(transport: &Transport, path: &str) -> Result<FileRead> {
|
||||
// Two header lines, then the bytes: `<size> <mtime>`, then either
|
||||
// `tooBig` or the digest. A header rather than a JSON envelope because
|
||||
// the content is bytes and may not be text at all.
|
||||
// Two header lines, then the bytes: `<size> <mtime>`, then either `tooBig`
|
||||
// or the digest. A header rather than a JSON envelope because the content
|
||||
// is bytes and may not be text at all.
|
||||
let script = format!(
|
||||
"{PATH_PRELUDE}set -e; \
|
||||
h=$(stat -L -c '%s %Y' -- \"$p\"); \
|
||||
@@ -281,24 +260,21 @@ fn split_read(out: &[u8]) -> Result<(u64, i64, &str, &[u8])> {
|
||||
))
|
||||
}
|
||||
|
||||
/// Replaces `path`'s contents, but only while it still hashes to
|
||||
/// `expected`.
|
||||
/// Replaces `path`'s contents, but only while it still hashes to `expected`.
|
||||
///
|
||||
/// Agents edit files while people read them, so a stale copy landing on
|
||||
/// top of somebody else's edit is the common case rather than the exotic
|
||||
/// one. The digest the reader was shown is compared on the machine, and a
|
||||
/// file that has moved on comes back as [`STALE`] rather than being
|
||||
/// overwritten.
|
||||
/// Agents edit files while people read them, so a stale copy landing on top of
|
||||
/// somebody else's edit is the common case rather than the exotic one. The
|
||||
/// digest the reader was shown is compared on the machine, and a file that has
|
||||
/// moved on comes back as [`STALE`] rather than being overwritten.
|
||||
///
|
||||
/// A temp file and a rename, so a connection dropped mid-write leaves the
|
||||
/// old file whole rather than a truncated one, and `chmod --reference` so
|
||||
/// the mode survives -- an executable script written as a fresh file would
|
||||
/// stop being one. What that trades away: the inode changes, so a hard
|
||||
/// link elsewhere stops being the same file. Editors do the same.
|
||||
/// A temp file and a rename, so a connection dropped mid-write leaves the old
|
||||
/// file whole, and `chmod --reference` so the mode survives -- an executable
|
||||
/// script written as a fresh file would stop being one. What that trades away:
|
||||
/// the inode changes, so a hard link elsewhere stops being the same file.
|
||||
///
|
||||
/// The check and the write are **not** atomic against a writer landing
|
||||
/// between them -- a window of microseconds on that machine. Accepted: the
|
||||
/// alternative is a lock this has no way to make every other writer take.
|
||||
/// The check and the write are **not** atomic against a writer landing between
|
||||
/// them -- a window of microseconds on that machine. Accepted: the alternative
|
||||
/// is a lock this has no way to make every other writer take.
|
||||
pub async fn write(
|
||||
transport: &Transport,
|
||||
path: &str,
|
||||
@@ -334,17 +310,16 @@ pub async fn write(
|
||||
}))
|
||||
}
|
||||
|
||||
/// The file is not the one that was read. Its own type rather than an
|
||||
/// error string, because the route answers it with a different status and
|
||||
/// the phone with a different question.
|
||||
/// The file is not the one that was read. Its own type rather than an error
|
||||
/// string, because the route answers it with a different status and the phone
|
||||
/// with a different question.
|
||||
#[derive(Debug)]
|
||||
pub struct Stale;
|
||||
|
||||
/// Creates an empty file, refusing to truncate one that is already there.
|
||||
///
|
||||
/// `set -C` is the shell's own noclobber, so an existing name fails with
|
||||
/// the shell's own message rather than with a check that could race the
|
||||
/// redirection it is guarding.
|
||||
/// `set -C` is the shell's own noclobber, so an existing name fails with the
|
||||
/// shell's own message rather than with a check that could race the redirection
|
||||
/// it is guarding.
|
||||
pub async fn create_file(transport: &Transport, path: &str) -> Result<()> {
|
||||
let script = format!("{PATH_PRELUDE}set -C; : > \"$p\"");
|
||||
transport
|
||||
@@ -354,9 +329,9 @@ pub async fn create_file(transport: &Transport, path: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a directory. Plain `mkdir`, not `-p`, for the same reason
|
||||
/// [`create_file`] sets noclobber: a name that exists is something the
|
||||
/// person typing it should be told about.
|
||||
/// Creates a directory. Plain `mkdir`, not `-p`, for the reason
|
||||
/// [`create_file`] sets noclobber: a name that exists is something the person
|
||||
/// typing it should be told about.
|
||||
pub async fn create_dir(transport: &Transport, path: &str) -> Result<()> {
|
||||
let script = format!("{PATH_PRELUDE}mkdir -- \"$p\"");
|
||||
transport
|
||||
@@ -376,8 +351,8 @@ fn text(captured: crate::session::transport::Captured) -> Result<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The names a listing has to survive. All four are legal, and each
|
||||
/// one broke a listing somewhere before it was separated with `\0`.
|
||||
/// The names a listing has to survive. All four are legal, and each one
|
||||
/// broke a listing somewhere before it was separated with `\0`.
|
||||
#[test]
|
||||
fn a_listing_survives_the_names_a_filesystem_allows() {
|
||||
let record = |own: &str, target: &str, size: &str, time: &str, name: &str| {
|
||||
@@ -406,8 +381,8 @@ mod tests {
|
||||
assert_eq!(entries[0].modified, 1756900000);
|
||||
assert_eq!(entries[2].kind, EntryKind::Directory);
|
||||
assert_eq!(entries[2].size, 4096);
|
||||
// The kind is the target's, so a link to a directory navigates --
|
||||
// and one whose target is gone is neither a file nor a directory.
|
||||
// The kind is the target's, so a link to a directory navigates -- and
|
||||
// one whose target is gone is neither a file nor a directory.
|
||||
assert!(entries[3].link);
|
||||
assert_eq!(entries[3].kind, EntryKind::Directory);
|
||||
assert_eq!(entries[4].kind, EntryKind::Other);
|
||||
@@ -431,9 +406,9 @@ mod tests {
|
||||
assert!(refused.contains("start it with / or ~"), "{refused}");
|
||||
}
|
||||
|
||||
/// The scripts, against a real tree, through the transport that runs
|
||||
/// them here -- which is cheap, because `sh` is wherever `cargo test`
|
||||
/// is. The remote transport runs the identical text.
|
||||
/// The scripts, against a real tree, through the transport that runs them
|
||||
/// here -- cheap, because `sh` is wherever `cargo test` is. The remote
|
||||
/// transport runs the identical text.
|
||||
fn tree() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("hello.txt"), "one\ntwo\n").unwrap();
|
||||
@@ -454,8 +429,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
// `pwd -P`, so a temp directory reached through a symlinked /tmp
|
||||
// answers with what it really is -- which is the path the phone
|
||||
// then navigates on.
|
||||
// answers with what it really is.
|
||||
assert!(listing.path.starts_with('/'), "{}", listing.path);
|
||||
let mut names: Vec<&str> = listing.entries.iter().map(|e| e.name.as_str()).collect();
|
||||
names.sort_unstable();
|
||||
@@ -498,8 +472,8 @@ mod tests {
|
||||
std::fs::write(dir.path().join("big"), vec![b'x'; FILE_LIMIT as usize + 1]).unwrap();
|
||||
assert!(matches!(read_at("big").await, FileRead::TooBig { .. }));
|
||||
|
||||
// Empty is text with nothing in it, which is what it is -- not a
|
||||
// fourth state and not the same as any of the three above.
|
||||
// Empty is text with nothing in it -- not a fourth state, and not the
|
||||
// same as any of the three above.
|
||||
std::fs::write(dir.path().join("empty"), "").unwrap();
|
||||
assert!(matches!(
|
||||
read_at("empty").await,
|
||||
@@ -592,9 +566,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A path that tries to close the quote and start a command of its
|
||||
/// own. It is an argument rather than syntax, so it stays one absurd
|
||||
/// filename -- the same property `ssh.rs` tests for the remote side.
|
||||
/// A path that tries to close the quote and start a command of its own. It
|
||||
/// is an argument rather than syntax, so it stays one absurd filename -- the
|
||||
/// same property `ssh.rs` tests for the remote side.
|
||||
#[tokio::test]
|
||||
async fn a_path_full_of_shell_crosses_as_data() {
|
||||
let dir = tree();
|
||||
@@ -614,8 +588,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The tilde is the one character the prelude gives a meaning, and it
|
||||
/// is the *machine's* home -- here, this one.
|
||||
/// The tilde is the one character the prelude gives a meaning, and it is the
|
||||
/// *machine's* home -- here, this one.
|
||||
#[tokio::test]
|
||||
async fn a_leading_tilde_means_the_machine_s_own_home() {
|
||||
let Some(home) = std::env::home_dir() else {
|
||||
|
||||
Reference in new issue
Block a user