#!/usr/bin/env python3
"""Read-only Linux inventory. Python 3.8+, standard library only.
No sudo, installs, network requests, uploads, service changes or project execution.
Usage: python3 lifecycle_inventory.py --asset-id laptop --output laptop
Optional: --package-lock /explicit/project/package-lock.json (npm v2/v3 only).
Writes PREFIX.csv and PREFIX.json, refusing to overwrite existing files.
"""
import argparse
import csv
import datetime
import json
import os
from pathlib import Path
import platform
import shutil
import subprocess
import sys

FIELDS = ['asset_id','asset_type','product','version','vendor','edition','distribution',
          'distribution_version','package_release','package_source','purl','image_ref',
          'parent_image_ref','environment','exposure','service','owner','criticality',
          'support_provider','support_until','support_evidence_ref','architecture']

def os_release():
    data = {}
    try:
        for line in Path('/etc/os-release').read_text().splitlines():
            if '=' in line and not line.startswith('#'):
                key,value = line.split('=',1)
                data[key] = value.strip().strip('"').strip("'")
    except OSError:
        pass
    return data

def command(args, warnings):
    executable = shutil.which(args[0])
    if not executable:
        return None
    try:
        # Fixed executable arguments only; never shell=True or execute package scripts.
        result = subprocess.run([executable]+args[1:], stdout=subprocess.PIPE,
            stderr=subprocess.PIPE, text=True, timeout=60,
            env={**os.environ, 'LC_ALL':'C', 'LANG':'C'})
        if result.returncode:
            warnings.append(args[0]+': query failed (exit '+str(result.returncode)+'); no raw stderr collected')
            return None
        return result.stdout
    except (OSError, subprocess.TimeoutExpired):
        warnings.append(args[0]+': query unavailable or timed out')
        return None

def collect(asset, lock=None):
    distro = os_release()
    rows, warnings = [], []
    def add(product, version, source, kind='server', arch='', **extra):
        row = dict.fromkeys(FIELDS,'')
        row.update(asset_id=asset,asset_type=kind,product=product,version=version,
            distribution=distro.get('ID','unknown'),distribution_version=distro.get('VERSION_ID',''),
            package_source=source,environment='unknown',exposure='unknown',architecture=arch)
        row.update(extra)
        rows.append(row)
    add(distro.get('NAME','Linux'), distro.get('VERSION_ID','unknown'), 'os-release', arch=platform.machine())
    add('Linux kernel (running)',platform.release(),'running-kernel',arch=platform.machine())
    if shutil.which('dpkg-query'):
        result=command(['dpkg-query','-W','-f=${db:Status-Status}\t${binary:Package}\t${Version}\t${Architecture}\n'],warnings)
        if result is not None:
            for line in result.splitlines():
                cols=line.split('\t')
                if len(cols)==4 and cols[0]=='installed':
                    add(cols[1],cols[2],'dpkg',arch=cols[3],package_release=cols[2])
    elif shutil.which('rpm'):
        result=command(['rpm','-qa','--qf','%{NAME}\t%{EPOCHNUM}:%{VERSION}-%{RELEASE}\t%{ARCH}\n'],warnings)
        if result is not None:
            for line in result.splitlines():
                cols=line.split('\t')
                if len(cols)==3:
                    add(cols[0],cols[1],'rpm',arch=cols[2],package_release=cols[1])
    else:
        warnings.append('No supported system package manager found: dpkg/rpm required for package coverage.')
    if lock:
        path=Path(lock)
        if path.stat().st_size > 20*1024*1024:
            raise ValueError('package-lock.json exceeds the 20 MB collector limit')
        content=json.loads(path.read_text())
        if content.get('lockfileVersion') not in (2,3) or not isinstance(content.get('packages'),dict):
            raise ValueError('Only npm package-lock v2/v3 is supported; no commands will be run in the project.')
        for location, item in content['packages'].items():
            if not location or not isinstance(item,dict) or item.get('link'):
                continue
            name=item.get('name') or location.rsplit('node_modules/',1)[-1]
            version=item.get('version')
            if isinstance(name,str) and isinstance(version,str):
                add(name,version,'npm-lockfile','application')
        warnings.append('npm lockfile records describe locked dependencies, not proof they are installed or deployed. Local paths, registry URLs and integrity values omitted.')
    return rows,warnings,distro

