use crate::cli::{
AddKind, EditArgs, FillArgs, FindArgs, GenArgs, GetArgs, HistoryArgs, ListArgs, OpenArgs,
OtpArgs, PullArgs, RecordArgs, RemoteArgs, RestoreArgs, RunArgs,
};
use crate::output;
use crate::session;
use anyhow::{Context, Result, bail};
use sefy_core::{
Change, ChangeKind, Classes, Field, Item, ItemKind, ItemSummary, NewItem, Payload, Query,
Recipe, Strength, Totp, Vault, Version,
};
use std::io::Read;
use std::path::{Path, PathBuf};
pub fn init(path: &Path, password_env: Option<&str>) -> Result<()> {
if path.exists() {
bail!("{} already exists", path.display());
}
let password = session::new_password(password_env)?;
Vault::create(path, password.as_bytes())?;
println!("created {}", path.display());
Ok(())
}
pub fn add(vault: &mut Vault, kind: AddKind) -> Result<()> {
let (item, description) = match kind {
AddKind::Note {
title,
text,
editor,
tag,
} => {
let text = match text {
Some(text) => text,
None if editor => crate::editor::edit("")?,
None => read_stdin().context("cannot read the note text from stdin")?,
};
(
NewItem::new(title.clone(), Payload::Note { text }).with_tags(tag),
title,
)
}
AddKind::Login {
title,
login,
url,
totp,
notes,
item_password_env,
tag,
} => {
let password =
session::secret("Password for this item: ", item_password_env.as_deref())?;
let mut fields = vec![
Field::public("login", login),
Field::secret("password", password),
];
push_optional(&mut fields, "url", url, false);
let totp = totp
.map(|key| checked(sefy_core::otp::FIELD, key))
.transpose()?;
push_optional(&mut fields, sefy_core::otp::FIELD, totp, true);
push_optional(&mut fields, "notes", notes, false);
(
NewItem::new(title.clone(), Payload::fields(ItemKind::Login, fields))
.with_tags(tag),
title,
)
}
AddKind::Card {
title,
holder,
expiry,
notes,
no_cvv,
no_pin,
number_env,
cvv_env,
pin_env,
tag,
} => {
let number = session::secret("Card number: ", number_env.as_deref())?;
let mut fields = vec![Field::secret("number", number)];
push_optional(&mut fields, "holder", holder, false);
push_optional(&mut fields, "expiry", expiry, false);
if !no_cvv {
push_typed(
&mut fields,
"cvv",
session::secret("CVV (empty if there is none): ", cvv_env.as_deref())?,
)?;
}
if !no_pin {
push_typed(
&mut fields,
"pin",
session::secret("PIN (empty if unknown): ", pin_env.as_deref())?,
)?;
}
push_optional(&mut fields, "notes", notes, false);
(
NewItem::new(title.clone(), Payload::fields(ItemKind::Card, fields)).with_tags(tag),
title,
)
}
AddKind::SshKey {
title,
private_key,
public_key,
host,
notes,
no_passphrase,
passphrase_env,
tag,
} => {
let private = std::fs::read_to_string(&private_key)
.with_context(|| format!("cannot read {}", private_key.display()))?;
let mut fields = vec![Field::secret("private-key", private)];
if !no_passphrase {
push_typed(
&mut fields,
"passphrase",
session::secret(
"Passphrase for the key (empty if it has none): ",
passphrase_env.as_deref(),
)?,
)?;
}
let public_path = public_key.unwrap_or_else(|| sibling_public_key(&private_key));
match std::fs::read_to_string(&public_path) {
Ok(public) => fields.push(Field::public("public-key", public.trim())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error)
.with_context(|| format!("cannot read {}", public_path.display()));
}
}
push_optional(&mut fields, "host", host, false);
push_optional(&mut fields, "notes", notes, false);
(
NewItem::new(title.clone(), Payload::fields(ItemKind::SshKey, fields))
.with_tags(tag),
title,
)
}
AddKind::Wifi(args) => record(ItemKind::Wifi, args)?,
AddKind::ApiToken(args) => record(ItemKind::ApiToken, args)?,
AddKind::Bank(args) => record(ItemKind::Bank, args)?,
AddKind::File { path, title, tag } => {
let bytes =
std::fs::read(&path).with_context(|| format!("cannot read {}", path.display()))?;
let filename = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_owned());
let title = title.unwrap_or_else(|| filename.clone());
(
NewItem::new(title.clone(), Payload::File { filename, bytes }).with_tags(tag),
title,
)
}
};
let id = vault.add(item)?;
vault.save()?;
println!("added {description:?} as {id}");
Ok(())
}
fn record(kind: ItemKind, args: RecordArgs) -> Result<(NewItem, String)> {
let template = kind
.template()
.with_context(|| format!("a {kind} is not a record"))?;
let given = assignments("--set", "NAME=VALUE", &args.set)?;
let from_env = assignments("--secret-env", "NAME=VAR", &args.secret_env)?;
let secret_names = || {
template
.fields
.iter()
.filter(|spec| spec.secret)
.map(|spec| spec.name)
.collect::<Vec<_>>()
.join(", ")
};
for (name, _) in &given {
if template.field(name).is_some_and(|spec| spec.secret) {
bail!(
"{name} is secret, so it is not taken on the command line\n\
leave it to the prompt, or pass --secret-env {name}=VAR"
);
}
}
for (name, _) in &from_env {
if template.field(name).is_some_and(|spec| !spec.secret) {
bail!("{name} is not secret; pass it with --set {name}=VALUE");
}
}
for name in &args.skip {
if !template.field(name).is_some_and(|spec| spec.secret) {
bail!(
"--skip names a secret field of a {kind}, and {name:?} is not one; \
those are: {}",
secret_names()
);
}
}
let mut fields = Vec::new();
for spec in template.fields {
if spec.secret {
if args.skip.iter().any(|name| name == spec.name) {
continue;
}
let value = match from_env.iter().find(|(name, _)| name == spec.name) {
Some((_, variable)) => session::secret("", Some(variable))?,
None => session::secret(
&format!("{} ({}; empty to leave out): ", spec.name, spec.description),
None,
)?,
};
push_typed(&mut fields, spec.name, value)?;
} else if let Some((_, value)) = given.iter().find(|(name, _)| name == spec.name) {
fields.push(Field::public(spec.name, value.clone()));
}
}
for (name, value) in &given {
if template.field(name).is_none() {
fields.push(Field::public(name.clone(), value.clone()));
}
}
for (name, variable) in &from_env {
if template.field(name).is_none() {
push_typed(&mut fields, name, session::secret("", Some(variable))?)?;
}
}
if fields.is_empty() {
bail!("a {kind} needs at least one field; nothing was given or typed");
}
Ok((
NewItem::new(args.title.clone(), Payload::fields(kind, fields)).with_tags(args.tag),
args.title,
))
}
fn push_typed(fields: &mut Vec<Field>, name: &str, value: String) -> Result<()> {
if !value.is_empty() {
fields.push(Field::secret(name, checked(name, value)?));
}
Ok(())
}
fn assignments(option: &str, shape: &str, raw: &[String]) -> Result<Vec<(String, String)>> {
let mut pairs: Vec<(String, String)> = Vec::new();
for assignment in raw {
let (name, value) = assignment
.split_once('=')
.filter(|(name, _)| !name.is_empty())
.with_context(|| format!("{option} takes {shape}, not {assignment:?}"))?;
if pairs.iter().any(|(seen, _)| seen == name) {
bail!("{option} names {name:?} twice");
}
pairs.push((name.to_owned(), value.to_owned()));
}
Ok(pairs)
}
fn checked(name: &str, value: String) -> Result<String> {
if name == sefy_core::otp::FIELD {
return Ok(sefy_core::otp::normalize(&value)?);
}
Ok(value)
}
fn value_of(item: &Item, field: Option<&str>, naming: &str) -> Result<(String, String)> {
let title = &item.summary.title;
match &item.payload {
Payload::Note { text } => match field {
None => Ok((text.clone(), "text".to_owned())),
Some(_) => bail!("{title:?} is a note, which has no fields; leave out {naming}"),
},
Payload::Fields { kind, fields } => {
let name = match field {
Some(name) => name,
None => kind
.template()
.and_then(|template| template.default_field())
.map(|field| field.name)
.with_context(|| {
format!("{title:?} has no default field; name one with {naming}")
})?,
};
match fields.iter().find(|field| field.name == name) {
Some(field) => Ok((field.value.clone(), field.name.clone())),
None => bail!(
"{title:?} has no {name:?}; it holds: {}",
field_names(fields)
),
}
}
Payload::File { .. } => bail!(
"{title:?} is a file; write it to disk with: sefy extract {}",
item.summary.id
),
Payload::Unknown { kind } => bail!(
"{title:?} is a {kind}, which this version of sefy does not know\n\
it was written by a newer sefy — upgrade to read it\n\
(the item is safe: it is listed, exported and synced as it is)"
),
}
}
pub fn get(vault: &Vault, args: GetArgs) -> Result<()> {
let summary = vault.resolve(&args.reference).map_err(output::explain)?;
let item = vault.get(summary.id)?;
let (value, description) = value_of(&item, args.field.as_deref(), "--field")?;
if args.stdout {
println!("{value}");
return Ok(());
}
if args.clear_after > 0 {
println!(
"copied {description} of {:?} to the clipboard; clearing in {}s",
item.summary.title, args.clear_after
);
} else {
println!(
"copied {description} of {:?} to the clipboard",
item.summary.title
);
}
flush_stdout();
let hold = output::to_clipboard(&value, args.clear_after)?;
if hold.cleared {
println!("clipboard cleared");
}
Ok(())
}
pub fn generate(vault: Option<&mut Vault>, args: GenArgs) -> Result<()> {
let recipe = if let Some(count) = args.words {
Recipe::Words {
count,
language: args.lang.into(),
separator: args.separator.clone(),
}
} else if args.pronounceable {
Recipe::Pronounceable {
length: args.length,
}
} else {
Recipe::Characters {
length: args.length,
classes: Classes {
uppercase: !args.no_uppercase,
digits: !args.no_digits,
symbols: !args.no_symbols,
},
}
};
let generated = sefy_core::generate(&recipe)?;
let context: Vec<&str> = [
args.save.as_deref(),
args.login.as_deref(),
args.url.as_deref(),
]
.into_iter()
.flatten()
.collect();
let strength = sefy_core::estimate_generated(&generated.value, generated.bits, &context);
let unit = match recipe {
Recipe::Words { count, .. } => format!("{count} words"),
_ => format!("{} characters", generated.value.chars().count()),
};
let mut description = format!(
"generated {unit}: {:.0} bits of entropy, strength {}/{}",
generated.bits,
strength.score,
Strength::MAX_SCORE
);
if let Some(warning) = &strength.warning {
description.push_str(&format!(" - {warning}"));
}
let say = |line: &str| {
if args.stdout {
eprintln!("{line}");
} else {
println!("{line}");
}
};
if let (Some(vault), Some(title)) = (vault, &args.save) {
let mut fields = Vec::new();
push_optional(&mut fields, "login", args.login.clone(), false);
fields.push(Field::secret("password", generated.value.as_str()));
push_optional(&mut fields, "url", args.url.clone(), false);
let id = vault.add(
NewItem::new(title.clone(), Payload::fields(ItemKind::Login, fields))
.with_tags(args.tag.clone()),
)?;
vault.save()?;
say(&format!("added {title:?} as {id}"));
}
say(&description);
if generated.bits < sefy_core::generate::OFFLINE_BITS {
say(&format!(
"under {:.0} bits: fine behind a site that limits sign-in attempts, \
too few for a master password; add length or words",
sefy_core::generate::OFFLINE_BITS
));
}
if args.stdout {
println!("{}", generated.value.as_str());
return Ok(());
}
if args.clear_after > 0 {
println!(
"copied it to the clipboard; clearing in {}s",
args.clear_after
);
} else {
println!("copied it to the clipboard");
}
flush_stdout();
let hold = output::to_clipboard(&generated.value, args.clear_after)?;
if hold.cleared {
println!("clipboard cleared");
}
Ok(())
}
fn flush_stdout() {
use std::io::Write;
let _ = std::io::stdout().flush();
}
pub fn show(vault: &Vault, reference: &str) -> Result<()> {
let summary = vault.resolve(reference).map_err(output::explain)?;
let item = vault.get(summary.id)?;
field("id", &item.summary.id.to_string());
field("title", &item.summary.title);
field("kind", item.summary.kind.as_str());
if !item.summary.tags.is_empty() {
field("tags", &item.summary.tags.join(", "));
}
let earlier = vault.earlier_versions(item.summary.id)?;
if earlier > 0 {
field(
"history",
&format!(
"{} (sefy history {})",
output::count(earlier, "earlier version"),
item.summary.id
),
);
}
match &item.payload {
Payload::Note { text } => {
println!("---");
println!("{text}");
}
Payload::Fields { fields, .. } => {
for stored in fields {
if stored.name == sefy_core::otp::FIELD {
field(&stored.name, "<hidden — sefy otp gives the code>");
} else if stored.secret {
field(
&stored.name,
&format!("<hidden — use sefy get --field {}>", stored.name),
);
} else {
field(&stored.name, &stored.value);
}
}
}
Payload::File { filename, bytes } => {
field("file", filename);
field("size", &format!("{} bytes", bytes.len()));
}
Payload::Unknown { kind } => {
println!("---");
println!("This item is a {kind}, a kind this version of sefy does not know.");
println!("A newer sefy wrote it. Upgrade to read its contents.");
println!("Nothing is lost: the item keeps its place in this vault.");
}
}
Ok(())
}
fn field(label: &str, value: &str) {
const LABEL_WIDTH: usize = "private-key:".len() + 1;
println!("{:<LABEL_WIDTH$}{value}", format!("{label}:"));
}
fn push_optional(fields: &mut Vec<Field>, name: &str, value: Option<String>, secret: bool) {
if let Some(value) = value {
fields.push(if secret {
Field::secret(name, value)
} else {
Field::public(name, value)
});
}
}
fn sibling_public_key(private_key: &Path) -> PathBuf {
let mut name = private_key.as_os_str().to_os_string();
name.push(".pub");
PathBuf::from(name)
}
fn field_names(fields: &[Field]) -> String {
fields
.iter()
.map(|field| field.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
pub fn ls(vault: &Vault, args: ListArgs) -> Result<()> {
let mut query = Query::all().tags(args.tag);
if let Some(kind) = args.kind {
query = query.kind(kind.into());
}
output::table(&vault.search(&query)?);
Ok(())
}
pub fn find(vault: &Vault, args: FindArgs) -> Result<()> {
let mut query = Query::all().tags(args.tag);
if let Some(text) = args.text {
query = query.text(text);
}
if let Some(kind) = args.kind {
query = query.kind(kind.into());
}
output::table(&vault.search(&query)?);
Ok(())
}
pub fn browse(vault: &Vault) -> Result<()> {
if !crate::picker::available() {
bail!(
"sefy with no command opens an interactive picker, and this is not a terminal\n\
list items with: sefy ls"
);
}
pick_from(vault, &vault.list()?)
}
pub fn pick_from(vault: &Vault, items: &[ItemSummary]) -> Result<()> {
if items.is_empty() {
println!("no items");
return Ok(());
}
let Some(picked) = crate::picker::choose(items, "Item")? else {
return Ok(());
};
show(vault, &picked.id.to_string())
}
pub fn edit(vault: &mut Vault, args: EditArgs) -> Result<()> {
let summary = vault.resolve(&args.reference).map_err(output::explain)?;
let existing = vault.get(summary.id)?;
let payload = build_edited_payload(&existing.payload, &args)?;
let tags = if args.clear_tags {
Some(Vec::new())
} else if args.tag.is_empty() {
None
} else {
Some(args.tag.clone())
};
if args.title.is_none() && payload.is_none() && tags.is_none() {
bail!("nothing to change; pass --title, --tag, or a field to edit");
}
vault.update(summary.id, args.title, payload, tags)?;
vault.save()?;
println!("updated {}", summary.id);
Ok(())
}
fn build_edited_payload(existing: &Payload, args: &EditArgs) -> Result<Option<Payload>> {
let touches_fields =
!args.set.is_empty() || !args.set_secret.is_empty() || !args.unset.is_empty();
match existing {
Payload::Note { text: current } => {
if touches_fields {
bail!("this item is a note; --set, --set-secret and --unset apply to records");
}
if args.editor {
return Ok(Some(Payload::Note {
text: crate::editor::edit(current)?,
}));
}
Ok(args.text.clone().map(|text| Payload::Note { text }))
}
Payload::Fields { kind, fields } => {
if args.text.is_some() || args.editor {
bail!("this item is a {kind}; --text and --editor apply to notes");
}
if !touches_fields {
return Ok(None);
}
Ok(Some(Payload::Fields {
kind: kind.clone(),
fields: edit_fields(kind, fields, args)?,
}))
}
Payload::File { .. } => {
if args.text.is_some() || args.editor || touches_fields {
bail!("this item is a file; only --title and tags can be edited");
}
Ok(None)
}
Payload::Unknown { kind } => {
if args.text.is_some() || args.editor || touches_fields {
bail!(
"this item is a {kind}, a kind this version of sefy does not know;\n\
only --title and tags can be edited"
);
}
Ok(None)
}
}
}
fn edit_fields(kind: &ItemKind, existing: &[Field], args: &EditArgs) -> Result<Vec<Field>> {
let mut fields = existing.to_vec();
for assignment in &args.set {
let (name, value) = assignment
.split_once('=')
.with_context(|| format!("--set takes NAME=VALUE, not {assignment:?}"))?;
set_field(
kind,
&mut fields,
name,
checked(name, value.to_owned())?,
None,
);
}
for name in &args.set_secret {
let value = session::secret(&format!("New value for {name}: "), None)?;
set_field(kind, &mut fields, name, checked(name, value)?, Some(true));
}
for name in &args.unset {
let before = fields.len();
fields.retain(|field| &field.name != name);
if fields.len() == before {
bail!(
"no field named {name:?}; it holds: {}",
field_names(existing)
);
}
}
if fields.is_empty() {
bail!("a {kind} cannot be left without any field");
}
Ok(fields)
}
fn set_field(
kind: &ItemKind,
fields: &mut Vec<Field>,
name: &str,
value: String,
secret: Option<bool>,
) {
if let Some(field) = fields.iter_mut().find(|field| field.name == name) {
field.value = value;
if let Some(secret) = secret {
field.secret = secret;
}
return;
}
let secret = secret.unwrap_or_else(|| {
kind.template()
.and_then(|template| template.field(name))
.is_some_and(|field| field.secret)
});
fields.push(Field {
name: name.to_owned(),
value,
secret,
});
}
pub fn history(vault: &Vault, args: HistoryArgs) -> Result<()> {
let summary = vault.resolve(&args.reference).map_err(output::explain)?;
refuse_unknown(&summary)?;
let versions = vault.history(summary.id)?;
match args.version {
None => list_versions(&summary, &versions),
Some(number) => {
let index = version_index(&summary, &versions, number)?;
compare_with_now(&summary, &versions, index);
}
}
Ok(())
}
pub fn restore(vault: &mut Vault, args: RestoreArgs) -> Result<()> {
let summary = vault.resolve(&args.reference).map_err(output::explain)?;
refuse_unknown(&summary)?;
let versions = vault.history(summary.id)?;
let index = version_index(&summary, &versions, args.version)?;
let wanted = &versions[index];
let title = &summary.title;
if wanted.current {
println!(
"version {} is what {title:?} holds now; nothing to restore",
args.version
);
return Ok(());
}
if let Some(name) = &args.field
&& !matches!(wanted.payload, Payload::Fields { .. })
{
bail!(
"{title:?} is a {}, which is restored whole; leave out --field {name}",
summary.kind
);
}
let changed = match vault.restore(summary.id, &wanted.uuid, args.field.as_deref()) {
Ok(changed) => changed,
Err(sefy_core::Error::FieldNotInVersion { name, available }) => bail!(
"version {} of {title:?} has no {name:?}; it held: {}",
args.version,
available.join(", ")
),
Err(other) => return Err(other.into()),
};
if !changed {
println!(
"nothing to restore: {title:?} already holds what version {} did{}",
args.version,
args.field
.as_deref()
.map(|name| format!(" in {name}"))
.unwrap_or_default()
);
return Ok(());
}
vault.save()?;
let what = match &args.field {
Some(name) => format!("the {name} of {title:?}"),
None => format!("{title:?}"),
};
println!(
"restored {what} from version {} ({})",
args.version,
written(wanted)
);
println!(
"what it replaced is kept as version {}; sefy history {} lists them",
versions.len(),
summary.id
);
Ok(())
}
fn refuse_unknown(summary: &ItemSummary) -> Result<()> {
if !summary.kind.is_known() {
bail!(
"{:?} is a {}, which this version of sefy does not know\n\
it was written by a newer sefy — upgrade to read its history",
summary.title,
summary.kind
);
}
Ok(())
}
fn version_index(summary: &ItemSummary, versions: &[Version], number: usize) -> Result<usize> {
if number == 0 || number > versions.len() {
bail!(
"{:?} has {}, numbered from 1; there is no version {number}",
summary.title,
output::count(versions.len(), "version")
);
}
Ok(number - 1)
}
fn written(version: &Version) -> String {
match &version.device {
Some(device) => format!(
"written {} on {device}",
crate::when::date_time(version.made_at)
),
None => format!("written {}", crate::when::date_time(version.made_at)),
}
}
fn list_versions(summary: &ItemSummary, versions: &[Version]) {
println!(
"{:?} ({}), {}",
summary.title,
summary.kind,
output::count(versions.len(), "version")
);
println!();
let number_width = versions.len().to_string().len();
let device_width = versions
.iter()
.map(|version| {
version
.device
.as_deref()
.map_or(1, |name| name.chars().count())
})
.max()
.unwrap_or(1);
for (index, version) in versions.iter().enumerate() {
let mut what = if index == 0 {
if version.made_at == summary.created_at {
"created".to_owned()
} else {
"earliest kept".to_owned()
}
} else {
let names = sefy_core::history::changed(&versions[index - 1].payload, &version.payload);
if names.is_empty() {
"no change".to_owned()
} else {
names.join(", ")
}
};
if version.conflict {
what.push_str(" (lost a merge conflict)");
}
if version.current {
what.push_str(" (current)");
}
println!(
" {:>number_width$} {} {:<device_width$} {what}",
index + 1,
crate::when::date_time(version.made_at),
version.device.as_deref().unwrap_or("-"),
);
}
if versions.len() > 1 {
println!();
println!(
"compare one with now: sefy history {id} VERSION\n\
bring one back: sefy restore {id} VERSION{by_field}",
id = summary.id,
by_field = if summary.kind.is_record() {
" [--field NAME]"
} else {
""
}
);
} else {
println!();
println!("no earlier versions yet; the next change to its contents keeps this one");
}
}
fn compare_with_now(summary: &ItemSummary, versions: &[Version], index: usize) {
let then = &versions[index];
let now = versions
.last()
.expect("history ends with the current version");
let number = index + 1;
if then.current {
println!(
"version {number} is what {:?} holds now; there is nothing to compare it with",
summary.title
);
return;
}
println!(
"version {number} of {:?}, {}, against what it holds now (version {}):",
summary.title,
written(then),
versions.len()
);
println!();
let changes: Vec<Change> = sefy_core::history::compare(&then.payload, &now.payload);
let width = changes
.iter()
.map(|change| change.name.chars().count())
.max()
.unwrap_or(0);
for change in &changes {
let label = format!("{:<width$}", change.name);
match (change.kind, change.secret) {
(ChangeKind::Same, _) => println!("{label} same"),
(ChangeKind::Changed, true) => {
println!(
"{label} {}",
secret_difference(change, &then.payload, &now.payload)
);
}
(ChangeKind::Removed, true) => println!("{label} only in version {number} (secret)"),
(ChangeKind::Added, true) => println!("{label} only now (secret)"),
(ChangeKind::Changed, false) => {
println!("{label} changed");
let old = sefy_core::history::text_of(&then.payload, &change.name).unwrap_or("");
let new = sefy_core::history::text_of(&now.payload, &change.name).unwrap_or("");
crate::diff::print(&sefy_core::history::lines(old, new));
}
(ChangeKind::Removed, false) => {
println!("{label} only in version {number}");
let old = sefy_core::history::text_of(&then.payload, &change.name).unwrap_or("");
crate::diff::print(&sefy_core::history::lines(old, ""));
}
(ChangeKind::Added, false) => {
println!("{label} only now");
let new = sefy_core::history::text_of(&now.payload, &change.name).unwrap_or("");
crate::diff::print(&sefy_core::history::lines("", new));
}
}
}
if changes.iter().any(|change| change.kind != ChangeKind::Same) {
println!();
println!(
"bring it back: sefy restore {} {number}{}",
summary.id,
if matches!(then.payload, Payload::Fields { .. }) {
" [--field NAME]"
} else {
""
}
);
}
}
fn secret_difference(change: &Change, then: &Payload, now: &Payload) -> String {
match (then, now) {
(Payload::File { bytes: old, .. }, Payload::File { bytes: new, .. })
if change.name == "contents" =>
{
format!(
"changed ({} then, {} now)",
output::count(old.len(), "byte"),
output::count(new.len(), "byte")
)
}
_ => "changed (secret, not shown)".to_owned(),
}
}
pub fn rm(vault: &mut Vault, reference: &str, yes: bool) -> Result<()> {
let summary = vault.resolve(reference).map_err(output::explain)?;
let earlier = vault.earlier_versions(summary.id)?;
let and_history = if earlier > 0 {
format!(" and {}", output::count(earlier, "earlier version"))
} else {
String::new()
};
if !yes
&& !confirm(&format!(
"remove {:?} ({}){and_history}? [y/N] ",
summary.title, summary.id
))?
{
println!("kept");
return Ok(());
}
vault.remove(summary.id)?;
vault.save()?;
println!("removed {}", summary.id);
Ok(())
}
pub fn extract(
vault: &Vault,
reference: &str,
output_path: Option<PathBuf>,
force: bool,
) -> Result<()> {
let summary = vault.resolve(reference).map_err(output::explain)?;
let item = vault.get(summary.id)?;
let Payload::File { filename, bytes } = &item.payload else {
bail!(
"{:?} is a {}, not a file",
item.summary.title,
item.summary.kind
);
};
let destination = output_path.unwrap_or_else(|| PathBuf::from(filename));
if destination.exists() && !force {
bail!(
"{} already exists; pass --force to overwrite",
destination.display()
);
}
std::fs::write(&destination, bytes)
.with_context(|| format!("cannot write {}", destination.display()))?;
println!("wrote {} ({} bytes)", destination.display(), bytes.len());
Ok(())
}
pub fn tags(vault: &Vault) -> Result<()> {
let tags = vault.tags()?;
if tags.is_empty() {
println!("no tags");
return Ok(());
}
let width = tags
.iter()
.map(|(name, _)| name.chars().count())
.max()
.unwrap_or(4);
for (name, count) in tags {
println!("{name:<width$} {count}");
}
Ok(())
}
pub fn export(
vault: &Vault,
output_path: Option<PathBuf>,
acknowledged: bool,
force: bool,
) -> Result<()> {
if !acknowledged {
bail!(
"export writes every secret in this vault in the clear\n\
the resulting file protects nothing — encrypt it, or delete it when done\n\
pass --i-know-this-writes-plaintext to go ahead"
);
}
let json = sefy_core::exchange::to_json(&sefy_core::exchange::export(vault)?)?;
match output_path {
Some(path) => {
if path.exists() && !force {
bail!(
"{} already exists; pass --force to overwrite",
path.display()
);
}
std::fs::write(&path, json.as_bytes())
.with_context(|| format!("cannot write {}", path.display()))?;
eprintln!("wrote {} in the clear", path.display());
}
None => println!("{json}"),
}
Ok(())
}
pub fn import(vault: &mut Vault, input: Option<PathBuf>) -> Result<()> {
let json = match input {
Some(path) => std::fs::read_to_string(&path)
.with_context(|| format!("cannot read {}", path.display()))?,
None => read_stdin().context("cannot read the export from stdin")?,
};
let export = sefy_core::exchange::from_json(&json)?;
let report = sefy_core::exchange::import(vault, &export)?;
println!("imported {}", output::count(report.added, "item"));
if report.skipped > 0 {
println!(
"{} already here, left alone",
output::count(report.skipped, "item")
);
}
if report.unsupported > 0 {
println!(
"{} of a kind this version does not know, not imported\n\
upgrade sefy and import again",
output::count(report.unsupported, "item")
);
}
Ok(())
}
pub fn merge(vault: &mut Vault, other: &Path, other_password_env: Option<&str>) -> Result<()> {
if other == vault.path() {
bail!("that is this vault; merging a file into itself would do nothing");
}
let password = session::secret(
&format!("Password for {}: ", other.display()),
other_password_env,
)?;
let source = session::open(other, &password)?;
let report = sefy_core::merge(vault, &source)?;
report_merge(&report, "nothing to merge; the two vaults already agree");
Ok(())
}
pub fn change_password(vault: &mut Vault, password_env: Option<&str>) -> Result<()> {
let password = session::new_password(password_env)?;
vault.change_password(password.as_bytes())?;
println!("password changed");
Ok(())
}
pub fn open(vault: &Vault, args: OpenArgs) -> Result<()> {
let summary = vault.resolve(&args.reference).map_err(output::explain)?;
let item = vault.get(summary.id)?;
let Payload::Fields { kind, fields } = &item.payload else {
bail!(
"{:?} is a {}, which has no site to open",
item.summary.title,
item.summary.kind.as_str()
);
};
let url = fields
.iter()
.find(|field| field.name == URL_FIELD)
.with_context(|| {
format!(
"{:?} has no {URL_FIELD:?} field; it holds: {}\n\
add one with: sefy edit {} --set url=https://…",
item.summary.title,
field_names(fields),
item.summary.id
)
})?;
crate::browser::open(&url.value)?;
println!("opened {}", url.value);
if args.no_password {
return Ok(());
}
let Some(secret) = kind
.template()
.and_then(|template| template.default_field())
.and_then(|spec| fields.iter().find(|field| field.name == spec.name))
else {
return Ok(());
};
println!(
"copied {} of {:?} to the clipboard; clearing in {}s",
secret.name, item.summary.title, args.clear_after
);
flush_stdout();
let hold = output::to_clipboard(&secret.value, args.clear_after)?;
if hold.cleared {
println!("clipboard cleared");
}
Ok(())
}
pub fn otp(vault: &mut Vault, args: OtpArgs) -> Result<()> {
let summary = vault.resolve(&args.reference).map_err(output::explain)?;
let item = vault.get(summary.id)?;
let title = &item.summary.title;
let Payload::Fields { kind, fields } = &item.payload else {
bail!(
"{title:?} is a {}; one-time passwords belong to a record such as a login",
item.summary.kind
);
};
let mut fields = fields.clone();
let say = |line: &str| {
if args.stdout {
eprintln!("{line}");
} else {
println!("{line}");
}
};
if args.set || args.key_env.is_some() {
let key = session::secret("Setup key or otpauth:// link: ", args.key_env.as_deref())?;
let key = checked(sefy_core::otp::FIELD, key)?;
set_field(kind, &mut fields, sefy_core::otp::FIELD, key, Some(true));
vault.update(
summary.id,
None,
Some(Payload::Fields {
kind: kind.clone(),
fields: fields.clone(),
}),
None,
)?;
vault.save()?;
say(&format!("stored the one-time password key of {title:?}"));
}
let stored = fields
.iter()
.find(|field| field.name == sefy_core::otp::FIELD)
.with_context(|| {
format!(
"{title:?} has no one-time password key\n\
store the one the site shows with: sefy otp {} --set",
summary.id
)
})?;
let totp = Totp::parse(&stored.value).with_context(|| {
format!(
"the {} field of {title:?} cannot make a code\n\
replace it with: sefy otp {} --set",
sefy_core::otp::FIELD,
summary.id
)
})?;
if args.qr {
let login = fields
.iter()
.find(|field| field.name == "login")
.map(|field| field.value.as_str());
return crate::qr::show(&totp.link(title, login));
}
let now = unix_now()?;
let code = totp.code_at(now);
if args.stdout {
println!("{code}");
return Ok(());
}
let valid = totp.remaining(now);
if args.clear_after > 0 {
println!(
"copied the one-time code of {title:?} to the clipboard; \
valid for {valid}s, clearing in {}s",
args.clear_after
);
} else {
println!("copied the one-time code of {title:?} to the clipboard; valid for {valid}s");
}
flush_stdout();
let hold = output::to_clipboard(&code, args.clear_after)?;
if hold.cleared {
println!("clipboard cleared");
}
Ok(())
}
pub fn fill(vault: &Vault, args: FillArgs) -> Result<()> {
use std::io::IsTerminal;
let summary = vault.resolve(&args.reference).map_err(output::explain)?;
let item = vault.get(summary.id)?;
let title = &item.summary.title;
let Payload::Fields { kind, fields } = &item.payload else {
bail!(
"{title:?} is a {}; fill hands over the fields of a record such as a login",
item.summary.kind
);
};
let steps: Vec<&Field> = kind
.template()
.into_iter()
.flat_map(|template| template.fill_order())
.filter_map(|spec| fields.iter().find(|field| field.name == spec.name))
.collect();
if steps.is_empty() {
bail!(
"{title:?} holds nothing a {kind} fills in; it holds: {}\n\
take a field with: sefy get {} --field NAME",
field_names(fields),
summary.id
);
}
if !std::io::stdin().is_terminal() {
bail!(
"fill waits for Enter between fields, and input is not a terminal\n\
take one field at a time with: sefy get {} --field NAME --stdout",
summary.id
);
}
let mut clipboard = output::Clipboard::open()?;
let total = steps.len();
for (index, field) in steps.iter().enumerate() {
let (value, label) = if field.name == sefy_core::otp::FIELD {
let totp = Totp::parse(&field.value).with_context(|| {
format!(
"the {} field of {title:?} cannot make a code\n\
replace it with: sefy otp {} --set",
sefy_core::otp::FIELD,
summary.id
)
})?;
let now = unix_now()?;
(
totp.code_at(now),
format!("one-time code (valid for {}s)", totp.remaining(now)),
)
} else {
(field.value.clone(), field.name.clone())
};
let position = format!("{}/{total}", index + 1);
if index + 1 < total {
clipboard.put(&value)?;
print!("{position} {label} is on the clipboard; paste it, then press Enter ");
flush_stdout();
let mut line = String::new();
if std::io::stdin().read_line(&mut line)? == 0 {
clipboard.take_back(&value);
println!();
bail!("input closed before the last field; the clipboard is cleared");
}
} else {
if args.clear_after > 0 {
println!(
"{position} {label} is on the clipboard; clearing in {}s",
args.clear_after
);
} else {
println!("{position} {label} is on the clipboard");
}
flush_stdout();
let hold = clipboard.hold(&value, args.clear_after)?;
if hold.cleared {
println!("clipboard cleared");
}
break;
}
}
Ok(())
}
pub fn run(
vault: Vault,
args: RunArgs,
password_env: Option<&str>,
) -> Result<std::convert::Infallible> {
let mappings = args
.env
.iter()
.map(|assignment| Mapping::parse(assignment))
.collect::<Result<Vec<_>>>()?;
for (index, mapping) in mappings.iter().enumerate() {
if let Some(earlier) = mappings[..index]
.iter()
.find(|earlier| earlier.variable.eq_ignore_ascii_case(mapping.variable))
{
if earlier.variable == mapping.variable {
bail!("--env sets {} twice", mapping.variable);
}
bail!(
"--env sets {} and {}, which Windows holds as one variable",
earlier.variable,
mapping.variable
);
}
}
let mut set = Vec::with_capacity(mappings.len());
for mapping in &mappings {
let value = mapping
.value(&vault)
.with_context(|| format!("cannot set {}", mapping.variable))?;
set.push((mapping.variable.to_owned(), value));
}
drop(vault);
let (program, arguments) = args
.command
.split_first()
.context("no command to run; name one after --")?;
Ok(crate::launch::replace_with(
program,
arguments,
password_env,
set,
)?)
}
struct Mapping<'a> {
variable: &'a str,
reference: &'a str,
field: Option<&'a str>,
}
impl<'a> Mapping<'a> {
fn parse(assignment: &'a str) -> Result<Self> {
let malformed = || format!("--env takes VAR=REFERENCE[#FIELD], not {assignment:?}");
let (variable, target) = assignment
.split_once('=')
.filter(|(variable, target)| !variable.is_empty() && !target.is_empty())
.with_context(malformed)?;
let (reference, field) = match target.rsplit_once('#') {
Some((reference, field)) if !reference.is_empty() && !field.is_empty() => {
(reference, Some(field))
}
Some(_) => bail!(
"{}\n\
a # starts a field name; an item whose title has one is taken by its id",
malformed()
),
None => (target, None),
};
Ok(Self {
variable,
reference,
field,
})
}
fn value(&self, vault: &Vault) -> Result<String> {
let summary = vault.resolve(self.reference).map_err(output::explain)?;
let item = vault.get(summary.id)?;
let (value, name) = value_of(&item, self.field, "#FIELD")?;
if value.contains('\0') {
bail!(
"the {name} of {:?} holds a NUL character, which a variable cannot carry",
item.summary.title
);
}
Ok(value)
}
}
fn unix_now() -> Result<u64> {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.context("the system clock is set before 1970; a one-time code needs the real time")
}
const URL_FIELD: &str = "url";
pub fn status(vault: &Vault) -> Result<()> {
let stats = vault.stats()?;
println!("vault {}", vault.path().display());
if let Some(size) = file_size(vault.path()) {
println!("size {size}");
}
println!(
"items {}{}",
output::count(stats.items, "item"),
if stats.by_kind.is_empty() {
String::new()
} else {
let breakdown: Vec<String> = stats
.by_kind
.iter()
.map(|(kind, count)| format!("{count} {kind}"))
.collect();
format!(" ({})", breakdown.join(", "))
}
);
println!("tags {}", output::count(stats.tags, "tag"));
let schema = if stats.schema > sefy_core::db::SCHEMA_VERSION {
format!("{} (written by a newer sefy)", stats.schema)
} else {
format!("{}", stats.schema)
};
println!("schema {schema}");
match &stats.last_sync {
Some(stamp) => println!(
"synced {} through {} ({})",
crate::when::moment(stamp.at, now()),
stamp.transport,
stamp.operation
),
None => println!("synced never"),
}
let plugins = sefy_core::plugin::discover_in(&sefy_core::plugin::search_paths());
if plugins.is_empty() {
println!("plugins none installed");
} else {
let names: Vec<String> = plugins
.iter()
.map(|plugin| {
if plugin.usable {
plugin.name().to_owned()
} else {
format!("{} (unusable)", plugin.name())
}
})
.collect();
println!("plugins {}", names.join(", "));
}
Ok(())
}
fn file_size(path: &Path) -> Option<String> {
let bytes = std::fs::metadata(path).ok()?.len();
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
Some(if bytes < KB {
format!("{bytes} B")
} else if bytes < MB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{:.1} MB", bytes as f64 / MB as f64)
})
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs() as i64)
.unwrap_or(0)
}
pub fn push(vault: &mut Vault, args: RemoteArgs) -> Result<()> {
let plugin = transport(args.transport.as_deref())?;
let report = sefy_core::push(vault, &plugin, &args.name)?;
println!("pushed {:?} through {}", args.name, plugin.name());
if let Some(message) = report.message {
println!("{message}");
}
Ok(())
}
pub fn pull(vault: &mut Vault, args: PullArgs, master: &str) -> Result<()> {
let plugin = transport(args.remote.transport.as_deref())?;
let remote_password = remote_password(&args, master)?;
let report = sefy_core::pull(
vault,
&plugin,
&args.remote.name,
remote_password.as_bytes(),
)?;
println!("pulled {:?} through {}", args.remote.name, plugin.name());
if let Some(message) = &report.transport.message {
println!("{message}");
}
report_merge(
&report.merged,
"nothing came back that this vault did not already have",
);
Ok(())
}
pub fn sync(vault: &mut Vault, args: PullArgs, master: &str) -> Result<()> {
let plugin = transport(args.remote.transport.as_deref())?;
let remote_password = remote_password(&args, master)?;
let report = sefy_core::sync(
vault,
&plugin,
&args.remote.name,
remote_password.as_bytes(),
)?;
println!("synced {:?} through {}", args.remote.name, plugin.name());
for message in [
report.pulled.transport.message.as_deref(),
report.pushed.message.as_deref(),
]
.into_iter()
.flatten()
{
println!("{message}");
}
report_merge(
&report.pulled.merged,
"nothing came back that this vault did not already have",
);
Ok(())
}
fn remote_password(args: &PullArgs, master: &str) -> Result<String> {
if let Some(variable) = args.remote_password_env.as_deref() {
return session::secret("", Some(variable));
}
if args.ask_remote_password {
return session::secret("Password of the remote copy: ", None);
}
Ok(master.to_owned())
}
fn transport(name: Option<&str>) -> Result<sefy_core::Plugin> {
let installed = sefy_core::plugin::discover();
if let Some(name) = name {
let found = sefy_core::plugin::find(installed, name).ok_or_else(|| {
anyhow::anyhow!(
"no transport called {name:?}\n\
run `sefy plugin list` to see what is installed."
)
})?;
return Ok(found);
}
let usable: Vec<sefy_core::Plugin> = installed
.into_iter()
.filter(|plugin| plugin.usable)
.collect();
match usable.len() {
0 => bail!(
"no usable transport installed\n\
a transport is an executable named {}<name>; `sefy plugin list` \
says where sefy looks and why anything found is unusable.",
sefy_core::plugin::PREFIX
),
1 => Ok(usable.into_iter().next().expect("just counted")),
_ => {
let names: Vec<&str> = usable.iter().map(sefy_core::Plugin::name).collect();
bail!(
"several transports are installed: {}\n\
say which one with --transport <NAME>.",
names.join(", ")
)
}
}
}
fn report_merge(report: &sefy_core::MergeReport, nothing_to_do: &str) {
if report.is_empty() {
println!("{nothing_to_do}");
return;
}
let brought = if report.versions > 0 {
format!(
"; {} brought across",
output::count(report.versions, "earlier version")
)
} else {
String::new()
};
println!(
"merged: {} added, {} updated, {} unchanged{brought}",
report.added, report.updated, report.unchanged
);
if report.unsupported > 0 {
println!(
"{} left where it was: a kind this version of sefy does not know.\n\
Nothing was lost — merge again from a build that knows it.",
output::count(report.unsupported, "item")
);
}
if !report.conflicts.is_empty() {
println!(
"\n{} changed on both sides.",
output::count(report.conflicts.len(), "item")
);
println!(
"The copy changed more recently is current; the other is kept in the item's history:"
);
for conflict in &report.conflicts {
let whose = match conflict.current {
sefy_core::Side::Here => "this vault's is current",
sefy_core::Side::There => "the other copy's is current",
};
println!(
" {:?} ({whose}): sefy history {}",
conflict.title, conflict.id
);
}
println!(
"Compare with sefy history ID VERSION; bring one back with sefy restore ID VERSION."
);
}
}
pub fn plugin_list(show_paths: bool) -> Result<()> {
let paths = sefy_core::plugin::search_paths();
let plugins = sefy_core::plugin::discover_in(&paths);
if show_paths {
println!("looked in:");
for path in &paths {
println!(" {}", path.display());
}
println!();
}
if plugins.is_empty() {
println!("no plugins installed");
println!(
"a plugin is an executable named {}<name>, on PATH or in {}",
sefy_core::plugin::PREFIX,
match sefy_core::plugin::plugin_directory() {
Some(directory) => directory.display().to_string(),
None => "sefy's data directory".to_owned(),
}
);
return Ok(());
}
let name_width = plugins
.iter()
.map(|plugin| plugin.name().chars().count())
.max()
.unwrap_or(4);
for plugin in &plugins {
let version = plugin
.manifest
.as_ref()
.map_or("?", |manifest| manifest.version.as_str());
let state = if plugin.usable {
let mut operations: Vec<&str> = plugin
.manifest
.iter()
.flat_map(|manifest| &manifest.operations)
.map(|operation| operation.as_str())
.collect();
operations.sort_unstable();
operations.join(", ")
} else {
format!(
"unusable: {}",
plugin.reason.as_deref().unwrap_or("no reason given")
)
};
println!(
"{:<name_width$} {:<8} {}",
plugin.name(),
version,
state,
name_width = name_width
);
}
Ok(())
}
fn read_stdin() -> Result<String> {
let mut text = String::new();
std::io::stdin().read_to_string(&mut text)?;
Ok(text.trim_end_matches(['\n', '\r']).to_owned())
}
fn confirm(question: &str) -> Result<bool> {
use std::io::{IsTerminal, Write};
if !std::io::stdin().is_terminal() {
bail!("cannot ask for confirmation: input is not a terminal; pass --yes");
}
print!("{question}");
std::io::stdout().flush()?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
Ok(matches!(
answer.trim().to_ascii_lowercase().as_str(),
"y" | "yes"
))
}