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(long = "memory-id", value_name = "ID", help = "Memory ID (UUID or 24-char ObjectId)")]
109 pub memory_id: MemoryId,
110 #[arg(long)]
111 pub json: bool,
112
113 #[command(flatten)]
114 pub lock: LockCliArgs,
115}
116
117#[derive(Args)]
119pub struct TicketsApplyArgs {
120 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
121 pub file: PathBuf,
122 #[arg(long = "memory-id", value_name = "ID", help = "Memory ID (UUID or 24-char ObjectId)")]
123 pub memory_id: MemoryId,
124 #[arg(long = "from-api", action = ArgAction::SetTrue)]
125 pub from_api: bool,
126 #[arg(long)]
127 pub json: bool,
128}
129
130#[derive(Args)]
132pub struct TicketsIssueArgs {
133 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
134 pub file: PathBuf,
135 #[arg(long)]
136 pub issuer: String,
137 #[arg(long, value_name = "SEQ")]
138 pub seq: i64,
139 #[arg(long = "expires-in", value_name = "SECS")]
140 pub expires_in: Option<u64>,
141 #[arg(long, value_name = "BYTES")]
142 pub capacity: Option<u64>,
143 #[arg(long)]
144 pub json: bool,
145
146 #[command(flatten)]
147 pub lock: LockCliArgs,
148}
149
150#[derive(Args)]
152pub struct TicketsRevokeArgs {
153 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
154 pub file: PathBuf,
155 #[arg(long)]
156 pub json: bool,
157
158 #[command(flatten)]
159 pub lock: LockCliArgs,
160}
161
162#[derive(Args)]
164pub struct TicketsStatusArgs {
165 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
166 pub file: PathBuf,
167 #[arg(long)]
168 pub json: bool,
169}
170
171pub fn handle_tickets(config: &CliConfig, args: TicketsArgs) -> Result<()> {
176 match args.command {
177 TicketsCommand::List(status) => handle_ticket_status(status),
178 TicketsCommand::Issue(issue) => handle_ticket_issue(issue),
179 TicketsCommand::Revoke(revoke) => handle_ticket_revoke(revoke),
180 TicketsCommand::Sync(sync) => handle_ticket_sync(config, sync),
181 TicketsCommand::Apply(apply) => handle_ticket_apply(config, apply),
182 }
183}
184
185fn ticket_public_key(config: &CliConfig) -> Result<&ed25519_dalek::VerifyingKey> {
186 config
187 .ticket_pubkey
188 .as_ref()
189 .ok_or_else(|| anyhow!("MEMVID_TICKET_PUBKEY is not set"))
190}
191
192fn ticket_to_json(ticket: &memvid_core::TicketRef) -> serde_json::Value {
193 json!({
194 "issuer": ticket.issuer,
195 "seq_no": ticket.seq_no,
196 "expires_in_secs": ticket.expires_in_secs,
197 "capacity_bytes": ticket.capacity_bytes,
198 "verified": ticket.verified,
199 })
200}
201
202pub fn handle_ticket_status(args: TicketsStatusArgs) -> Result<()> {
203 let mem = open_read_only_mem(&args.file)?;
204 let ticket = mem.current_ticket();
205 if args.json {
206 println!(
207 "{}",
208 serde_json::to_string_pretty(&ticket_to_json(&ticket))?
209 );
210 } else {
211 println!("Ticket issuer: {}", ticket.issuer);
212 println!("Sequence: {}", ticket.seq_no);
213 println!("Expires in (secs): {}", ticket.expires_in_secs);
214 if ticket.capacity_bytes != 0 {
215 println!("Capacity bytes: {}", ticket.capacity_bytes);
216 } else {
217 println!("Capacity bytes: (tier default)");
218 }
219 }
220 Ok(())
221}
222
223pub fn handle_ticket_issue(args: TicketsIssueArgs) -> Result<()> {
224 let mut mem = Memvid::open(&args.file)?;
225 apply_lock_cli(&mut mem, &args.lock);
226 let mut ticket = Ticket::new(&args.issuer, args.seq);
227 if let Some(expires) = args.expires_in {
228 ticket = ticket.expires_in_secs(expires);
229 }
230 if let Some(capacity) = args.capacity {
231 ticket = ticket.capacity_bytes(capacity);
232 }
233 apply_ticket_with_warning(&mut mem, ticket)?;
234 let ticket = mem.current_ticket();
235 if args.json {
236 println!(
237 "{}",
238 serde_json::to_string_pretty(&ticket_to_json(&ticket))?
239 );
240 } else {
241 println!(
242 "Applied ticket seq={} issuer={}",
243 ticket.seq_no, ticket.issuer
244 );
245 }
246 Ok(())
247}
248
249pub fn handle_ticket_revoke(args: TicketsRevokeArgs) -> Result<()> {
250 let mut mem = Memvid::open(&args.file)?;
251 apply_lock_cli(&mut mem, &args.lock);
252 let current = mem.current_ticket();
253 let next_seq = current.seq_no.saturating_add(1).max(1);
254 let ticket = Ticket::new("", next_seq);
255 apply_ticket_with_warning(&mut mem, ticket)?;
256 let ticket = mem.current_ticket();
257 if args.json {
258 println!(
259 "{}",
260 serde_json::to_string_pretty(&ticket_to_json(&ticket))?
261 );
262 } else {
263 println!("Ticket cleared; seq advanced to {}", ticket.seq_no);
264 }
265 Ok(())
266}
267
268pub fn handle_ticket_sync(config: &CliConfig, args: TicketsSyncArgs) -> Result<()> {
269 let pubkey = ticket_public_key(config)?;
270 let response = api_fetch_ticket(config, &args.memory_id)?;
271
272 let file_path = std::fs::canonicalize(&args.file)
274 .with_context(|| format!("failed to get absolute path for {:?}", args.file))?;
275 let file_metadata = std::fs::metadata(&args.file)
276 .with_context(|| format!("failed to get file metadata for {:?}", args.file))?;
277 let file_size = file_metadata.len() as i64;
278 let file_name = args
279 .file
280 .file_name()
281 .and_then(|n| n.to_str())
282 .unwrap_or("unknown.mv2");
283 let machine_id = hostname::get()
284 .map(|h| h.to_string_lossy().to_string())
285 .unwrap_or_else(|_| "unknown".to_string());
286 let signature_bytes = BASE64_STANDARD
287 .decode(response.payload.signature.as_bytes())
288 .context("ticket signature is not valid base64")?;
289
290 let issuer = response.payload.issuer.clone();
292 let seq_no = response.payload.sequence;
293 let expires_in = response.payload.expires_in;
294 let capacity_bytes = response.payload.capacity_bytes;
295
296 verify_ticket_signature(
297 pubkey,
298 &args.memory_id,
299 &issuer,
300 seq_no,
301 expires_in,
302 capacity_bytes,
303 &signature_bytes,
304 )
305 .context("ticket signature verification failed")?;
306
307 let mut mem = Memvid::open(&args.file)?;
308 apply_lock_cli(&mut mem, &args.lock);
309
310 let current = mem.current_ticket();
312 if current.seq_no >= seq_no {
313 let capacity = mem.get_capacity();
315
316 let file_path_str = file_path.to_string_lossy();
318 let register_request = crate::api::RegisterFileRequest {
319 file_name,
320 file_path: &file_path_str,
321 file_size,
322 machine_id: &machine_id,
323 };
324 if let Some(api_key) = config.api_key.as_deref() {
325 if let Err(e) = crate::api::register_file(config, &args.memory_id, ®ister_request, api_key) {
326 log::warn!("Failed to register file with dashboard: {}", e);
327 }
328 }
329
330 if args.json {
331 let json = json!({
332 "issuer": current.issuer,
333 "seq_no": current.seq_no,
334 "expires_in": current.expires_in_secs,
335 "capacity_bytes": if current.capacity_bytes > 0 { Some(current.capacity_bytes) } else { None },
336 "memory_id": args.memory_id,
337 "verified": current.verified,
338 "already_bound": true,
339 });
340 println!("{}", serde_json::to_string_pretty(&json)?);
341 } else {
342 let verified_str = if current.verified { " ✓" } else { "" };
343 println!(
344 "Already bound to memory {} (seq={}, issuer={}{})",
345 args.memory_id, current.seq_no, current.issuer, verified_str
346 );
347 println!(
348 "Current capacity: {:.2} GB",
349 capacity as f64 / 1024.0 / 1024.0 / 1024.0
350 );
351 }
352 return Ok(());
353 }
354
355 let file_path_str = file_path.to_string_lossy();
358 let register_request = crate::api::RegisterFileRequest {
359 file_name,
360 file_path: &file_path_str,
361 file_size,
362 machine_id: &machine_id,
363 };
364 if let Some(api_key) = config.api_key.as_deref() {
365 crate::api::register_file(config, &args.memory_id, ®ister_request, api_key)
366 .context("failed to register file with dashboard - this memory may already be bound to another file")?;
367 } else {
368 bail!("API key required for binding. Set MEMVID_API_KEY.");
369 }
370
371 let binding = MemoryBinding {
373 memory_id: *args.memory_id,
374 memory_name: issuer.clone(), bound_at: Utc::now(),
376 api_url: config.api_url.clone(),
377 };
378
379 if mem.get_memory_binding().is_none() {
381 let temp_ticket = Ticket::new(&issuer, seq_no.saturating_sub(1)).expires_in_secs(expires_in);
383 mem.bind_memory(binding, temp_ticket)
384 .context("failed to bind memory")?;
385 }
386
387 let signed_ticket = SignedTicket::new(
389 &issuer,
390 seq_no,
391 expires_in,
392 capacity_bytes,
393 *args.memory_id,
394 signature_bytes,
395 );
396
397 mem.apply_signed_ticket(signed_ticket)
398 .context("failed to apply signed ticket")?;
399 mem.commit()?;
400
401 let cache_entry = CachedTicket {
402 memory_id: *args.memory_id,
403 issuer: issuer.clone(),
404 seq_no,
405 expires_in,
406 capacity_bytes,
407 signature: response.payload.signature.clone(),
408 };
409 store_cached_ticket(config, &cache_entry)?;
410
411 let capacity = mem.get_capacity();
412
413 if args.json {
414 let json = json!({
415 "issuer": issuer,
416 "seq_no": seq_no,
417 "expires_in": expires_in,
418 "capacity_bytes": capacity_bytes,
419 "memory_id": args.memory_id,
420 "request_id": response.request_id,
421 "verified": true,
422 });
423 println!("{}", serde_json::to_string_pretty(&json)?);
424 } else {
425 println!(
426 "Bound to memory {} (seq={}, issuer={}) ✓",
427 args.memory_id, seq_no, issuer
428 );
429 println!(
430 "New capacity: {:.2} GB",
431 capacity as f64 / 1024.0 / 1024.0 / 1024.0
432 );
433 }
434 Ok(())
435}
436
437pub fn handle_ticket_apply(config: &CliConfig, args: TicketsApplyArgs) -> Result<()> {
438 if !args.from_api {
439 bail!("use --from-api to submit the ticket fetched from the API");
440 }
441 let cached = load_cached_ticket(config, &args.memory_id)
442 .context("no cached ticket available; run `tickets sync` first")?;
443 let request = ApiApplyTicketRequest {
444 issuer: &cached.issuer,
445 seq_no: cached.seq_no,
446 expires_in: cached.expires_in,
447 capacity_bytes: cached.capacity_bytes,
448 signature: &cached.signature,
449 };
450 let request_id = api_apply_ticket(config, &args.memory_id, &request)?;
451 if args.json {
452 let json = json!({
453 "memory_id": args.memory_id,
454 "seq_no": cached.seq_no,
455 "request_id": request_id,
456 });
457 println!("{}", serde_json::to_string_pretty(&json)?);
458 } else {
459 println!(
460 "Submitted ticket seq={} for memory {} (request {})",
461 cached.seq_no, args.memory_id, request_id
462 );
463 }
464 Ok(())
465}