Files
PiCloud/crates/picloud-cli/src/linkstate.rs
MechaCat02 5c33b7490b feat(project-tool): multi-repo single-owner ownership + takeover (§7 M3)
Ports the M3 milestone from the superseded feat/hierarchies branch onto main's
evolved apply engine. A group node is authoritatively managed by at most one
project-root; `pic apply --dir` claims, conflicts, takes over, and (with
--prune) structurally reaps owned nodes.

- Migration 0066: `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; `pic plan --dir` surfaces
  ownership conflicts + structural-prune candidates read-only.
- Server: prepare_tree resolves ownership read-only (token folds each group's
  owner key, so a claim/takeover by another repo between plan and apply trips
  StateMoved). apply_tree upserts the project in-tx, claims unclaimed declared
  groups, and takes over owned ones under `--takeover`. --prune reaps
  owned-but-undeclared groups leaf-first (delete_group_tx 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.

Adaptation to main (which lacks the superseded branch's M2 declarative group
create/reparent): groups pre-exist, so ownership stamps existing declared
nodes rather than claim-on-create; the declarative attach-point is deferred.

Review fixes (adversarial pass, 3 findings, all closed):
- HIGH: an empty `[group]` node was claimed capability-free — require a
  baseline GroupScriptsWrite (editor) on every group apply node, so claiming
  ownership needs write authority (Ownership ⟂ RBAC).
- MEDIUM: structural prune would silently CASCADE a group's §11.6 shared
  collections + secrets (which postdate M3's original design). Guard: a prune
  candidate holding shared data/secrets is KEPT with a warning (plus its
  candidate ancestors, so a preserved child never aborts a parent delete).
- LOW: a corrupt `.picloud/project.json` silently re-minted a new key
  (orphaning ownership) — now fails loudly if the file exists but won't parse.

Tests: tests/ownership.rs (claim → conflict → takeover → flip; non-admin
takeover → 403; prune-owned-only; prune-refuses-to-cascade-shared-data);
format_conflicts + validate_project_key unit tests; schema golden reblessed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:29:12 +02:00

133 lines
5.2 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);
}
// A present-but-unreadable/corrupt `project.json` must NOT be silently
// re-minted: a fresh key would orphan every group this repo already claimed
// (the old key is lost, so the next apply hits an ownership conflict). Fail
// loudly and let the operator restore or intentionally re-key it.
let path = project_path(base);
if path.exists() {
anyhow::bail!(
"{} exists but could not be parsed as a project key; refusing to mint a new one \
(a new key would orphan this repo's group ownership). Restore or delete it.",
path.display()
);
}
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));
}
}