use std::fs;
use std::io::{ErrorKind, Write as _};
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use similar::{Algorithm, udiff::unified_diff};
use crate::VERSION;
use crate::cli::UpgradeArgs;
use crate::context::read_policy;
use crate::exit::Code;
use crate::home::{Home, write_private};
use crate::integrity::{self, HookPins};
use crate::render::{DIFF_CONTEXT_LINES, Deferred};
use crate::upgrade::{Base, Plan};
pub fn run(args: &UpgradeArgs) -> Result<Code> {
let home = Home::locate()?;
if !home.policy_path().is_file() {
bail!(
"no policy at {}; run `openmoat init`",
home.policy_path().display()
);
}
if args.undo {
return undo(&home, args.yes);
}
let plan = Plan::for_home(&home)?;
let mut out = Deferred::default();
describe(&plan, &mut out)?;
let binary = crate::install::hook_binary()?;
let hook_upgrade = crate::install::migrate_hooks::needed(&home, &binary)?;
if hook_upgrade {
writeln!(
out,
"Hook update required; after upgrading, review and trust changed OpenMoat hooks in your agent"
)?;
}
if plan.applicable() == 0 || args.dry_run {
if hook_upgrade && !args.dry_run {
person("openmoat policy upgrade")?;
integrity::refuse_drift(&home, "upgrade existing hooks")?;
crate::install::migrate_hooks::update(&home, &binary)?;
integrity::repin(&home, &binary, HookPins::Keep)?;
}
if !args.dry_run && up_to_date_without_record(&plan) && crate::terminal::person() {
write_private(&home.policy_base_path(), plan.merge.base.as_bytes())?;
}
out.finish()?;
return Ok(Code::Ok);
}
person("openmoat policy upgrade")?;
integrity::refuse_drift(&home, "upgrade the policy")?;
lint(&home, &plan.merge.text)?;
let count = plan.applicable();
let question = format!("Apply {count} change{}? [y/N] ", plural(count));
if !args.yes && !crate::commands::edit::confirm(&mut out, &question)? {
writeln!(out, "the policy was not changed")?;
out.finish()?;
return Ok(Code::Ok);
}
apply(&home, &plan, &mut out)?;
out.finish()?;
Ok(Code::Ok)
}
fn describe(plan: &Plan, out: &mut Deferred) -> Result<()> {
match plan.base {
Base::Recorded => {}
Base::Assumed => writeln!(
out,
"No record of the default this policy started from; comparing with the \
OpenMoat 0.3.0 default (the policy predates OpenMoat recording it)."
)?,
Base::Unrelated => {
writeln!(
out,
"This policy was not made from OpenMoat's default, so no change of the default \
is offered; compare it with `openmoat policy lint` and the default in docs/POLICY.md."
)?;
if !plan.renamed {
return Ok(());
}
}
}
let count = plan.applicable();
if count > 0 {
let from_default = count - usize::from(plan.renamed);
writeln!(
out,
"Policy update available: {from_default} change{} from the OpenMoat {VERSION} default",
plural(from_default)
)?;
if plan.renamed {
writeln!(
out,
"and 1 rename: `ask` is now `review` (the rule list `ask:` and the verdict `ask`, \
read the same until you rename them)"
)?;
}
let diff = unified_diff(
Algorithm::Myers,
&plan.current,
&plan.merge.text,
DIFF_CONTEXT_LINES,
Some(("policy.yaml (installed)", "policy.yaml (upgraded)")),
);
write!(out, "{diff}")?;
writeln!(
out,
"`openmoat policy upgrade` applies them after asking; `--undo` takes them back."
)?;
}
for change in plan.merge.conflicts() {
writeln!(
out,
"Not applied: you changed the policy at line {}; the default now has:",
change.line
)?;
for line in change.default.lines() {
writeln!(out, " {line}")?;
}
}
if plan.merge.conflicts().next().is_some() {
writeln!(
out,
"Make those by hand with `openmoat edit` if you want them."
)?;
} else if count == 0 {
writeln!(
out,
"The policy has every change of the OpenMoat {VERSION} default."
)?;
}
Ok(())
}
fn up_to_date_without_record(plan: &Plan) -> bool {
plan.base == Base::Assumed && plan.merge.changes.is_empty()
}
fn person(command: &str) -> Result<()> {
crate::terminal::refuse_agent(command)?;
if !crate::terminal::interactive() {
bail!(
"`{command}` must be run by a person in a terminal, not from a hook or script; \
`openmoat policy upgrade --dry-run` shows the changes anywhere"
);
}
Ok(())
}
fn lint(home: &Home, text: &str) -> Result<()> {
let draft = home.root().join("policy.upgrade.yaml");
write_private(&draft, text.as_bytes())?;
let checked = home.load_policy_at(&draft);
let _ = fs::remove_file(&draft);
checked
.map(drop)
.context("the upgraded policy does not lint; the policy was not changed")
}
fn backups(home: &Home) -> (PathBuf, PathBuf) {
let root = home.root();
(
root.join("policy.yaml.before-upgrade"),
root.join("policy.base.yaml.before-upgrade"),
)
}
fn apply(home: &Home, plan: &Plan, out: &mut Deferred) -> Result<()> {
let (policy_backup, base_backup) = backups(home);
write_private(&policy_backup, plan.current.as_bytes())?;
copy_or_remove(&home.policy_base_path(), &base_backup)?;
write_private(&home.policy_path(), plan.merge.text.as_bytes())?;
if plan.base != Base::Unrelated {
write_private(&home.policy_base_path(), plan.merge.base.as_bytes())?;
}
let binary = crate::install::hook_binary()?;
crate::install::migrate_hooks::update(home, &binary)?;
let lock = integrity::repin(home, &binary, HookPins::Keep)?;
let count = plan.applicable();
writeln!(
out,
"✔ {} upgraded ({count} change{})",
home.policy_path().display(),
plural(count)
)?;
writeln!(out, "✔ lock re-pinned ({} files)", lock.entries.len())?;
writeln!(
out,
" the previous policy is in {}; undo: openmoat policy upgrade --undo",
policy_backup.display()
)?;
writeln!(
out,
" run `openmoat sandbox sync` so the agents' sandboxes follow the upgraded policy"
)?;
Ok(())
}
fn undo(home: &Home, yes: bool) -> Result<Code> {
let (policy_backup, base_backup) = backups(home);
if !policy_backup.is_file() {
bail!(
"no upgrade to undo: {} does not exist",
policy_backup.display()
);
}
person("openmoat policy upgrade --undo")?;
integrity::refuse_drift(home, "undo the policy upgrade")?;
let current = read_policy(&home.policy_path())?;
let previous = read_policy(&policy_backup)?;
let mut out = Deferred::default();
let diff = unified_diff(
Algorithm::Myers,
¤t,
&previous,
DIFF_CONTEXT_LINES,
Some((
"policy.yaml (installed)",
"policy.yaml (before the upgrade)",
)),
);
write!(out, "{diff}")?;
if !yes && !crate::commands::edit::confirm(&mut out, "Restore it? [y/N] ")? {
writeln!(out, "the policy was not changed")?;
out.finish()?;
return Ok(Code::Ok);
}
write_private(&home.policy_path(), previous.as_bytes())?;
copy_or_remove(&base_backup, &home.policy_base_path())?;
fs::remove_file(&policy_backup)
.with_context(|| format!("removing {}", policy_backup.display()))?;
remove_if_present(&base_backup)?;
let lock = integrity::repin(home, &crate::install::hook_binary()?, HookPins::Keep)?;
writeln!(
out,
"✔ {} restored to before the upgrade",
home.policy_path().display()
)?;
writeln!(out, "✔ lock re-pinned ({} files)", lock.entries.len())?;
out.finish()?;
Ok(Code::Ok)
}
fn copy_or_remove(from: &Path, to: &Path) -> Result<()> {
if from.is_file() {
write_private(to, read_policy(from)?.as_bytes())
} else {
remove_if_present(to)
}
}
fn remove_if_present(path: &Path) -> Result<()> {
match fs::remove_file(path) {
Err(e) if e.kind() != ErrorKind::NotFound => {
Err(e).with_context(|| format!("removing {}", path.display()))
}
_ => Ok(()),
}
}
fn plural(count: usize) -> &'static str {
if count == 1 { "" } else { "s" }
}