diff --git a/Lsof.8 b/Lsof.8 index 51192e19..1e5fd037 100644 --- a/Lsof.8 +++ b/Lsof.8 @@ -900,6 +900,47 @@ When specified without a PGID set that's all it does. .B \-H directs lsof to print human readable sizes, e.g. 123.4K 456.7M. .TP \w'names'u+4 +.B \-J +selects JSON output mode. Instead of the traditional tabular or +.B \-F +field output, lsof produces a single JSON object on stdout containing +a +.B "processes" +array. Each process object contains its fields and a +.B "files" +array of open file entries. +.IP +Field selection follows the same rules as +.BR \-F : +use +.B \-F +with field characters to select which fields appear in the JSON output. +Without +.BR \-F , +the default field set is used. +.IP +.B \-J +is mutually exclusive with +.B \-j +and +.BR \-t . +Warnings and errors are sent to stderr; stdout is always valid JSON. +.TP \w'names'u+4 +.B \-j +selects JSON Lines output mode. Each open file produces one JSON +object per line, combining process and file fields in a single +denormalized record. This format is suitable for streaming pipelines, +log ingestion (Splunk, Datadog, Elastic), and line\-oriented tools. +.IP +Field selection follows the same rules as +.BR \-J . +.IP +.B \-j +is mutually exclusive with +.B \-J +and +.BR \-t . +.TP \w'names'u+4 .BI \-i " [i]" selects the listing of files any of whose Internet address matches the address specified in \fIi\fP. diff --git a/Makefile.am b/Makefile.am index fbe28fac..410c6ca3 100644 --- a/Makefile.am +++ b/Makefile.am @@ -132,7 +132,9 @@ DIALECT_NEUTRAL_TESTS = tests/case-00-hello.bash \ tests/case-20-offset-field.bash \ tests/case-20-repeat-count.bash \ tests/case-21-exit-Q-status.bash \ - tests/case-22-empty-process-name.bash + tests/case-22-empty-process-name.bash \ + tests/case-30-json-output.bash \ + tests/case-31-jsonl-output.bash TESTS = $(DIALECT_NEUTRAL_TESTS) EXTRA_DIST += $(DIALECT_NEUTRAL_TESTS) \ tests/case-13-classic.bash \ diff --git a/lib/common.h b/lib/common.h index 1e6c67ee..d5beb43e 100644 --- a/lib/common.h +++ b/lib/common.h @@ -684,6 +684,9 @@ extern int ErrStat; extern uid_t Euid; extern int Fcntx; extern int Ffield; +extern int Fjson; +extern int Fjsonl; +extern int Fjson_first_proc; extern int Ffilesys; extern int Fhelp; extern int Fhost; diff --git a/lib/dialects/aix/Makefile b/lib/dialects/aix/Makefile index 980e78f8..00a57915 100644 --- a/lib/dialects/aix/Makefile +++ b/lib/dialects/aix/Makefile @@ -155,7 +155,7 @@ misc.o: ${HDR} misc.c node.o: ${HDR} node.c -print.o: ${HDR} print.c +print.o: ${HDR} version.h print.c proc.o: ${HDR} proc.c diff --git a/lib/dialects/darwin/Makefile b/lib/dialects/darwin/Makefile index 81fab7eb..e7ababc0 100644 --- a/lib/dialects/darwin/Makefile +++ b/lib/dialects/darwin/Makefile @@ -162,7 +162,7 @@ misc.o: ${HDR} misc.c node.o: ${HDR} node.c -print.o: ${HDR} print.c +print.o: ${HDR} version.h print.c proc.o: ${HDR} proc.c diff --git a/lib/dialects/freebsd/Makefile b/lib/dialects/freebsd/Makefile index 33c0aaec..e1e40b18 100644 --- a/lib/dialects/freebsd/Makefile +++ b/lib/dialects/freebsd/Makefile @@ -141,7 +141,7 @@ misc.o: ${HDR} misc.c node.o: ${HDR} node.c -print.o: ${HDR} print.c +print.o: ${HDR} version.h print.c proc.o: ${HDR} proc.c diff --git a/lib/dialects/hpux/kmem/Makefile b/lib/dialects/hpux/kmem/Makefile index 080063da..d159227f 100644 --- a/lib/dialects/hpux/kmem/Makefile +++ b/lib/dialects/hpux/kmem/Makefile @@ -147,7 +147,7 @@ misc.o: ${HDR} misc.c node.o: ${HDR} node.c -print.o: ${HDR} print.c +print.o: ${HDR} version.h print.c proc.o: ${HDR} proc.c diff --git a/lib/dialects/hpux/pstat/Makefile b/lib/dialects/hpux/pstat/Makefile index 9eace673..f30140e1 100644 --- a/lib/dialects/hpux/pstat/Makefile +++ b/lib/dialects/hpux/pstat/Makefile @@ -136,7 +136,7 @@ misc.o: ${HDR} misc.c node.o: ${HDR} node.c -print.o: ${HDR} print.c +print.o: ${HDR} version.h print.c proc.o: ${HDR} proc.c diff --git a/lib/dialects/linux/Makefile b/lib/dialects/linux/Makefile index f8adaa60..cbfefebf 100644 --- a/lib/dialects/linux/Makefile +++ b/lib/dialects/linux/Makefile @@ -167,7 +167,7 @@ misc.o: ${HDR} misc.c node.o: ${HDR} node.c -print.o: ${HDR} print.c +print.o: ${HDR} version.h print.c proc.o: ${HDR} proc.c diff --git a/lib/dialects/netbsd/Makefile b/lib/dialects/netbsd/Makefile index 602673f2..c62f67e5 100644 --- a/lib/dialects/netbsd/Makefile +++ b/lib/dialects/netbsd/Makefile @@ -145,7 +145,7 @@ misc.o: ${HDR} misc.c node.o: ${HDR} node.c -print.o: ${HDR} print.c +print.o: ${HDR} version.h print.c proc.o: ${HDR} proc.c diff --git a/lib/dialects/openbsd/Makefile b/lib/dialects/openbsd/Makefile index 5b566fc0..36ee75c1 100644 --- a/lib/dialects/openbsd/Makefile +++ b/lib/dialects/openbsd/Makefile @@ -145,7 +145,7 @@ misc.o: ${HDR} misc.c node.o: ${HDR} node.c -print.o: ${HDR} print.c +print.o: ${HDR} version.h print.c proc.o: ${HDR} proc.c diff --git a/lib/dialects/osr/Makefile b/lib/dialects/osr/Makefile index 79c38dfd..1b3e1f02 100644 --- a/lib/dialects/osr/Makefile +++ b/lib/dialects/osr/Makefile @@ -151,7 +151,7 @@ misc.o: ${HDR} misc.c node.o: ${HDR} node.c -print.o: ${HDR} print.c +print.o: ${HDR} version.h print.c proc.o: ${HDR} proc.c diff --git a/lib/dialects/sun/Makefile b/lib/dialects/sun/Makefile index c3e0cd4b..449947bc 100644 --- a/lib/dialects/sun/Makefile +++ b/lib/dialects/sun/Makefile @@ -146,7 +146,7 @@ misc.o: ${HDR} misc.c node.o: ${HDR} node.c -print.o: ${HDR} print.c +print.o: ${HDR} version.h print.c proc.o: ${HDR} proc.c diff --git a/lib/dialects/uw/Makefile b/lib/dialects/uw/Makefile index cf9f38e6..c5fdb1a5 100644 --- a/lib/dialects/uw/Makefile +++ b/lib/dialects/uw/Makefile @@ -145,7 +145,7 @@ misc.o: ${HDR} misc.c node.o: ${HDR} node.c -print.o: ${HDR} print.c +print.o: ${HDR} version.h print.c proc.o: ${HDR} proc.c diff --git a/lib/proto.h b/lib/proto.h index 2caceb80..2d28a90c 100644 --- a/lib/proto.h +++ b/lib/proto.h @@ -187,6 +187,8 @@ extern void print_init(struct lsof_context *ctx); extern void printname(struct lsof_context *ctx, int nl); extern char *print_kptr(KA_T kp, char *buf, size_t bufl); extern int print_proc(struct lsof_context *ctx); +extern void json_open_envelope(void); +extern void json_close_envelope(void); extern void fd_to_string(enum lsof_fd_type fd_type, int fd_num, char *buf); extern void printrawaddr(struct lsof_context *ctx, struct sockaddr *sa); extern void print_tcptpi(struct lsof_context *ctx, int nl); diff --git a/src/main.c b/src/main.c index 0c2e98a0..bfcc714b 100644 --- a/src/main.c +++ b/src/main.c @@ -47,6 +47,58 @@ static int GetOpt(struct lsof_context *ctx, int ct, char *opt[], char *rules, int *err); static char *sv_fmt_str(struct lsof_context *ctx, char *f); +/* + * select_default_fields() - enable the default set of FieldSel entries + * + * Shared by -F (with no field chars) and -J/-j (when no -F given). + */ +static void select_default_fields(void) { + int i; + for (i = 0; FieldSel[i].nm; i++) { + +#if !defined(HASPPID) + if (FieldSel[i].id == LSOF_FID_PPID) + continue; +#endif /* !defined(HASPPID) */ + +#if !defined(HASTASKS) + if (FieldSel[i].id == LSOF_FID_TCMD) + continue; +#endif /* !defined(HASTASKS) */ + +#if !defined(HASFSTRUCT) + if (FieldSel[i].id == LSOF_FID_CT || FieldSel[i].id == LSOF_FID_FA || + FieldSel[i].id == LSOF_FID_FG || FieldSel[i].id == LSOF_FID_NI) + continue; +#endif /* !defined(HASFSTRUCT) */ + +#if defined(HASSELINUX) + if ((FieldSel[i].id == LSOF_FID_CNTX) && !CntxStatus) + continue; +#else /* !defined(HASSELINUX) */ + if (FieldSel[i].id == LSOF_FID_CNTX) + continue; +#endif /* !defined(HASSELINUX) */ + + if (FieldSel[i].id == LSOF_FID_RDEV) + continue; /* for compatibility */ + +#if !defined(HASTASKS) + if (FieldSel[i].id == LSOF_FID_TID) + continue; +#endif /* !defined(HASTASKS) */ + +#if !defined(HASZONES) + if (FieldSel[i].id == LSOF_FID_ZONE) + continue; +#endif /* !defined(HASZONES) */ + + FieldSel[i].st = 1; + if (FieldSel[i].opt && FieldSel[i].ov) + *(FieldSel[i].opt) |= FieldSel[i].ov; + } +} + /* * main() - main function for lsof */ @@ -151,7 +203,7 @@ int main(int argc, char *argv[]) { * Create option mask. */ (void)snpf(options, sizeof(options), - "?a%sbc:%sD:d:%s%sf:F:g:hHi:%s%slL:%s%snNo:Op:QPr:%ss:S:tT:u:" + "?a%sbc:%sD:d:%s%sf:F:g:hHi:%s%sJjlL:%s%snNo:Op:QPr:%ss:S:tT:u:" "UvVwx:%s%s%s", #if defined(HAS_AFS) && defined(HASAOPT) @@ -429,51 +481,7 @@ int main(int argc, char *argv[]) { } else if (*GOv == '0') Terminator = '\0'; } - for (i = 0; FieldSel[i].nm; i++) { - -#if !defined(HASPPID) - if (FieldSel[i].id == LSOF_FID_PPID) - continue; -#endif /* !defined(HASPPID) */ - -#if !defined(HASTASKS) - if (FieldSel[i].id == LSOF_FID_TCMD) - continue; -#endif /* !defined(HASTASKS) */ - -#if !defined(HASFSTRUCT) - if (FieldSel[i].id == LSOF_FID_CT || - FieldSel[i].id == LSOF_FID_FA || - FieldSel[i].id == LSOF_FID_FG || - FieldSel[i].id == LSOF_FID_NI) - continue; -#endif /* !defined(HASFSTRUCT) */ - -#if defined(HASSELINUX) - if ((FieldSel[i].id == LSOF_FID_CNTX) && !CntxStatus) - continue; -#else /* !defined(HASSELINUX) */ - if (FieldSel[i].id == LSOF_FID_CNTX) - continue; -#endif /* !defined(HASSELINUX) */ - - if (FieldSel[i].id == LSOF_FID_RDEV) - continue; /* for compatibility */ - -#if !defined(HASTASKS) - if (FieldSel[i].id == LSOF_FID_TID) - continue; -#endif /* !defined(HASTASKS) */ - -#if !defined(HASZONES) - if (FieldSel[i].id == LSOF_FID_ZONE) - continue; -#endif /* !defined(HASZONES) */ - - FieldSel[i].st = 1; - if (FieldSel[i].opt && FieldSel[i].ov) - *(FieldSel[i].opt) |= FieldSel[i].ov; - } + select_default_fields(); #if defined(HASFSTRUCT) Ffield = FsvFlagX = 1; @@ -556,6 +564,24 @@ int main(int argc, char *argv[]) { case '?': Fhelp = 1; break; + case 'J': + if (GOp == '+') { + (void)fprintf(stderr, "%s: +J is not supported\n", Pn); + err = 1; + break; + } + Fjson = 1; + Ffield = 1; + break; + case 'j': + if (GOp == '+') { + (void)fprintf(stderr, "%s: +j is not supported\n", Pn); + err = 1; + break; + } + Fjsonl = 1; + Ffield = 1; + break; case 'i': if (!GOv || *GOv == '-' || *GOv == '+') { Fnet = 1; @@ -1097,6 +1123,14 @@ int main(int argc, char *argv[]) { (void)fprintf(stderr, "%s: -x must accompany +d or +D\n", Pn); err++; } + if (Fjson && Fjsonl) { + (void)fprintf(stderr, "%s: -J and -j are mutually exclusive\n", Pn); + err++; + } + if ((Fjson || Fjsonl) && Fterse) { + (void)fprintf(stderr, "%s: -J/-j and -t are mutually exclusive\n", Pn); + err++; + } #if defined(HASEOPT) if (Efsysl) { @@ -1126,6 +1160,24 @@ int main(int argc, char *argv[]) { } #endif /* defined(HASEOPT) */ + /* + * If -J/-j was given, ensure field selections are set. + * If -F was also given with field chars, those selections are already + * in FieldSel[]. Otherwise, enable the default field set. + */ + if (Fjson || Fjsonl) { + int has_fields = 0; + for (i = 0; FieldSel[i].nm; i++) { + if (FieldSel[i].st && FieldSel[i].id != LSOF_FID_PID && + FieldSel[i].id != LSOF_FID_MARK) { + has_fields = 1; + break; + } + } + if (!has_fields) + select_default_fields(); + } + if (DChelp || err || Fhelp || fh || version) usage(ctx, err ? 1 : 0, fh, version); /* @@ -1312,6 +1364,9 @@ int main(int argc, char *argv[]) { (void)qsort((QSORT_P *)slp, (size_t)Nlproc, (size_t)sizeof(struct lproc *), comppid); } + if (Fjson) { + json_open_envelope(); + } if ((n = Nlproc)) { #if defined(HASNCACHE) @@ -1468,6 +1523,11 @@ int main(int argc, char *argv[]) { } Lf = lf; } + if (Fjson) { + json_close_envelope(); + } else if (Fjsonl && RptTm) { + putchar('\n'); + } /* * If a repeat time is set, sleep for the specified time. * @@ -1515,7 +1575,9 @@ int main(int argc, char *argv[]) { } #endif /* defined(HAS_STRFTIME) */ - if (Ffield) { + if (Fjson || Fjsonl) { + /* JSON modes handle their own cycle separation */ + } else if (Ffield) { putchar(LSOF_FID_MARK); #if defined(HAS_STRFTIME) diff --git a/src/print.c b/src/print.c index 494f9eb3..351ef574 100644 --- a/src/print.c +++ b/src/print.c @@ -31,6 +31,8 @@ #include "common.h" #include "cli.h" #include "proto.h" +#include "version.h" +#include /* * Local definitions, structures and function prototypes @@ -93,6 +95,99 @@ static char *lkup_svcnam(struct lsof_context *ctx, int h, int p, int pr, static int printinaddr(struct lsof_context *ctx); static int human_readable_size(SZOFFTYPE sz, int print, int col); +/* + * JSON output helpers + */ + +static void json_puts_escaped(const char *s) { + const unsigned char *p = (const unsigned char *)s; + while (*p) { + switch (*p) { + case '"': + fputs("\\\"", stdout); + break; + case '\\': + fputs("\\\\", stdout); + break; + case '\b': + fputs("\\b", stdout); + break; + case '\f': + fputs("\\f", stdout); + break; + case '\n': + fputs("\\n", stdout); + break; + case '\r': + fputs("\\r", stdout); + break; + case '\t': + fputs("\\t", stdout); + break; + default: + if (*p < 0x20) + printf("\\u%04x", (unsigned int)*p); + else + putchar(*p); + break; + } + p++; + } +} + +/* + * json_print_key() - print comma separator and escaped JSON key + */ +static void json_print_key(int *sep, const char *key) { + if (*sep) + putchar(','); + putchar('"'); + json_puts_escaped(key); + printf("\":"); + *sep = 1; +} + +static void json_print_str(int *sep, const char *key, const char *val) { + json_print_key(sep, key); + putchar('"'); + json_puts_escaped(val); + putchar('"'); +} + +static void json_print_char(int *sep, const char *key, char val) { + json_print_key(sep, key); + putchar('"'); + if (val == '"') + fputs("\\\"", stdout); + else if (val == '\\') + fputs("\\\\", stdout); + else if (val < 0x20) + printf("\\u%04x", (unsigned int)(unsigned char)val); + else + putchar(val); + putchar('"'); +} + +static void json_print_int(int *sep, const char *key, int val) { + json_print_key(sep, key); + printf("%d", val); +} + +static void json_print_uint64_str(int *sep, const char *key, uint64_t val) { + json_print_key(sep, key); + printf("\"%" PRIu64 "\"", val); +} + +static void json_print_long(int *sep, const char *key, long val) { + json_print_key(sep, key); + printf("%ld", val); +} + +static void json_print_ulong(int *sep, const char *key, unsigned long val) { + json_print_key(sep, key); + printf("%lu", val); +} + #if !defined(HASNORPC_H) /* * fill_portmap() -- fill the RPC portmap program name table via a conversation @@ -1787,6 +1882,195 @@ int human_readable_size(SZOFFTYPE sz, int print, int col) { return strlen(buf); } +/* + * json_open_envelope() - emit JSON opening envelope for -J mode + */ +void json_open_envelope(void) { + printf("{\"lsof_version\":\"%s\",\"processes\":[", LSOF_VERSION); + Fjson_first_proc = 1; +} + +/* + * json_close_envelope() - emit JSON closing envelope for -J mode + */ +void json_close_envelope(void) { + printf("]}\n"); +} + +/* + * json_print_file() - print a single file entry as JSON fields + */ +static void json_print_file(struct lsof_context *ctx, int *sep) { + char buf[128]; + char fd_str[FDLEN]; + char type[TYPEL]; + char *cp; + unsigned long ul; + + if (FieldSel[LSOF_FIX_FD].st) { + if (Lf->fd_type == LSOF_FD_NUMERIC) { + char num_buf[32]; + snprintf(num_buf, sizeof(num_buf), "%d", Lf->fd_num); + json_print_str(sep, "fd", num_buf); + } else { + fd_to_string(Lf->fd_type, Lf->fd_num, fd_str); + json_print_str(sep, "fd", fd_str); + } + } + if (FieldSel[LSOF_FIX_ACCESS].st) { + char a = access_to_char(Lf->access); + if (a != ' ') + json_print_char(sep, "access", a); + } + if (FieldSel[LSOF_FIX_LOCK].st) { + char l = lock_to_char(Lf->lock); + if (l != ' ') + json_print_char(sep, "lock", l); + } + if (FieldSel[LSOF_FIX_TYPE].st && Lf->type != LSOF_FILE_NONE) { + file_type_to_string(Lf->type, Lf->unknown_file_type_number, type, + TYPEL); + json_print_str(sep, "type", type); + } + if (FieldSel[LSOF_FIX_DEVCH].st && Lf->dev_ch && Lf->dev_ch[0]) { + for (cp = Lf->dev_ch; *cp == ' '; cp++) + ; + if (*cp) + json_print_str(sep, "device", cp); + } + if (FieldSel[LSOF_FIX_DEVN].st && Lf->dev_def) { + if (sizeof(unsigned long) > sizeof(dev_t)) + ul = (unsigned long)((unsigned int)Lf->dev); + else + ul = (unsigned long)Lf->dev; + snprintf(buf, sizeof(buf), "0x%lx", ul); + json_print_str(sep, "device_number", buf); + } + if (FieldSel[LSOF_FIX_RDEV].st && Lf->rdev_def) { + if (sizeof(unsigned long) > sizeof(dev_t)) + ul = (unsigned long)((unsigned int)Lf->rdev); + else + ul = (unsigned long)Lf->rdev; + snprintf(buf, sizeof(buf), "0x%lx", ul); + json_print_str(sep, "raw_device", buf); + } + if (FieldSel[LSOF_FIX_SIZE].st && Lf->sz_def) { + json_print_uint64_str(sep, "size", (uint64_t)Lf->sz); + } + if (FieldSel[LSOF_FIX_OFFSET].st && Lf->off_def) { + json_print_uint64_str(sep, "offset", (uint64_t)Lf->off); + } + if (FieldSel[LSOF_FIX_INODE].st && Lf->inp_ty == 1) { + json_print_uint64_str(sep, "inode", (uint64_t)Lf->inode); + } + if (FieldSel[LSOF_FIX_NLINK].st && Lf->nlink_def) { + json_print_long(sep, "nlink", Lf->nlink); + } + if (FieldSel[LSOF_FIX_PROTO].st && Lf->inp_ty == 2) { + for (cp = Lf->iproto; *cp == ' '; cp++) + ; + if (*cp) + json_print_str(sep, "protocol", cp); + } + +#if defined(HASFSTRUCT) + if (FieldSel[LSOF_FIX_FG].st && (Fsv & FSV_FG) && (Lf->fsv & FSV_FG) && + (FsvFlagX || Lf->ffg || Lf->pof)) { + json_print_str(sep, "flags", print_fflags(ctx, Lf->ffg, Lf->pof)); + } +#endif /* defined(HASFSTRUCT) */ + + if (FieldSel[LSOF_FIX_NAME].st && Lf->nm) { + json_print_str(sep, "name", Lf->nm); + } + + /* TCP/TPI info as nested object */ + if (Lf->lts.type >= 0 && FieldSel[LSOF_FIX_TCPTPI].st) { + if (*sep) + putchar(','); + printf("\"tcp_info\":{"); + int tsep = 0; + + if ((Ftcptpi & TCPTPI_STATE) && Lf->lts.type == 0) { + if (!TcpNstates) + (void)build_IPstates(ctx); + int s = Lf->lts.state.i; + if (s >= 0 && s < TcpNstates && TcpSt[s]) + json_print_str(&tsep, "state", TcpSt[s]); + else { + snprintf(buf, sizeof(buf), "UNKNOWN_%d", s); + json_print_str(&tsep, "state", buf); + } + } +#if defined(HASTCPTPIQ) + if (Ftcptpi & TCPTPI_QUEUES) { + if (Lf->lts.rqs) + json_print_ulong(&tsep, "recv_queue", Lf->lts.rq); + if (Lf->lts.sqs) + json_print_ulong(&tsep, "send_queue", Lf->lts.sq); + } +#endif /* defined(HASTCPTPIQ) */ +#if defined(HASTCPTPIW) + if (Ftcptpi & TCPTPI_WINDOWS) { + if (Lf->lts.rws) + json_print_ulong(&tsep, "read_window", Lf->lts.rw); + if (Lf->lts.wws) + json_print_ulong(&tsep, "write_window", Lf->lts.ww); + } +#endif /* defined(HASTCPTPIW) */ + putchar('}'); + *sep = 1; + } +} + +/* + * json_print_proc_fields() - print process-level JSON fields + */ +static void json_print_proc_fields(struct lsof_context *ctx, int *sep) { + int ty; + char *cp; + + if (FieldSel[LSOF_FIX_PID].st) + json_print_int(sep, "pid", Lp->pid); + +#if defined(HASTASKS) + if (FieldSel[LSOF_FIX_TID].st && Lp->tid) + json_print_int(sep, "tid", Lp->tid); + if (FieldSel[LSOF_FIX_TCMD].st && Lp->tcmd) + json_print_str(sep, "task_cmd", Lp->tcmd); +#endif + +#if defined(HASZONES) + if (FieldSel[LSOF_FIX_ZONE].st && Fzone && Lp->zn) + json_print_str(sep, "zone", Lp->zn); +#endif + +#if defined(HASSELINUX) + if (FieldSel[LSOF_FIX_CNTX].st && Fcntx && Lp->cntx && CntxStatus) + json_print_str(sep, "security_context", Lp->cntx); +#endif + + if (FieldSel[LSOF_FIX_PGID].st && Fpgid) + json_print_int(sep, "pgid", Lp->pgid); + +#if defined(HASPPID) + if (FieldSel[LSOF_FIX_PPID].st && Fppid) + json_print_int(sep, "ppid", Lp->ppid); +#endif + + if (FieldSel[LSOF_FIX_CMD].st) + json_print_str(sep, "command", Lp->cmd ? Lp->cmd : "(unknown)"); + + if (FieldSel[LSOF_FIX_UID].st) + json_print_int(sep, "uid", (int)Lp->uid); + + if (FieldSel[LSOF_FIX_LOGIN].st) { + cp = printuid(ctx, (UID_ARG)Lp->uid, &ty); + if (ty == 0) + json_print_str(sep, "login", cp); + } +} + /* * print_proc() - print process */ @@ -1820,6 +2104,56 @@ int print_proc(struct lsof_context *ctx) { } return (0); } + /* + * JSON output modes — only emit on PrPass 1 (actual printing pass) + */ + if ((Fjson || Fjsonl) && PrPass) { + for (Lf = Lp->file; Lf; Lf = Lf->next) { + if (is_file_sel(ctx, Lp, Lf)) + break; + } + if (!Lf) + return (rv); + rv = 1; + + if (Fjson) { + /* Nested JSON: process object with files array */ + if (!Fjson_first_proc) + putchar(','); + Fjson_first_proc = 0; + int sep = 0; + putchar('{'); + json_print_proc_fields(ctx, &sep); + if (sep) + putchar(','); + printf("\"files\":["); + int first_file = 1; + for (Lf = Lp->file; Lf; Lf = Lf->next) { + if (!is_file_sel(ctx, Lp, Lf)) + continue; + if (!first_file) + putchar(','); + putchar('{'); + int fsep = 0; + json_print_file(ctx, &fsep); + putchar('}'); + first_file = 0; + } + printf("]}"); + } else { + /* JSON Lines: one line per file, process fields denormalized */ + for (Lf = Lp->file; Lf; Lf = Lf->next) { + if (!is_file_sel(ctx, Lp, Lf)) + continue; + int sep = 0; + putchar('{'); + json_print_proc_fields(ctx, &sep); + json_print_file(ctx, &sep); + printf("}\n"); + } + } + return (rv); + } /* * If fields have been selected, output the process-only ones, provided * that some file has also been selected. diff --git a/src/store.c b/src/store.c index 8e7dfd25..a51c49cf 100644 --- a/src/store.c +++ b/src/store.c @@ -72,6 +72,9 @@ int NcacheReload = 1; /* 1 == call ncache_load() */ #endif /* defined(HASNCACHE) */ int Ffield = 0; /* -f and -F status */ +int Fjson = 0; /* -J JSON output status */ +int Fjsonl = 0; /* -j JSON Lines output status */ +int Fjson_first_proc = 1; /* first process flag for JSON comma handling */ int FgColW; /* FILE-FLAG column width */ int Fhelp = 0; /* -h option status */ int Fhost = 1; /* -H option status */ diff --git a/tests/case-30-json-output.bash b/tests/case-30-json-output.bash new file mode 100755 index 00000000..4ad075bc --- /dev/null +++ b/tests/case-30-json-output.bash @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +source tests/common.bash + +# Skip if python3 not available (needed for JSON validation) +if ! command -v python3 &>/dev/null; then + echo "SKIP: python3 not available" >> $report + exit 77 +fi + +{ + # Test 1: -J produces valid JSON + output=$($lsof -J -p $$ 2>/dev/null) + if ! echo "$output" | python3 -m json.tool > /dev/null 2>&1; then + echo "FAIL: -J output is not valid JSON" >> $report + exit 1 + fi + echo "PASS: -J produces valid JSON" >> $report + + # Test 2: -J output has lsof_version and processes keys + echo "$output" | python3 -c " +import sys, json +d = json.load(sys.stdin) +assert 'lsof_version' in d, 'missing lsof_version' +assert 'processes' in d, 'missing processes' +assert isinstance(d['processes'], list), 'processes is not a list' +assert len(d['processes']) > 0, 'no processes found' +" 2>&1 || { echo "FAIL: -J output structure invalid" >> $report; exit 1; } + echo "PASS: -J output structure valid" >> $report + + # Test 3: -J -Fpcfn only includes selected fields + output_sel=$($lsof -J -Fpcfn -p $$ 2>/dev/null) + echo "$output_sel" | python3 -c " +import sys, json +d = json.load(sys.stdin) +p = d['processes'][0] +assert 'pid' in p, 'missing pid' +assert 'command' in p, 'missing command' +f = p['files'][0] +allowed_file_keys = {'fd', 'name'} +actual_file_keys = set(f.keys()) +extra_keys = actual_file_keys - allowed_file_keys +assert not extra_keys, 'unexpected file keys with -Fpcfn: %s' % extra_keys +assert 'uid' not in p, 'uid should not be present with -Fpcfn' +" 2>&1 || { echo "FAIL: -J field selection not working" >> $report; exit 1; } + echo "PASS: -J field selection works" >> $report + + # Test 4: -J empty result produces valid JSON + output_empty=$($lsof -J -p 999999 2>/dev/null) + echo "$output_empty" | python3 -c " +import sys, json +d = json.load(sys.stdin) +assert d['processes'] == [], 'empty result should have empty processes array' +" 2>&1 || { echo "FAIL: -J empty result is not valid JSON" >> $report; exit 1; } + echo "PASS: -J empty result is valid JSON" >> $report + + # Test 5: -J and -j are mutually exclusive + if $lsof -J -j 2>/dev/null; then + echo "FAIL: -J -j should error" >> $report + exit 1 + fi + echo "PASS: -J -j mutual exclusivity" >> $report + + # Test 6: -J and -t are mutually exclusive + if $lsof -J -t 2>/dev/null; then + echo "FAIL: -J -t should error" >> $report + exit 1 + fi + echo "PASS: -J -t mutual exclusivity" >> $report + + exit 0 +} > $report 2>&1 diff --git a/tests/case-31-jsonl-output.bash b/tests/case-31-jsonl-output.bash new file mode 100755 index 00000000..6a6b23c0 --- /dev/null +++ b/tests/case-31-jsonl-output.bash @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +source tests/common.bash + +# Skip if python3 not available +if ! command -v python3 &>/dev/null; then + echo "SKIP: python3 not available" >> $report + exit 77 +fi + +{ + # Test 1: -j produces valid JSON Lines (each line is valid JSON) + tmpout=$(mktemp) + $lsof -j -p $$ 2>/dev/null > "$tmpout" + fail=0 + while IFS= read -r line; do + if [ -z "$line" ]; then + continue + fi + if ! echo "$line" | python3 -m json.tool > /dev/null 2>&1; then + echo "FAIL: -j line is not valid JSON: $line" >> $report + fail=1 + break + fi + done < "$tmpout" + rm -f "$tmpout" + if [ $fail -ne 0 ]; then + exit 1 + fi + echo "PASS: -j produces valid JSON Lines" >> $report + + # Test 2: -j lines contain both process and file fields (denormalized) + line=$($lsof -j -p $$ 2>/dev/null | head -1) + echo "$line" | python3 -c " +import sys, json +d = json.load(sys.stdin) +assert 'pid' in d, 'missing pid' +assert 'fd' in d or 'command' in d, 'missing file or process fields' +" 2>&1 || { echo "FAIL: -j line missing expected fields" >> $report; exit 1; } + echo "PASS: -j lines have denormalized fields" >> $report + + # Test 3: -j with no matching process produces empty output + output=$($lsof -j -p 999999 2>/dev/null) + if [ -n "$output" ]; then + echo "FAIL: -j with no matches should produce empty output" >> $report + exit 1 + fi + echo "PASS: -j empty result is empty" >> $report + + exit 0 +} > $report 2>&1