File Coverage

lib/Apophis.xs
Criterion Covered Total %
statement 421 641 65.6
branch 158 312 50.6
condition n/a
subroutine n/a
pod n/a
total 579 953 60.7


line stmt bran cond sub pod time code
1             /*
2             * Apophis.xs - Content-addressable storage with deterministic UUID v5
3             *
4             * Named after Apophis, the Egyptian serpent of chaos — here tamed
5             * to bring order to content through deterministic hashing.
6             *
7             * 100% XS: all logic in C, Perl layer is just XSLoader.
8             * Uses Horus library for RFC 9562 UUID v5 (SHA-1 namespace) generation.
9             */
10              
11             #define PERL_NO_GET_CONTEXT
12             #include "EXTERN.h"
13             #include "perl.h"
14             #include "XSUB.h"
15              
16             #include "../ppport.h"
17              
18             #include
19             #include
20             #include
21              
22             /* Horus UUID library - pure C, no Perl deps */
23             #define HORUS_FATAL(msg) croak("%s", (msg))
24             #include "horus_core.h"
25              
26             /* ------------------------------------------------------------------ */
27             /* Constants */
28             /* ------------------------------------------------------------------ */
29              
30             #define APOPHIS_STREAM_BUF 65536 /* 64KB read chunks for streaming */
31             #define APOPHIS_PATH_MAX 4096
32              
33             /* ------------------------------------------------------------------ */
34             /* Internal: namespace UUID generation */
35             /* ------------------------------------------------------------------ */
36              
37             /* Derive a namespace UUID from a human-readable string via v5(DNS, name) */
38             static void
39 13           apophis_derive_namespace(unsigned char *ns_out,
40             const char *name, STRLEN name_len)
41             {
42 13           horus_uuid_v5(ns_out, HORUS_NS_DNS,
43             (const unsigned char *)name, (size_t)name_len);
44 13           }
45              
46             /* Format 16-byte UUID binary to 36-char string SV */
47             static SV *
48 32           apophis_uuid_to_sv(pTHX_ const unsigned char *uuid)
49             {
50             char buf[HORUS_FMT_STR_LEN + 1];
51 32           horus_format_uuid(buf, uuid, HORUS_FMT_STR);
52 32           return newSVpvn(buf, HORUS_FMT_STR_LEN);
53             }
54              
55             /* ------------------------------------------------------------------ */
56             /* Internal: content identification */
57             /* ------------------------------------------------------------------ */
58              
59             /* Identify in-memory content: v5(namespace, content) */
60             static void
61 37           apophis_identify_content(unsigned char *uuid_out,
62             const unsigned char *ns_bytes,
63             const char *content, STRLEN content_len)
64             {
65 37           horus_uuid_v5(uuid_out, ns_bytes,
66             (const unsigned char *)content, (size_t)content_len);
67 37           }
68              
69             /* Identify via streaming SHA-1 — O(1) memory */
70             static void
71 11           apophis_identify_stream(pTHX_ unsigned char *uuid_out,
72             const unsigned char *ns_bytes,
73             PerlIO *fh)
74             {
75             horus_sha1_ctx ctx;
76             unsigned char buf[APOPHIS_STREAM_BUF];
77             unsigned char digest[20];
78             SSize_t nread;
79              
80 11           horus_sha1_init(&ctx);
81 11           horus_sha1_update(&ctx, ns_bytes, 16); /* namespace first per RFC */
82              
83 29 100         while ((nread = PerlIO_read(fh, buf, sizeof(buf))) > 0) {
84 18           horus_sha1_update(&ctx, (const unsigned char *)buf, (size_t)nread);
85             }
86              
87 11           horus_sha1_final(digest, &ctx);
88 11           memcpy(uuid_out, digest, 16);
89 11           horus_stamp_version_variant(uuid_out, 5);
90 11           }
91              
92             /* ------------------------------------------------------------------ */
93             /* Internal: path computation */
94             /* ------------------------------------------------------------------ */
95              
96             /* Build 2-level sharded path: store_dir/a3/bb/a3bb189e-...-1e3a
97             * Returns length written (excluding NUL). */
98             static int
99 72           apophis_build_path(char *out, size_t out_size,
100             const char *store_dir, STRLEN store_len,
101             const char *id, STRLEN id_len)
102             {
103             /* UUID is 36 chars: a3bb189e-8bf9-5f18-b3f6-1b2f5f5c1e3a
104             * Shard on first 2 and chars 3-4 (skipping no hyphens needed,
105             * first 4 hex chars are positions 0-3 of the UUID string) */
106 72 50         if (id_len < 5)
107 0           croak("Apophis: invalid UUID id");
108              
109 144           return snprintf(out, out_size, "%.*s/%c%c/%c%c/%.*s",
110             (int)store_len, store_dir,
111 72           id[0], id[1], /* first shard level */
112 72           id[2], id[3], /* second shard level */
113             (int)id_len, id);
114             }
115              
116             /* Build path for .meta sidecar */
117             static int
118 9           apophis_build_meta_path(char *out, size_t out_size,
119             const char *content_path, int content_path_len)
120             {
121 9           return snprintf(out, out_size, "%.*s.meta",
122             content_path_len, content_path);
123             }
124              
125             /* ------------------------------------------------------------------ */
126             /* Internal: recursive mkdir */
127             /* ------------------------------------------------------------------ */
128              
129             static void
130 20           apophis_mkdir_p(const char *path)
131             {
132             char buf[APOPHIS_PATH_MAX];
133             char *p;
134             size_t len;
135              
136 20           len = strlen(path);
137 20 50         if (len >= sizeof(buf))
138 0           croak("Apophis: path too long");
139              
140 20           memcpy(buf, path, len + 1);
141              
142 420 100         for (p = buf + 1; *p; p++) {
143 400 100         if (*p == '/') {
144 60           *p = '\0';
145 60 100         if (mkdir(buf, 0777) != 0 && errno != EEXIST) {
    50          
146 0           croak("Apophis: cannot create directory '%s': %s",
147             buf, strerror(errno));
148             }
149 60           *p = '/';
150             }
151             }
152             /* Final component */
153 20 50         if (mkdir(buf, 0777) != 0 && errno != EEXIST) {
    0          
154 0           croak("Apophis: cannot create directory '%s': %s",
155             buf, strerror(errno));
156             }
157 20           }
158              
159             /* Ensure parent directory of a file path exists */
160             static void
161 20           apophis_ensure_parent_dir(const char *file_path)
162             {
163             char buf[APOPHIS_PATH_MAX];
164             char *last_slash;
165             size_t len;
166              
167 20           len = strlen(file_path);
168 20 50         if (len >= sizeof(buf))
169 0           croak("Apophis: path too long");
170              
171 20           memcpy(buf, file_path, len + 1);
172 20           last_slash = strrchr(buf, '/');
173 20 50         if (last_slash) {
174 20           *last_slash = '\0';
175 20           apophis_mkdir_p(buf);
176             }
177 20           }
178              
179             /* ------------------------------------------------------------------ */
180             /* Internal: atomic file write (temp + rename) */
181             /* ------------------------------------------------------------------ */
182              
183             static void
184 20           apophis_atomic_write(pTHX_ const char *path,
185             const char *content, STRLEN content_len)
186             {
187             char tmp_path[APOPHIS_PATH_MAX];
188             PerlIO *fh;
189             SSize_t written;
190              
191 20           snprintf(tmp_path, sizeof(tmp_path), "%s.tmp.%d",
192 20           path, (int)getpid());
193              
194 20           fh = PerlIO_open(tmp_path, "wb");
195 20 50         if (!fh)
196 0           croak("Apophis: cannot write '%s': %s", tmp_path, strerror(errno));
197              
198 20           written = PerlIO_write(fh, content, content_len);
199 20           PerlIO_close(fh);
200              
201 20 50         if (written != (SSize_t)content_len) {
202 0           unlink(tmp_path);
203 0           croak("Apophis: short write to '%s'", tmp_path);
204             }
205              
206 20 50         if (rename(tmp_path, path) != 0) {
207 0           unlink(tmp_path);
208 0           croak("Apophis: cannot rename '%s' -> '%s': %s",
209             tmp_path, path, strerror(errno));
210             }
211 20           }
212              
213             /* ------------------------------------------------------------------ */
214             /* Internal: metadata sidecar (key=value\n format) */
215             /* ------------------------------------------------------------------ */
216              
217             static void
218 1           apophis_meta_write(pTHX_ const char *meta_path, HV *meta)
219             {
220             PerlIO *fh;
221             HE *entry;
222             char tmp_path[APOPHIS_PATH_MAX];
223              
224 1           snprintf(tmp_path, sizeof(tmp_path), "%s.tmp.%d",
225 1           meta_path, (int)getpid());
226              
227 1           fh = PerlIO_open(tmp_path, "w");
228 1 50         if (!fh)
229 0           croak("Apophis: cannot write metadata '%s': %s",
230             tmp_path, strerror(errno));
231              
232 1           hv_iterinit(meta);
233 3 100         while ((entry = hv_iternext(meta))) {
234 2           SV *val = hv_iterval(meta, entry);
235             I32 klen;
236 2           const char *key = hv_iterkey(entry, &klen);
237             STRLEN vlen;
238 2           const char *vstr = SvPV(val, vlen);
239 2           PerlIO_write(fh, key, (SSize_t)klen);
240 2           PerlIO_write(fh, "=", 1);
241 2           PerlIO_write(fh, vstr, (SSize_t)vlen);
242 2           PerlIO_write(fh, "\n", 1);
243             }
244              
245 1           PerlIO_close(fh);
246              
247 1 50         if (rename(tmp_path, meta_path) != 0) {
248 0           unlink(tmp_path);
249 0           croak("Apophis: cannot rename metadata '%s': %s",
250             tmp_path, strerror(errno));
251             }
252 1           }
253              
254             static HV *
255 2           apophis_meta_read(pTHX_ const char *meta_path)
256             {
257             PerlIO *fh;
258             HV *meta;
259             struct stat st;
260             char *buf, *p, *end;
261             SSize_t nread;
262              
263 2 100         if (stat(meta_path, &st) != 0) return NULL;
264              
265 1           fh = PerlIO_open(meta_path, "r");
266 1 50         if (!fh) return NULL;
267              
268 1           buf = (char *)malloc((size_t)st.st_size + 1);
269 1 50         if (!buf) { PerlIO_close(fh); return NULL; }
270              
271 1           nread = PerlIO_read(fh, buf, (Size_t)st.st_size);
272 1           PerlIO_close(fh);
273              
274 1 50         if (nread < 0) { free(buf); return NULL; }
275 1           buf[nread] = '\0';
276              
277 1           meta = newHV();
278 1           p = buf;
279 1           end = buf + nread;
280              
281 3 100         while (p < end) {
282 2           char *line_end = strchr(p, '\n');
283             char *eq;
284 2 50         if (!line_end) line_end = end;
285              
286 2           eq = (char *)memchr(p, '=', (size_t)(line_end - p));
287 2 50         if (eq) {
288 2           hv_store(meta, p, (I32)(eq - p),
289             newSVpvn(eq + 1, (STRLEN)(line_end - eq - 1)), 0);
290             }
291              
292 2           p = line_end + 1;
293             }
294              
295 1           free(buf);
296 1           return meta;
297             }
298              
299             /* ------------------------------------------------------------------ */
300             /* Internal: object field accessors */
301             /* ------------------------------------------------------------------ */
302              
303             /* Get the 16-byte namespace bytes from the object */
304             static const unsigned char *
305 49           apophis_get_ns(pTHX_ HV *self)
306             {
307 49           SV **svp = hv_fetchs(self, "_ns_bytes", 0);
308 49 50         if (!svp || !SvOK(*svp))
    50          
309 0           croak("Apophis: object has no namespace (not properly constructed)");
310 49           return (const unsigned char *)SvPV_nolen(*svp);
311             }
312              
313             /* Get store_dir from object, or from opts, or croak */
314             static const char *
315 68           apophis_get_store_dir(pTHX_ HV *self, HV *opts, STRLEN *len_out)
316             {
317             SV **svp;
318              
319             /* Check opts first */
320 68 100         if (opts) {
321 3           svp = hv_fetchs(opts, "store_dir", 0);
322 3 100         if (svp && SvOK(*svp))
    50          
323 2           return SvPV(*svp, *len_out);
324             }
325              
326             /* Fall back to object */
327 66           svp = hv_fetchs(self, "store_dir", 0);
328 66 50         if (svp && SvOK(*svp))
    50          
329 66           return SvPV(*svp, *len_out);
330              
331 0           croak("Apophis: no store_dir specified");
332             return NULL; /* not reached */
333             }
334              
335              
336             /* ================================================================== */
337             /* Custom Ops - bypass method dispatch for hot-path operations */
338             /* ================================================================== */
339              
340             /* Forward declarations */
341             static OP *pp_apophis_identify(pTHX);
342             static OP *pp_apophis_store(pTHX);
343             static OP *pp_apophis_exists(pTHX);
344             static OP *pp_apophis_fetch(pTHX);
345             static OP *pp_apophis_verify(pTHX);
346             static OP *pp_apophis_remove(pTHX);
347              
348             /* XOP structs for debug names (5.14+ only) */
349             #if PERL_VERSION >= 14
350             static XOP apophis_xop_identify;
351             static XOP apophis_xop_store;
352             static XOP apophis_xop_exists;
353             static XOP apophis_xop_fetch;
354             static XOP apophis_xop_verify;
355             static XOP apophis_xop_remove;
356             #endif
357              
358             /*
359             * pp_apophis_identify - Custom op: content → UUID v5 string
360             *
361             * Stack input: self_sv, content_ref_sv
362             * Stack output: uuid_string_sv
363             *
364             * Fuses: namespace extraction + SHA-1 + v5 stamp + format
365             * Zero intermediate SVs, no method dispatch overhead.
366             */
367             static OP *
368 0           pp_apophis_identify(pTHX) {
369 0           dSP;
370 0           SV *content_ref_sv = POPs;
371 0           SV *self_sv = POPs;
372             HV *hv;
373             const unsigned char *ns;
374             SV *content_sv;
375             const char *content;
376             STRLEN content_len;
377             unsigned char uuid[16];
378              
379 0 0         if (!sv_isobject(self_sv))
380 0           croak("Apophis: pp_identify: not an object");
381 0           hv = (HV *)SvRV(self_sv);
382 0           ns = apophis_get_ns(aTHX_ hv);
383              
384 0 0         if (!SvROK(content_ref_sv))
385 0           croak("Apophis: pp_identify: argument must be a scalar reference");
386 0           content_sv = SvRV(content_ref_sv);
387 0           content = SvPV(content_sv, content_len);
388              
389 0           apophis_identify_content(uuid, ns, content, content_len);
390              
391 0 0         EXTEND(SP, 1);
392 0           PUSHs(sv_2mortal(apophis_uuid_to_sv(aTHX_ uuid)));
393 0           PUTBACK;
394 0           return NORMAL;
395             }
396              
397             /*
398             * pp_apophis_store - Custom op: fused identify + mkdir + atomic write
399             *
400             * Stack input: self_sv, content_ref_sv
401             * Stack output: uuid_string_sv
402             *
403             * Fuses the entire store pipeline into a single op:
404             * 1. Extract namespace bytes from object
405             * 2. SHA-1 hash content → UUID v5
406             * 3. Compute 2-level sharded path
407             * 4. stat() for CAS dedup check
408             * 5. mkdir -p parent directories
409             * 6. Atomic write (temp + rename)
410             * 7. Format and return UUID string
411             */
412             static OP *
413 0           pp_apophis_store(pTHX) {
414 0           dSP;
415 0           SV *content_ref_sv = POPs;
416 0           SV *self_sv = POPs;
417             HV *hv;
418             const unsigned char *ns;
419             SV *content_sv;
420             const char *content;
421             STRLEN content_len;
422             unsigned char uuid[16];
423             char id_str[HORUS_FMT_STR_LEN + 1];
424             const char *store_dir;
425             STRLEN store_dir_len;
426             char path[APOPHIS_PATH_MAX];
427             struct stat st;
428              
429 0 0         if (!sv_isobject(self_sv))
430 0           croak("Apophis: pp_store: not an object");
431 0           hv = (HV *)SvRV(self_sv);
432 0           ns = apophis_get_ns(aTHX_ hv);
433              
434 0 0         if (!SvROK(content_ref_sv))
435 0           croak("Apophis: pp_store: argument must be a scalar reference");
436 0           content_sv = SvRV(content_ref_sv);
437 0           content = SvPV(content_sv, content_len);
438              
439             /* Identify */
440 0           apophis_identify_content(uuid, ns, content, content_len);
441 0           horus_format_uuid(id_str, uuid, HORUS_FMT_STR);
442              
443             /* Get store_dir from object */
444 0           store_dir = apophis_get_store_dir(aTHX_ hv, NULL, &store_dir_len);
445              
446             /* Build sharded path */
447 0           apophis_build_path(path, sizeof(path),
448             store_dir, store_dir_len,
449             id_str, HORUS_FMT_STR_LEN);
450              
451             /* CAS dedup: only write if not already stored */
452 0 0         if (stat(path, &st) != 0) {
453 0           apophis_ensure_parent_dir(path);
454 0           apophis_atomic_write(aTHX_ path, content, content_len);
455             }
456              
457 0 0         EXTEND(SP, 1);
458 0           PUSHs(sv_2mortal(newSVpvn(id_str, HORUS_FMT_STR_LEN)));
459 0           PUTBACK;
460 0           return NORMAL;
461             }
462              
463             /*
464             * pp_apophis_exists - Custom op: UUID → boolean existence check
465             *
466             * Stack input: self_sv, id_sv
467             * Stack output: bool_sv
468             *
469             * Fuses: path computation + stat() into a single op.
470             */
471             static OP *
472 0           pp_apophis_exists(pTHX) {
473 0           dSP;
474 0           SV *id_sv = POPs;
475 0           SV *self_sv = POPs;
476             HV *hv;
477             const char *store_dir;
478             STRLEN store_dir_len;
479             const char *id_str;
480             STRLEN id_len;
481             char path[APOPHIS_PATH_MAX];
482             struct stat st;
483              
484 0 0         if (!sv_isobject(self_sv))
485 0           croak("Apophis: pp_exists: not an object");
486 0           hv = (HV *)SvRV(self_sv);
487              
488 0           store_dir = apophis_get_store_dir(aTHX_ hv, NULL, &store_dir_len);
489 0           id_str = SvPV(id_sv, id_len);
490              
491 0           apophis_build_path(path, sizeof(path),
492             store_dir, store_dir_len, id_str, id_len);
493              
494 0 0         EXTEND(SP, 1);
495 0 0         PUSHs(stat(path, &st) == 0 ? &PL_sv_yes : &PL_sv_no);
496 0           PUTBACK;
497 0           return NORMAL;
498             }
499              
500             /*
501             * pp_apophis_fetch - Custom op: UUID → content scalar ref or undef
502             *
503             * Stack input: self_sv, id_sv
504             * Stack output: \$content or undef
505             *
506             * Fuses: path computation + stat + open + read into a single op.
507             */
508             static OP *
509 0           pp_apophis_fetch(pTHX) {
510 0           dSP;
511 0           SV *id_sv = POPs;
512 0           SV *self_sv = POPs;
513             HV *hv;
514             const char *store_dir;
515             STRLEN store_dir_len;
516             const char *id_str;
517             STRLEN id_len;
518             char path[APOPHIS_PATH_MAX];
519             struct stat st;
520              
521 0 0         if (!sv_isobject(self_sv))
522 0           croak("Apophis: pp_fetch: not an object");
523 0           hv = (HV *)SvRV(self_sv);
524              
525 0           store_dir = apophis_get_store_dir(aTHX_ hv, NULL, &store_dir_len);
526 0           id_str = SvPV(id_sv, id_len);
527              
528 0           apophis_build_path(path, sizeof(path),
529             store_dir, store_dir_len, id_str, id_len);
530              
531 0 0         EXTEND(SP, 1);
532 0 0         if (stat(path, &st) != 0) {
533 0           PUSHs(&PL_sv_undef);
534             } else {
535 0           PerlIO *fh = PerlIO_open(path, "rb");
536 0 0         if (!fh)
537 0           croak("Apophis: pp_fetch: cannot open '%s': %s",
538             path, strerror(errno));
539              
540 0           SV *content = newSV((STRLEN)st.st_size + 1);
541 0           SvPOK_on(content);
542 0           SSize_t nread = PerlIO_read(fh, SvPVX(content), (Size_t)st.st_size);
543 0           PerlIO_close(fh);
544              
545 0 0         if (nread < 0) {
546 0           SvREFCNT_dec(content);
547 0           croak("Apophis: pp_fetch: read error on '%s'", path);
548             }
549 0           SvCUR_set(content, (STRLEN)nread);
550 0           *SvEND(content) = '\0';
551              
552 0           PUSHs(sv_2mortal(newRV_noinc(content)));
553             }
554 0           PUTBACK;
555 0           return NORMAL;
556             }
557              
558             /*
559             * pp_apophis_verify - Custom op: fused re-read + re-hash + compare
560             *
561             * Stack input: self_sv, id_sv
562             * Stack output: bool_sv
563             *
564             * Fuses: path computation + open + streaming SHA-1 + format + memcmp.
565             */
566             static OP *
567 0           pp_apophis_verify(pTHX) {
568 0           dSP;
569 0           SV *id_sv = POPs;
570 0           SV *self_sv = POPs;
571             HV *hv;
572             const unsigned char *ns;
573             const char *store_dir;
574             STRLEN store_dir_len;
575             const char *id_str;
576             STRLEN id_len;
577             char path[APOPHIS_PATH_MAX];
578             PerlIO *fh;
579             unsigned char uuid[16];
580             char recomputed[HORUS_FMT_STR_LEN + 1];
581              
582 0 0         if (!sv_isobject(self_sv))
583 0           croak("Apophis: pp_verify: not an object");
584 0           hv = (HV *)SvRV(self_sv);
585 0           ns = apophis_get_ns(aTHX_ hv);
586              
587 0           store_dir = apophis_get_store_dir(aTHX_ hv, NULL, &store_dir_len);
588 0           id_str = SvPV(id_sv, id_len);
589              
590 0           apophis_build_path(path, sizeof(path),
591             store_dir, store_dir_len, id_str, id_len);
592              
593 0 0         EXTEND(SP, 1);
594 0           fh = PerlIO_open(path, "rb");
595 0 0         if (!fh) {
596 0           PUSHs(&PL_sv_no);
597             } else {
598 0           apophis_identify_stream(aTHX_ uuid, ns, fh);
599 0           PerlIO_close(fh);
600              
601 0           horus_format_uuid(recomputed, uuid, HORUS_FMT_STR);
602 0 0         PUSHs((id_len == HORUS_FMT_STR_LEN &&
    0          
603             memcmp(id_str, recomputed, HORUS_FMT_STR_LEN) == 0)
604             ? &PL_sv_yes : &PL_sv_no);
605             }
606 0           PUTBACK;
607 0           return NORMAL;
608             }
609              
610             /*
611             * pp_apophis_remove - Custom op: fused path + unlink + meta cleanup
612             *
613             * Stack input: self_sv, id_sv
614             * Stack output: bool_sv
615             *
616             * Fuses: path computation + unlink + meta sidecar cleanup.
617             */
618             static OP *
619 0           pp_apophis_remove(pTHX) {
620 0           dSP;
621 0           SV *id_sv = POPs;
622 0           SV *self_sv = POPs;
623             HV *hv;
624             const char *store_dir;
625             STRLEN store_dir_len;
626             const char *id_str;
627             STRLEN id_len;
628             char path[APOPHIS_PATH_MAX];
629             int path_len;
630             char meta_path[APOPHIS_PATH_MAX];
631             int removed;
632              
633 0 0         if (!sv_isobject(self_sv))
634 0           croak("Apophis: pp_remove: not an object");
635 0           hv = (HV *)SvRV(self_sv);
636              
637 0           store_dir = apophis_get_store_dir(aTHX_ hv, NULL, &store_dir_len);
638 0           id_str = SvPV(id_sv, id_len);
639              
640 0           path_len = apophis_build_path(path, sizeof(path),
641             store_dir, store_dir_len,
642             id_str, id_len);
643              
644 0           removed = (unlink(path) == 0);
645              
646 0           apophis_build_meta_path(meta_path, sizeof(meta_path),
647             path, path_len);
648 0           unlink(meta_path); /* ignore error — may not exist */
649              
650 0 0         EXTEND(SP, 1);
651 0 0         PUSHs(removed ? &PL_sv_yes : &PL_sv_no);
652 0           PUTBACK;
653 0           return NORMAL;
654             }
655              
656             /*
657             * apophis_make_custom_op - Create a custom OP node
658             *
659             * Used by the optimize/import system to inject custom ops into optrees.
660             */
661             static OP *
662 6           apophis_make_custom_op(pTHX_ OP *(*pp_func)(pTHX))
663             {
664             OP *op;
665 6           NewOp(1101, op, 1, OP);
666 6           op->op_type = OP_CUSTOM;
667 6           op->op_ppaddr = pp_func;
668 6           op->op_next = op; /* will be linked by caller */
669 6           op->op_flags = OPf_WANT_SCALAR;
670 6           return op;
671             }
672              
673              
674             /* ================================================================== */
675             /* XSUBs */
676             /* ================================================================== */
677              
678             MODULE = Apophis PACKAGE = Apophis
679              
680             BOOT:
681             #if PERL_VERSION >= 14
682             /* Register custom ops with debug names */
683 12           XopENTRY_set(&apophis_xop_identify, xop_name, "apophis_identify");
684 12           XopENTRY_set(&apophis_xop_identify, xop_desc, "Apophis content identification (SHA-1 → UUID v5)");
685 12           XopENTRY_set(&apophis_xop_identify, xop_class, OA_BASEOP);
686 12           Perl_custom_op_register(aTHX_ pp_apophis_identify, &apophis_xop_identify);
687              
688 12           XopENTRY_set(&apophis_xop_store, xop_name, "apophis_store");
689 12           XopENTRY_set(&apophis_xop_store, xop_desc, "Apophis fused store (identify + mkdir + atomic write)");
690 12           XopENTRY_set(&apophis_xop_store, xop_class, OA_BASEOP);
691 12           Perl_custom_op_register(aTHX_ pp_apophis_store, &apophis_xop_store);
692              
693 12           XopENTRY_set(&apophis_xop_exists, xop_name, "apophis_exists");
694 12           XopENTRY_set(&apophis_xop_exists, xop_desc, "Apophis fused existence check (path + stat)");
695 12           XopENTRY_set(&apophis_xop_exists, xop_class, OA_BASEOP);
696 12           Perl_custom_op_register(aTHX_ pp_apophis_exists, &apophis_xop_exists);
697              
698 12           XopENTRY_set(&apophis_xop_fetch, xop_name, "apophis_fetch");
699 12           XopENTRY_set(&apophis_xop_fetch, xop_desc, "Apophis fused fetch (path + stat + read)");
700 12           XopENTRY_set(&apophis_xop_fetch, xop_class, OA_BASEOP);
701 12           Perl_custom_op_register(aTHX_ pp_apophis_fetch, &apophis_xop_fetch);
702              
703 12           XopENTRY_set(&apophis_xop_verify, xop_name, "apophis_verify");
704 12           XopENTRY_set(&apophis_xop_verify, xop_desc, "Apophis fused verify (read + re-hash + compare)");
705 12           XopENTRY_set(&apophis_xop_verify, xop_class, OA_BASEOP);
706 12           Perl_custom_op_register(aTHX_ pp_apophis_verify, &apophis_xop_verify);
707              
708 12           XopENTRY_set(&apophis_xop_remove, xop_name, "apophis_remove");
709 12           XopENTRY_set(&apophis_xop_remove, xop_desc, "Apophis fused remove (path + unlink + meta cleanup)");
710 12           XopENTRY_set(&apophis_xop_remove, xop_class, OA_BASEOP);
711 12           Perl_custom_op_register(aTHX_ pp_apophis_remove, &apophis_xop_remove);
712             #endif
713              
714             # ------------------------------------------------------------------ #
715             # new(class, %args) -> blessed object #
716             # ------------------------------------------------------------------ #
717              
718             SV *
719             new(class, ...)
720             const char *class
721             PREINIT:
722             HV *self;
723             SV *self_ref;
724             int i;
725 14           const char *namespace_str = NULL;
726 14           STRLEN namespace_len = 0;
727 14           const char *store_dir = NULL;
728 14           STRLEN store_dir_len = 0;
729             unsigned char ns_bytes[16];
730             CODE:
731 14 50         if ((items - 1) % 2 != 0)
732 0           croak("Apophis->new: odd number of arguments");
733              
734             /* Parse args */
735 35 100         for (i = 1; i < items; i += 2) {
736 21           const char *key = SvPV_nolen(ST(i));
737 21 100         if (strEQ(key, "namespace")) {
738 13           namespace_str = SvPV(ST(i+1), namespace_len);
739 8 50         } else if (strEQ(key, "store_dir")) {
740 8           store_dir = SvPV(ST(i+1), store_dir_len);
741             }
742             }
743              
744 14 100         if (!namespace_str)
745 1           croak("Apophis->new: 'namespace' is required");
746              
747             /* Derive namespace UUID */
748 13           apophis_derive_namespace(ns_bytes, namespace_str, namespace_len);
749              
750             /* Build object */
751 13           self = newHV();
752 13           hv_stores(self, "_ns_bytes", newSVpvn((const char *)ns_bytes, 16));
753 13           hv_stores(self, "_ns_str", apophis_uuid_to_sv(aTHX_ ns_bytes));
754              
755 13 100         if (store_dir)
756 8           hv_stores(self, "store_dir", newSVpvn(store_dir, store_dir_len));
757              
758 13           self_ref = newRV_noinc((SV *)self);
759 13           sv_bless(self_ref, gv_stashpv(class, GV_ADD));
760 13           RETVAL = self_ref;
761             OUTPUT:
762             RETVAL
763              
764             # ------------------------------------------------------------------ #
765             # namespace() -> UUID string #
766             # ------------------------------------------------------------------ #
767              
768             SV *
769             namespace(self)
770             SV *self
771             PREINIT:
772             HV *hv;
773             SV **svp;
774             CODE:
775 3 50         if (!sv_isobject(self))
776 0           croak("Apophis::namespace: not an object");
777 3           hv = (HV *)SvRV(self);
778 3           svp = hv_fetchs(hv, "_ns_str", 0);
779 3 50         if (!svp || !SvOK(*svp))
    50          
