Files
PiCloud/crates/picloud-cli/src/linkstate.rs
MechaCat02 193336a8a6 feat(hierarchies): multi-repo single-owner ownership + structural prune (M3)
§7 of the groups/project-tool design. A group node is now authoritatively
managed by exactly one project-root; `pic apply --dir` claims, conflicts,
takes over, and (with --prune) structurally reaps owned nodes.

- Migration 0052: `projects(id, key UNIQUE)` + promote the inert
  `groups.owner_project` (0047) to a real FK (ON DELETE SET NULL) + index.
- CLI mints a stable, gitignored project key in `.picloud/project.json`
  (`pic init`, or lazily on first tree plan/apply) and presents it on every
  tree request; `pic apply --dir --takeover` flag.
- Server: prepare_tree resolves ownership read-only (plan surfaces conflicts
  + prune candidates; token folds each group's owner key). apply_tree upserts
  the project in-tx, claims created groups on insert, reconciles existing-group
  ownership under the per-node advisory lock (first-commit-wins), and prunes
  owned-but-undeclared groups leaf-first (delete=RESTRICT, never another repo's
  or a UI-owned node).
- Authz (§7.4, ownership ⟂ RBAC): takeover requires GroupAdmin per contested
  node — enforced in authz_tree (pre-tx) AND re-verified in-tx at the ownership
  decision, so --force (which waives the staleness token) can't open a
  takeover-without-admin window. The attacker-supplied project key is
  length/charset-validated server-side.
- Tests: tests/ownership.rs (claim → conflict → takeover → flip; non-admin
  takeover → 403; prune-owned-only); format_conflicts unit test; schema golden
  reblessed. tree_shape M2 journey updated to reparent in-place (a fresh dir is
  now a distinct project and would correctly conflict).

Closes the M3 milestone; reviewed (4 findings: 1 authz-bypass via --force +
key validation + 2 LOW, all fixed). M4 (trigger/route templates) remains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 23:04:53 +02:00

121 lines
4.6 KiB
Rust

//! `.picloud/` link state — gitignored, per-project metadata the project tool
//! carries between CLI invocations. Today it holds just the bound-plan token:
//! `pic plan` records the fingerprint of the live state it diffed against, and
//! `pic apply` replays it so the server can refuse if the app moved underneath.
//!
//! All paths are relative to the manifest's directory (the project root).
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
const DIR: &str = ".picloud";
const PLAN_FILE: &str = "plan.json";
const PROJECT_FILE: &str = "project.json";
/// The repo's stable project identity (§7, M3): a random, opaque key minted on
/// first need and persisted (gitignored) in `.picloud/`. It is the server-side
/// ownership handle — the same repo keeps the same project across clones/CI
/// because the key travels in the working tree, never in a committed file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectLink {
pub key: String,
}
fn project_path(base: &Path) -> PathBuf {
base.join(DIR).join(PROJECT_FILE)
}
/// Read the project key, if one has been minted under `base/.picloud/`.
#[must_use]
pub fn read_project_key(base: &Path) -> Option<String> {
let body = fs::read(project_path(base)).ok()?;
serde_json::from_slice::<ProjectLink>(&body)
.ok()
.map(|l| l.key)
}
/// Read the project key, minting and persisting a fresh one if absent. Used by
/// the tree `plan`/`apply` paths so a repo that never ran `pic init` still gets
/// a stable ownership identity on first use. The new key is a random v4 UUID.
pub fn ensure_project_key(base: &Path) -> Result<String> {
if let Some(k) = read_project_key(base) {
return Ok(k);
}
let key = uuid::Uuid::new_v4().to_string();
write_project_key(base, &key)?;
Ok(key)
}
/// Persist `key` as the project identity, creating `.picloud/` (self-ignored)
/// if needed. Idempotent overwrite — callers usually go through
/// [`ensure_project_key`].
pub fn write_project_key(base: &Path, key: &str) -> Result<()> {
let dir = base.join(DIR);
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
let ignore = dir.join(".gitignore");
if !ignore.exists() {
fs::write(&ignore, "*\n").context("writing .picloud/.gitignore")?;
}
let body = serde_json::to_vec_pretty(&ProjectLink {
key: key.to_string(),
})
.context("encoding .picloud/project.json")?;
fs::write(project_path(base), body).context("writing .picloud/project.json")?;
Ok(())
}
/// The recorded result of the last `pic plan`, scoped to the app it was for.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanLink {
/// App slug the token belongs to — guards against replaying a token from a
/// different app if the manifest's `slug` changed.
pub app: String,
pub state_token: String,
}
fn plan_path(base: &Path) -> PathBuf {
base.join(DIR).join(PLAN_FILE)
}
/// Record the bound-plan token for `app` under `base/.picloud/`.
pub fn write_plan(base: &Path, app: &str, state_token: &str) -> Result<()> {
let dir = base.join(DIR);
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
// Self-ignore: a `.gitignore` of `*` inside `.picloud/` keeps the whole
// dir out of git regardless of the project root's `.gitignore` — so this
// is safe even when reached via `pic plan`/`pull` (which, unlike `init`,
// don't touch the root `.gitignore`).
let ignore = dir.join(".gitignore");
if !ignore.exists() {
fs::write(&ignore, "*\n").context("writing .picloud/.gitignore")?;
}
let link = PlanLink {
app: app.to_string(),
state_token: state_token.to_string(),
};
let body = serde_json::to_vec_pretty(&link).context("encoding .picloud/plan.json")?;
fs::write(plan_path(base), body).context("writing .picloud/plan.json")?;
Ok(())
}
/// Read the recorded plan token, if any. Returns `None` when absent or
/// unreadable (treated as "no prior plan" — never an error).
#[must_use]
pub fn read_plan(base: &Path) -> Option<PlanLink> {
let body = fs::read(plan_path(base)).ok()?;
serde_json::from_slice(&body).ok()
}
/// Remove the recorded plan token (best-effort) **iff it belongs to `app`**.
/// Called after a successful apply consumes it, so the next apply requires a
/// fresh plan — without clobbering a token recorded for a different app that
/// happens to share the directory.
pub fn clear_plan(base: &Path, app: &str) {
if read_plan(base).is_some_and(|l| l.app == app) {
let _ = fs::remove_file(plan_path(base));
}
}