suno_core/identity.rs
1//! Account-identity guard: the trust-on-first-use pin that arms deletion.
2//!
3//! A library is pinned (trust on first use) to the Suno account it is first
4//! synced against; a later run against a different account refuses rather than
5//! treating that account's clips as absent from source and deleting the pinned
6//! account's files. [`owner_gate`] is the PHASE 1 verdict for an authenticated
7//! account and [`adopt_decision`] the PHASE 2 first-use adoption decision; both
8//! are pure so the full matrix is unit-tested here rather than inline in the
9//! CLI. The pin itself is the [`Owner`] on the durable
10//! [`LineageStore`](crate::LineageStore).
11
12use std::collections::BTreeSet;
13
14use serde::{Deserialize, Serialize};
15
16use crate::LineageStore;
17
18/// The identity guard pins a library to the account it is first synced against
19/// and refuses to run it against a different account, so a mistyped or swapped
20/// token can never make one account's clips look absent from source and delete
21/// another account's files. `user_id` is the stable identity; `display_name`
22/// is cosmetic (for messages) and refreshed opportunistically on a match.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Owner {
25 pub user_id: String,
26 pub display_name: String,
27}
28
29/// The PHASE 1 identity verdict: whether an authenticated account may run
30/// against a library, computed with no network (see [`owner_gate`]).
31///
32/// This is the composition that gates deletion, kept pure so the full matrix
33/// (including the lock-in cases where a configured id or the owner pin refuses
34/// even when `--allow-account-change` is set) is unit-tested here rather than
35/// inline in the CLI.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum OwnerGate {
38 /// A configured `account_id` differs from the authenticated id: always
39 /// refuse, regardless of `--allow-account-change`.
40 AbortConfigMismatch,
41 /// The pinned owner differs and re-pinning was not permitted: refuse.
42 AbortMismatch,
43 /// The pinned owner differs but re-pinning was permitted: pin the new owner
44 /// and run additively (no deletions this invocation).
45 Repin,
46 /// The authenticated account owns this library: proceed (the caller then
47 /// refreshes the pinned display name).
48 Proceed,
49 /// The library is not pinned yet: defer to the PHASE 2 adoption decision.
50 FirstUse,
51}
52
53impl OwnerGate {
54 /// Whether this outcome forces an additive (no-deletion) run.
55 pub fn is_additive(self) -> bool {
56 matches!(self, OwnerGate::Repin)
57 }
58}
59
60/// Decide whether an authenticated account may run against a library (PHASE 1).
61///
62/// A configured `account_id` that differs always aborts, even with
63/// `allow_change` set, because it is an explicit operator assertion. Otherwise
64/// an unpinned library defers to first-use adoption, a matching owner proceeds,
65/// and a differing owner either re-pins (when `allow_change`) or aborts.
66pub fn owner_gate(
67 store_owner: Option<&Owner>,
68 configured_id: Option<&str>,
69 authed_user_id: &str,
70 allow_change: bool,
71) -> OwnerGate {
72 if let Some(configured) = configured_id
73 && configured != authed_user_id
74 {
75 return OwnerGate::AbortConfigMismatch;
76 }
77 match store_owner {
78 None => OwnerGate::FirstUse,
79 Some(owner) if owner.user_id == authed_user_id => OwnerGate::Proceed,
80 Some(_) if allow_change => OwnerGate::Repin,
81 Some(_) => OwnerGate::AbortMismatch,
82 }
83}
84
85/// The PHASE 2 first-use adoption decision for a not-yet-pinned library.
86///
87/// Computed by [`adopt_decision`] from the account's listed clip ids, the
88/// library's already-owned clip ids, whether the listing is complete, and
89/// whether `--allow-account-change` was passed.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum AdoptDecision {
92 /// The destination holds no clips yet: pin it as a fresh library (normal
93 /// mode; a fresh library has nothing to delete).
94 PinFresh,
95 /// A complete listing overlaps the existing library: same account, pin it
96 /// (normal mode).
97 PinAdopt,
98 /// A complete listing shares nothing with the existing library but
99 /// `--allow-account-change` was passed: adopt it and run additively.
100 AdoptForced,
101 /// A complete listing shares nothing with the existing library and no
102 /// override was passed: refuse.
103 Abort,
104 /// A narrowed (incomplete) listing cannot confirm identity: do not pin.
105 SkipPin,
106}
107
108impl AdoptDecision {
109 /// Whether this outcome forces an additive (no-deletion) run.
110 pub fn is_additive(self) -> bool {
111 matches!(self, AdoptDecision::AdoptForced)
112 }
113}
114
115/// Decide how to adopt a not-yet-pinned library from this run's listing.
116///
117/// An empty library is adopted outright; otherwise identity is confirmed by an
118/// overlap between the authenticated account's `listed` clip ids and the
119/// library's `owned` clip ids, but only on a fully `enumerated` listing. A
120/// complete listing with no overlap is a different (or wiped) account: it
121/// refuses, unless `allow_change` opts into a forced additive adoption. A
122/// narrowed listing (a `--limit`/`--since` run, where deletion is disabled
123/// anyway) cannot confirm identity, so the library is left unpinned.
124pub fn adopt_decision(
125 listed: &[&str],
126 owned: &BTreeSet<&str>,
127 enumerated: bool,
128 allow_change: bool,
129) -> AdoptDecision {
130 if owned.is_empty() {
131 return AdoptDecision::PinFresh;
132 }
133 if !enumerated {
134 return AdoptDecision::SkipPin;
135 }
136 if listed.iter().any(|id| owned.contains(id)) {
137 AdoptDecision::PinAdopt
138 } else if allow_change {
139 AdoptDecision::AdoptForced
140 } else {
141 AdoptDecision::Abort
142 }
143}
144
145impl LineageStore {
146 /// The account this library is pinned to, if any.
147 pub fn owner(&self) -> Option<&Owner> {
148 self.owner.as_ref()
149 }
150
151 /// Pin this library to `owner`, replacing any prior pin.
152 pub fn pin_owner(&mut self, owner: Owner) {
153 self.owner = Some(owner);
154 }
155
156 /// Refresh the pinned owner's display name when it has changed, returning
157 /// whether it changed. A no-op when the library is not pinned.
158 pub fn refresh_display_name(&mut self, display_name: &str) -> bool {
159 match &mut self.owner {
160 Some(owner) if owner.display_name != display_name => {
161 owner.display_name = display_name.to_owned();
162 true
163 }
164 _ => false,
165 }
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 fn owner(id: &str, name: &str) -> Owner {
174 Owner {
175 user_id: id.to_owned(),
176 display_name: name.to_owned(),
177 }
178 }
179
180 #[test]
181 fn refresh_display_name_only_when_changed_and_never_when_unpinned() {
182 let mut store = LineageStore::new();
183 // Unpinned: nothing to refresh.
184 assert!(!store.refresh_display_name("Alice"));
185 assert!(store.owner().is_none());
186
187 store.pin_owner(owner("user_a", "Alice"));
188 // Same name is a no-op.
189 assert!(!store.refresh_display_name("Alice"));
190 // A changed name updates and reports the change.
191 assert!(store.refresh_display_name("Alice Cooper"));
192 assert_eq!(store.owner().unwrap().display_name, "Alice Cooper");
193 // The user id is left untouched.
194 assert_eq!(store.owner().unwrap().user_id, "user_a");
195 }
196
197 #[test]
198 fn owner_gate_covers_the_full_matrix() {
199 let alice = owner("user_a", "Alice");
200
201 // Unpinned defers to first-use, regardless of the flag.
202 assert_eq!(owner_gate(None, None, "user_a", false), OwnerGate::FirstUse);
203 assert_eq!(owner_gate(None, None, "user_a", true), OwnerGate::FirstUse);
204
205 // A matching owner proceeds.
206 assert_eq!(
207 owner_gate(Some(&alice), None, "user_a", false),
208 OwnerGate::Proceed
209 );
210
211 // A differing owner aborts without the flag, re-pins with it.
212 assert_eq!(
213 owner_gate(Some(&alice), None, "user_b", false),
214 OwnerGate::AbortMismatch
215 );
216 assert_eq!(
217 owner_gate(Some(&alice), None, "user_b", true),
218 OwnerGate::Repin
219 );
220
221 // A configured id that differs ALWAYS aborts, even with the flag and
222 // even on a first-use (unpinned) library.
223 assert_eq!(
224 owner_gate(Some(&alice), Some("user_c"), "user_a", true),
225 OwnerGate::AbortConfigMismatch
226 );
227 assert_eq!(
228 owner_gate(None, Some("user_c"), "user_a", true),
229 OwnerGate::AbortConfigMismatch
230 );
231 // A configured id that matches does not interfere.
232 assert_eq!(
233 owner_gate(Some(&alice), Some("user_a"), "user_a", false),
234 OwnerGate::Proceed
235 );
236
237 // Only Repin is additive.
238 assert!(OwnerGate::Repin.is_additive());
239 for gate in [
240 OwnerGate::AbortConfigMismatch,
241 OwnerGate::AbortMismatch,
242 OwnerGate::Proceed,
243 OwnerGate::FirstUse,
244 ] {
245 assert!(!gate.is_additive());
246 }
247 }
248
249 #[test]
250 fn adopt_decision_covers_every_branch() {
251 let owned: BTreeSet<&str> = ["c1", "c2"].into_iter().collect();
252 let empty: BTreeSet<&str> = BTreeSet::new();
253
254 // Empty library adopts outright regardless of the listing or the flag.
255 assert_eq!(
256 adopt_decision(&["x", "y"], &empty, true, false),
257 AdoptDecision::PinFresh
258 );
259 // Non-empty but not enumerated: cannot confirm, so leave it unpinned.
260 assert_eq!(
261 adopt_decision(&["c1"], &owned, false, false),
262 AdoptDecision::SkipPin
263 );
264 assert_eq!(
265 adopt_decision(&["c1"], &owned, false, true),
266 AdoptDecision::SkipPin
267 );
268 // Enumerated with overlap: same account, adopt in normal mode.
269 assert_eq!(
270 adopt_decision(&["c1", "z"], &owned, true, false),
271 AdoptDecision::PinAdopt
272 );
273 // Enumerated with no overlap: refuse without the flag, force-adopt with.
274 assert_eq!(
275 adopt_decision(&["z1", "z2"], &owned, true, false),
276 AdoptDecision::Abort
277 );
278 assert_eq!(
279 adopt_decision(&["z1", "z2"], &owned, true, true),
280 AdoptDecision::AdoptForced
281 );
282
283 // Only the forced adoption is additive.
284 assert!(AdoptDecision::AdoptForced.is_additive());
285 for decision in [
286 AdoptDecision::PinFresh,
287 AdoptDecision::PinAdopt,
288 AdoptDecision::Abort,
289 AdoptDecision::SkipPin,
290 ] {
291 assert!(!decision.is_additive());
292 }
293 }
294
295 #[test]
296 fn older_store_without_owner_loads_as_none_and_pinned_roundtrips() {
297 // A store written before the owner field existed loads with owner None.
298 let json = r#"{"nodes":{},"edges":[]}"#;
299 let store: LineageStore = serde_json::from_str(json).unwrap();
300 assert!(store.owner().is_none());
301 // An unpinned store omits the field entirely (skip_serializing_if).
302 let value = serde_json::to_value(&store).unwrap();
303 assert!(value.get("owner").is_none());
304
305 // A pinned store round-trips and serialises the owner.
306 let mut pinned = LineageStore::new();
307 pinned.pin_owner(owner("user_a", "Alice"));
308 let back: LineageStore =
309 serde_json::from_str(&serde_json::to_string(&pinned).unwrap()).unwrap();
310 assert_eq!(back, pinned);
311 assert_eq!(back.owner().unwrap().user_id, "user_a");
312 }
313}