780 0           croak("Apophis: object has no namespace");
781 3           RETVAL = newSVsv(*svp);
782             OUTPUT:
783             RETVAL
784              
785             # ------------------------------------------------------------------ #
786             # identify(\$content) -> UUID string #
787             # ------------------------------------------------------------------ #
788              
789             SV *
790             identify(self, content_ref)
791             SV *self
792             SV *content_ref
793             PREINIT:
794             HV *hv;
795             const unsigned char *ns;
796             SV *content_sv;
797             const char *content;
798             STRLEN content_len;
799             unsigned char uuid[16];
800             CODE:
801 14 50         if (!sv_isobject(self))
802 0           croak("Apophis::identify: not an object");
803 14           hv = (HV *)SvRV(self);
804 14           ns = apophis_get_ns(aTHX_ hv);
805              
806 14 100         if (!SvROK(content_ref))
807 1           croak("Apophis::identify: argument must be a scalar reference");
808 13           content_sv = SvRV(content_ref);
809 13           content = SvPV(content_sv, content_len);
810              
811 13           apophis_identify_content(uuid, ns, content, content_len);
812 13           RETVAL = apophis_uuid_to_sv(aTHX_ uuid);
813             OUTPUT:
814             RETVAL
815              
816             # ------------------------------------------------------------------ #
817             # identify_file($path) -> UUID string #
818             # ------------------------------------------------------------------ #
819              
820             SV *
821             identify_file(self, path)
822             SV *self
823             const char *path
824             PREINIT:
825             HV *hv;
826             const unsigned char *ns;
827             unsigned char uuid[16];
828             PerlIO *fh;
829             CODE:
830 4 50         if (!sv_isobject(self))
831 0           croak("Apophis::identify_file: not an object");
832 4           hv = (HV *)SvRV(self);
833 4           ns = apophis_get_ns(aTHX_ hv);
834              
835 4           fh = PerlIO_open(path, "rb");
836 4 50         if (!fh)
837 0           croak("Apophis::identify_file: cannot open '%s': %s",
838             path, strerror(errno));
839              
840 4           apophis_identify_stream(aTHX_ uuid, ns, fh);
841 4           PerlIO_close(fh);
842              
843 4           RETVAL = apophis_uuid_to_sv(aTHX_ uuid);
844             OUTPUT:
845             RETVAL
846              
847             # ------------------------------------------------------------------ #
848             # path_for($id, %opts) -> path string #
849             # ------------------------------------------------------------------ #
850              
851             SV *
852             path_for(self, id, ...)
853             SV *self
854             SV *id
855             PREINIT:
856             HV *hv;
857 4           HV *opts = NULL;
858             const char *store_dir;
859             STRLEN store_dir_len;
860             const char *id_str;
861             STRLEN id_len;
862             char path[APOPHIS_PATH_MAX];
863             int path_len;
864             CODE:
865 4 50         if (!sv_isobject(self))
866 0           croak("Apophis::path_for: not an object");
867 4           hv = (HV *)SvRV(self);
868              
869             /* Parse optional key-value pairs into opts HV */
870 4 50         if (items > 2) {
871             int i;
872 0 0         if ((items - 2) % 2 != 0)
873 0           croak("Apophis::path_for: odd number of optional arguments");
874 0           opts = newHV();
875 0           sv_2mortal((SV *)opts);
876 0 0         for (i = 2; i < items; i += 2) {
877             STRLEN klen;
878 0           const char *k = SvPV(ST(i), klen);
879 0           hv_store(opts, k, klen, SvREFCNT_inc(ST(i+1)), 0);
880             }
881             }
882              
883 4           store_dir = apophis_get_store_dir(aTHX_ hv, opts, &store_dir_len);
884 4           id_str = SvPV(id, id_len);
885              
886 4           path_len = apophis_build_path(path, sizeof(path),
887             store_dir, store_dir_len,
888             id_str, id_len);
889 4           RETVAL = newSVpvn(path, path_len);
890             OUTPUT:
891             RETVAL
892              
893             # ------------------------------------------------------------------ #
894             # store(\$content, %opts) -> UUID string #
895             # ------------------------------------------------------------------ #
896              
897             SV *
898             store(self, content_ref, ...)
899             SV *self
900             SV *content_ref
901             PREINIT:
902             HV *hv;
903 14           HV *opts = NULL;
904 14           HV *meta = NULL;
905             const unsigned char *ns;
906             SV *content_sv;
907             const char *content;
908             STRLEN content_len;
909             unsigned char uuid[16];
910             char id_str[HORUS_FMT_STR_LEN + 1];
911             const char *store_dir;
912             STRLEN store_dir_len;
913             char path[APOPHIS_PATH_MAX];
914             int path_len;
915             struct stat st;
916             CODE:
917 14 50         if (!sv_isobject(self))
918 0           croak("Apophis::store: not an object");
919 14           hv = (HV *)SvRV(self);
920 14           ns = apophis_get_ns(aTHX_ hv);
921              
922 14 50         if (!SvROK(content_ref))
923 0           croak("Apophis::store: argument must be a scalar reference");
924 14           content_sv = SvRV(content_ref);
925 14           content = SvPV(content_sv, content_len);
926              
927             /* Parse opts */
928 14 100         if (items > 2) {
929             int i;
930 2 50         if ((items - 2) % 2 != 0)
931 0           croak("Apophis::store: odd number of optional arguments");
932 2           opts = newHV();
933 2           sv_2mortal((SV *)opts);
934 4 100         for (i = 2; i < items; i += 2) {
935             STRLEN klen;
936 2           const char *k = SvPV(ST(i), klen);
937 2           SV *v = ST(i+1);
938 2 100         if (strEQ(k, "meta") && SvROK(v) && SvTYPE(SvRV(v)) == SVt_PVHV) {
    50          
    50          
939 1           meta = (HV *)SvRV(v);
940             } else {
941 1           hv_store(opts, k, klen, SvREFCNT_inc(v), 0);
942             }
943             }
944             }
945              
946             /* Identify content */
947 14           apophis_identify_content(uuid, ns, content, content_len);
948 14           horus_format_uuid(id_str, uuid, HORUS_FMT_STR);
949              
950             /* Build path */
951 14           store_dir = apophis_get_store_dir(aTHX_ hv, opts, &store_dir_len);
952 14           path_len = apophis_build_path(path, sizeof(path),
953             store_dir, store_dir_len,
954             id_str, HORUS_FMT_STR_LEN);
955              
956             /* CAS dedup: skip if already exists */
957 14 100         if (stat(path, &st) != 0) {
958 13           apophis_ensure_parent_dir(path);
959 13           apophis_atomic_write(aTHX_ path, content, content_len);
960             }
961              
962             /* Write metadata sidecar if provided */
963 14 100         if (meta) {
964             char meta_path[APOPHIS_PATH_MAX];
965 1           apophis_build_meta_path(meta_path, sizeof(meta_path),
966             path, path_len);
967 1           apophis_meta_write(aTHX_ meta_path, meta);
968             }
969              
970 14           RETVAL = newSVpvn(id_str, HORUS_FMT_STR_LEN);
971             OUTPUT:
972             RETVAL
973              
974             # ------------------------------------------------------------------ #
975             # fetch($id, %opts) -> \$content or undef #
976             # ------------------------------------------------------------------ #
977              
978             SV *
979             fetch(self, id, ...)
980             SV *self
981             SV *id
982             PREINIT:
983             HV *hv;
984 11           HV *opts = NULL;
985             const char *store_dir;
986             STRLEN store_dir_len;
987             const char *id_str;
988             STRLEN id_len;
989             char path[APOPHIS_PATH_MAX];
990             PerlIO *fh;
991             struct stat st;
992             SV *content;
993             SSize_t nread;
994             CODE:
995 11 50         if (!sv_isobject(self))
996 0           croak("Apophis::fetch: not an object");
997 11           hv = (HV *)SvRV(self);
998              
999 11 100         if (items > 2) {
1000             int i;
1001 1 50         if ((items - 2) % 2 != 0)
1002 0           croak("Apophis::fetch: odd number of optional arguments");
1003 1           opts = newHV();
1004 1           sv_2mortal((SV *)opts);
1005 2 100         for (i = 2; i < items; i += 2) {
1006             STRLEN klen;
1007 1           const char *k = SvPV(ST(i), klen);
1008 1           hv_store(opts, k, klen, SvREFCNT_inc(ST(i+1)), 0);
1009             }
1010             }
1011              
1012 11           store_dir = apophis_get_store_dir(aTHX_ hv, opts, &store_dir_len);
1013 11           id_str = SvPV(id, id_len);
1014 11           apophis_build_path(path, sizeof(path),
1015             store_dir, store_dir_len, id_str, id_len);
1016              
1017             /* Check existence */
1018 11 100         if (stat(path, &st) != 0) {
1019 2           RETVAL = &PL_sv_undef;
1020             } else {
1021             /* Read entire file */
1022 9           fh = PerlIO_open(path, "rb");
1023 9 50         if (!fh)
1024 0           croak("Apophis::fetch: cannot open '%s': %s",
1025             path, strerror(errno));
1026              
1027 9           content = newSV((STRLEN)st.st_size + 1);
1028 9           SvPOK_on(content);
1029 9           nread = PerlIO_read(fh, SvPVX(content), (Size_t)st.st_size);
1030 9           PerlIO_close(fh);
1031              
1032 9 50         if (nread < 0) {
1033 0           SvREFCNT_dec(content);
1034 0           croak("Apophis::fetch: read error on '%s'", path);
1035             }
1036 9           SvCUR_set(content, (STRLEN)nread);
1037 9           *SvEND(content) = '\0';
1038              
1039 9           RETVAL = newRV_noinc(content);
1040             }
1041             OUTPUT:
1042             RETVAL
1043              
1044             # ------------------------------------------------------------------ #
1045             # exists($id, %opts) -> bool #
1046             # ------------------------------------------------------------------ #
1047              
1048             bool
1049             exists(self, id, ...)
1050             SV *self
1051             SV *id
1052             PREINIT:
1053             HV *hv;
1054 6           HV *opts = NULL;
1055             const char *store_dir;
1056             STRLEN store_dir_len;
1057             const char *id_str;
1058             STRLEN id_len;
1059             char path[APOPHIS_PATH_MAX];
1060             struct stat st;
1061             CODE:
1062 6 50         if (!sv_isobject(self))
1063 0           croak("Apophis::exists: not an object");
1064 6           hv = (HV *)SvRV(self);
1065              
1066 6 50         if (items > 2) {
1067             int i;
1068 0 0         if ((items - 2) % 2 != 0)
1069 0           croak("Apophis::exists: odd number of optional arguments");
1070 0           opts = newHV();
1071 0           sv_2mortal((SV *)opts);
1072 0 0         for (i = 2; i < items; i += 2) {
1073             STRLEN klen;
1074 0           const char *k = SvPV(ST(i), klen);
1075 0           hv_store(opts, k, klen, SvREFCNT_inc(ST(i+1)), 0);
1076             }
1077             }
1078              
1079 6           store_dir = apophis_get_store_dir(aTHX_ hv, opts, &store_dir_len);
1080 6           id_str = SvPV(id, id_len);
1081 6           apophis_build_path(path, sizeof(path),
1082             store_dir, store_dir_len, id_str, id_len);
1083              
1084 6 100         RETVAL = (stat(path, &st) == 0) ? TRUE : FALSE;
1085             OUTPUT:
1086             RETVAL
1087              
1088             # ------------------------------------------------------------------ #
1089             # remove($id, %opts) -> bool #
1090             # ------------------------------------------------------------------ #
1091              
1092             bool
1093             remove(self, id, ...)
1094             SV *self
1095             SV *id
1096             PREINIT:
1097             HV *hv;
1098 4           HV *opts = NULL;
1099             const char *store_dir;
1100             STRLEN store_dir_len;
1101             const char *id_str;
1102             STRLEN id_len;
1103             char path[APOPHIS_PATH_MAX];
1104             int path_len;
1105             char meta_path[APOPHIS_PATH_MAX];
1106             int removed;
1107             CODE:
1108 4 50         if (!sv_isobject(self))
1109 0           croak("Apophis::remove: not an object");
1110 4           hv = (HV *)SvRV(self);
1111              
1112 4 50         if (items > 2) {
1113             int i;
1114 0 0         if ((items - 2) % 2 != 0)
1115 0           croak("Apophis::remove: odd number of optional arguments");
1116 0           opts = newHV();
1117 0           sv_2mortal((SV *)opts);
1118 0 0         for (i = 2; i < items; i += 2) {
1119             STRLEN klen;
1120 0           const char *k = SvPV(ST(i), klen);
1121 0           hv_store(opts, k, klen, SvREFCNT_inc(ST(i+1)), 0);
1122             }
1123             }
1124              
1125 4           store_dir = apophis_get_store_dir(aTHX_ hv, opts, &store_dir_len);
1126 4           id_str = SvPV(id, id_len);
1127 4           path_len = apophis_build_path(path, sizeof(path),
1128             store_dir, store_dir_len,
1129             id_str, id_len);
1130              
1131 4           removed = (unlink(path) == 0);
1132              
1133             /* Also remove metadata sidecar if it exists */
1134 4           apophis_build_meta_path(meta_path, sizeof(meta_path),
1135             path, path_len);
1136 4           unlink(meta_path); /* ignore error — may not exist */
1137              
1138 4 100         RETVAL = removed ? TRUE : FALSE;
1139             OUTPUT:
1140             RETVAL
1141              
1142             # ------------------------------------------------------------------ #
1143             # verify($id, %opts) -> bool #
1144             # ------------------------------------------------------------------ #
1145              
1146             bool
1147             verify(self, id, ...)
1148             SV *self
1149             SV *id
1150             PREINIT:
1151             HV *hv;
1152 5           HV *opts = NULL;
1153             const unsigned char *ns;
1154             const char *store_dir;
1155             STRLEN store_dir_len;
1156             const char *id_str;
1157             STRLEN id_len;
1158             char path[APOPHIS_PATH_MAX];
1159             PerlIO *fh;
1160             unsigned char uuid[16];
1161             char recomputed[HORUS_FMT_STR_LEN + 1];
1162             CODE:
1163 5 50         if (!sv_isobject(self))
1164 0           croak("Apophis::verify: not an object");
1165 5           hv = (HV *)SvRV(self);
1166 5           ns = apophis_get_ns(aTHX_ hv);
1167              
1168 5 50         if (items > 2) {
1169             int i;
1170 0 0         if ((items - 2) % 2 != 0)
1171 0           croak("Apophis::verify: odd number of optional arguments");
1172 0           opts = newHV();
1173 0           sv_2mortal((SV *)opts);
1174 0 0         for (i = 2; i < items; i += 2) {
1175             STRLEN klen;
1176 0           const char *k = SvPV(ST(i), klen);
1177 0           hv_store(opts, k, klen, SvREFCNT_inc(ST(i+1)), 0);
1178             }
1179             }
1180              
1181 5           store_dir = apophis_get_store_dir(aTHX_ hv, opts, &store_dir_len);
1182 5           id_str = SvPV(id, id_len);
1183 5           apophis_build_path(path, sizeof(path),
1184             store_dir, store_dir_len, id_str, id_len);
1185              
1186 5           fh = PerlIO_open(path, "rb");
1187 5 100         if (!fh) {
1188 1           RETVAL = FALSE;
1189             } else {
1190 4           apophis_identify_stream(aTHX_ uuid, ns, fh);
1191 4           PerlIO_close(fh);
1192              
1193 4           horus_format_uuid(recomputed, uuid, HORUS_FMT_STR);
1194 4           RETVAL = (id_len == HORUS_FMT_STR_LEN &&
1195 4           memcmp(id_str, recomputed, HORUS_FMT_STR_LEN) == 0)
1196 4 50         ? TRUE : FALSE;
    100          
1197             }
1198             OUTPUT:
1199             RETVAL
1200              
1201             # ------------------------------------------------------------------ #
1202             # store_many(\@refs, %opts) -> @ids #
1203             # ------------------------------------------------------------------ #
1204              
1205             void
1206             store_many(self, refs, ...)
1207             SV *self
1208             SV *refs
1209             PREINIT:
1210             HV *hv;
1211 1           HV *opts = NULL;
1212             const unsigned char *ns;
1213             const char *store_dir;
1214             STRLEN store_dir_len;
1215             AV *av;
1216             I32 len, i;
1217             PPCODE:
1218 1 50         if (!sv_isobject(self))
1219 0           croak("Apophis::store_many: not an object");
1220 1           hv = (HV *)SvRV(self);
1221 1           ns = apophis_get_ns(aTHX_ hv);
1222              
1223 1 50         if (!SvROK(refs) || SvTYPE(SvRV(refs)) != SVt_PVAV)
    50          
