Skip to main content

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}