Mirrors hivectl exactly: a `completions <shell>` verb that walks the live clap tree, and a package that pipes it into installShellCompletion for bash/zsh/fish. Generating from the command tree rather than writing a script by hand is what keeps completions from drifting away from the verbs they complete — the same reason `markdown-docs` renders the docs from that tree. Dispatched before PathArgs::resolve() for the same reason markdown-docs is: emitting a completion script needs none of the SWARMCTL_AUTHELIA_* deployment env vars, and requiring them would make the package's own build-time invocation fail — exactly where it runs. swarmctl leaves mkBinPackage for its own derivation, since the extractor installs a binary and nothing else.
587 lines
22 KiB
Rust
587 lines
22 KiB
Rust
//! `swarmctl` — the swarm operator's local CLI.
|
|
//!
|
|
//! Runs as **root on the host the swarm-controller runs on**, and acts
|
|
//! directly. That is a deliberate scope, not a shortcut: the alternative
|
|
//! examined for the first verb was to make the write rootless by moving
|
|
//! authelia's users database into a directory the controller owns, and it
|
|
//! does not work — relocating the file only turns a write problem into a
|
|
//! read problem, because authelia then has to reach *across the same
|
|
//! boundary in the other direction*. Making that read work needs either a
|
|
//! hand-pinned gid (the container's uids are allocated inside it, at
|
|
//! activation) or world-readable password hashes. Both are worse than
|
|
//! root.
|
|
//!
|
|
//! So there is no socket, no HTTP route and no privileged helper here.
|
|
//! When a verb eventually has to run as a non-root user or from another
|
|
//! host, the answer is a **group-gated admin socket** — separate from the
|
|
//! controller's `0666` gateway-facing one — not a widening of what root
|
|
//! does here.
|
|
//!
|
|
//! Distinct from `hivectl`, which drives one hive's `hive-c0re` over its
|
|
//! admin socket. This crate deliberately does not link `swarm-controller`,
|
|
//! for the same reason `hivectl` does not link `hive-c0re`.
|
|
|
|
mod users;
|
|
|
|
use std::fs::{self, File, Permissions};
|
|
use std::io::Write as _;
|
|
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use clap::{Args, Parser, Subcommand};
|
|
|
|
use users::{User, UserStore};
|
|
|
|
/// Canonical user store. A compiled-in default is legitimate here for the
|
|
/// same reason it is on the daemon's socket path: this is a path this
|
|
/// process **creates**, not an address it hopes to find something at. It
|
|
/// lives under the controller's state directory because the controller is
|
|
/// this store's eventual reader.
|
|
const DEFAULT_STORE: &str = "/var/lib/swarm-controller/users.json";
|
|
|
|
#[derive(Parser)]
|
|
#[command(name = "swarmctl", version, about = "swarm-level operator CLI")]
|
|
struct Cli {
|
|
#[command(flatten)]
|
|
paths: PathArgs,
|
|
#[command(subcommand)]
|
|
command: Verb,
|
|
}
|
|
|
|
/// Where the deployment put the things this CLI has to touch.
|
|
///
|
|
/// Every one of these is supplied by the nix module that installs
|
|
/// `swarmctl`, because every one of them is derived from options the
|
|
/// module owns (the container name, the authelia instance name, the
|
|
/// package). They are **required rather than defaulted**: a default here
|
|
/// would be an address we hope to find something at, and one that
|
|
/// resolves cleanly to the wrong place is worse than an error.
|
|
#[derive(Args)]
|
|
struct PathArgs {
|
|
/// authelia binary used to hash passwords. The argon2 parameters must
|
|
/// match the verifier's, so this has to be the *configured* package
|
|
/// rather than whatever is on `PATH`.
|
|
#[arg(long, value_name = "PATH")]
|
|
authelia_bin: Option<PathBuf>,
|
|
/// Host-side path of authelia's users database — i.e. the path inside
|
|
/// the container, prefixed with the container's root.
|
|
#[arg(long, value_name = "PATH")]
|
|
users_file: Option<PathBuf>,
|
|
/// Machine name of the authelia container, for `systemctl -M`.
|
|
#[arg(long, value_name = "NAME")]
|
|
machine: Option<String>,
|
|
/// authelia's systemd unit inside that container.
|
|
#[arg(long, value_name = "UNIT")]
|
|
unit: Option<String>,
|
|
/// Canonical user store.
|
|
#[arg(long, value_name = "PATH")]
|
|
store: Option<PathBuf>,
|
|
}
|
|
|
|
struct Paths {
|
|
authelia_bin: PathBuf,
|
|
users_file: PathBuf,
|
|
machine: String,
|
|
unit: String,
|
|
store: PathBuf,
|
|
}
|
|
|
|
impl PathArgs {
|
|
fn resolve(self) -> Result<Paths> {
|
|
Ok(Paths {
|
|
authelia_bin: path_from(self.authelia_bin, "SWARMCTL_AUTHELIA_BIN")?,
|
|
users_file: path_from(self.users_file, "SWARMCTL_AUTHELIA_USERS_FILE")?,
|
|
machine: string_from(self.machine, "SWARMCTL_AUTHELIA_MACHINE")?,
|
|
unit: string_from(self.unit, "SWARMCTL_AUTHELIA_UNIT")?,
|
|
store: self
|
|
.store
|
|
.or_else(|| std::env::var_os("SWARMCTL_STORE").map(PathBuf::from))
|
|
.unwrap_or_else(|| PathBuf::from(DEFAULT_STORE)),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn path_from(flag: Option<PathBuf>, env: &str) -> Result<PathBuf> {
|
|
flag.or_else(|| std::env::var_os(env).map(PathBuf::from))
|
|
.with_context(|| missing(env))
|
|
}
|
|
|
|
fn string_from(flag: Option<String>, env: &str) -> Result<String> {
|
|
flag.or_else(|| std::env::var(env).ok())
|
|
.with_context(|| missing(env))
|
|
}
|
|
|
|
fn missing(env: &str) -> String {
|
|
format!(
|
|
"{env} is unset and no flag was given — swarmctl is installed and \
|
|
configured by the swarm-controller nix module, which supplies it; \
|
|
running outside that deployment needs the value passed explicitly"
|
|
)
|
|
}
|
|
|
|
/// Named `Verb` rather than the conventional `Command` because
|
|
/// [`std::process::Command`] is in scope here and the clash is a
|
|
/// confusing one — the compiler reports it as an orphan-rule violation on
|
|
/// a derive, several errors away from the actual cause.
|
|
#[derive(Subcommand)]
|
|
enum Verb {
|
|
/// Manage subjects in the swarm's SSO provider.
|
|
User {
|
|
#[command(subcommand)]
|
|
command: UserVerb,
|
|
},
|
|
/// Emit the full CLI reference as `CommonMark` to stdout.
|
|
///
|
|
/// Hidden tooling command used by the docs build to keep the published
|
|
/// `swarmctl` reference in lockstep with the code — same pattern as
|
|
/// `hivectl markdown-docs` (`hivectl/src/main.rs`). Deliberately
|
|
/// dispatched *before* `PathArgs::resolve()` in `main` below: this
|
|
/// verb needs none of the `SWARMCTL_AUTHELIA_*` deployment env vars,
|
|
/// and requiring them here would make `swarmctl markdown-docs` fail
|
|
/// outside a real deployment — exactly where the docs build runs it.
|
|
#[command(hide = true)]
|
|
MarkdownDocs,
|
|
/// Generate a shell completion script for `swarmctl` and print it to
|
|
/// stdout.
|
|
///
|
|
/// Supports bash, zsh, fish, elvish and powershell. The nix package
|
|
/// already installs bash/zsh/fish system-wide; this is for ad-hoc or
|
|
/// other-shell use.
|
|
///
|
|
/// Dispatched before `PathArgs::resolve()` for the same reason as
|
|
/// `markdown-docs`: emitting a completion script needs none of the
|
|
/// `SWARMCTL_AUTHELIA_*` deployment env vars, and requiring them would
|
|
/// make the package's own build-time invocation fail.
|
|
Completions {
|
|
/// Shell to emit completions for.
|
|
shell: clap_complete::Shell,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum UserVerb {
|
|
/// Add a user, generating a password for them.
|
|
Add(AddArgs),
|
|
/// Change an existing user's attributes.
|
|
///
|
|
/// Every flag is optional and they compose, so one call can set
|
|
/// several things at once. Deliberately does **not** touch the
|
|
/// password: regenerating a credential is a different intent from
|
|
/// editing an attribute, and folded together an attribute edit can
|
|
/// invalidate a login by accident.
|
|
Update(UpdateArgs),
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct AddArgs {
|
|
/// Login name. Conservative ASCII only — it is a YAML map key and
|
|
/// reaches access-control rules and logs.
|
|
username: String,
|
|
/// Name shown in the SSO UI. Defaults to the username.
|
|
#[arg(long, value_name = "TEXT")]
|
|
display_name: Option<String>,
|
|
#[arg(long, value_name = "ADDRESS")]
|
|
email: Option<String>,
|
|
/// Repeatable.
|
|
#[arg(long = "group", value_name = "GROUP")]
|
|
groups: Vec<String>,
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct UpdateArgs {
|
|
/// Login name of an existing user.
|
|
username: String,
|
|
/// Name shown in the SSO UI.
|
|
#[arg(long, value_name = "TEXT")]
|
|
display_name: Option<String>,
|
|
#[arg(long, value_name = "ADDRESS")]
|
|
email: Option<String>,
|
|
/// Repeatable. Adding a group the user is already in is not an error.
|
|
#[arg(long = "add-group", value_name = "GROUP")]
|
|
add_groups: Vec<String>,
|
|
/// Repeatable. Fails if the user is not in the group — a revocation
|
|
/// that reports success without revoking is the failure nobody
|
|
/// re-checks.
|
|
#[arg(long = "remove-group", value_name = "GROUP")]
|
|
remove_groups: Vec<String>,
|
|
}
|
|
|
|
fn main() -> Result<()> {
|
|
let Cli { paths, command } = Cli::parse();
|
|
match command {
|
|
// Resolved lazily, inside the one arm that actually touches the
|
|
// deployment env vars — see the `MarkdownDocs` doc comment above
|
|
// for why an unconditional resolve up front would be wrong.
|
|
Verb::User {
|
|
command: UserVerb::Add(args),
|
|
} => user_add(&paths.resolve()?, args),
|
|
Verb::User {
|
|
command: UserVerb::Update(args),
|
|
} => user_update(&paths.resolve()?, args),
|
|
Verb::MarkdownDocs => {
|
|
print!("{}", clap_markdown::help_markdown::<Cli>());
|
|
Ok(())
|
|
}
|
|
Verb::Completions { shell } => {
|
|
// Generated from the live clap tree — the same single source
|
|
// of truth `markdown-docs` renders — so completions cannot
|
|
// drift from the actual verbs and flags.
|
|
use clap::CommandFactory as _;
|
|
clap_complete::generate(
|
|
shell,
|
|
&mut Cli::command(),
|
|
"swarmctl",
|
|
&mut std::io::stdout(),
|
|
);
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn user_add(paths: &Paths, args: AddArgs) -> Result<()> {
|
|
users::validate_username(&args.username)?;
|
|
|
|
let mut store = load_store(&paths.store, &paths.users_file)?;
|
|
if store.users.contains_key(&args.username) {
|
|
bail!(
|
|
"user {:?} already exists in {}",
|
|
args.username,
|
|
paths.store.display()
|
|
);
|
|
}
|
|
|
|
let generated = generate_password(&paths.authelia_bin)?;
|
|
let display_name = args.display_name.unwrap_or_else(|| args.username.clone());
|
|
store.users.insert(
|
|
args.username.clone(),
|
|
User {
|
|
displayname: display_name,
|
|
password: generated.digest,
|
|
email: args.email,
|
|
groups: args.groups,
|
|
},
|
|
);
|
|
|
|
publish(paths, &store)?;
|
|
|
|
println!("added {} to {}", args.username, paths.users_file.display());
|
|
println!("password: {}", generated.password);
|
|
println!("this password is stored nowhere — record it now");
|
|
Ok(())
|
|
}
|
|
|
|
fn user_update(paths: &Paths, args: UpdateArgs) -> Result<()> {
|
|
let mut store = load_store(&paths.store, &paths.users_file)?;
|
|
let Some(user) = store.users.get_mut(&args.username) else {
|
|
bail!(
|
|
"no user {:?} in {} — `swarmctl user add` creates one",
|
|
args.username,
|
|
paths.store.display()
|
|
);
|
|
};
|
|
|
|
let changes = users::apply_update(
|
|
user,
|
|
&users::UserUpdate {
|
|
displayname: args.display_name,
|
|
email: args.email,
|
|
add_groups: args.add_groups,
|
|
remove_groups: args.remove_groups,
|
|
},
|
|
)?;
|
|
// Read back before the borrow ends: this is what the operator gets
|
|
// instead of a group registry we don't have — a typo'd `--add-group`
|
|
// is a real group with nobody reading it, and seeing the resulting
|
|
// list is the only way to notice.
|
|
let groups = users::fmt_groups(&user.groups);
|
|
|
|
publish(paths, &store)?;
|
|
|
|
for change in &changes {
|
|
println!("{change}");
|
|
}
|
|
println!("{} is now in groups: {groups}", args.username);
|
|
Ok(())
|
|
}
|
|
|
|
/// Write the store + the rendered users file, then restart authelia.
|
|
///
|
|
/// Shared by every verb that mutates the store, so the ordering rules
|
|
/// below hold for all of them rather than for whichever one was written
|
|
/// first.
|
|
fn publish(paths: &Paths, store: &UserStore) -> Result<()> {
|
|
// Render before writing anything: a value this refuses to emit should
|
|
// stop the whole operation, not leave the canonical store one user
|
|
// ahead of the file authelia reads.
|
|
let rendered = users::render_yaml(store)?;
|
|
let store_json = serde_json::to_string_pretty(store).context("serialising the user store")?;
|
|
|
|
// Store first, and the order matters. If the store lands and the
|
|
// users file does not, the next run re-renders and repairs it. The
|
|
// other order loses a user: the store would not know about someone
|
|
// authelia does, and the next render would silently drop them.
|
|
write_atomic(&paths.store, &format!("{store_json}\n"))?;
|
|
write_atomic(&paths.users_file, &rendered)?;
|
|
|
|
restart_authelia(&paths.machine, &paths.unit)
|
|
}
|
|
|
|
/// Load the canonical store, or start an empty one if this deployment has
|
|
/// never had a user added.
|
|
///
|
|
/// The guard exists because starting empty means the next write
|
|
/// **overwrites** authelia's users file. That is only safe when the file
|
|
/// is the untouched first-boot seed; anything else is a user database
|
|
/// somebody meant to be there, and losing it is the one unrecoverable
|
|
/// mistake this tool can make.
|
|
fn load_store(store_path: &Path, users_file: &Path) -> Result<UserStore> {
|
|
match fs::read_to_string(store_path) {
|
|
Ok(raw) => serde_json::from_str(&raw)
|
|
.with_context(|| format!("parsing the user store at {}", store_path.display())),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
match fs::read_to_string(users_file) {
|
|
Ok(existing) if !users::is_untouched_seed(&existing) => bail!(
|
|
"no user store at {} but {} already holds users — refusing to \
|
|
overwrite it. Reconstruct the store, or move the file aside if \
|
|
it is disposable.",
|
|
store_path.display(),
|
|
users_file.display()
|
|
),
|
|
Ok(_) => Ok(UserStore::default()),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(UserStore::default()),
|
|
Err(e) => Err(e).with_context(|| {
|
|
format!(
|
|
"reading {} to check it is safe to take over",
|
|
users_file.display()
|
|
)
|
|
}),
|
|
}
|
|
}
|
|
Err(e) => Err(e).with_context(|| format!("reading {}", store_path.display())),
|
|
}
|
|
}
|
|
|
|
struct Generated {
|
|
password: String,
|
|
digest: String,
|
|
}
|
|
|
|
/// Generate a password and its argon2 digest using the configured
|
|
/// authelia.
|
|
///
|
|
/// 🚨 `--random` rather than `--password <pw>` is a security requirement,
|
|
/// not a convenience: `/proc/<pid>/cmdline` is world-readable, so a
|
|
/// password passed on argv is readable by any local process for the
|
|
/// lifetime of the call. Letting authelia generate it means the plaintext
|
|
/// never crosses a command line at all.
|
|
fn generate_password(bin: &Path) -> Result<Generated> {
|
|
let out = Command::new(bin)
|
|
.args(["crypto", "hash", "generate", "argon2", "--random"])
|
|
.output()
|
|
.with_context(|| format!("running {}", bin.display()))?;
|
|
|
|
if !out.status.success() {
|
|
bail!(
|
|
"{} failed ({}): {}",
|
|
bin.display(),
|
|
out.status,
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
);
|
|
}
|
|
|
|
let stdout = String::from_utf8(out.stdout).context("authelia printed non-UTF-8 output")?;
|
|
let password = parse_field(&stdout, "Random Password:");
|
|
let digest = parse_field(&stdout, "Digest:");
|
|
|
|
// The raw output is deliberately NOT included in this error: it
|
|
// contains the freshly generated plaintext, and an error message is
|
|
// exactly the thing that ends up in a log or a bug report. Naming the
|
|
// missing marker is enough to diagnose an upstream format change —
|
|
// run the command by hand to see the rest.
|
|
match (password, digest) {
|
|
(Some(password), Some(digest)) => Ok(Generated { password, digest }),
|
|
(password, digest) => bail!(
|
|
"could not parse {}'s output: {}{}missing",
|
|
bin.display(),
|
|
if password.is_none() {
|
|
"'Random Password:' "
|
|
} else {
|
|
""
|
|
},
|
|
if digest.is_none() { "'Digest:' " } else { "" }
|
|
),
|
|
}
|
|
}
|
|
|
|
fn parse_field(stdout: &str, marker: &str) -> Option<String> {
|
|
stdout
|
|
.lines()
|
|
.find_map(|line| line.trim().strip_prefix(marker))
|
|
.map(|value| value.trim().to_owned())
|
|
.filter(|value| !value.is_empty())
|
|
}
|
|
|
|
/// authelia re-reads its file backend at startup, so a users change needs
|
|
/// a restart.
|
|
///
|
|
/// The file watcher (`authentication_backend.file.watch`) would remove
|
|
/// this step entirely, and is deliberately not relied on: it could not be
|
|
/// verified against the pinned build, and it carries two unknowns —
|
|
/// whether the watch survives the `rename(2)` used above, and whether it
|
|
/// can observe a partially written file. An explicit restart assumes
|
|
/// nothing.
|
|
fn restart_authelia(machine: &str, unit: &str) -> Result<()> {
|
|
let status = Command::new("systemctl")
|
|
.args(["-M", machine, "restart", unit])
|
|
.status()
|
|
.context("running systemctl")?;
|
|
if !status.success() {
|
|
bail!(
|
|
"restarting {unit} in {machine} failed ({status}); the users file is \
|
|
already written, so re-running the restart by hand completes the change"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Replace `path`'s contents atomically, preserving the existing owner
|
|
/// and mode.
|
|
///
|
|
/// Atomic because a reader must never see a half-written user database,
|
|
/// and because the temp file is created in the *same directory* —
|
|
/// `rename(2)` is only atomic within a filesystem.
|
|
///
|
|
/// Owner and mode are read off the existing file rather than asserted:
|
|
/// authelia's file is created by its own unit as its own user, and
|
|
/// stamping our idea of the right values onto it would silently
|
|
/// re-permission a file another service opens. Both are applied to the
|
|
/// temp file *before* the rename, so the finished file is never visible
|
|
/// with the wrong ones.
|
|
fn write_atomic(path: &Path, contents: &str) -> Result<()> {
|
|
let dir = path
|
|
.parent()
|
|
.with_context(|| format!("{} has no parent directory", path.display()))?;
|
|
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
|
|
|
let name = path
|
|
.file_name()
|
|
.with_context(|| format!("{} has no file name", path.display()))?;
|
|
let tmp = dir.join(format!(".{}.swarmctl.tmp", name.to_string_lossy()));
|
|
|
|
let existing = fs::metadata(path).ok();
|
|
// 0600 only when the file does not exist yet: this content is
|
|
// password hashes, so the conservative value is the right default and
|
|
// the existing value is the right answer.
|
|
let mode = existing
|
|
.as_ref()
|
|
.map_or(0o600, |meta| meta.permissions().mode() & 0o7777);
|
|
|
|
let mut file = File::create(&tmp).with_context(|| format!("creating {}", tmp.display()))?;
|
|
file.write_all(contents.as_bytes())
|
|
.with_context(|| format!("writing {}", tmp.display()))?;
|
|
file.sync_all()
|
|
.with_context(|| format!("flushing {}", tmp.display()))?;
|
|
drop(file);
|
|
|
|
fs::set_permissions(&tmp, Permissions::from_mode(mode))
|
|
.with_context(|| format!("setting mode on {}", tmp.display()))?;
|
|
if let Some(meta) = existing.as_ref() {
|
|
std::os::unix::fs::chown(&tmp, Some(meta.uid()), Some(meta.gid()))
|
|
.with_context(|| format!("setting owner on {}", tmp.display()))?;
|
|
}
|
|
|
|
fs::rename(&tmp, path).with_context(|| format!("renaming {} into place", tmp.display()))?;
|
|
|
|
// The rename itself is metadata: without this the file can survive a
|
|
// crash while the directory entry pointing at it does not.
|
|
File::open(dir)
|
|
.and_then(|d| d.sync_all())
|
|
.with_context(|| format!("flushing directory {}", dir.display()))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_authelia_hash_output() {
|
|
let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n";
|
|
assert_eq!(
|
|
parse_field(out, "Random Password:").as_deref(),
|
|
Some("hunter2")
|
|
);
|
|
assert_eq!(
|
|
parse_field(out, "Digest:").as_deref(),
|
|
Some("$argon2id$v=19$m=65536$abc")
|
|
);
|
|
}
|
|
|
|
/// An upstream format change must read as "missing", not as an empty
|
|
/// password silently written into the store.
|
|
#[test]
|
|
fn an_empty_field_reads_as_missing() {
|
|
assert_eq!(parse_field("Digest: \n", "Digest:"), None);
|
|
assert_eq!(parse_field("nothing here\n", "Digest:"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn write_atomic_preserves_an_existing_files_mode() {
|
|
let dir = std::env::temp_dir().join(format!("swarmctl-test-{}", std::process::id()));
|
|
fs::create_dir_all(&dir).expect("temp dir");
|
|
let path = dir.join("users.yml");
|
|
|
|
fs::write(&path, "users: {}\n").expect("seed");
|
|
fs::set_permissions(&path, Permissions::from_mode(0o640)).expect("chmod");
|
|
|
|
write_atomic(&path, "users:\n mara:\n").expect("rewrite");
|
|
|
|
let mode = fs::metadata(&path).expect("stat").permissions().mode() & 0o7777;
|
|
assert_eq!(mode, 0o640, "the existing mode must survive the replace");
|
|
assert_eq!(
|
|
fs::read_to_string(&path).expect("read"),
|
|
"users:\n mara:\n"
|
|
);
|
|
|
|
fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn write_atomic_defaults_a_new_file_to_0600() {
|
|
let dir = std::env::temp_dir().join(format!("swarmctl-new-{}", std::process::id()));
|
|
fs::create_dir_all(&dir).expect("temp dir");
|
|
let path = dir.join("users.json");
|
|
|
|
write_atomic(&path, "{}\n").expect("write");
|
|
|
|
let mode = fs::metadata(&path).expect("stat").permissions().mode() & 0o7777;
|
|
assert_eq!(mode, 0o600, "a new store holds password hashes");
|
|
|
|
fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// The guard that stands between a missing store and an overwritten
|
|
/// user database.
|
|
#[test]
|
|
fn load_store_refuses_to_take_over_a_populated_users_file() {
|
|
let dir = std::env::temp_dir().join(format!("swarmctl-guard-{}", std::process::id()));
|
|
fs::create_dir_all(&dir).expect("temp dir");
|
|
let store = dir.join("absent.json");
|
|
let users_file = dir.join("users.yml");
|
|
|
|
fs::write(&users_file, "users:\n mara:\n password: \"x\"\n").expect("write");
|
|
let err = load_store(&store, &users_file).expect_err("must refuse");
|
|
assert!(
|
|
err.to_string().contains("refusing to overwrite"),
|
|
"unexpected error: {err}"
|
|
);
|
|
|
|
fs::write(&users_file, "users: {}\n").expect("seed");
|
|
let taken = load_store(&store, &users_file).expect("the seed is takeable");
|
|
assert!(taken.users.is_empty());
|
|
|
|
fs::remove_dir_all(&dir).ok();
|
|
}
|
|
}
|