1224 0           croak("Apophis::store_many: first argument must be an array ref");
1225 1           av = (AV *)SvRV(refs);
1226 1           len = av_len(av) + 1;
1227              
1228 1 50         if (items > 2) {
1229             int j;
1230 0 0         if ((items - 2) % 2 != 0)
1231 0           croak("Apophis::store_many: odd number of optional arguments");
1232 0           opts = newHV();
1233 0           sv_2mortal((SV *)opts);
1234 0 0         for (j = 2; j < items; j += 2) {
1235             STRLEN klen;
1236 0           const char *k = SvPV(ST(j), klen);
1237 0           hv_store(opts, k, klen, SvREFCNT_inc(ST(j+1)), 0);
1238             }
1239             }
1240              
1241 1           store_dir = apophis_get_store_dir(aTHX_ hv, opts, &store_dir_len);
1242              
1243 1 50         EXTEND(SP, len);
    50          
1244 4 100         for (i = 0; i < len; i++) {
1245 3           SV **svp = av_fetch(av, i, 0);
1246             SV *content_sv;
1247             const char *content;
1248             STRLEN content_len;
1249             unsigned char uuid[16];
1250             char id_str[HORUS_FMT_STR_LEN + 1];
1251             char path[APOPHIS_PATH_MAX];
1252             struct stat st;
1253              
1254 3 50         if (!svp || !SvROK(*svp))
    50          
1255 0           croak("Apophis::store_many: element %d must be a scalar ref",
1256             (int)i);
1257              
1258 3           content_sv = SvRV(*svp);
1259 3           content = SvPV(content_sv, content_len);
1260              
1261 3           apophis_identify_content(uuid, ns, content, content_len);
1262 3           horus_format_uuid(id_str, uuid, HORUS_FMT_STR);
1263              
1264 3           apophis_build_path(path, sizeof(path),
1265             store_dir, store_dir_len,
1266             id_str, HORUS_FMT_STR_LEN);
1267              
1268 3 50         if (stat(path, &st) != 0) {
1269 3           apophis_ensure_parent_dir(path);
1270 3           apophis_atomic_write(aTHX_ path, content, content_len);
1271             }
1272              
1273 3           PUSHs(sv_2mortal(newSVpvn(id_str, HORUS_FMT_STR_LEN)));
1274             }
1275              
1276             # ------------------------------------------------------------------ #
1277             # find_missing(\@ids, %opts) -> @missing_ids #
1278             # ------------------------------------------------------------------ #
1279              
1280             void
1281             find_missing(self, ids, ...)
1282             SV *self
1283             SV *ids
1284             PREINIT:
1285             HV *hv;
1286 1           HV *opts = NULL;
1287             const char *store_dir;
1288             STRLEN store_dir_len;
1289             AV *av;
1290             I32 len, i;
1291             PPCODE:
1292 1 50         if (!sv_isobject(self))
1293 0           croak("Apophis::find_missing: not an object");
1294 1           hv = (HV *)SvRV(self);
1295              
1296 1 50         if (!SvROK(ids) || SvTYPE(SvRV(ids)) != SVt_PVAV)
    50          
