use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Result};
use crate::op;
use crate::prot::{self, ProtData};
pub const VAULT_NAME: &str = ".prot";
const PROT_FILE: &str = ".prot";
const VAULT_DESCRIPTION: &str = "Managed by dotprot — protected .env and config files.";
fn prot_path(cwd: &Path) -> PathBuf {
cwd.join(PROT_FILE)
}
fn expand_patterns(cwd: &Path, patterns: &[String]) -> Result<Vec<String>> {
let mut matches: BTreeSet<String> = BTreeSet::new();
for pattern in patterns {
let abs_pattern = cwd.join(pattern);
let abs_pattern = abs_pattern.to_string_lossy();
for entry in glob::glob(&abs_pattern)? {
let path = match entry {
Ok(p) => p,
Err(_) => continue, };
if !path.is_file() {
continue;
}
if let Ok(rel) = path.strip_prefix(cwd) {
let rel = rel.to_string_lossy().to_string();
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.exists()
}
pub fn setup() -> Result<()> {
op::assert_signed_in()?;
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 ensure_vault(prot: &mut ProtData) -> Result<String> {
if let Some(v) = &prot.vault {
return Ok(v.clone());
}
let id = match op::find_vault(VAULT_NAME)? {
Some(found) => found,
None => {
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
}
};
prot.vault = Some(id.clone());
Ok(id)
}
pub fn lock(cwd: &Path, keep: bool) -> Result<()> {
op::assert_signed_in()?;
let file = prot_path(cwd);
let mut prot = match prot::read(&file)? {
Some(p) => p,
None => {
let p = ProtData::empty();
prot::write(&file, &p)?;
println!("Created {PROT_FILE} (protecting: {}).", p.patterns.join(", "));
p
}
};
let vault = ensure_vault(&mut prot)?;
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 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, &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)?;
if keep {
println!(" uploaded {rel_file} -> 1Password (kept on disk)");
} else {
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(cwd: &Path) -> Result<()> {
op::assert_signed_in()?;
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.");
}
let vault = ensure_vault(&mut prot)?;
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(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(path)?;
f.write_all(content)?;
Ok(())
}
pub fn toggle(cwd: &Path, keep: bool) -> 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(cwd, keep),
};
let mut present = 0;
let mut absent = 0;
for (rel_file, _) in &prot.documents {
if file_exists(&cwd.join(rel_file)) {
present += 1;
} else {
absent += 1;
}
}
if present > 0 && absent > 0 {
bail!(
"Mixed state: {present} recorded file(s) are present and {absent} are missing. \
Use `dotprot lock` or `dotprot unlock` explicitly to resolve the ambiguity."
);
}
if absent > 0 {
unlock(cwd)
} else {
lock(cwd, keep)
}
}