use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use crate::backup;
use crate::op::OpBackend;
use crate::prot::{self, ProtData};
pub const VAULT_NAME: &str = ".prot";
pub const PROT_FILE: &str = ".prot";
const VAULT_DESCRIPTION: &str = "Managed by dotprot — protected .env and config files.";
fn ensure_signed_in(op: &impl OpBackend) -> Result<()> {
ensure_signed_in_with(op, || {
prompt_yes_no("You are not signed in to 1Password. Sign in now?")
})
}
fn ensure_signed_in_with(
op: &impl OpBackend,
confirm: impl FnOnce() -> Result<bool>,
) -> Result<()> {
if op.is_signed_in()? {
return Ok(());
}
if !confirm()? {
bail!("You are not signed in to 1Password. Run `op signin` first.");
}
op.sign_in()?;
if op.is_signed_in()? {
Ok(())
} else {
bail!("Still not signed in to 1Password after `op signin`. Aborting.");
}
}
fn prompt_yes_no(question: &str) -> Result<bool> {
use std::io::{IsTerminal, Write};
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
return Ok(false);
}
print!("{question} [y/N] ");
std::io::stdout().flush().ok();
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
let answer = answer.trim().to_lowercase();
Ok(answer == "y" || answer == "yes")
}
fn prot_path(cwd: &Path) -> PathBuf {
cwd.join(PROT_FILE)
}
fn backup_prot(home: Option<&Path>, cwd: &Path, data: &ProtData) {
let Some(home) = home else {
eprintln!(" warning: could not determine your home directory — {PROT_FILE} not backed up");
return;
};
if let Err(e) = backup::save(home, cwd, data) {
eprintln!(" warning: could not back up {PROT_FILE}: {e:#}");
}
}
fn expand_patterns(cwd: &Path, patterns: &[String]) -> Result<Vec<String>> {
let mut matches: BTreeSet<String> = BTreeSet::new();
let escaped_cwd = glob::Pattern::escape(&cwd.to_string_lossy());
for pattern in patterns {
let abs_pattern = if Path::new(pattern).has_root() {
cwd.join(pattern).to_string_lossy().into_owned()
} else {
format!("{escaped_cwd}{}{pattern}", std::path::MAIN_SEPARATOR)
};
for entry in glob::glob(&abs_pattern)? {
let path = match entry {
Ok(p) => p,
Err(e) => {
eprintln!(" warning: could not read {} — skipped", e.path().display());
continue;
}
};
if !path.is_file() {
continue;
}
let rel = path.strip_prefix(cwd).ok().filter(|rel| {
!rel.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
});
let Some(rel) = rel else {
eprintln!(
" warning: {} is outside {} — skipped (dotprot only \
protects files inside the working directory)",
path.display(),
cwd.display()
);
continue;
};
let rel = rel.to_string_lossy().to_string();
if rel.chars().any(|c| c.is_control()) || rel != rel.trim() {
eprintln!(
" warning: {rel:?} has control characters or leading/\
trailing whitespace in its name — skipped (unsupported \
in {PROT_FILE})"
);
continue;
}
if rel != PROT_FILE {
matches.insert(rel);
}
}
}
Ok(matches.into_iter().collect())
}
fn document_title(cwd: &Path, rel_file: &str) -> String {
cwd.join(rel_file).to_string_lossy().to_string()
}
fn file_exists(p: &Path) -> bool {
p.try_exists().unwrap_or(true)
}
pub fn setup(op: &impl OpBackend) -> Result<()> {
ensure_signed_in(op)?;
if let Some(id) = op.find_vault(VAULT_NAME)? {
println!("Vault \"{VAULT_NAME}\" already exists ({id}).");
return Ok(());
}
let id = op.create_vault(VAULT_NAME, VAULT_DESCRIPTION)?;
println!("Created vault \"{VAULT_NAME}\" ({id}).");
Ok(())
}
fn resolve_vault(
op: &impl OpBackend,
prot: &mut ProtData,
create_if_missing: bool,
) -> Result<String> {
if let Some(id) = &prot.vault {
return match op.vault_name(id)? {
Some(name) if name == VAULT_NAME => Ok(id.clone()),
Some(name) => bail!(
"The vault recorded in {PROT_FILE} ({id}) is named \"{name}\", not \
\"{VAULT_NAME}\". Refusing to touch it. If that vault was renamed, \
rename it back; if the ID is stale, remove the `vault:` line from \
{PROT_FILE} and rerun."
),
None => bail!(
"The vault recorded in {PROT_FILE} ({id}) was not found in your \
1Password account. If it was deleted, remove the `vault:` line \
from {PROT_FILE} and rerun."
),
};
}
let id = match op.find_vault(VAULT_NAME)? {
Some(found) => found,
None if create_if_missing => {
let created = op.create_vault(VAULT_NAME, VAULT_DESCRIPTION)?;
println!("Created 1Password vault \"{VAULT_NAME}\" ({created}).");
println!("(one-time setup — future runs reuse it)");
created
}
None => bail!(
"No \"{VAULT_NAME}\" vault found in your 1Password account, but \
{PROT_FILE} has documents recorded. Nothing to restore from."
),
};
prot.vault = Some(id.clone());
Ok(id)
}
pub fn lock(op: &impl OpBackend, cwd: &Path, keep: bool, home: Option<&Path>) -> Result<()> {
ensure_signed_in(op)?;
let file = prot_path(cwd);
let mut prot = match prot::read(&file)? {
Some(p) => p,
None => {
let p = ProtData::empty();
prot::write(&file, &p)?;
backup_prot(home, cwd, &p);
println!(
"Created {PROT_FILE} (protecting: {}).",
p.patterns.join(", ")
);
p
}
};
let files = expand_patterns(cwd, &prot.patterns)?;
if files.is_empty() {
bail!(
"No files match the patterns in {PROT_FILE} ({}).\n\
Either the files are already locked, or no matching files exist.",
prot.patterns.join(", ")
);
}
let vault = resolve_vault(op, &mut prot, true)?;
let mut locked = 0;
for rel_file in &files {
let abs_file = cwd.join(rel_file);
let content = fs::read(&abs_file)?;
let title = document_title(cwd, rel_file);
let file_name = Path::new(rel_file)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| rel_file.clone());
if content.is_empty() {
println!(" skip {rel_file} (empty file — nothing to protect)");
continue;
}
let id = match prot.document_id(rel_file) {
Some(existing) => {
let existing = existing.to_string();
op.edit_document(&vault, &existing, &title, &file_name, &content)?;
existing
}
None => op.create_document(&vault, &title, &file_name, &content)?,
};
let round_trip = op.get_document(&vault, &id)?;
if round_trip != content {
bail!(
"Verification failed for {rel_file}: the copy in 1Password does not \
match the file on disk. Left {rel_file} in place; nothing deleted."
);
}
prot.set_document(rel_file, &id);
prot::write(&file, &prot)?;
backup_prot(home, cwd, &prot);
if keep {
println!(" uploaded {rel_file} -> 1Password (kept on disk)");
} else {
let current = fs::read(&abs_file)
.with_context(|| format!("re-reading {rel_file} before delete"))?;
if current != content {
bail!(
"{rel_file} changed on disk while it was being uploaded, so the \
copy in 1Password is already stale. Left {rel_file} in place — \
run `dotprot lock` again to store the new contents."
);
}
fs::remove_file(&abs_file)?;
println!(" locked {rel_file} -> 1Password");
}
locked += 1;
}
if keep {
println!(
"Uploaded {locked} file(s) to vault \"{VAULT_NAME}\". \
Originals kept on disk (--keep); run `dotprot lock` to remove them."
);
} else {
println!("Locked {locked} file(s) into vault \"{VAULT_NAME}\".");
}
Ok(())
}
pub fn unlock(op: &impl OpBackend, cwd: &Path) -> Result<()> {
ensure_signed_in(op)?;
let file = prot_path(cwd);
let mut prot = match prot::read(&file)? {
Some(p) => p,
None => bail!(
"No {PROT_FILE} found in {}. Nothing to unlock.",
cwd.display()
),
};
if prot.documents.is_empty() {
bail!("{PROT_FILE} has no locked documents recorded. Nothing to unlock.");
}
for (rel_file, _) in &prot.documents {
validate_restore_path(rel_file)?;
}
let vault = resolve_vault(op, &mut prot, false)?;
let mut unlocked = 0;
for (rel_file, id) in &prot.documents {
let abs_file = cwd.join(rel_file);
if file_exists(&abs_file) {
println!(" skip {rel_file} (already present on disk)");
continue;
}
let content = op.get_document(&vault, id)?;
write_owner_only(&abs_file, &content)?;
unlocked += 1;
println!(" unlocked {rel_file} <- 1Password");
}
println!("Unlocked {unlocked} file(s) from vault \"{VAULT_NAME}\".");
Ok(())
}
fn write_owner_only(path: &Path, content: &[u8]) -> Result<()> {
use std::io::Write;
let mut opts = fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(path).map_err(|e| {
let hint = match e.kind() {
std::io::ErrorKind::AlreadyExists => format!(
"refusing to write {} — something already exists at that path \
(possibly a symlink); remove it and run `dotprot unlock` again",
path.display()
),
std::io::ErrorKind::NotFound => format!(
"cannot write {} — its parent directory does not exist (git \
doesn't track empty directories); create the directory and \
run `dotprot unlock` again",
path.display()
),
_ => format!("opening {} for writing", path.display()),
};
anyhow::Error::new(e).context(hint)
})?;
f.write_all(content)?;
Ok(())
}
fn validate_restore_path(rel_file: &str) -> Result<()> {
use std::path::Component;
let path = Path::new(rel_file);
if path.components().next().is_none()
|| !path.components().all(|c| matches!(c, Component::Normal(_)))
{
bail!(
"Refusing to restore \"{rel_file}\": {PROT_FILE} entries must be \
plain relative paths inside this directory (no absolute or rooted \
paths, no `..`)."
);
}
Ok(())
}
pub fn restore(cwd: &Path, home: Option<&Path>) -> Result<()> {
let Some(home) = home else {
bail!("Could not determine your home directory, so there is no backup location to read.");
};
let backup_file = backup::backup_file(home, cwd);
let file = prot_path(cwd);
let backup_bytes = match fs::read(&backup_file) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
"No backup of {PROT_FILE} found for {} (looked at {}).\n\
dotprot backs up {PROT_FILE} whenever it writes it — has \
`dotprot lock` run in this directory before?",
cwd.display(),
backup_file.display()
),
Err(e) => {
return Err(e).with_context(|| format!("reading backup {}", backup_file.display()))
}
};
if file_exists(&file) {
let current =
fs::read(&file).with_context(|| format!("reading existing {}", file.display()))?;
if current == backup_bytes {
println!("{PROT_FILE} already matches its backup — nothing to restore.");
return Ok(());
}
bail!(
"A {PROT_FILE} already exists here and differs from the backup \
({}).\nRefusing to overwrite it — move it aside first if you \
really want the backup.",
backup_file.display()
);
}
write_owner_only(&file, &backup_bytes)?;
println!("Restored {PROT_FILE} from {}.", backup_file.display());
Ok(())
}
pub fn toggle(op: &impl OpBackend, cwd: &Path, keep: bool, home: Option<&Path>) -> Result<()> {
let file = prot_path(cwd);
let prot = prot::read(&file)?;
let prot = match prot {
Some(p) if !p.documents.is_empty() => p,
_ => return lock(op, cwd, keep, home),
};
for (rel_file, _) in &prot.documents {
validate_restore_path(rel_file)?;
}
let mut present: Vec<&str> = Vec::new();
let mut absent: Vec<&str> = Vec::new();
for (rel_file, _) in &prot.documents {
if file_exists(&cwd.join(rel_file)) {
present.push(rel_file);
} else {
absent.push(rel_file);
}
}
if !present.is_empty() && !absent.is_empty() {
bail!(
"Mixed state: some recorded files are present on disk and others are missing, \
so it's unclear whether you mean to lock or unlock.\n\
\x20 present: {}\n\
\x20 missing: {}\n\
Use `dotprot lock` or `dotprot unlock` explicitly to resolve the ambiguity.",
present.join(", "),
absent.join(", "),
);
}
if !absent.is_empty() {
if keep {
eprintln!("note: --keep has no effect on unlock (nothing is deleted).");
}
unlock(op, cwd)
} else {
lock(op, cwd, keep, home)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::op::OpBackend;
use std::cell::RefCell;
struct MockOp {
calls: RefCell<Vec<String>>,
docs: RefCell<Vec<(String, Vec<u8>)>>,
corrupt_readback: bool,
signed_in: RefCell<bool>,
signin_fails: bool,
rewrite_on_get: Option<(PathBuf, Vec<u8>)>,
vault_name_response: Option<String>,
find_vault_response: Option<String>,
}
impl MockOp {
fn new() -> Self {
MockOp {
calls: RefCell::new(Vec::new()),
docs: RefCell::new(Vec::new()),
corrupt_readback: false,
signed_in: RefCell::new(true),
signin_fails: false,
rewrite_on_get: None,
vault_name_response: Some(VAULT_NAME.to_string()),
find_vault_response: Some("VAULT".to_string()),
}
}
fn corrupting() -> Self {
let mut m = Self::new();
m.corrupt_readback = true;
m
}
fn signed_out() -> Self {
let m = Self::new();
*m.signed_in.borrow_mut() = false;
m
}
fn called(&self, name: &str) -> bool {
self.calls.borrow().iter().any(|c| c == name)
}
fn store(&self, id: &str, content: &[u8]) {
let mut docs = self.docs.borrow_mut();
if let Some(entry) = docs.iter_mut().find(|(i, _)| i == id) {
entry.1 = content.to_vec();
} else {
docs.push((id.to_string(), content.to_vec()));
}
}
}
impl OpBackend for MockOp {
fn is_signed_in(&self) -> Result<bool> {
self.calls.borrow_mut().push("is_signed_in".into());
Ok(*self.signed_in.borrow())
}
fn sign_in(&self) -> Result<()> {
self.calls.borrow_mut().push("sign_in".into());
if !self.signin_fails {
*self.signed_in.borrow_mut() = true;
}
Ok(())
}
fn find_vault(&self, _name: &str) -> Result<Option<String>> {
self.calls.borrow_mut().push("find_vault".into());
Ok(self.find_vault_response.clone())
}
fn vault_name(&self, _id: &str) -> Result<Option<String>> {
self.calls.borrow_mut().push("vault_name".into());
Ok(self.vault_name_response.clone())
}
fn create_vault(&self, _name: &str, _description: &str) -> Result<String> {
self.calls.borrow_mut().push("create_vault".into());
Ok("VAULT".into())
}
fn create_document(
&self,
_vault: &str,
_title: &str,
_file_name: &str,
content: &[u8],
) -> Result<String> {
self.calls.borrow_mut().push("create_document".into());
let id = format!("DOC{}", self.docs.borrow().len());
self.store(&id, content);
Ok(id)
}
fn edit_document(
&self,
_vault: &str,
id: &str,
_title: &str,
_file_name: &str,
content: &[u8],
) -> Result<()> {
self.calls.borrow_mut().push("edit_document".into());
self.store(id, content);
Ok(())
}
fn get_document(&self, _vault: &str, id: &str) -> Result<Vec<u8>> {
self.calls.borrow_mut().push("get_document".into());
if let Some((path, bytes)) = &self.rewrite_on_get {
fs::write(path, bytes).unwrap();
}
let bytes = self
.docs
.borrow()
.iter()
.find(|(i, _)| i == id)
.map(|(_, b)| b.clone())
.unwrap_or_default();
if self.corrupt_readback {
Ok(b"CORRUPTED".to_vec())
} else {
Ok(bytes)
}
}
}
fn setup_dir(secret: &[u8]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".env"), secret).unwrap();
let mut prot = ProtData::empty();
prot.vault = Some("VAULT".to_string());
prot::write(&dir.path().join(PROT_FILE), &prot).unwrap();
dir
}
#[test]
fn lock_deletes_only_after_successful_readback() {
let dir = setup_dir(b"SECRET=1\n");
let op = MockOp::new();
lock(&op, dir.path(), false, None).unwrap();
assert!(!dir.path().join(".env").exists(), ".env should be deleted");
let calls = op.calls.borrow();
let upload = calls.iter().position(|c| c == "create_document").unwrap();
let verify = calls.iter().position(|c| c == "get_document").unwrap();
assert!(
upload < verify,
"upload must happen before read-back verify"
);
let prot = prot::read(&dir.path().join(PROT_FILE)).unwrap().unwrap();
assert_eq!(prot.document_id(".env"), Some("DOC0"));
}
#[test]
fn lock_keeps_file_when_readback_mismatches() {
let dir = setup_dir(b"SECRET=1\n");
let op = MockOp::corrupting();
let err = lock(&op, dir.path(), false, None).unwrap_err();
assert!(
dir.path().join(".env").exists(),
".env must survive a failed verification"
);
assert!(
err.to_string().contains("Verification failed"),
"expected a verification-failed error, got: {err}"
);
let prot = prot::read(&dir.path().join(PROT_FILE)).unwrap().unwrap();
assert_eq!(prot.document_id(".env"), None);
}
#[test]
fn lock_with_keep_uploads_and_verifies_but_does_not_delete() {
let dir = setup_dir(b"SECRET=1\n");
let op = MockOp::new();
lock(&op, dir.path(), true, None).unwrap();
assert!(
dir.path().join(".env").exists(),
"--keep must leave the file on disk"
);
let calls = op.calls.borrow();
assert!(calls.iter().any(|c| c == "get_document"), "must verify");
let prot = prot::read(&dir.path().join(PROT_FILE)).unwrap().unwrap();
assert_eq!(prot.document_id(".env"), Some("DOC0"));
}
#[test]
fn unlock_restores_file_with_owner_only_mode() {
let dir = setup_dir(b"SECRET=restored\n");
let op = MockOp::new();
lock(&op, dir.path(), false, None).unwrap();
assert!(!dir.path().join(".env").exists());
unlock(&op, dir.path()).unwrap();
let restored = fs::read(dir.path().join(".env")).unwrap();
assert_eq!(restored, b"SECRET=restored\n");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(dir.path().join(".env"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "restored file must be 0600");
}
}
#[test]
fn lock_keeps_file_that_changed_during_upload() {
let dir = setup_dir(b"SECRET=1\n");
let mut op = MockOp::new();
op.rewrite_on_get = Some((dir.path().join(".env"), b"SECRET=2\n".to_vec()));
let err = lock(&op, dir.path(), false, None).unwrap_err();
assert!(
err.to_string().contains("changed on disk"),
"expected a changed-on-disk error, got: {err}"
);
assert_eq!(
fs::read(dir.path().join(".env")).unwrap(),
b"SECRET=2\n",
"the modified file must survive untouched"
);
}
#[cfg(unix)]
#[test]
fn unlock_refuses_to_write_through_dangling_symlink() {
let dir = setup_dir(b"SECRET=1\n");
let op = MockOp::new();
lock(&op, dir.path(), false, None).unwrap();
let target = dir.path().join("attacker-target");
std::os::unix::fs::symlink(&target, dir.path().join(".env")).unwrap();
let err = unlock(&op, dir.path()).unwrap_err();
assert!(
format!("{err:#}").contains("refusing to write"),
"expected a refusal error, got: {err:#}"
);
assert!(
!target.exists(),
"secret must not be written through the symlink"
);
}
#[test]
fn unlock_rejects_paths_that_escape_the_directory() {
let dir = setup_dir(b"SECRET=1\n");
let op = MockOp::new();
lock(&op, dir.path(), false, None).unwrap();
let mut prot = prot::read(&dir.path().join(PROT_FILE)).unwrap().unwrap();
let id = prot.document_id(".env").unwrap().to_string();
prot.documents = vec![("../escaped.env".to_string(), id)];
prot::write(&dir.path().join(PROT_FILE), &prot).unwrap();
let err = unlock(&op, dir.path()).unwrap_err();
assert!(
err.to_string().contains("Refusing to restore"),
"expected a traversal refusal, got: {err}"
);
assert!(
!dir.path().join("../escaped.env").exists(),
"nothing may be written outside the working directory"
);
}
#[test]
fn unlock_reports_missing_parent_directory_accurately() {
let dir = setup_dir(b"SECRET=1\n");
let op = MockOp::new();
lock(&op, dir.path(), false, None).unwrap();
let mut prot = prot::read(&dir.path().join(PROT_FILE)).unwrap().unwrap();
let id = prot.document_id(".env").unwrap().to_string();
prot.documents = vec![("config/.env".to_string(), id)];
prot::write(&dir.path().join(PROT_FILE), &prot).unwrap();
let err = unlock(&op, dir.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("parent directory does not exist"),
"expected a missing-directory hint, got: {msg}"
);
assert!(
!msg.contains("already exists"),
"must not claim something exists when the parent dir is missing: {msg}"
);
}
#[test]
fn validate_restore_path_allows_only_plain_relative_paths() {
assert!(validate_restore_path(".env").is_ok());
assert!(validate_restore_path("nested/dir/.env").is_ok());
assert!(validate_restore_path("../up.env").is_err());
assert!(validate_restore_path("/abs/path.env").is_err());
assert!(validate_restore_path("a/../b.env").is_err());
assert!(validate_restore_path("").is_err());
}
#[cfg(windows)]
#[test]
fn validate_restore_path_rejects_rooted_driveless_windows_paths() {
assert!(validate_restore_path(r"\Users\victim\startup.bat").is_err());
assert!(validate_restore_path(r"C:\Users\victim\x").is_err());
assert!(validate_restore_path(r"C:relative.env").is_err());
}
#[test]
fn unlock_validates_all_paths_before_restoring_anything() {
let dir = setup_dir(b"SECRET=1\n");
let op = MockOp::new();
lock(&op, dir.path(), false, None).unwrap();
let mut prot = prot::read(&dir.path().join(PROT_FILE)).unwrap().unwrap();
let id = prot.document_id(".env").unwrap().to_string();
prot.documents = vec![
(".env".to_string(), id.clone()),
("../evil".to_string(), id),
];
prot::write(&dir.path().join(PROT_FILE), &prot).unwrap();
let err = unlock(&op, dir.path()).unwrap_err();
assert!(err.to_string().contains("Refusing to restore"));
assert!(
!dir.path().join(".env").exists(),
"a tampered .prot must abort before any file is restored"
);
}
#[test]
fn toggle_rejects_tampered_document_paths() {
let dir = setup_dir(b"SECRET=1\n");
let op = MockOp::new();
lock(&op, dir.path(), false, None).unwrap();
let mut prot = prot::read(&dir.path().join(PROT_FILE)).unwrap().unwrap();
let id = prot.document_id(".env").unwrap().to_string();
prot.documents.push(("/etc/hosts".to_string(), id));
prot::write(&dir.path().join(PROT_FILE), &prot).unwrap();
let err = toggle(&op, dir.path(), false, None).unwrap_err();
assert!(
err.to_string().contains("Refusing to restore"),
"expected toggle to reject the tampered path, got: {err}"
);
}
#[test]
fn lock_works_in_directory_with_glob_metacharacters() {
let outer = tempfile::tempdir().unwrap();
let dir = outer.path().join("we[i]rd dir");
fs::create_dir(&dir).unwrap();
fs::write(dir.join(".env"), b"SECRET=1\n").unwrap();
let mut prot = ProtData::empty();
prot.vault = Some("VAULT".to_string());
prot::write(&dir.join(PROT_FILE), &prot).unwrap();
let op = MockOp::new();
lock(&op, &dir, false, None).unwrap();
assert!(
!dir.join(".env").exists(),
".env must lock even when the project path contains [ ] metacharacters"
);
}
#[test]
fn expand_patterns_skips_matches_outside_the_working_directory() {
let outer = tempfile::tempdir().unwrap();
let dir = outer.path().join("project");
fs::create_dir(&dir).unwrap();
fs::write(outer.path().join("outside.env"), b"SECRET=1\n").unwrap();
let matches = expand_patterns(&dir, &["../outside.env".to_string()]).unwrap();
assert!(
matches.is_empty(),
"files outside cwd must not be treated as protectable: {matches:?}"
);
}
#[test]
fn expand_patterns_does_not_reanchor_absolute_patterns_under_cwd() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("shared")).unwrap();
fs::write(dir.path().join("shared/x.env"), b"SECRET=1\n").unwrap();
let matches = expand_patterns(dir.path(), &["/shared/x.env".to_string()]).unwrap();
assert!(
matches.is_empty(),
"an absolute pattern must not match a cwd-relative file: {matches:?}"
);
}
#[test]
fn expand_patterns_matches_absolute_pattern_inside_cwd() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".env"), b"SECRET=1\n").unwrap();
let abs = dir.path().join(".env").to_string_lossy().to_string();
let matches = expand_patterns(dir.path(), &[abs]).unwrap();
assert_eq!(matches, vec![".env".to_string()]);
}
#[cfg(unix)]
#[test]
fn expand_patterns_skips_filenames_with_control_characters() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".env\nx"), b"SECRET=1\n").unwrap();
fs::write(dir.path().join(".env"), b"SECRET=1\n").unwrap();
let matches = expand_patterns(dir.path(), &[".env*".to_string()]).unwrap();
assert_eq!(
matches,
vec![".env".to_string()],
"a newline in a filename would corrupt the .prot line format"
);
}
#[cfg(unix)]
#[test]
fn expand_patterns_skips_filenames_with_edge_whitespace() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".env "), b"SECRET=1\n").unwrap();
fs::write(dir.path().join(".env"), b"SECRET=1\n").unwrap();
let matches = expand_patterns(dir.path(), &[".env*".to_string()]).unwrap();
assert_eq!(
matches,
vec![".env".to_string()],
"edge whitespace is trimmed by the .prot parser and must be refused"
);
}
#[test]
fn lock_mirrors_prot_to_the_home_backup() {
let dir = setup_dir(b"SECRET=1\n");
let home = tempfile::tempdir().unwrap();
let op = MockOp::new();
lock(&op, dir.path(), false, Some(home.path())).unwrap();
let backup = crate::backup::backup_file(home.path(), dir.path());
let backed_up = prot::read(&backup).unwrap().expect("backup must exist");
assert_eq!(
backed_up.document_id(".env"),
Some("DOC0"),
"backup must carry the recorded document id"
);
}
#[test]
fn restore_recovers_a_deleted_prot() {
let dir = setup_dir(b"SECRET=1\n");
let home = tempfile::tempdir().unwrap();
let op = MockOp::new();
lock(&op, dir.path(), false, Some(home.path())).unwrap();
fs::remove_file(dir.path().join(PROT_FILE)).unwrap();
restore(dir.path(), Some(home.path())).unwrap();
let recovered = prot::read(&dir.path().join(PROT_FILE)).unwrap().unwrap();
assert_eq!(
recovered.document_id(".env"),
Some("DOC0"),
"restored .prot must still map .env to its document"
);
unlock(&op, dir.path()).unwrap();
assert_eq!(fs::read(dir.path().join(".env")).unwrap(), b"SECRET=1\n");
}
#[test]
fn restore_is_a_noop_when_prot_matches_the_backup() {
let dir = setup_dir(b"SECRET=1\n");
let home = tempfile::tempdir().unwrap();
let op = MockOp::new();
lock(&op, dir.path(), false, Some(home.path())).unwrap();
let before = fs::read(dir.path().join(PROT_FILE)).unwrap();
restore(dir.path(), Some(home.path())).unwrap();
assert_eq!(fs::read(dir.path().join(PROT_FILE)).unwrap(), before);
}
#[test]
fn restore_refuses_to_overwrite_a_differing_prot() {
let dir = setup_dir(b"SECRET=1\n");
let home = tempfile::tempdir().unwrap();
let op = MockOp::new();
lock(&op, dir.path(), false, Some(home.path())).unwrap();
fs::write(dir.path().join(PROT_FILE), b"hand-edited\n").unwrap();
let err = restore(dir.path(), Some(home.path())).unwrap_err();
assert!(
err.to_string().contains("Refusing to overwrite"),
"expected an overwrite refusal, got: {err}"
);
assert_eq!(
fs::read(dir.path().join(PROT_FILE)).unwrap(),
b"hand-edited\n",
"the diverged .prot must be left untouched"
);
}
#[test]
fn restore_errors_clearly_when_no_backup_exists() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let err = restore(dir.path(), Some(home.path())).unwrap_err();
assert!(
err.to_string().contains("No backup"),
"expected a no-backup error, got: {err}"
);
}
#[test]
fn lock_succeeds_even_when_backup_location_is_unavailable() {
let dir = setup_dir(b"SECRET=1\n");
let op = MockOp::new();
lock(&op, dir.path(), false, None).unwrap();
assert!(!dir.path().join(".env").exists(), ".env should still lock");
}
#[test]
fn lock_refuses_vault_id_that_is_not_the_prot_vault() {
let dir = setup_dir(b"SECRET=1\n");
let mut op = MockOp::new();
op.vault_name_response = Some("Personal".to_string());
let err = lock(&op, dir.path(), false, None).unwrap_err();
assert!(
err.to_string().contains("is named \"Personal\""),
"expected a wrong-vault refusal, got: {err}"
);
assert!(
!op.called("create_document") && !op.called("edit_document"),
"must not write documents into a vault that isn't \".prot\""
);
assert!(dir.path().join(".env").exists(), ".env must be untouched");
}
#[test]
fn lock_refuses_vault_id_that_no_longer_exists() {
let dir = setup_dir(b"SECRET=1\n");
let mut op = MockOp::new();
op.vault_name_response = None;
let err = lock(&op, dir.path(), false, None).unwrap_err();
assert!(
err.to_string().contains("was not found"),
"expected a vault-not-found error, got: {err}"
);
assert!(dir.path().join(".env").exists(), ".env must be untouched");
}
#[test]
fn unlock_errors_instead_of_creating_a_missing_vault() {
let dir = setup_dir(b"SECRET=1\n");
let op = MockOp::new();
lock(&op, dir.path(), false, None).unwrap();
let mut prot = prot::read(&dir.path().join(PROT_FILE)).unwrap().unwrap();
prot.vault = None;
prot::write(&dir.path().join(PROT_FILE), &prot).unwrap();
let mut op = MockOp::new();
op.find_vault_response = None;
let err = unlock(&op, dir.path()).unwrap_err();
assert!(
err.to_string().contains("No \".prot\" vault found"),
"expected a missing-vault error, got: {err}"
);
assert!(
!op.called("create_vault"),
"unlock must never create a vault — the recorded documents \
couldn't be in a fresh one"
);
}
#[test]
fn ensure_signed_in_is_noop_when_already_signed_in() {
let op = MockOp::new(); ensure_signed_in_with(&op, || panic!("must not prompt when already signed in")).unwrap();
assert!(
!op.called("sign_in"),
"must not sign in when already signed in"
);
}
#[test]
fn ensure_signed_in_signs_in_when_user_confirms() {
let op = MockOp::signed_out();
ensure_signed_in_with(&op, || Ok(true)).unwrap();
assert!(op.called("sign_in"), "should have run sign_in on confirm");
}
#[test]
fn ensure_signed_in_errors_and_skips_signin_when_user_declines() {
let op = MockOp::signed_out();
let err = ensure_signed_in_with(&op, || Ok(false)).unwrap_err();
assert!(
err.to_string().contains("not signed in"),
"expected a not-signed-in error, got: {err}"
);
assert!(
!op.called("sign_in"),
"must not sign in when the user declines / non-interactive"
);
}
#[test]
fn ensure_signed_in_errors_when_signin_does_not_take() {
let mut op = MockOp::signed_out();
op.signin_fails = true; let err = ensure_signed_in_with(&op, || Ok(true)).unwrap_err();
assert!(
err.to_string().contains("Still not signed in"),
"expected a post-signin failure, got: {err}"
);
}
#[test]
fn lock_aborts_without_touching_files_when_not_signed_in() {
let dir = setup_dir(b"SECRET=1\n");
let op = MockOp::signed_out();
let err = lock(&op, dir.path(), false, None).unwrap_err();
assert!(err.to_string().contains("not signed in"));
assert!(
dir.path().join(".env").exists(),
".env must be untouched when not signed in"
);
assert!(
!op.called("create_document"),
"must not upload when signed out"
);
}
}