1297 0           croak("Apophis::find_missing: first argument must be an array ref");
1298 1           av = (AV *)SvRV(ids);
1299 1           len = av_len(av) + 1;
1300              
1301 1 50         if (items > 2) {
1302             int j;
1303 0 0         if ((items - 2) % 2 != 0)
1304 0           croak("Apophis::find_missing: odd number of optional arguments");
1305 0           opts = newHV();
1306 0           sv_2mortal((SV *)opts);
1307 0 0         for (j = 2; j < items; j += 2) {
1308             STRLEN klen;
1309 0           const char *k = SvPV(ST(j), klen);
1310 0           hv_store(opts, k, klen, SvREFCNT_inc(ST(j+1)), 0);
1311             }
1312             }
1313              
1314 1           store_dir = apophis_get_store_dir(aTHX_ hv, opts, &store_dir_len);
1315              
1316 4 100         for (i = 0; i < len; i++) {
1317 3           SV **svp = av_fetch(av, i, 0);
1318             const char *id_str;
1319             STRLEN id_len;
1320             char path[APOPHIS_PATH_MAX];
1321             struct stat st;
1322              
1323 3 50         if (!svp || !SvOK(*svp)) continue;
    50          
1324              
1325 3           id_str = SvPV(*svp, id_len);
1326 3           apophis_build_path(path, sizeof(path),
1327             store_dir, store_dir_len, id_str, id_len);
1328              
1329 3 100         if (stat(path, &st) != 0) {
1330 1 50         XPUSHs(sv_2mortal(newSVpvn(id_str, id_len)));
1331             }
1332             }
1333              
1334             # ------------------------------------------------------------------ #
1335             # meta($id, %opts) -> \%meta or undef #
1336             # ------------------------------------------------------------------ #
1337              
1338             SV *
1339             meta(self, id, ...)
1340             SV *self
1341             SV *id
1342             PREINIT:
1343             HV *hv;
1344 2           HV *opts = NULL;
1345             const char *store_dir;
1346             STRLEN store_dir_len;
1347             const char *id_str;
1348             STRLEN id_len;
1349             char path[APOPHIS_PATH_MAX];
1350             int path_len;
1351             char meta_path[APOPHIS_PATH_MAX];
1352             HV *meta;
1353             CODE:
1354 2 50         if (!sv_isobject(self))
1355 0           croak("Apophis::meta: not an object");
1356 2           hv = (HV *)SvRV(self);
1357              
1358 2 50         if (items > 2) {
1359             int i;
1360 0 0         if ((items - 2) % 2 != 0)
1361 0           croak("Apophis::meta: odd number of optional arguments");
1362 0           opts = newHV();
1363 0           sv_2mortal((SV *)opts);
1364 0 0         for (i = 2; i < items; i += 2) {
1365             STRLEN klen;
1366 0           const char *k = SvPV(ST(i), klen);
1367 0           hv_store(opts, k, klen, SvREFCNT_inc(ST(i+1)), 0);
1368             }
1369             }
1370              
1371 2           store_dir = apophis_get_store_dir(aTHX_ hv, opts, &store_dir_len);
1372 2           id_str = SvPV(id, id_len);
1373 2           path_len = apophis_build_path(path, sizeof(path),
1374             store_dir, store_dir_len,
1375             id_str, id_len);
1376 2           apophis_build_meta_path(meta_path, sizeof(meta_path),
1377             path, path_len);
1378              
1379 2           meta = apophis_meta_read(aTHX_ meta_path);
1380 2 100         if (meta) {
1381 1           RETVAL = newRV_noinc((SV *)meta);
1382             } else {
1383 1           RETVAL = &PL_sv_undef;
1384             }
1385             OUTPUT:
1386             RETVAL
1387              
1388             # ------------------------------------------------------------------ #
1389             # Custom op direct invocation XSUBs #
1390             # #
1391             # These call the pp_ functions directly, giving the same speedup #
1392             # as injected custom ops but accessible as regular function calls. #
1393             # ------------------------------------------------------------------ #
1394              
1395             # op_identify($self, \$content) -> UUID string
1396             # Calls pp_apophis_identify directly — no method dispatch.
1397              
1398             SV *
1399             op_identify(self, content_ref)
1400             SV *self
1401             SV *content_ref
1402             PREINIT:
1403             HV *hv;
1404             const unsigned char *ns;
1405             SV *content_sv;
1406             const char *content;
1407             STRLEN content_len;
1408             unsigned char uuid[16];
1409             CODE:
1410 2 50         if (!sv_isobject(self))
1411 0           croak("Apophis::op_identify: not an object");
1412 2           hv = (HV *)SvRV(self);
1413 2           ns = apophis_get_ns(aTHX_ hv);
1414              
1415 2 50         if (!SvROK(content_ref))
1416 0           croak("Apophis::op_identify: argument must be a scalar reference");
1417 2           content_sv = SvRV(content_ref);
1418 2           content = SvPV(content_sv, content_len);
1419              
1420 2           apophis_identify_content(uuid, ns, content, content_len);
1421 2           RETVAL = apophis_uuid_to_sv(aTHX_ uuid);
1422             OUTPUT:
1423             RETVAL
1424              
1425             # op_store($self, \$content) -> UUID string
1426             # Fused identify + mkdir + atomic write — single call, no intermediates.
1427              
1428             SV *
1429             op_store(self, content_ref)
1430             SV *self
1431             SV *content_ref
1432             PREINIT:
1433             HV *hv;
1434             const unsigned char *ns;
1435             SV *content_sv;
1436             const char *content;
1437             STRLEN content_len;
1438             unsigned char uuid[16];
1439             char id_str[HORUS_FMT_STR_LEN + 1];
1440             const char *store_dir;
1441             STRLEN store_dir_len;
1442             char path[APOPHIS_PATH_MAX];
1443             struct stat st;
1444             CODE:
1445 5 50         if (!sv_isobject(self))
1446 0           croak("Apophis::op_store: not an object");
1447 5           hv = (HV *)SvRV(self);
1448 5           ns = apophis_get_ns(aTHX_ hv);
1449              
1450 5 50         if (!SvROK(content_ref))
1451 0           croak("Apophis::op_store: argument must be a scalar reference");
1452 5           content_sv = SvRV(content_ref);
1453 5           content = SvPV(content_sv, content_len);
1454              
1455 5           apophis_identify_content(uuid, ns, content, content_len);
1456 5           horus_format_uuid(id_str, uuid, HORUS_FMT_STR);
1457              
1458 5           store_dir = apophis_get_store_dir(aTHX_ hv, NULL, &store_dir_len);
1459 5           apophis_build_path(path, sizeof(path),
1460             store_dir, store_dir_len,
1461             id_str, HORUS_FMT_STR_LEN);
1462              
1463 5 100         if (stat(path, &st) != 0) {
1464 4           apophis_ensure_parent_dir(path);
1465 4           apophis_atomic_write(aTHX_ path, content, content_len);
1466             }
1467              
1468 5           RETVAL = newSVpvn(id_str, HORUS_FMT_STR_LEN);
1469             OUTPUT:
1470             RETVAL
1471              
1472             # op_exists($self, $id) -> bool
1473             # Fused path computation + stat — single call.
1474              
1475             bool
1476             op_exists(self, id)
1477             SV *self
1478             SV *id
1479             PREINIT:
1480             HV *hv;
1481             const char *store_dir;
1482             STRLEN store_dir_len;
1483             const char *id_str;
1484             STRLEN id_len;
1485             char path[APOPHIS_PATH_MAX];
1486             struct stat st;
1487             CODE:
1488 6 50         if (!sv_isobject(self))
1489 0           croak("Apophis::op_exists: not an object");
1490 6           hv = (HV *)SvRV(self);
1491              
1492 6           store_dir = apophis_get_store_dir(aTHX_ hv, NULL, &store_dir_len);
1493 6           id_str = SvPV(id, id_len);
1494 6           apophis_build_path(path, sizeof(path),
1495             store_dir, store_dir_len, id_str, id_len);
1496              
1497 6 100         RETVAL = (stat(path, &st) == 0) ? TRUE : FALSE;
1498             OUTPUT:
1499             RETVAL
1500              
1501             # op_fetch($self, $id) -> \$content or undef
1502             # Fused path computation + stat + read — single call.
1503              
1504             SV *
1505             op_fetch(self, id)
1506             SV *self
1507             SV *id
1508             PREINIT:
1509             HV *hv;
1510             const char *store_dir;
1511             STRLEN store_dir_len;
1512             const char *id_str;
1513             STRLEN id_len;
1514             char path[APOPHIS_PATH_MAX];
1515             PerlIO *fh;
1516             struct stat st;
1517             SV *content;
1518             SSize_t nread;
1519             CODE:
1520 3 50         if (!sv_isobject(self))
1521 0           croak("Apophis::op_fetch: not an object");
1522 3           hv = (HV *)SvRV(self);
1523              
1524 3           store_dir = apophis_get_store_dir(aTHX_ hv, NULL, &store_dir_len);
1525 3           id_str = SvPV(id, id_len);
1526 3           apophis_build_path(path, sizeof(path),
1527             store_dir, store_dir_len, id_str, id_len);
1528              
1529 3 100         if (stat(path, &st) != 0) {
1530 1           RETVAL = &PL_sv_undef;
1531             } else {
1532 2           fh = PerlIO_open(path, "rb");
1533 2 50         if (!fh)
1534 0           croak("Apophis::op_fetch: cannot open '%s': %s",
1535             path, strerror(errno));
1536              
1537 2           content = newSV((STRLEN)st.st_size + 1);
1538 2           SvPOK_on(content);
1539 2           nread = PerlIO_read(fh, SvPVX(content), (Size_t)st.st_size);
1540 2           PerlIO_close(fh);
1541              
1542 2 50         if (nread < 0) {
1543 0           SvREFCNT_dec(content);
1544 0           croak("Apophis::op_fetch: read error on '%s'", path);
1545             }
1546 2           SvCUR_set(content, (STRLEN)nread);
1547 2           *SvEND(content) = '\0';
1548              
1549 2           RETVAL = newRV_noinc(content);
1550             }
1551             OUTPUT:
1552             RETVAL
1553              
1554             # op_verify($self, $id) -> bool
1555             # Fused read + streaming SHA-1 + compare — single call.
1556              
1557             bool
1558             op_verify(self, id)
1559             SV *self
1560             SV *id
1561             PREINIT:
1562             HV *hv;
1563             const unsigned char *ns;
1564             const char *store_dir;
1565             STRLEN store_dir_len;
1566             const char *id_str;
1567             STRLEN id_len;
1568             char path[APOPHIS_PATH_MAX];
1569             PerlIO *fh;
1570             unsigned char uuid[16];
1571             char recomputed[HORUS_FMT_STR_LEN + 1];
1572             CODE:
1573 4 50         if (!sv_isobject(self))
1574 0           croak("Apophis::op_verify: not an object");
1575 4           hv = (HV *)SvRV(self);
1576 4           ns = apophis_get_ns(aTHX_ hv);
1577              
1578 4           store_dir = apophis_get_store_dir(aTHX_ hv, NULL, &store_dir_len);
1579 4           id_str = SvPV(id, id_len);
1580 4           apophis_build_path(path, sizeof(path),
1581             store_dir, store_dir_len, id_str, id_len);
1582              
1583 4           fh = PerlIO_open(path, "rb");
1584 4 100         if (!fh) {
1585 1           RETVAL = FALSE;
1586             } else {
1587 3           apophis_identify_stream(aTHX_ uuid, ns, fh);
1588 3           PerlIO_close(fh);
1589              
1590 3           horus_format_uuid(recomputed, uuid, HORUS_FMT_STR);
1591 3           RETVAL = (id_len == HORUS_FMT_STR_LEN &&
1592 3           memcmp(id_str, recomputed, HORUS_FMT_STR_LEN) == 0)
1593 3 50         ? TRUE : FALSE;
    50          
1594             }
1595             OUTPUT:
1596             RETVAL
1597              
1598             # op_remove($self, $id) -> bool
1599             # Fused path + unlink + meta cleanup — single call.
1600              
1601             bool
1602             op_remove(self, id)
1603             SV *self
1604             SV *id
1605             PREINIT:
1606             HV *hv;
1607             const char *store_dir;
1608             STRLEN store_dir_len;
1609             const char *id_str;
1610             STRLEN id_len;
1611             char path[APOPHIS_PATH_MAX];
1612             int path_len;
1613             char meta_path[APOPHIS_PATH_MAX];
1614             int removed;
1615             CODE:
1616 2 50         if (!sv_isobject(self))
1617 0           croak("Apophis::op_remove: not an object");
1618 2           hv = (HV *)SvRV(self);
1619              
1620 2           store_dir = apophis_get_store_dir(aTHX_ hv, NULL, &store_dir_len);
1621 2           id_str = SvPV(id, id_len);
1622 2           path_len = apophis_build_path(path, sizeof(path),
1623             store_dir, store_dir_len,
1624             id_str, id_len);
1625              
1626 2           removed = (unlink(path) == 0);
1627              
1628 2           apophis_build_meta_path(meta_path, sizeof(meta_path),
1629             path, path_len);
1630 2           unlink(meta_path); /* ignore error — may not exist */
1631              
1632 2 100         RETVAL = removed ? TRUE : FALSE;
1633             OUTPUT:
1634             RETVAL
1635              
1636             # ------------------------------------------------------------------ #
1637             # Custom op introspection and testing #
1638             # ------------------------------------------------------------------ #
1639              
1640             # _make_op($type) -> confirmation string
1641             # Creates a custom OP node and returns its pp_addr name for testing.
1642              
1643             SV *
1644             _make_op(type)
1645             const char *type
1646             CODE:
1647 6 100         if (strEQ(type, "identify")) {
1648 1           OP *op = apophis_make_custom_op(aTHX_ pp_apophis_identify);
1649 1           RETVAL = newSVpvf("CUSTOM_OP@apophis_identify[%p]", (void *)op->op_ppaddr);
1650 1           FreeOp(op);
1651 5 100         } else if (strEQ(type, "store")) {
1652 1           OP *op = apophis_make_custom_op(aTHX_ pp_apophis_store);
1653 1           RETVAL = newSVpvf("CUSTOM_OP@apophis_store[%p]", (void *)op->op_ppaddr);
1654 1           FreeOp(op);
1655 4 100         } else if (strEQ(type, "exists")) {
1656 1           OP *op = apophis_make_custom_op(aTHX_ pp_apophis_exists);
1657 1           RETVAL = newSVpvf("CUSTOM_OP@apophis_exists[%p]", (void *)op->op_ppaddr);
1658 1           FreeOp(op);
1659 3 100         } else if (strEQ(type, "fetch")) {
1660 1           OP *op = apophis_make_custom_op(aTHX_ pp_apophis_fetch);
1661 1           RETVAL = newSVpvf("CUSTOM_OP@apophis_fetch[%p]", (void *)op->op_ppaddr);
1662 1           FreeOp(op);
1663 2 100         } else if (strEQ(type, "verify")) {
1664 1           OP *op = apophis_make_custom_op(aTHX_ pp_apophis_verify);
1665 1           RETVAL = newSVpvf("CUSTOM_OP@apophis_verify[%p]", (void *)op->op_ppaddr);
1666 1           FreeOp(op);
1667 1 50         } else if (strEQ(type, "remove")) {
1668 1           OP *op = apophis_make_custom_op(aTHX_ pp_apophis_remove);
1669 1           RETVAL = newSVpvf("CUSTOM_OP@apophis_remove[%p]", (void *)op->op_ppaddr);
1670 1           FreeOp(op);
1671             } else {
1672 0           croak("Apophis::_make_op: unknown type '%s'", type);
1673             }
1674             OUTPUT:
1675             RETVAL