001/*
002 * jPOS Project [http://jpos.org]
003 * Copyright (C) 2000-2026 jPOS Software SRL
004 *
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jpos.iso.packager;
020
021import org.jpos.iso.Dataset;
022import org.jpos.iso.DatasetElement;
023import org.jpos.iso.DatasetFormat;
024import org.jpos.iso.ISOBinaryField;
025import org.jpos.iso.ISOComponent;
026import org.jpos.iso.ISODataset;
027import org.jpos.iso.ISODatasetField;
028import org.jpos.iso.ISODatasetPackager;
029import org.jpos.iso.ISOException;
030import org.jpos.iso.ISOUtil;
031import org.jpos.tlv.TLVList;
032import org.jpos.tlv.TLVMsg;
033import org.jpos.util.LogEvent;
034import org.jpos.util.Logger;
035import org.xml.sax.Attributes;
036
037import java.io.ByteArrayOutputStream;
038import java.io.IOException;
039import java.io.InputStream;
040import java.util.ArrayList;
041import java.util.Arrays;
042import java.util.List;
043import java.util.Set;
044import java.util.TreeSet;
045
046/**
047 * Packager for ISO 8583:2023 composite fields that contain one or more datasets.
048 */
049public class DatasetPackager extends GenericPackager implements ISODatasetPackager {
050    private static final int TLV_DATASET_MAX_IDENTIFIER = 0x70;
051    private static final int DATASET_ENVELOPE_SIZE = 3;
052    private static final int DBM_INITIAL_BITS = 16;
053    private static final int DBM_CONTINUATION_BITS = 8;
054
055    private int fieldId;
056
057    /**
058     * Creates an empty dataset packager.
059     *
060     * @throws ISOException on packager initialization errors
061     */
062    public DatasetPackager() throws ISOException {
063        super();
064    }
065
066    /**
067     * Returns the outer ISO field number this dataset packager is bound to.
068     *
069     * @return outer field number, or {@code 0} when not initialized from XML
070     */
071    @Override
072    public int getFieldNumber() {
073        return fieldId;
074    }
075
076    /**
077     * Captures the outer field id declared in the XML packager definition.
078     *
079     * @param atts XML attributes for the current field packager
080     */
081    @Override
082    public void setGenericPackagerParams(Attributes atts) {
083        super.setGenericPackagerParams(atts);
084        fieldId = Integer.parseInt(atts.getValue("id"));
085    }
086
087    /**
088     * Packs a dataset field payload, including each dataset envelope.
089     *
090     * @param m dataset field component
091     * @return packed payload bytes
092     * @throws ISOException on packing errors
093     */
094    @Override
095    public byte[] pack(ISOComponent m) throws ISOException {
096        if (!(m instanceof ISODatasetField)) {
097            throw new ISOException("Can't call dataset packager on " + (m != null ? m.getClass().getName() : "null"));
098        }
099        LogEvent evt = new LogEvent(this, "pack");
100        try (ByteArrayOutputStream out = new ByteArrayOutputStream(128)) {
101            ISODatasetField field = (ISODatasetField) m;
102            for (Dataset dataset : field.getDatasets()) {
103                byte[] content = packDatasetContent(dataset);
104                if (content.length == 0) {
105                    throw new ISOException(String.format("Dataset %02X cannot be empty", dataset.getIdentifier()));
106                }
107                if (content.length > 0xFFFF) {
108                    throw new ISOException(String.format("Dataset %02X too long: %d", dataset.getIdentifier(), content.length));
109                }
110                out.write(dataset.getIdentifier() & 0xFF);
111                out.write((content.length >> 8) & 0xFF);
112                out.write(content.length & 0xFF);
113                out.write(content);
114            }
115            byte[] packed = out.toByteArray();
116            if (logger != null) {
117                evt.addMessage(ISOUtil.hexString(packed));
118            }
119            return packed;
120        } catch (ISOException e) {
121            evt.addMessage(e);
122            throw e;
123        } catch (Exception e) {
124            evt.addMessage(e);
125            throw new ISOException(e);
126        } finally {
127            Logger.log(evt);
128        }
129    }
130
131    /**
132     * Unpacks one or more datasets from a field payload.
133     *
134     * @param m destination dataset field
135     * @param b payload bytes
136     * @return number of bytes consumed
137     * @throws ISOException on unpacking errors
138     */
139    @Override
140    public int unpack(ISOComponent m, byte[] b) throws ISOException {
141        if (!(m instanceof ISODatasetField)) {
142            throw new ISOException("Can't call dataset packager on " + (m != null ? m.getClass().getName() : "null"));
143        }
144        LogEvent evt = new LogEvent(this, "unpack");
145        try {
146            ISODatasetField field = (ISODatasetField) m;
147            int consumed = 0;
148            while (consumed < b.length) {
149                if (b.length - consumed < DATASET_ENVELOPE_SIZE) {
150                    throw new ISOException("Truncated dataset envelope");
151                }
152                int identifier = b[consumed] & 0xFF;
153                int length = ((b[consumed + 1] & 0xFF) << 8) | (b[consumed + 2] & 0xFF);
154                consumed += DATASET_ENVELOPE_SIZE;
155                if (length <= 0) {
156                    throw new ISOException(String.format("Dataset %02X has invalid length %d", identifier, length));
157                }
158                if (consumed + length > b.length) {
159                    throw new ISOException(String.format("Dataset %02X overruns composite field", identifier));
160                }
161                byte[] content = Arrays.copyOfRange(b, consumed, consumed + length);
162                field.addDataset(unpackDataset(identifier, resolveDatasetFormat(identifier), content));
163                consumed += length;
164            }
165            if (logger != null) {
166                evt.addMessage(ISOUtil.hexString(b));
167            }
168            return consumed;
169        } catch (ISOException e) {
170            evt.addMessage(e);
171            throw e;
172        } catch (Exception e) {
173            evt.addMessage(e);
174            throw new ISOException(e);
175        } finally {
176            Logger.log(evt);
177        }
178    }
179
180    /**
181     * Unpacks one or more datasets from a stream-backed payload.
182     *
183     * @param m destination dataset field
184     * @param in source stream
185     * @throws IOException on stream errors
186     * @throws ISOException on unpacking errors
187     */
188    @Override
189    public void unpack(ISOComponent m, InputStream in) throws IOException, ISOException {
190        unpack(m, in.readAllBytes());
191    }
192
193    /**
194     * Resolves the format implied by a dataset identifier.
195     *
196     * @param identifier dataset identifier
197     * @return inferred dataset format
198     */
199    protected DatasetFormat resolveDatasetFormat(int identifier) {
200        return identifier <= TLV_DATASET_MAX_IDENTIFIER ? DatasetFormat.TLV : DatasetFormat.DBM;
201    }
202
203    /**
204     * Packs the inner dataset payload without the outer dataset envelope.
205     *
206     * @param dataset dataset to encode
207     * @return encoded dataset payload
208     * @throws ISOException on packing errors
209     */
210    protected byte[] packDatasetContent(Dataset dataset) throws ISOException {
211        if (dataset.getFormat() == DatasetFormat.TLV) {
212            return packTLV(dataset);
213        }
214        return packDBM(dataset);
215    }
216
217    /**
218     * Decodes a dataset payload without the outer dataset envelope.
219     *
220     * @param identifier dataset identifier
221     * @param format dataset format
222     * @param content dataset payload bytes
223     * @return decoded dataset
224     * @throws ISOException on decoding errors
225     */
226    protected Dataset unpackDataset(int identifier, DatasetFormat format, byte[] content) throws ISOException {
227        return format == DatasetFormat.TLV
228          ? unpackTLV(identifier, content)
229          : unpackDBM(identifier, content);
230    }
231
232    /**
233     * Decodes a TLV dataset payload.
234     *
235     * @param identifier dataset identifier
236     * @param content TLV payload bytes
237     * @return decoded dataset
238     * @throws ISOException on decoding errors
239     */
240    protected ISODataset unpackTLV(int identifier, byte[] content) throws ISOException {
241        TLVList tlv = new TLVList();
242        try {
243            tlv.unpack(content);
244        } catch (RuntimeException e) {
245            throw new ISOException(String.format("Invalid TLV dataset %02X", identifier), e);
246        }
247        ISODataset dataset = new ISODataset(identifier, DatasetFormat.TLV);
248        for (TLVMsg tag : tlv.getTags()) {
249            ISOBinaryField field = new ISOBinaryField(tag.getTag(), tag.getValue());
250            dataset.addElement(tag.getTag(), field, isConstructedTag(tag.getTag()));
251        }
252        return dataset;
253    }
254
255    /**
256     * Encodes a TLV dataset payload.
257     *
258     * @param dataset dataset to encode
259     * @return TLV payload bytes
260     * @throws ISOException on encoding errors
261     */
262    protected byte[] packTLV(Dataset dataset) throws ISOException {
263        try (ByteArrayOutputStream out = new ByteArrayOutputStream(128)) {
264            for (DatasetElement element : dataset.getElements()) {
265                TLVMsg tlv = new TLVMsg(element.getId(), element.getBytes());
266                out.write(tlv.getTLV());
267            }
268            return out.toByteArray();
269        } catch (IllegalArgumentException e) {
270            throw new ISOException(String.format("Unable to pack TLV dataset %02X", dataset.getIdentifier()), e);
271        } catch (IOException e) {
272            throw new ISOException(e);
273        }
274    }
275
276    /**
277     * Decodes a DBM dataset payload.
278     *
279     * @param identifier dataset identifier
280     * @param content DBM payload bytes
281     * @return decoded dataset
282     * @throws ISOException on decoding errors
283     */
284    protected ISODataset unpackDBM(int identifier, byte[] content) throws ISOException {
285        DBMBitmap dbm = unpackDBMBitmap(content);
286        ISODataset dataset = new ISODataset(identifier, DatasetFormat.DBM);
287        int consumed = dbm.consumed;
288        for (int elementId : dbm.elements) {
289            if (elementId >= fld.length || fld[elementId] == null) {
290                throw new ISOException(String.format("No packager defined for dataset %02X element %d", identifier, elementId));
291            }
292            ISOComponent component = fld[elementId].createComponent(elementId);
293            consumed += fld[elementId].unpack(component, content, consumed);
294            dataset.addElement(elementId, component);
295        }
296        if (dbm.tlvContinuation) {
297            if (consumed >= content.length) {
298                throw new ISOException(String.format("Dataset %02X sets DBM TLV continuation but has no trailing TLV content", identifier));
299            }
300            unpackTLVContinuation(identifier, dataset, Arrays.copyOfRange(content, consumed, content.length));
301            consumed = content.length;
302        }
303        if (consumed != content.length) {
304            throw new ISOException(String.format("Dataset %02X content mismatch: consumed=%d len=%d", identifier, consumed, content.length));
305        }
306        return dataset;
307    }
308
309    /**
310     * Encodes a DBM dataset payload.
311     *
312     * @param dataset dataset to encode
313     * @return DBM payload bytes
314     * @throws ISOException on encoding errors
315     */
316    protected byte[] packDBM(Dataset dataset) throws ISOException {
317        List<DatasetElement> elements = dataset.getElements();
318        if (elements.isEmpty()) {
319            return new byte[0];
320        }
321        List<DatasetElement> dbmElements = dbmAddressableElements(dataset);
322        List<DatasetElement> tlvElements = trailingTLVElements(dataset);
323        validateDBMElements(dataset, dbmElements);
324        byte[] bitmap = packDBMBitmap(dbmElements, !tlvElements.isEmpty());
325        try (ByteArrayOutputStream out = new ByteArrayOutputStream(128)) {
326            out.write(bitmap);
327            for (int elementId : sortedElementIds(dbmElements)) {
328                DatasetElement element = dataset.getElement(elementId);
329                if (elementId >= fld.length || fld[elementId] == null) {
330                    throw new ISOException(String.format("No packager defined for dataset %02X element %d", dataset.getIdentifier(), elementId));
331                }
332                out.write(fld[elementId].pack(element.getComponent()));
333            }
334            if (!tlvElements.isEmpty()) {
335                out.write(packTLVElements(tlvElements));
336            }
337            return out.toByteArray();
338        } catch (IOException e) {
339            throw new ISOException(e);
340        }
341    }
342
343    private void validateDBMElements(Dataset dataset, List<DatasetElement> dbmElements) throws ISOException {
344        Set<Integer> seen = new TreeSet<>();
345        for (DatasetElement element : dbmElements) {
346            if (!seen.add(element.getId())) {
347                throw new ISOException(String.format("DBM dataset %02X contains duplicate element %d", dataset.getIdentifier(), element.getId()));
348            }
349        }
350    }
351
352    private int[] sortedElementIds(List<DatasetElement> elements) {
353        return elements.stream().mapToInt(DatasetElement::getId).sorted().toArray();
354    }
355
356    private DBMBitmap unpackDBMBitmap(byte[] content) throws ISOException {
357        if (content.length < 2) {
358            throw new ISOException("DBM content too short");
359        }
360        List<byte[]> words = new ArrayList<>();
361        int offset = 0;
362        byte[] first = Arrays.copyOfRange(content, offset, offset + 2);
363        words.add(first);
364        offset += 2;
365
366        boolean continuation = isBitSet(first[0], 1);
367        while (continuation) {
368            if (offset >= content.length) {
369                throw new ISOException("Truncated DBM continuation");
370            }
371            byte[] next = new byte[] { content[offset] };
372            words.add(next);
373            offset++;
374            continuation = isBitSet(next[0], 1);
375        }
376
377        List<Integer> elements = new ArrayList<>();
378        int elementId = 1;
379        for (int i = 0; i < words.size(); i++) {
380            byte[] word = words.get(i);
381            boolean last = i == words.size() - 1;
382            int size = i == 0 ? DBM_INITIAL_BITS : DBM_CONTINUATION_BITS;
383            int lastUsableBit = last ? size - 1 : size;
384            int startBit = 2;
385            for (int bit = startBit; bit <= lastUsableBit; bit++) {
386                int byteIndex = (bit - 1) / 8;
387                int bitInByte = ((bit - 1) % 8) + 1;
388                if (isBitSet(word[byteIndex], bitInByte)) {
389                    elements.add(elementId);
390                }
391                elementId++;
392            }
393        }
394
395        boolean tlvContinuation = isBitSet(words.get(words.size() - 1)[words.get(words.size() - 1).length - 1], 8);
396        return new DBMBitmap(elements, tlvContinuation, offset);
397    }
398
399    private byte[] packDBMBitmap(List<DatasetElement> elements, boolean tlvContinuation) {
400        int highestElement = elements.stream().mapToInt(DatasetElement::getId).max().orElse(0);
401        int extraWords = 0;
402        while (capacity(extraWords) < highestElement) {
403            extraWords++;
404        }
405        byte[] bitmap = new byte[2 + extraWords];
406        if (extraWords > 0) {
407            setBit(bitmap, 1);
408            for (int i = 2; i < bitmap.length - 1; i++) {
409                bitmap[i] |= (byte) 0x80;
410            }
411        }
412        for (DatasetElement element : elements) {
413            setElementBit(bitmap, element.getId(), extraWords);
414        }
415        if (tlvContinuation) {
416            bitmap[bitmap.length - 1] |= 0x01;
417        }
418        return bitmap;
419    }
420
421    private List<DatasetElement> dbmAddressableElements(Dataset dataset) {
422        List<DatasetElement> elements = new ArrayList<>();
423        for (DatasetElement element : dataset.getElements()) {
424            if (isDBMAddressable(element.getId())) {
425                elements.add(element);
426            }
427        }
428        return elements;
429    }
430
431    private List<DatasetElement> trailingTLVElements(Dataset dataset) {
432        List<DatasetElement> elements = new ArrayList<>();
433        for (DatasetElement element : dataset.getElements()) {
434            if (!isDBMAddressable(element.getId())) {
435                elements.add(element);
436            }
437        }
438        return elements;
439    }
440
441    private boolean isDBMAddressable(int elementId) {
442        return elementId > 0 && elementId < fld.length && fld[elementId] != null;
443    }
444
445    private void unpackTLVContinuation(int identifier, ISODataset dataset, byte[] content) throws ISOException {
446        TLVList tlv = new TLVList();
447        try {
448            tlv.unpack(content);
449        } catch (RuntimeException e) {
450            throw new ISOException(String.format("Invalid DBM TLV continuation in dataset %02X", identifier), e);
451        }
452        for (TLVMsg tag : tlv.getTags()) {
453            dataset.addElement(tag.getTag(), new ISOBinaryField(tag.getTag(), tag.getValue()), isConstructedTag(tag.getTag()));
454        }
455    }
456
457    private byte[] packTLVElements(List<DatasetElement> elements) throws ISOException {
458        try (ByteArrayOutputStream out = new ByteArrayOutputStream(64)) {
459            for (DatasetElement element : elements) {
460                TLVMsg tlv = new TLVMsg(element.getId(), element.getBytes());
461                out.write(tlv.getTLV());
462            }
463            return out.toByteArray();
464        } catch (IllegalArgumentException e) {
465            throw new ISOException("Unable to pack DBM TLV continuation", e);
466        } catch (IOException e) {
467            throw new ISOException(e);
468        }
469    }
470
471    private int capacity(int extraWords) {
472        int capacity = extraWords == 0 ? 14 : 15;
473        if (extraWords > 0) {
474            capacity += (extraWords - 1) * 7;
475            capacity += 6;
476        }
477        return capacity;
478    }
479
480    private void setElementBit(byte[] bitmap, int elementId, int extraWords) {
481        if (extraWords == 0) {
482            if (elementId < 1 || elementId > 14) {
483                throw new IllegalArgumentException("DBM element out of range for single-word bitmap: " + elementId);
484            }
485            setBit(bitmap, elementId + 1);
486            return;
487        }
488        if (elementId <= 15) {
489            setBit(bitmap, elementId + 1);
490            return;
491        }
492        int remaining = elementId - 15;
493        for (int word = 1; word <= extraWords; word++) {
494            int wordCapacity = word < extraWords ? 7 : 6;
495            if (remaining <= wordCapacity) {
496                bitmap[word + 1] |= (byte) (0x80 >> remaining);
497                return;
498            }
499            remaining -= wordCapacity;
500        }
501        throw new IllegalArgumentException("DBM element out of range: " + elementId);
502    }
503
504    private void setBit(byte[] bitmap, int bitNumber) {
505        int byteIndex = (bitNumber - 1) / 8;
506        int shift = 7 - ((bitNumber - 1) % 8);
507        bitmap[byteIndex] |= (byte) (1 << shift);
508    }
509
510    private boolean isBitSet(byte value, int bitNumber) {
511        return ((value >> (8 - bitNumber)) & 0x01) == 0x01;
512    }
513
514    private boolean isConstructedTag(int tag) {
515        String hexTag = Integer.toHexString(tag);
516        if ((hexTag.length() & 0x01) == 1) {
517            hexTag = '0' + hexTag;
518        }
519        byte[] tagBytes = ISOUtil.hex2byte(hexTag);
520        return (tagBytes[0] & 0x20) == 0x20;
521    }
522
523    private static class DBMBitmap {
524        private final List<Integer> elements;
525        private final boolean tlvContinuation;
526        private final int consumed;
527
528        private DBMBitmap(List<Integer> elements, boolean tlvContinuation, int consumed) {
529            this.elements = elements;
530            this.tlvContinuation = tlvContinuation;
531            this.consumed = consumed;
532        }
533    }
534}