mod common;
use common::*;
fn populated(dir: &TempDir, password: &str) -> String {
let path = dir.join("k.keychain");
let as_str = path.to_str().expect("utf-8 path").to_string();
kc_ok(&["create", &as_str], Some(password));
kc_ok(
&[
"add",
"generic",
"-a",
"alice",
"-s",
"svc",
"-G",
"tag-bytes",
"-D",
"app password",
"-j",
"made by kc",
"-l",
"the label",
"-w",
"generic-secret",
&as_str,
],
Some(password),
);
kc_ok(
&[
"add",
"internet",
"-a",
"bob",
"-s",
"example.com",
"-S",
"realm.example",
"-r",
"htps",
"--path",
"/login",
"-P",
"8443",
"-j",
"web login",
"-w",
"internet-secret",
&as_str,
],
Some(password),
);
as_str
}
#[test]
fn the_password_can_come_from_an_environment_variable() {
let dir = TempDir::new("options-env");
let path = dir.join("k.keychain");
let as_str = path.to_str().expect("utf-8 path");
let output = kc_with_env(
&["create", "-e", "KC_TEST_PW", as_str],
&[("KC_TEST_PW", "envpw")],
);
assert!(
output.status.success(),
"create failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let output = kc_with_env(
&[
"add",
"generic",
"-e",
"KC_TEST_PW",
"-a",
"alice",
"-s",
"svc",
"-w",
"s3cret",
as_str,
],
&[("KC_TEST_PW", "envpw")],
);
assert!(
output.status.success(),
"add failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let output = kc_with_env(
&[
"find",
"generic",
"-a",
"alice",
"-w",
"-e",
"KC_TEST_PW",
as_str,
],
&[("KC_TEST_PW", "envpw")],
);
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "s3cret");
let output = kc_with_env(
&[
"find",
"generic",
"-a",
"alice",
"-w",
"-e",
"KC_TEST_PW",
as_str,
],
&[("KC_TEST_PW", "envpw\n")],
);
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "s3cret");
let output = kc_with_env(
&[
"find",
"generic",
"-a",
"alice",
"-w",
"-e",
"KC_TEST_PW",
as_str,
],
&[("KC_TEST_PW", "wrong")],
);
assert!(!output.status.success());
assert_eq!(output.status.code(), Some(45), "wrong-password exit code");
}
#[test]
fn an_unset_environment_variable_says_so() {
let dir = TempDir::new("options-env-missing");
let path = dir.join("k.keychain");
let as_str = path.to_str().expect("utf-8 path");
kc_ok(&["create", as_str], Some("pw"));
let output = kc_with_env(&["ls", "-e", "KC_TEST_UNSET_PW", as_str], &[]);
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("KC_TEST_UNSET_PW is not set"),
"unexpected error: {stderr}"
);
}
#[test]
fn the_password_can_come_from_a_file_or_from_stdin() {
let dir = TempDir::new("options-file");
let path = dir.join("k.keychain");
let as_str = path.to_str().expect("utf-8 path");
let password_file = dir.join("pw.txt");
std::fs::write(&password_file, "filepw\n").expect("write the password file");
let password_file = password_file.to_str().expect("utf-8 path");
kc_ok(&["create", "-f", password_file, as_str], None);
kc_ok(
&[
"add",
"generic",
"-f",
password_file,
"-a",
"alice",
"-s",
"svc",
"-w",
"s3cret",
as_str,
],
None,
);
assert_eq!(
kc_ok(
&[
"find",
"generic",
"-a",
"alice",
"-w",
"-f",
password_file,
as_str
],
None
),
"s3cret"
);
assert_eq!(
kc_ok(
&["find", "generic", "-a", "alice", "-w", "-f", "-", as_str],
Some("filepw")
),
"s3cret"
);
assert_eq!(
kc_ok(
&["find", "generic", "-a", "alice", "-w", as_str],
Some("filepw")
),
"s3cret"
);
let output = kc(
&[
"find",
"generic",
"-a",
"alice",
"-w",
"-f",
dir.join("absent.txt").to_str().expect("utf-8 path"),
as_str,
],
None,
);
assert!(!output.status.success());
assert!(
String::from_utf8_lossy(&output.stderr).contains("could not read"),
"unexpected error"
);
}
#[test]
fn the_password_sources_are_mutually_exclusive() {
let dir = TempDir::new("options-exclusive");
let path = dir.join("k.keychain");
let as_str = path.to_str().expect("utf-8 path");
kc_ok(&["create", as_str], Some("pw"));
let output = kc(&["ls", "-e", "KC_TEST_PW", "-f", "/dev/null", as_str], None);
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("cannot be used with"),
"unexpected: {stderr}"
);
}
#[test]
fn the_password_can_be_supplied_directly() {
let dir = TempDir::new("options-argv");
let path = dir.join("k.keychain");
let as_str = path.to_str().expect("utf-8 path");
kc_ok(&["create", as_str], Some("pw"));
for args in [
vec!["ls", "-p", "pw", as_str],
vec!["ls", "--password", "pw", as_str],
] {
let output = kc(&args, None);
assert!(
output.status.success(),
"{args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
#[test]
fn password_flags_and_generator_follow_the_new_contract() {
let dir = TempDir::new("options-uppercase");
let path = dir.join("generated.keychain");
let as_str = path.to_str().unwrap();
let output = kc(&["create", "--password-gen", as_str], None);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let password = stdout
.lines()
.find_map(|line| line.strip_prefix("password: "))
.expect("generated password is reported");
assert_eq!(password.len(), 40);
assert!(password.bytes().all(|byte| byte.is_ascii_hexdigit()));
let output = kc(&["ls", "-p", password, as_str], None);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let output = kc_with_env(&["ls", as_str, "-E"], &[("KC_PASSWORD", password)]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn generated_password_policies_control_character_classes() {
let dir = TempDir::new("options-policies");
for policy in ["alphanumeric", "mixed-case", "symbol", "secure"] {
let path = dir.join(&format!("{policy}.keychain"));
let output = kc(
&[
"create",
"--password-gen",
"--password-policy",
policy,
path.to_str().unwrap(),
],
None,
);
assert!(
output.status.success(),
"{policy}: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let password = stdout
.lines()
.find_map(|line| line.strip_prefix("password: "))
.unwrap();
assert_eq!(password.len(), 40);
match policy {
"alphanumeric" => assert!(password.bytes().all(|byte| byte.is_ascii_alphanumeric())),
"mixed-case" => {
assert!(password.bytes().all(|byte| byte.is_ascii_alphanumeric()));
assert!(password.bytes().any(|byte| byte.is_ascii_lowercase()));
assert!(password.bytes().any(|byte| byte.is_ascii_uppercase()));
assert!(password.bytes().any(|byte| byte.is_ascii_digit()));
}
"symbol" => assert!(password.bytes().any(|byte| !byte.is_ascii_alphanumeric())),
"secure" => {
assert!(password.bytes().any(|byte| byte.is_ascii_lowercase()));
assert!(password.bytes().any(|byte| byte.is_ascii_uppercase()));
assert!(password.bytes().any(|byte| byte.is_ascii_digit()));
assert!(password.bytes().any(|byte| !byte.is_ascii_alphanumeric()));
}
_ => unreachable!(),
}
}
let output = kc(
&[
"create",
"--password-policy",
"secure",
dir.join("invalid.keychain").to_str().unwrap(),
],
None,
);
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("--password-gen"));
}
#[test]
fn uppercase_metadata_aliases_are_first_class() {
let dir = TempDir::new("options-metadata-aliases");
let path = dir.join("aliases.keychain");
let path = path.to_str().unwrap();
kc_ok(&["create", "-p", "pw", path], None);
kc_ok(
&[
"add", "generic", "-p", "pw", "-A", "alice", "-S", "svc", "-L", "label", "-C",
"comment", "-w", "secret", path,
],
None,
);
assert_eq!(
kc_ok(
&[
"find", "generic", "-p", "pw", "-A", "alice", "-S", "svc", "-L", "label", "-C",
"comment", "-w", path,
],
None,
),
"secret"
);
}
#[test]
fn format_secret_prints_the_secret_and_nothing_else() {
let dir = TempDir::new("options-format");
let keychain = populated(&dir, "pw");
let secret = kc_ok(
&[
"--format", "secret", "find", "generic", "-a", "alice", &keychain,
],
Some("pw"),
);
assert_eq!(secret, "generic-secret");
let secrets = kc_ok(&["--format", "secret", "show", &keychain], Some("pw"));
let mut lines: Vec<&str> = secrets.lines().collect();
lines.sort_unstable();
assert_eq!(lines, ["generic-secret", "internet-secret"]);
let output = kc(&["--format", "secret", "info", &keychain], None);
assert!(!output.status.success());
assert!(
String::from_utf8_lossy(&output.stderr).contains("--format secret applies to"),
"unexpected error"
);
}
#[test]
fn format_json_is_the_json_flag() {
let dir = TempDir::new("options-json");
let keychain = populated(&dir, "pw");
let with_flag = kc_ok(
&["--json", "find", "generic", "-a", "alice", &keychain],
None,
);
let with_format = kc_ok(
&[
"--format", "json", "find", "generic", "-a", "alice", &keychain,
],
None,
);
assert_eq!(with_flag, with_format);
let parsed: serde_json::Value = serde_json::from_str(&with_format).expect("valid JSON");
assert_eq!(parsed["item"]["account"], "alice");
let output = kc(
&[
"--json", "--format", "text", "find", "generic", "-a", "alice", &keychain,
],
None,
);
assert!(!output.status.success());
}
#[test]
fn find_filters_on_every_attribute_it_offers() {
let dir = TempDir::new("options-filters");
let keychain = populated(&dir, "pw");
let cases: Vec<Vec<&str>> = vec![
vec!["find", "generic", "-a", "alice"],
vec!["find", "generic", "-s", "svc"],
vec!["find", "generic", "-l", "the label"],
vec!["find", "generic", "-D", "app password"],
vec!["find", "generic", "-j", "made by kc"],
vec!["find", "generic", "-G", "tag-bytes"],
vec!["find", "generic", "--attr", "acct=alice"],
vec!["find", "generic", "--attr", "gena=tag-bytes", "-a", "alice"],
];
for case in cases {
let mut args = case.clone();
args.extend_from_slice(&["-w", &keychain]);
assert_eq!(
kc_ok(&args, Some("pw")),
"generic-secret",
"filter {case:?} did not match"
);
}
let internet: Vec<Vec<&str>> = vec![
vec!["find", "internet", "-S", "realm.example"],
vec!["find", "internet", "-d", "realm.example"],
vec!["find", "internet", "-j", "web login"],
vec!["find", "internet", "--path", "/login"],
vec!["find", "internet", "-P", "8443"],
vec!["find", "internet", "--attr", "ptcl=htps"],
vec!["find", "internet", "--attr", "port=8443"],
];
for case in internet {
let mut args = case.clone();
args.extend_from_slice(&["-w", &keychain]);
assert_eq!(
kc_ok(&args, Some("pw")),
"internet-secret",
"filter {case:?} did not match"
);
}
let output = kc(
&["find", "generic", "-j", "not this comment", &keychain],
Some("pw"),
);
assert_eq!(output.status.code(), Some(44));
let output = kc(&["find", "generic", "--attr", "nonsense", &keychain], None);
assert!(!output.status.success());
assert!(
String::from_utf8_lossy(&output.stderr).contains("expected NAME=VALUE"),
"unexpected error"
);
let output = kc(&["find", "generic", "--attr", "srvr=x", &keychain], None);
assert_eq!(output.status.code(), Some(44));
}
#[test]
fn the_generic_attribute_and_security_domain_survive_a_round_trip() {
let dir = TempDir::new("options-attributes");
let keychain = populated(&dir, "pw");
let shown = kc_ok(&["show", &keychain], None);
assert!(
shown.contains("gena tag-bytes"),
"unexpected: {shown}"
);
assert!(
shown.contains("sdmn realm.example"),
"unexpected: {shown}"
);
let output = kc(
&[
"add",
"internet",
"-a",
"bob",
"-s",
"example.com",
"-S",
"realm.example",
"-r",
"htps",
"--path",
"/login",
"-P",
"8443",
"-w",
"again",
&keychain,
],
Some("pw"),
);
assert_eq!(output.status.code(), Some(46), "duplicate exit code");
}