1use std::path::PathBuf;
4use std::str::FromStr;
5
6use anyhow::{anyhow, bail, Context, Result};
7use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine};
8use chrono::Utc;
9use clap::{ArgAction, Args, Subcommand};
10use memvid_core::types::{MemoryBinding, SignedTicket};
11use memvid_core::{verify_ticket_signature, Memvid, Ticket};
12use serde_json::json;
13use uuid::Uuid;
14
15#[derive(Debug, Clone, Copy)]
17pub struct MemoryId(Uuid);
18
19impl serde::Serialize for MemoryId {
20 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
21 where
22 S: serde::Serializer,
23 {
24 self.0.serialize(serializer)
25 }
26}
27
28impl MemoryId {
29 pub fn as_uuid(&self) -> &Uuid {
30 &self.0
31 }
32}
33
34impl std::ops::Deref for MemoryId {
35 type Target = Uuid;
36 fn deref(&self) -> &Self::Target {
37 &self.0
38 }
39}
40
41impl std::fmt::Display for MemoryId {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 write!(f, "{}", self.0)
44 }
45}
46
47impl FromStr for MemoryId {
48 type Err = String;
49
50 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
51 if let Ok(uuid) = Uuid::parse_str(s) {
53 return Ok(MemoryId(uuid));
54 }
55
56 if s.len() == 24 && s.chars().all(|c| c.is_ascii_hexdigit()) {
58 let padded = format!("{}00000000", s);
60 if let Ok(uuid) = Uuid::parse_str(&padded) {
61 return Ok(MemoryId(uuid));
62 }
63 }
64
65 Err(format!(
66 "invalid memory ID '{}': expected UUID (32 chars) or ObjectId (24 chars)",
67 s
68 ))
69 }
70}
71
72use crate::api::{
73 apply_ticket as api_apply_ticket, fetch_ticket as api_fetch_ticket,
74 ApplyTicketRequest as ApiApplyTicketRequest,
75};
76use crate::commands::{apply_ticket_with_warning, LockCliArgs};
77use crate::config::CliConfig;
78use crate::ticket_cache::{load as load_cached_ticket, store as store_cached_ticket, CachedTicket};
79use crate::utils::{apply_lock_cli, open_read_only_mem};
80
81#[derive(Subcommand)]
83pub enum TicketsCommand {
84 List(TicketsStatusArgs),
86 Issue(TicketsIssueArgs),
88 Revoke(TicketsRevokeArgs),
90 Sync(TicketsSyncArgs),
92 Apply(TicketsApplyArgs),
94}
95
96#[derive(Args)]
98pub struct TicketsArgs {
99 #[command(subcommand)]
100 pub command: TicketsCommand,
101}
102
103#[derive(Args)]
105pub struct TicketsSyncArgs {
106 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
107 pub file: PathBuf,
108 #[arg(
109 long = "memory-id",
110 value_name = "ID",
111 help = "Memory ID (UUID or 24-char ObjectId)"
112 )]
113 pub memory_id: MemoryId,
114 #[arg(long)]
115 pub json: bool,
116
117 #[command(flatten)]
118 pub lock: LockCliArgs,
119}
120
121#[derive(Args)]
123pub struct TicketsApplyArgs {
124 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
125 pub file: PathBuf,
126 #[arg(
127 long = "memory-id",
128 value_name = "ID",
129 help = "Memory ID (UUID or 24-char ObjectId)"
130 )]
131 pub memory_id: MemoryId,
132 #[arg(long = "from-api", action = ArgAction::SetTrue)]
133 pub from_api: bool,
134 #[arg(long)]
135 pub json: bool,
136}
137
138#[derive(Args)]
140pub struct TicketsIssueArgs {
141 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
142 pub file: PathBuf,
143 #[arg(long)]
144 pub issuer: String,
145 #[arg(long, value_name = "SEQ")]
146 pub seq: i64,
147 #[arg(long = "expires-in", value_name = "SECS")]
148 pub expires_in: Option<u64>,
149 #[arg(long, value_name = "BYTES")]
150 pub capacity: Option<u64>,
151 #[arg(long)]
152 pub json: bool,
153
154 #[command(flatten)]
155 pub lock: LockCliArgs,
156}
157
158#[derive(Args)]
160pub struct TicketsRevokeArgs {
161 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
162 pub file: PathBuf,
163 #[arg(long)]
164 pub json: bool,
165
166 #[command(flatten)]
167 pub lock: LockCliArgs,
168}
169
170#[derive(Args)]
172pub struct TicketsStatusArgs {
173 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
174 pub file: PathBuf,
175 #[arg(long)]
176 pub json: bool,
177}
178
179pub fn handle_tickets(config: &CliConfig, args: TicketsArgs) -> Result<()> {
184 match args.command {
185 TicketsCommand::List(status) => handle_ticket_status(status),
186 TicketsCommand::Issue(issue) => handle_ticket_issue(issue),
187 TicketsCommand::Revoke(revoke) => handle_ticket_revoke(revoke),
188 TicketsCommand::Sync(sync) => handle_ticket_sync(config, sync),
189 TicketsCommand::Apply(apply) => handle_ticket_apply(config, apply),
190 }
191}
192
193fn ticket_public_key(config: &CliConfig) -> Result<&ed25519_dalek::VerifyingKey> {
194 config
195 .ticket_pubkey
196 .as_ref()
197 .ok_or_else(|| anyhow!("MEMVID_TICKET_PUBKEY is not set"))
198}
199
200fn ticket_to_json(ticket: &memvid_core::TicketRef) -> serde_json::Value {
201 json!({
202 "issuer": ticket.issuer,
203 "seq_no": ticket.seq_no,
204 "expires_in_secs": ticket.expires_in_secs,
205 "capacity_bytes": ticket.capacity_bytes,
206 "verified": ticket.verified,
207 })
208}
209
210pub fn handle_ticket_status(args: TicketsStatusArgs) -> Result<()> {
211 let mem = open_read_only_mem(&args.file)?;
212 let ticket = mem.current_ticket();
213 if args.json {
214 println!(
215 "{}",
216 serde_json::to_string_pretty(&ticket_to_json(&ticket))?
217 );
218 } else {
219 println!("Ticket issuer: {}", ticket.issuer);
220 println!("Sequence: {}", ticket.seq_no);
221 println!("Expires in (secs): {}", ticket.expires_in_secs);
222 if ticket.capacity_bytes != 0 {
223 println!("Capacity bytes: {}", ticket.capacity_bytes);
224 } else {
225 println!("Capacity bytes: (tier default)");
226 }
227 }
228 Ok(())
229}
230
231pub fn handle_ticket_issue(args: TicketsIssueArgs) -> Result<()> {
232 let mut mem = Memvid::open(&args.file)?;
233 apply_lock_cli(&mut mem, &args.lock);
234 let mut ticket = Ticket::new(&args.issuer, args.seq);
235 if let Some(expires) = args.expires_in {
236 ticket = ticket.expires_in_secs(expires);
237 }
238 if let Some(capacity) = args.capacity {
239 ticket = ticket.capacity_bytes(capacity);
240 }
241 apply_ticket_with_warning(&mut mem, ticket)?;
242 let ticket = mem.current_ticket();
243 if args.json {
244 println!(
245 "{}",
246 serde_json::to_string_pretty(&ticket_to_json(&ticket))?
247 );
248 } else {
249 println!(
250 "Applied ticket seq={} issuer={}",
251 ticket.seq_no, ticket.issuer
252 );
253 }
254 Ok(())
255}
256
257pub fn handle_ticket_revoke(args: TicketsRevokeArgs) -> Result<()> {
258 let mut mem = Memvid::open(&args.file)?;
259 apply_lock_cli(&mut mem, &args.lock);
260 let current = mem.current_ticket();
261 let next_seq = current.seq_no.saturating_add(1).max(1);
262 let ticket = Ticket::new("", next_seq);
263 apply_ticket_with_warning(&mut mem, ticket)?;
264 let ticket = mem.current_ticket();
265 if args.json {
266 println!(
267 "{}",
268 serde_json::to_string_pretty(&ticket_to_json(&ticket))?
269 );
270 } else {
271 println!("Ticket cleared; seq advanced to {}", ticket.seq_no);
272 }
273 Ok(())
274}
275
276pub fn handle_ticket_sync(config: &CliConfig, args: TicketsSyncArgs) -> Result<()> {
277 let pubkey = ticket_public_key(config)?;
278 let response = api_fetch_ticket(config, &args.memory_id)?;
279
280 let file_path = std::fs::canonicalize(&args.file)
282 .with_context(|| format!("failed to get absolute path for {:?}", args.file))?;
283 let file_metadata = std::fs::metadata(&args.file)
284 .with_context(|| format!("failed to get file metadata for {:?}", args.file))?;
285 let file_size = file_metadata.len() as i64;
286 let file_name = args
287 .file
288 .file_name()
289 .and_then(|n| n.to_str())
290 .unwrap_or("unknown.mv2");
291 let machine_id = hostname::get()
292 .map(|h| h.to_string_lossy().to_string())
293 .unwrap_or_else(|_| "unknown".to_string());
294 let signature_bytes = BASE64_STANDARD
295 .decode(response.payload.signature.as_bytes())
296 .context("ticket signature is not valid base64")?;
297
298 let issuer = response.payload.issuer.clone();
300 let seq_no = response.payload.sequence;
301 let expires_in = response.payload.expires_in;
302 let capacity_bytes = response.payload.capacity_bytes;
303
304 verify_ticket_signature(
305 pubkey,
306 &args.memory_id,
307 &issuer,
308 seq_no,
309 expires_in,
310 capacity_bytes,
311 &signature_bytes,
312 )
313 .context("ticket signature verification failed")?;
314
315 let mut mem = Memvid::open(&args.file)?;
316 apply_lock_cli(&mut mem, &args.lock);
317
318 let current = mem.current_ticket();
320 if current.seq_no >= seq_no {
321 let capacity = mem.get_capacity();
323
324 let file_path_str = file_path.to_string_lossy();
326 let register_request = crate::api::RegisterFileRequest {
327 file_name,
328 file_path: &file_path_str,
329 file_size,
330 machine_id: &machine_id,
331 };
332 if let Some(api_key) = config.api_key.as_deref() {
333 if let Err(e) =
334 crate::api::register_file(config, &args.memory_id, ®ister_request, api_key)
335 {
336 log::warn!("Failed to register file with dashboard: {}", e);
337 }
338 }
339
340 if args.json {
341 let json = json!({
342 "issuer": current.issuer,
343 "seq_no": current.seq_no,
344 "expires_in": current.expires_in_secs,
345 "capacity_bytes": if current.capacity_bytes > 0 { Some(current.capacity_bytes) } else { None },
346 "memory_id": args.memory_id,
347 "verified": current.verified,
348 "already_bound": true,
349 });
350 println!("{}", serde_json::to_string_pretty(&json)?);
351 } else {
352 let verified_str = if current.verified { " ✓" } else { "" };
353 println!(
354 "Already bound to memory {} (seq={}, issuer={}{})",
355 args.memory_id, current.seq_no, current.issuer, verified_str
356 );
357 println!(
358 "Current capacity: {:.2} GB",
359 capacity as f64 / 1024.0 / 1024.0 / 1024.0
360 );
361 }
362 return Ok(());
363 }
364
365 let file_path_str = file_path.to_string_lossy();
368 let register_request = crate::api::RegisterFileRequest {
369 file_name,
370 file_path: &file_path_str,
371 file_size,
372 machine_id: &machine_id,
373 };
374 if let Some(api_key) = config.api_key.as_deref() {
375 crate::api::register_file(config, &args.memory_id, ®ister_request, api_key)
376 .context("failed to register file with dashboard - this memory may already be bound to another file")?;
377 } else {
378 bail!("API key required for binding. Set MEMVID_API_KEY.");
379 }
380
381 let binding = MemoryBinding {
383 memory_id: *args.memory_id,
384 memory_name: issuer.clone(), bound_at: Utc::now(),
386 api_url: config.api_url.clone(),
387 };
388
389 if mem.get_memory_binding().is_none() {
391 let temp_ticket =
393 Ticket::new(&issuer, seq_no.saturating_sub(1)).expires_in_secs(expires_in);
394 mem.bind_memory(binding, temp_ticket)
395 .context("failed to bind memory")?;
396 }
397
398 let signed_ticket = SignedTicket::new(
400 &issuer,
401 seq_no,
402 expires_in,
403 capacity_bytes,
404 *args.memory_id,
405 signature_bytes,
406 );
407
408 mem.apply_signed_ticket(signed_ticket)
409 .context("failed to apply signed ticket")?;
410 mem.commit()?;
411
412 let cache_entry = CachedTicket {
413 memory_id: *args.memory_id,
414 issuer: issuer.clone(),
415 seq_no,
416 expires_in,
417 capacity_bytes,
418 signature: response.payload.signature.clone(),
419 };
420 store_cached_ticket(config, &cache_entry)?;
421
422 let capacity = mem.get_capacity();
423
424 if args.json {
425 let json = json!({
426 "issuer": issuer,
427 "seq_no": seq_no,
428 "expires_in": expires_in,
429 "capacity_bytes": capacity_bytes,
430 "memory_id": args.memory_id,
431 "request_id": response.request_id,
432 "verified": true,
433 });
434 println!("{}", serde_json::to_string_pretty(&json)?);
435 } else {
436 println!(
437 "Bound to memory {} (seq={}, issuer={}) ✓",
438 args.memory_id, seq_no, issuer
439 );
440 println!(
441 "New capacity: {:.2} GB",
442 capacity as f64 / 1024.0 / 1024.0 / 1024.0
443 );
444 }
445 Ok(())
446}
447
448pub fn handle_ticket_apply(config: &CliConfig, args: TicketsApplyArgs) -> Result<()> {
449 if !args.from_api {
450 bail!("use --from-api to submit the ticket fetched from the API");
451 }
452 let cached = load_cached_ticket(config, &args.memory_id)
453 .context("no cached ticket available; run `tickets sync` first")?;
454 let request = ApiApplyTicketRequest {
455 issuer: &cached.issuer,
456 seq_no: cached.seq_no,
457 expires_in: cached.expires_in,
458 capacity_bytes: cached.capacity_bytes,
459 signature: &cached.signature,
460 };
461 let request_id = api_apply_ticket(config, &args.memory_id, &request)?;
462 if args.json {
463 let json = json!({
464 "memory_id": args.memory_id,
465 "seq_no": cached.seq_no,
466 "request_id": request_id,
467 });
468 println!("{}", serde_json::to_string_pretty(&json)?);
469 } else {
470 println!(
471 "Submitted ticket seq={} for memory {} (request {})",
472 cached.seq_no, args.memory_id, request_id
473 );
474 }
475 Ok(())
476}