def safe_cell(value):
    # CSV-safe text for spreadsheet viewers. JSON retains the exact original value.
    value=str(value)
    return "'"+value if value.startswith(('=','+','-','@','\t','\r','\n')) else value

def main():
    parser=argparse.ArgumentParser(description=__doc__,formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument('--asset-id',required=True,help='Your alias, e.g. laptop or droplet. Hostname is never read.')
    parser.add_argument('--output',required=True,help='New file prefix; existing outputs will not be overwritten.')
    parser.add_argument('--package-lock',help='Optional explicit npm v2/v3 lockfile; never searches the disk.')
    args=parser.parse_args()
    if platform.system()!='Linux':
        parser.error('Linux only')
    if not args.asset_id.strip() or len(args.asset_id)>100 or any(ord(c)<32 for c in args.asset_id):
        parser.error('Use a nonempty asset alias of at most 100 characters without control characters.')
    prefix=Path(args.output)
    csv_path=Path(str(prefix)+'.csv'); json_path=Path(str(prefix)+'.json')
    if csv_path.exists() or json_path.exists():
        parser.error('Output already exists. Choose another --output prefix.')
    if not prefix.parent.is_dir():
        parser.error('Output directory must already exist.')
    rows,warnings,distro=collect(args.asset_id,args.package_lock)
    limitations=[
        'Installed inventory only: no EOL verdicts or vulnerability assessment.',
        'dpkg/rpm provenance identifies the package database, not a verified distribution support entitlement. Third-party repositories may supply packages.',
        'Does not discover containers/images, Snap, Flatpak, AppImage, manually copied software, other users runtimes, virtualenv/pip, nvm installations or project dependencies unless an explicit npm lockfile is supplied.',
        'Package names/versions and distribution release are retained for backport-aware matching; no upstream-only support decision is made.',
        'No hostnames, IP addresses, usernames, environment dumps, credentials, repository URLs or service configuration are collected.',
        'An installed package does not establish that its service is running or internet-exposed.',
        'Large machine exports can exceed the 500-row customer pilot limit. No records are silently truncated; internal matching tests may use the full export.'
    ]
    payload={'schema_version':'lifecycle.inventory.v0.1','collected_at':datetime.datetime.now(datetime.timezone.utc).isoformat(),
        'asset_id':args.asset_id,'collector':'linux-readonly-0.1','records':rows,
        'record_count':len(rows),'warnings':warnings,'limitations':limitations}
    # Exclusive create and owner-only permissions. Never remove a pre-existing path.
    created=[]
    oldmask=os.umask(0o077)
    try:
        with csv_path.open('x',newline='') as f:
            created.append(csv_path)
            w=csv.DictWriter(f,fieldnames=FIELDS);w.writeheader()
            for row in rows: w.writerow({k:safe_cell(v) for k,v in row.items()})
        with json_path.open('x') as f:
            created.append(json_path)
            json.dump(payload,f,indent=2,ensure_ascii=True);f.write('\n')
    except BaseException:
        for path in created: path.unlink(missing_ok=True)
        raise
    finally:
        os.umask(oldmask)
    print('Saved '+str(len(rows))+' records to '+str(csv_path)+' and '+str(json_path))
    print('No uploads or system changes. Upload the JSON for matching; CSV is for inspection.')
    for warning in warnings: print('Coverage note: '+warning)
    print('Coverage limits are recorded in the JSON. Root is not required.')

if __name__=='__main__':
    try: main()
    except (OSError,ValueError) as exc:
        print('Inventory error: '+str(exc),file=sys.stderr);sys.exit(1)
