From 1338db935b91c973daacf124abb0df933faf3f40 Mon Sep 17 00:00:00 2001 From: Alejandro Revilla Date: Fri, 9 Oct 2026 19:05:24 -0300 Subject: [PATCH 1/2] DatasetElement: share isConstructedTag between packagers DatasetPackager and XMLPackager each had a private copy of the BER-TLV constructed-tag check. Move it to a public static DatasetElement.isConstructedTag(int) so ISOMsg can set the constructed flag when it builds dataset elements (#790). Refs #790 --- .../main/java/org/jpos/iso/DatasetElement.java | 15 +++++++++++++++ .../org/jpos/iso/packager/DatasetPackager.java | 13 ++----------- .../java/org/jpos/iso/packager/XMLPackager.java | 10 +--------- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/jpos/src/main/java/org/jpos/iso/DatasetElement.java b/jpos/src/main/java/org/jpos/iso/DatasetElement.java index 8219203527..2172705cd5 100644 --- a/jpos/src/main/java/org/jpos/iso/DatasetElement.java +++ b/jpos/src/main/java/org/jpos/iso/DatasetElement.java @@ -81,6 +81,21 @@ public boolean isConstructed() { return constructed; } + /** + * Indicates whether a BER-TLV tag is constructed, that is, whether bit 6 + * of its first byte is set. + * + * @param tag TLV tag, for example {@code 0x71} or {@code 0x9F26} + * @return {@code true} for constructed tags + */ + public static boolean isConstructedTag(int tag) { + String hexTag = Integer.toHexString(tag); + if ((hexTag.length() & 0x01) == 1) + hexTag = "0" + hexTag; + byte[] tagBytes = ISOUtil.hex2byte(hexTag); + return (tagBytes[0] & 0x20) == 0x20; + } + /** * Returns the component value. * diff --git a/jpos/src/main/java/org/jpos/iso/packager/DatasetPackager.java b/jpos/src/main/java/org/jpos/iso/packager/DatasetPackager.java index b41fc8bf37..446d46e75a 100644 --- a/jpos/src/main/java/org/jpos/iso/packager/DatasetPackager.java +++ b/jpos/src/main/java/org/jpos/iso/packager/DatasetPackager.java @@ -247,7 +247,7 @@ protected ISODataset unpackTLV(int identifier, byte[] content) throws ISOExcepti ISODataset dataset = new ISODataset(identifier, DatasetFormat.TLV); for (TLVMsg tag : tlv.getTags()) { ISOBinaryField field = new ISOBinaryField(tag.getTag(), tag.getValue()); - dataset.addElement(tag.getTag(), field, isConstructedTag(tag.getTag())); + dataset.addElement(tag.getTag(), field, DatasetElement.isConstructedTag(tag.getTag())); } return dataset; } @@ -450,7 +450,7 @@ private void unpackTLVContinuation(int identifier, ISODataset dataset, byte[] co throw new ISOException(String.format("Invalid DBM TLV continuation in dataset %02X", identifier), e); } for (TLVMsg tag : tlv.getTags()) { - dataset.addElement(tag.getTag(), new ISOBinaryField(tag.getTag(), tag.getValue()), isConstructedTag(tag.getTag())); + dataset.addElement(tag.getTag(), new ISOBinaryField(tag.getTag(), tag.getValue()), DatasetElement.isConstructedTag(tag.getTag())); } } @@ -511,15 +511,6 @@ private boolean isBitSet(byte value, int bitNumber) { return ((value >> (8 - bitNumber)) & 0x01) == 0x01; } - private boolean isConstructedTag(int tag) { - String hexTag = Integer.toHexString(tag); - if ((hexTag.length() & 0x01) == 1) { - hexTag = '0' + hexTag; - } - byte[] tagBytes = ISOUtil.hex2byte(hexTag); - return (tagBytes[0] & 0x20) == 0x20; - } - private static class DBMBitmap { private final List elements; private final boolean tlvContinuation; diff --git a/jpos/src/main/java/org/jpos/iso/packager/XMLPackager.java b/jpos/src/main/java/org/jpos/iso/packager/XMLPackager.java index 7b20e8ed2c..6e0947a88f 100644 --- a/jpos/src/main/java/org/jpos/iso/packager/XMLPackager.java +++ b/jpos/src/main/java/org/jpos/iso/packager/XMLPackager.java @@ -280,7 +280,7 @@ else if (TYPE_AMOUNT.equals (type)) { throw new SAXException("invalid dataset element"); int elementId = dataset.getFormat() == DatasetFormat.TLV ? parseHexId(id) : parseDecimalOrHexId(id); byte[] bytes = value == null ? new byte[0] : ISOUtil.hex2byte(value.getBytes(), 0, value.length() / 2); - dataset.addElement(elementId, new ISOBinaryField(elementId, bytes), dataset.getFormat() == DatasetFormat.TLV && isConstructedTag(elementId)); + dataset.addElement(elementId, new ISOBinaryField(elementId, bytes), dataset.getFormat() == DatasetFormat.TLV && DatasetElement.isConstructedTag(elementId)); } else if (HEADER_TAG.equals (name)) { BaseHeader bh = new BaseHeader(); bh.setAsciiEncoding (ASCII_ENCODING.equalsIgnoreCase(atts.getValue(ENCODING_ATTR))); @@ -438,12 +438,4 @@ private int parseHexId(String id) throws SAXException { throw new SAXException("Invalid hex id " + id, ex); } } - - private boolean isConstructedTag(int tag) { - String hexTag = Integer.toHexString(tag); - if ((hexTag.length() & 0x01) == 1) - hexTag = "0" + hexTag; - byte[] tagBytes = ISOUtil.hex2byte(hexTag); - return (tagBytes[0] & 0x20) == 0x20; - } } From d339ee8d4e29e38732956561cb50d0586ad8b8c3 Mon Sep 17 00:00:00 2001 From: Alejandro Revilla Date: Fri, 9 Oct 2026 19:05:24 -0300 Subject: [PATCH 2/2] ISOMsg: strict path readers, dataset element writers, dataset-aware clone Add findValue(String) and findDatasetElements(String). Both validate the whole path before reading (decimal or 0x-prefixed hex segments in 0..Integer.MAX_VALUE; anything else throws IllegalArgumentException), return empty for absent or broken hierarchies, and descend into dataset fields: 55.0x9F26 (no envelope) and 104.0x71.0x01 (envelope). The field packager's hasDatasetEnvelope() decides the layout when available; otherwise it is inferred from the remaining segment count. findDatasetElements collects every occurrence across all datasets with the addressed identifier; findValue returns the first. Add setDatasetElement/withDatasetElement/unsetDatasetElement, which take the dataset coordinates as ints and build dataset fields without a packager. setDatasetPath/unsetDatasetPath share the same helpers. clone(String...) now copies dataset element paths, keeping dataset identifiers, formats and constructed flags. Behavior changes: - With a dataset field packager, set/unset with a dataset path whose segment count does not match the envelope now throw. Previously set silently replaced the dataset field with a plain sub-message. - Datasets created for envelope-less fields are always TLV; fields numbered above 0x70 used to get DBM. - TLV elements written by path record the constructed flag. - Dataset paths remove an element from every dataset carrying the identifier, not only the first. - clone(String...) throws IllegalArgumentException for malformed paths. Refs #790 --- jpos/src/main/java/org/jpos/iso/ISOMsg.java | 531 ++++++++++++++--- .../java/org/jpos/iso/ISOMsgPathTest.java | 554 ++++++++++++++++++ .../test/java/org/jpos/iso/ISOMsgTest.java | 6 +- 3 files changed, 1012 insertions(+), 79 deletions(-) create mode 100644 jpos/src/test/java/org/jpos/iso/ISOMsgPathTest.java diff --git a/jpos/src/main/java/org/jpos/iso/ISOMsg.java b/jpos/src/main/java/org/jpos/iso/ISOMsg.java index ce6ae284a4..5d9a336643 100644 --- a/jpos/src/main/java/org/jpos/iso/ISOMsg.java +++ b/jpos/src/main/java/org/jpos/iso/ISOMsg.java @@ -302,6 +302,8 @@ public ISOMsg with(int fldno, String value) { * * @param fpath dot-separated field path (i.e. 63.2) * @param value field value + * @throws IllegalArgumentException if fpath addresses a dataset field and does not + * match the field packager's dataset envelope, or the value cannot be stored there */ public void set(String fpath, String value) { try { @@ -354,7 +356,8 @@ public ISOMsg with(String fpath, String value) { * Creates an ISOField associated with fldno within this ISOMsg * @param fpath dot-separated field path (i.e. 63.2) * @param c component - * @throws ISOException on error + * @throws ISOException on error, including a path that addresses a dataset field + * and does not match the field packager's dataset envelope */ public void set(String fpath, ISOComponent c) throws ISOException { if (setDatasetPath(fpath, c)) @@ -404,6 +407,8 @@ public ISOMsg with(String fpath, ISOComponent c) throws ISOException { * * @param fpath dot-separated field path (i.e. 63.2) * @param value binary field value + * @throws IllegalArgumentException if fpath addresses a dataset field and does not + * match the field packager's dataset envelope, or the value cannot be stored there */ public void set(String fpath, byte[] value) { try { @@ -496,6 +501,8 @@ public void unset (int ... flds) { * Unset a field referenced by a fpath if it exists, otherwise ignore. * * @param fpath dot-separated field path (i.e. 63.2) + * @throws IllegalArgumentException if fpath addresses a dataset field and does not + * match the field packager's dataset envelope */ public void unset(String fpath) { try { @@ -565,6 +572,115 @@ public ISOMsg without(String ... fpaths) { unset(fpaths); return this; } + + /** + * Sets an element in a dataset field without a dataset envelope (such as DE 55 + * ICC data), where the dataset identifier is the field number. + * + *

Unlike {@link #set(String, String)}, no packager is needed: the dataset + * field is created if absent, along with any intermediate composite fields. + * New datasets use {@link DatasetFormat#TLV}. The value replaces every existing + * element with the same identifier in the field's first dataset; TLV elements + * record whether the tag is constructed.

+ * + * @param fpath dot-separated path of the dataset field (i.e. 55, 127.55) + * @param elementId element identifier (TLV tag) + * @param value a {@code String}, {@code byte[]} or {@link ISOComponent}; + * {@code null} removes the element, as {@link #unsetDatasetElement(String, int)} + * @throws IllegalArgumentException if fpath is malformed or elementId is negative + * @throws ISOException if the path runs through, or ends at, a field of the wrong + * type, or the value type is not supported + */ + public void setDatasetElement(String fpath, int elementId, Object value) throws ISOException { + writeDatasetElement(fpath, false, 0, elementId, value); + } + + /** + * Sets an element in a dataset field with a dataset envelope (ISO 8583:2023 + * composite data elements such as DE 104). + * + *

Unlike {@link #set(String, String)}, no packager is needed: the dataset + * field is created if absent, along with any intermediate composite fields. + * New datasets use {@link DatasetFormat#TLV} for identifiers up to {@code 0x70} + * and {@link DatasetFormat#DBM} above. The value replaces every existing element + * with the same identifier in the first dataset carrying {@code datasetId}; + * TLV elements record whether the tag is constructed.

+ * + * @param fpath dot-separated path of the dataset field (i.e. 104) + * @param datasetId dataset identifier + * @param elementId element identifier (TLV tag or DBM bit number) + * @param value a {@code String}, {@code byte[]} or {@link ISOComponent}; + * {@code null} removes the element, as {@link #unsetDatasetElement(String, int, int)} + * @throws IllegalArgumentException if fpath is malformed or an identifier is negative + * @throws ISOException if the path runs through, or ends at, a field of the wrong + * type, or the value type is not supported + */ + public void setDatasetElement(String fpath, int datasetId, int elementId, Object value) throws ISOException { + writeDatasetElement(fpath, true, datasetId, elementId, value); + } + + /** + * Sets an element in a dataset field without a dataset envelope and returns + * this message for fluent chaining. + * + * @param fpath dot-separated path of the dataset field (i.e. 55) + * @param elementId element identifier (TLV tag) + * @param value element value + * @return this message + * @throws ISOException on errors, see {@link #setDatasetElement(String, int, Object)} + */ + public ISOMsg withDatasetElement(String fpath, int elementId, Object value) throws ISOException { + setDatasetElement(fpath, elementId, value); + return this; + } + + /** + * Sets an element in a dataset field with a dataset envelope and returns this + * message for fluent chaining. + * + * @param fpath dot-separated path of the dataset field (i.e. 104) + * @param datasetId dataset identifier + * @param elementId element identifier (TLV tag or DBM bit number) + * @param value element value + * @return this message + * @throws ISOException on errors, see {@link #setDatasetElement(String, int, int, Object)} + */ + public ISOMsg withDatasetElement(String fpath, int datasetId, int elementId, Object value) throws ISOException { + setDatasetElement(fpath, datasetId, elementId, value); + return this; + } + + /** + * Removes an element from a dataset field without a dataset envelope. + * + *

Every occurrence is removed. Datasets left empty are removed, and so is + * the dataset field once it has no datasets. An absent path is ignored.

+ * + * @param fpath dot-separated path of the dataset field (i.e. 55) + * @param elementId element identifier (TLV tag) + * @throws IllegalArgumentException if fpath is malformed or elementId is negative + * @throws ISOException if the path ends at a field that is not a dataset field + */ + public void unsetDatasetElement(String fpath, int elementId) throws ISOException { + writeDatasetElement(fpath, false, 0, elementId, null); + } + + /** + * Removes an element from a dataset field with a dataset envelope. + * + *

Every occurrence is removed from every dataset carrying {@code datasetId}. + * Datasets left empty are removed, and so is the dataset field once it has no + * datasets. An absent path is ignored.

+ * + * @param fpath dot-separated path of the dataset field (i.e. 104) + * @param datasetId dataset identifier + * @param elementId element identifier (TLV tag or DBM bit number) + * @throws IllegalArgumentException if fpath is malformed or an identifier is negative + * @throws ISOException if the path ends at a field that is not a dataset field + */ + public void unsetDatasetElement(String fpath, int datasetId, int elementId) throws ISOException { + writeDatasetElement(fpath, true, datasetId, elementId, null); + } /** * In order to interchange Composites and Leafs we use * getComposite(). A Composite component returns itself and @@ -754,6 +870,80 @@ public ISOComponent getComponent (String fpath) throws ISOException { } return obj; } + + /** + * Strict variant of {@link #getValue(String)}. + * + *

The whole path is validated before the message is read, so a malformed + * path always throws {@link IllegalArgumentException}, regardless of the + * message contents. Each segment is a decimal number ({@code 62}) or a + * {@code 0x}-prefixed hexadecimal number ({@code 0x9F26}) in the range + * {@code 0..Integer.MAX_VALUE}; signs, whitespace and empty segments are invalid.

+ * + *

Every segment but the last must address a sub-{@link ISOMsg}. A path + * through a leaf field or an absent field returns {@link Optional#empty()}. + * The value is a {@code String}, a {@code byte[]}, the sub-{@code ISOMsg} for + * a composite field, or the {@code List} of a dataset field.

+ * + *

When the path reaches an {@link ISODatasetField} with segments left, it + * addresses a dataset element, using the same layout as {@link #set(String, String)}: + * {@code 55.0x9F26} (no envelope, the dataset identifier is the field number) or + * {@code 104.0x71.0x01} (envelope: dataset identifier, then element identifier). + * If the field packager of the message holding the dataset field is a + * {@link DatasetFieldPackager}, its {@link ISODatasetPackager#hasDatasetEnvelope()} + * decides the layout and a path that does not match it returns empty. Otherwise + * the layout is inferred from the number of remaining segments. More than two + * segments past a dataset field return empty.

+ * + *

A dataset element path returns the value of the first element in + * {@link #findDatasetElements(String)}, so {@code findValue(p).isPresent()} is + * equivalent to {@code !findDatasetElements(p).isEmpty()}. Constructed TLV + * elements return their encoded value; decode their children with + * {@link org.jpos.tlv.TLVList}.

+ * + *

Without a packager, {@code set("55.0x9F26", value)} creates a plain + * composite field rather than a dataset field; this method reads it back + * through the normal composite walk. Use + * {@link #setDatasetElement(String, int, Object)} to create dataset fields + * without a packager.

+ * + * @param fpath dot-separated field path (i.e. 62.1, 55.0x9F26) + * @return the value, or empty if the path is not present + * @throws IllegalArgumentException if fpath is malformed + * @throws ISOException if a dataset element value cannot be read + */ + public Optional findValue(String fpath) throws ISOException { + int[] path = parsePath(fpath); + PathTarget target = locate(path); + if (target == null) + return Optional.empty(); + if (target.datasetField() == null) + return Optional.ofNullable(target.msg().getValue(path[target.index()])); + List elements = target.msg().datasetElements(target.datasetField(), path, target.index()); + return elements.isEmpty() ? Optional.empty() : Optional.ofNullable(elements.get(0).getValue()); + } + + /** + * Returns every occurrence of the dataset element addressed by a path. + * + *

Uses the same path grammar and dataset layout rules as {@link #findValue(String)}. + * Elements are collected from every dataset carrying the addressed dataset + * identifier, in dataset order and then element order, so repeated tags (such + * as EMV issuer scripts) are all returned with their constructed flag.

+ * + * @param fpath dot-separated dataset element path (i.e. 55.0x71, 104.0x71.0x01) + * @return unmodifiable list of matching elements; empty if the path is absent or + * does not address a dataset element + * @throws IllegalArgumentException if fpath is malformed + * @throws ISOException on dataset access errors + */ + public List findDatasetElements(String fpath) throws ISOException { + int[] path = parsePath(fpath); + PathTarget target = locate(path); + if (target == null || target.datasetField() == null) + return Collections.emptyList(); + return target.msg().datasetElements(target.datasetField(), path, target.index()); + } /** * Return the String value associated with the given ISOField number * @param fldno the Field Number @@ -962,17 +1152,34 @@ public Object clone(int ... fields) { } /** - * Partially clone an ISOMsg by field paths + * Partially clone an ISOMsg by field paths. + * + *

Paths use the grammar of {@link #findValue(String)}. Absent paths are + * skipped. A dataset element path (i.e. 55.0x9F26, 104.0x71.0x01) copies every + * occurrence of the element, keeping dataset identifiers, formats and constructed + * flags; elements are added in path order, and a path repeated or covered by an + * earlier path replaces the copied elements instead of duplicating them.

+ * * @param fpaths string array of field paths to copy * @return new ISOMsg instance + * @throws IllegalArgumentException if a path is malformed */ public ISOMsg clone(String ... fpaths) { try { ISOMsg m = (ISOMsg) super.clone(); m.fields = new TreeMap(); + Map createdDatasets = new IdentityHashMap<>(); for (String fpath : fpaths) { + int[] path = parsePath(fpath); try { - ISOComponent component = getComponent(fpath); + PathTarget target = locate(path); + if (target == null) + continue; + if (target.datasetField() != null) { + m.cloneDatasetElements(target, path, createdDatasets); + continue; + } + ISOComponent component = target.msg().getComponent(path[target.index()]); if (component instanceof ISOMsg || component instanceof ISODatasetField) { m.set(fpath, cloneComponent(component)); } else if (component != null) { @@ -982,6 +1189,11 @@ public ISOMsg clone(String ... fpaths) { //should never happen } } + // datasets created only to keep repeated dataset identifiers aligned + for (Map.Entry e : createdDatasets.entrySet()) { + if (e.getKey().isEmpty()) + e.getValue().removeDataset(e.getKey()); + } return m; } catch (CloneNotSupportedException e) { throw new InternalError(); @@ -1619,105 +1831,276 @@ private int parseInt (String s) { } private boolean setDatasetPath(String fpath, Object value) throws ISOException { + DatasetAddress address = datasetAddress(fpath); + if (address == null) + return false; + putDatasetElement(address, value); + return true; + } + + private boolean unsetDatasetPath(String fpath) throws ISOException { + DatasetAddress address = datasetAddress(fpath); + if (address == null) + return false; + removeDatasetElement(address); + return true; + } + + /** + * Resolves a lenient top-level dataset path against this message's packager. + * + * @return the address, or {@code null} if fpath is not a dataset path + * @throws ISOException if the field packager is a dataset packager and the + * number of segments does not match its dataset envelope + */ + private DatasetAddress datasetAddress(String fpath) throws ISOException { StringTokenizer st = new StringTokenizer(fpath, "."); if (st.countTokens() < 2) - return false; + return null; int fieldNo = parseInt(st.nextToken()); - ISOFieldPackager fp = null; - if (packager instanceof ISOBasePackager) { - fp = ((ISOBasePackager) packager).getFieldPackager(fieldNo); - } - if (!(fp instanceof DatasetFieldPackager)) - return false; + Boolean envelope = datasetEnvelope(fieldNo); + if (envelope == null) + return null; + int expected = envelope ? 2 : 1; + if (st.countTokens() != expected) + throw new ISOException( + "Path '" + fpath + "' does not match the dataset " + (envelope ? "envelope" : "layout") + + " of field " + fieldNo + " (expected " + expected + " segment" + (expected > 1 ? "s" : "") + + " after the field)" + ); + int datasetId = envelope ? parseInt(st.nextToken()) : fieldNo; + int elementId = parseInt(st.nextToken()); + return new DatasetAddress(fieldNo, envelope, datasetId, elementId); + } - DatasetFieldPackager dfp = (DatasetFieldPackager) fp; - ISODatasetPackager datasetPackager = dfp.getISODatasetPackager(); - int datasetId; - int elementId; + /** + * Resolves the dataset element addressed by the segments of a strict path that + * follow the dataset field at {@code path[index]}, held by this message. + * + * @return the address, or {@code null} if the segments do not address an element + */ + private DatasetAddress datasetAddress(int[] path, int index) { + int fieldNo = path[index]; + int remaining = path.length - index - 1; + Boolean envelope = datasetEnvelope(fieldNo); + if (envelope == null) + envelope = remaining == 2; + if (envelope) + return remaining == 2 ? new DatasetAddress(fieldNo, true, path[index + 1], path[index + 2]) : null; + return remaining == 1 ? new DatasetAddress(fieldNo, false, fieldNo, path[index + 1]) : null; + } - if (!datasetPackager.hasDatasetEnvelope()) { - if (st.countTokens() != 1) - return false; - datasetId = fieldNo; - elementId = parseInt(st.nextToken()); - } else { - if (st.countTokens() != 2) - return false; - datasetId = parseInt(st.nextToken()); - elementId = parseInt(st.nextToken()); - } + /** + * @return whether this message's packager defines a dataset envelope for + * the field, or {@code null} if it does not define a dataset field there + */ + private Boolean datasetEnvelope(int fieldNo) { + if (packager instanceof ISOBasePackager bp + && bp.getFieldPackager(fieldNo) instanceof DatasetFieldPackager dfp) + return dfp.getISODatasetPackager().hasDatasetEnvelope(); + return null; + } + private void putDatasetElement(DatasetAddress address, Object value) throws ISOException { + int elementId = address.elementId(); + ISOComponent elementComponent = toDatasetComponent(elementId, value); ISODatasetField field; - ISOComponent component = getComponent(fieldNo); + ISOComponent component = getComponent(address.fieldNo()); if (component == null) { - field = new ISODatasetField(fieldNo); + field = new ISODatasetField(address.fieldNo()); set(field); - } else if (component instanceof ISODatasetField) { - field = (ISODatasetField) component; + } else if (component instanceof ISODatasetField f) { + field = f; } else { - throw new ISOException("Field " + fieldNo + " is not a dataset field"); + throw new ISOException("Field " + address.fieldNo() + " is not a dataset field"); } - ISODataset dataset = (ISODataset) field.getDataset(datasetId); + ISODataset dataset = (ISODataset) field.getDataset(address.datasetId()); if (dataset == null) { - dataset = new ISODataset(datasetId, datasetId <= 0x70 ? DatasetFormat.TLV : DatasetFormat.DBM); + // without an envelope the dataset is raw TLV (i.e. ICC data); + // otherwise ISO 8583:2023 identifiers above 0x70 are DBM + DatasetFormat format = address.envelope() && address.datasetId() > 0x70 ? DatasetFormat.DBM : DatasetFormat.TLV; + dataset = new ISODataset(address.datasetId(), format); field.addDataset(dataset); } - dataset.putElement(elementId, toDatasetComponent(elementId, value)); - return true; + dataset.putElement(elementId, elementComponent, + dataset.getFormat() == DatasetFormat.TLV && DatasetElement.isConstructedTag(elementId)); } - private boolean unsetDatasetPath(String fpath) throws ISOException { - StringTokenizer st = new StringTokenizer(fpath, "."); - if (st.countTokens() < 2) - return false; + private void removeDatasetElement(DatasetAddress address) throws ISOException { + ISOComponent component = getComponent(address.fieldNo()); + if (component == null) + return; + if (!(component instanceof ISODatasetField field)) + throw new ISOException("Field " + address.fieldNo() + " is not a dataset field"); + + for (Dataset dataset : field.getDatasets(address.datasetId())) { + if (dataset instanceof ISODataset isoDataset) { + isoDataset.removeElement(address.elementId()); + if (isoDataset.isEmpty()) + field.removeDataset(isoDataset); + } + } + if (!field.hasDatasets()) + unset(address.fieldNo()); + } - int fieldNo = parseInt(st.nextToken()); - ISOFieldPackager fp = null; - if (packager instanceof ISOBasePackager) { - fp = ((ISOBasePackager) packager).getFieldPackager(fieldNo); + private void writeDatasetElement(String fpath, boolean envelope, int datasetId, int elementId, Object value) + throws ISOException { + if (datasetId < 0 || elementId < 0) + throw new IllegalArgumentException("Invalid dataset element " + datasetId + "/" + elementId + " for '" + fpath + "'"); + int[] path = parsePath(fpath); + ISOMsg m = this; + for (int i = 0; i < path.length - 1; i++) { + ISOComponent c = m.getComponent(path[i]); + if (c instanceof ISOMsg sub) { + m = sub; + } else if (value == null) { + return; // nothing to remove + } else if (c != null) { + throw new ISOException("Field " + path[i] + " in path '" + fpath + "' is not a composite field"); + } else { + ISOMsg sub = new ISOMsg(path[i]); + m.set(sub); + m = sub; + } } - if (!(fp instanceof DatasetFieldPackager)) - return false; + int fieldNo = path[path.length - 1]; + DatasetAddress address = new DatasetAddress(fieldNo, envelope, envelope ? datasetId : fieldNo, elementId); + if (value == null) + m.removeDatasetElement(address); + else + m.putDatasetElement(address, value); + } - DatasetFieldPackager dfp = (DatasetFieldPackager) fp; - ISODatasetPackager datasetPackager = dfp.getISODatasetPackager(); - int datasetId; - int elementId; + private List datasetElements(ISODatasetField field, int[] path, int index) { + DatasetAddress address = datasetAddress(path, index); + if (address == null) + return Collections.emptyList(); + List elements = new ArrayList<>(); + for (Dataset dataset : field.getDatasets(address.datasetId())) + elements.addAll(dataset.getElements(address.elementId())); + return Collections.unmodifiableList(elements); + } - if (!datasetPackager.hasDatasetEnvelope()) { - if (st.countTokens() != 1) - return false; - datasetId = fieldNo; - elementId = parseInt(st.nextToken()); + /** + * Copies the dataset element addressed by {@code source} into this (cloned) message. + * Same-identifier datasets are matched by position, so repeated datasets stay apart. + */ + private void cloneDatasetElements(PathTarget source, int[] path, Map createdDatasets) + throws ISOException { + DatasetAddress address = source.msg().datasetAddress(path, source.index()); + if (address == null) + return; + int elementId = address.elementId(); + List sourceDatasets = source.datasetField().getDatasets(address.datasetId()); + if (sourceDatasets.stream().allMatch(d -> d.getElements(elementId).isEmpty())) + return; + + ISOMsg m = this; + for (int i = 0; i < source.index(); i++) { + ISOComponent c = m.getComponent(path[i]); + ISOMsg sub; + if (c instanceof ISOMsg msg) { + sub = msg; + } else { + sub = new ISOMsg(path[i]); + m.set(sub); + } + m = sub; + } + ISODatasetField field; + ISOComponent c = m.getComponent(address.fieldNo()); + if (c instanceof ISODatasetField f) { + field = f; + } else if (c == null) { + field = new ISODatasetField(address.fieldNo()); + m.set(field); } else { - if (st.countTokens() != 2) - return false; - datasetId = parseInt(st.nextToken()); - elementId = parseInt(st.nextToken()); + return; // cannot happen: the clone mirrors the source hierarchy } - ISOComponent component = getComponent(fieldNo); - if (component == null) - return true; - if (!(component instanceof ISODatasetField)) - throw new ISOException("Field " + fieldNo + " is not a dataset field"); - - ISODatasetField field = (ISODatasetField) component; - ISODataset dataset = (ISODataset) field.getDataset(datasetId); - if (dataset == null) - return true; - - dataset.removeElement(elementId); - if (dataset.isEmpty()) { - field.removeDataset(dataset); - if (!field.hasDatasets()) - unset(fieldNo); + List targetDatasets = field.getDatasets(address.datasetId()); + for (int k = 0; k < sourceDatasets.size(); k++) { + Dataset src = sourceDatasets.get(k); + ISODataset dst; + if (k < targetDatasets.size() && targetDatasets.get(k) instanceof ISODataset d) { + dst = d; + } else { + dst = new ISODataset(address.datasetId(), src.getFormat()); + field.addDataset(dst); + createdDatasets.put(dst, field); + } + dst.removeElement(elementId); + for (DatasetElement element : src.getElements(elementId)) + dst.addElement(elementId, cloneDatasetComponent(element.getComponent()), element.isConstructed()); } - return true; } + /** + * Walks a strict path. + * + * @return the message holding the component addressed by the last segment + * ({@code datasetField} is {@code null}), the message holding the dataset + * field at {@code path[index]} that the path descends into, or {@code null} + * if the path runs through an absent or leaf field + */ + private PathTarget locate(int[] path) { + ISOMsg m = this; + for (int i = 0; i < path.length - 1; i++) { + ISOComponent c = m.getComponent(path[i]); + if (c instanceof ISODatasetField field) + return new PathTarget(m, i, field); + if (!(c instanceof ISOMsg sub)) + return null; + m = sub; + } + return new PathTarget(m, path.length - 1, null); + } + + /** + * Parses a strict field path. Segments are decimal ({@code 62}) or + * {@code 0x}-prefixed hexadecimal ({@code 0x9F26}) numbers in the range + * {@code 0..Integer.MAX_VALUE}. + * + * @throws IllegalArgumentException if fpath is malformed + */ + private static int[] parsePath(String fpath) { + if (fpath == null || fpath.isEmpty()) + throw new IllegalArgumentException("Invalid path '" + fpath + "': empty path"); + String[] segments = fpath.split("\\.", -1); + int[] path = new int[segments.length]; + for (int i = 0; i < segments.length; i++) + path[i] = parsePathSegment(fpath, segments[i], i + 1); + return path; + } + + private static int parsePathSegment(String fpath, String segment, int position) { + if (segment.isEmpty()) + throw new IllegalArgumentException("Invalid path '" + fpath + "': empty segment at position " + position); + boolean hex = segment.startsWith("0x"); + String digits = hex ? segment.substring(2) : segment; + boolean valid = !digits.isEmpty(); + for (int i = 0; valid && i < digits.length(); i++) { + char ch = digits.charAt(i); + valid = ch >= '0' && ch <= '9' || hex && (ch >= 'a' && ch <= 'f' || ch >= 'A' && ch <= 'F'); + } + if (!valid) + throw new IllegalArgumentException( + "Invalid path '" + fpath + "': invalid segment '" + segment + "' at position " + position); + try { + return Integer.parseInt(digits, hex ? 16 : 10); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "Invalid path '" + fpath + "': segment '" + segment + "' at position " + position + " is out of range", e); + } + } + + private record PathTarget(ISOMsg msg, int index, ISODatasetField datasetField) { } + + private record DatasetAddress(int fieldNo, boolean envelope, int datasetId, int elementId) { } + private ISOComponent toDatasetComponent(int elementId, Object value) throws ISOException { if (value instanceof ISOComponent) { ISOComponent component = (ISOComponent) value; diff --git a/jpos/src/test/java/org/jpos/iso/ISOMsgPathTest.java b/jpos/src/test/java/org/jpos/iso/ISOMsgPathTest.java new file mode 100644 index 0000000000..839586e7f5 --- /dev/null +++ b/jpos/src/test/java/org/jpos/iso/ISOMsgPathTest.java @@ -0,0 +1,554 @@ +/* + * jPOS Project [http://jpos.org] + * Copyright (C) 2000-2026 jPOS Software SRL + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jpos.iso; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Optional; + +import org.jpos.iso.packager.GenericPackager; +import org.jpos.iso.packager.XMLPackager; +import org.jpos.tlv.TLVList; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Strict field path readers, dataset element writers and dataset-aware + * {@link ISOMsg#clone(String...)}. + */ +public class ISOMsgPathTest { + private static final byte[] AC = ISOUtil.hex2byte("1122334455667788"); + private static final byte[] IAD = ISOUtil.hex2byte("06011203A0B800"); + // issuer script templates: 9F18 script id + 86 command + private static final byte[] SCRIPT1 = ISOUtil.hex2byte("9F180411223344" + "86058418000000"); + private static final byte[] SCRIPT2 = ISOUtil.hex2byte("9F180455667788" + "86058424000000"); + + // ---- path grammar + + @ParameterizedTest + @ValueSource(strings = { + "", ".", "62..1", ".62", "62.", "62.1.", "6a", "-1", "+1", " 1", "1 ", "62. 1", + "0X1F", "0x", "0x.1", "0xG1", "0x-1", "0x+1", "2147483648", "0x80000000", "99999999999", + "٣", "1" + }) + public void malformedPathsThrow(String fpath) throws Exception { + for (ISOMsg m : List.of(new ISOMsg(), populated())) { + assertThrows(IllegalArgumentException.class, () -> m.findValue(fpath), fpath); + assertThrows(IllegalArgumentException.class, () -> m.findDatasetElements(fpath), fpath); + assertThrows(IllegalArgumentException.class, () -> m.clone(fpath), fpath); + assertThrows(IllegalArgumentException.class, () -> m.setDatasetElement(fpath, 0x9F26, AC), fpath); + assertThrows(IllegalArgumentException.class, () -> m.unsetDatasetElement(fpath, 0x9F26), fpath); + } + } + + @Test + public void nullPathThrows() { + ISOMsg m = new ISOMsg(); + assertThrows(IllegalArgumentException.class, () -> m.findValue(null)); + assertThrows(IllegalArgumentException.class, () -> m.findDatasetElements(null)); + } + + @Test + public void malformedPathValidityDoesNotDependOnContents() { + ISOMsg m = new ISOMsg(); + m.set(62, "leaf"); + // hasField stops at the leaf and never parses "x"; findValue always does + assertFalse(m.hasField("62.x")); + assertThrows(IllegalArgumentException.class, () -> m.findValue("62.x")); + assertThrows(IllegalArgumentException.class, () -> m.findValue("64.x")); + } + + @Test + public void malformedPathMessageNamesSegment() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> new ISOMsg().findValue("62..1")); + assertEquals("Invalid path '62..1': empty segment at position 2", e.getMessage()); + e = assertThrows(IllegalArgumentException.class, () -> new ISOMsg().findValue("62.0X1")); + assertEquals("Invalid path '62.0X1': invalid segment '0X1' at position 2", e.getMessage()); + e = assertThrows(IllegalArgumentException.class, () -> new ISOMsg().findValue("0x80000000")); + assertEquals("Invalid path '0x80000000': segment '0x80000000' at position 1 is out of range", e.getMessage()); + } + + @Test + public void validPathForms() throws Exception { + ISOMsg m = new ISOMsg(); + m.set(7, "0123456789"); + m.set(10, "61000000"); + assertEquals(Optional.of("0123456789"), m.findValue("007")); + assertEquals(Optional.of("61000000"), m.findValue("0x0A")); + assertEquals(Optional.of("61000000"), m.findValue("0x0a")); + assertEquals(Optional.empty(), m.findValue("2147483647")); + assertEquals(Optional.empty(), m.findValue("0x7FFFFFFF")); + } + + // ---- strict walk + + @Test + public void strictWalk() throws Exception { + ISOMsg m = populated(); + assertEquals(Optional.of("0100"), m.findValue("0")); + assertEquals(Optional.of("000000"), m.findValue("3")); + assertArrayEquals(ISOUtil.hex2byte("CAFEBABE"), (byte[]) m.findValue("52").orElseThrow()); + assertEquals(Optional.of("value63.2.3"), m.findValue("63.2.3")); + assertSame(m.getComponent(63), m.findValue("63").orElseThrow()); + assertSame(m.getComponent("63.2"), m.findValue("63.2").orElseThrow()); + + assertEquals(Optional.empty(), m.findValue("4"), "absent"); + assertEquals(Optional.empty(), m.findValue("64.1"), "absent first level"); + assertEquals(Optional.empty(), m.findValue("63.9.1"), "absent second level"); + assertEquals(Optional.empty(), m.findValue("63.2.9"), "absent leaf"); + assertEquals(Optional.empty(), m.findValue("62.1"), "through a leaf"); + assertEquals(Optional.empty(), m.findValue("63.2.3.1"), "through a nested leaf"); + assertEquals(Optional.empty(), new ISOMsg().findValue("0"), "absent MTI"); + + // the lenient readers disagree on a path through a leaf + assertThrows(ISOException.class, () -> m.getValue("62.1")); + assertNull(m.getString("62.1")); + assertNull(m.getComponent("62.1")); + } + + @Test + public void findDatasetElementsOnNonDatasetPaths() throws Exception { + ISOMsg m = populated(); + assertTrue(m.findDatasetElements("3").isEmpty()); + assertTrue(m.findDatasetElements("62.1").isEmpty()); + assertTrue(m.findDatasetElements("63.2.3").isEmpty()); + assertTrue(m.findDatasetElements("64.1").isEmpty()); + assertTrue(m.findDatasetElements("55").isEmpty(), "bare dataset field"); + } + + // ---- dataset descent + + @Test + public void datasetPathsWithPackager() throws Exception { + GenericPackager packager = cmfv3(); + ISOMsg m = roundTrip(packager, cmfMsg(packager) + .with("55.0x9F26", AC) + .with("55.0x9F10", IAD) + .with("104.0x01.0xDF01", ISOUtil.hex2byte("CAFEBABE")) + .with("49.0x71.2", "1234")); + + assertArrayEquals(AC, (byte[]) m.findValue("55.0x9F26").orElseThrow()); + assertArrayEquals(IAD, (byte[]) m.findValue("55.0x9F10").orElseThrow()); + assertArrayEquals(ISOUtil.hex2byte("CAFEBABE"), (byte[]) m.findValue("104.0x01.0xDF01").orElseThrow()); + assertEquals(Optional.of("1234"), m.findValue("49.0x71.2")); + + assertEquals(Optional.empty(), m.findValue("55.0x9F27"), "absent tag"); + assertEquals(Optional.empty(), m.findValue("104.0x02.0xDF01"), "absent dataset"); + assertEquals(Optional.empty(), m.findValue("104.0x01.0xDF01.1"), "more than two segments"); + assertEquals(Optional.empty(), m.findValue("104.0xDF01"), "envelope field, one segment"); + assertEquals(Optional.empty(), m.findValue("55.55.0x9F26"), "no-envelope field, two segments"); + assertTrue(m.findDatasetElements("55.55.0x9F26").isEmpty()); + + // without the packager the layout is inferred from the segment count, so the + // dataset that DE 55 stores under its own field number is reachable both ways + m.setPackager(null); + assertArrayEquals(AC, (byte[]) m.findValue("55.0x9F26").orElseThrow()); + assertArrayEquals(AC, (byte[]) m.findValue("55.55.0x9F26").orElseThrow()); + assertEquals(Optional.empty(), m.findValue("104.0xDF01")); + } + + @Test + public void bareDatasetFieldValue() throws Exception { + ISOMsg m = populated(); + ISODatasetField f55 = (ISODatasetField) m.getComponent(55); + assertEquals(f55.getDatasets(), m.findValue("55").orElseThrow()); + } + + @Test + public void datasetPathsWithoutPackager() throws Exception { + ISOMsg m = populated(); + ISODatasetField f104 = new ISODatasetField(104); + f104.addDataset(new ISODataset(0x01, DatasetFormat.TLV).with(0xDF01, "CAFE".getBytes())); + f104.addDataset(new ISODataset(0x71, DatasetFormat.DBM).with(2, "1234")); + m.set(f104); + + assertArrayEquals(AC, (byte[]) m.findValue("55.0x9F26").orElseThrow()); + assertArrayEquals("CAFE".getBytes(), (byte[]) m.findValue("104.0x01.0xDF01").orElseThrow()); + assertEquals(Optional.of("1234"), m.findValue("104.0x71.2")); + assertEquals(Optional.empty(), m.findValue("104.0xDF01"), "inferred no-envelope: no dataset 104"); + assertEquals(Optional.empty(), m.findValue("104.0x01.0xDF01.1")); + } + + @Test + public void nestedDatasetField() throws Exception { + ISOMsg m = new ISOMsg("0100"); + ISOMsg f127 = new ISOMsg(127); + ISODatasetField f55 = new ISODatasetField(55); + f55.addDataset(new ISODataset(55, DatasetFormat.TLV).with(0x9F26, AC)); + f127.set(f55); + m.set(f127); + + assertArrayEquals(AC, (byte[]) m.findValue("127.55.0x9F26").orElseThrow()); + assertArrayEquals(AC, (byte[]) m.findValue("127.55.55.0x9F26").orElseThrow(), "inferred envelope"); + + // the sub-message's packager decides the layout of its dataset fields + f127.setPackager(cmfv3()); + assertArrayEquals(AC, (byte[]) m.findValue("127.55.0x9F26").orElseThrow()); + assertEquals(Optional.empty(), m.findValue("127.55.55.0x9F26")); + } + + @Test + public void xmlRoundTrip() throws Exception { + ISOMsg m = populated(); + m.setDatasetElement("104", 0x71, 2, "1234"); + m.setPackager(new XMLPackager()); + ISOMsg unpacked = new ISOMsg(); + unpacked.setPackager(new XMLPackager()); + unpacked.unpack(m.pack()); + + assertInstanceOf(ISODatasetField.class, unpacked.getComponent(55)); + assertArrayEquals(AC, (byte[]) unpacked.findValue("55.0x9F26").orElseThrow()); + assertArrayEquals("1234".getBytes(), (byte[]) unpacked.findValue("104.0x71.2").orElseThrow()); + assertEquals(Optional.of("value63.2.3"), unpacked.findValue("63.2.3")); + } + + @Test + public void setWithoutPackagerCreatesPlainComposite() throws Exception { + ISOMsg m = new ISOMsg("0100"); + m.set("55.0x9F26", AC); + assertInstanceOf(ISOMsg.class, m.getComponent(55)); + assertArrayEquals(AC, (byte[]) m.findValue("55.0x9F26").orElseThrow()); + assertTrue(m.findDatasetElements("55.0x9F26").isEmpty()); + } + + // ---- repeats + + @Test + public void repeatedTags() throws Exception { + ISOMsg m = new ISOMsg("0100"); + ISODatasetField f55 = new ISODatasetField(55); + ISODataset icc = new ISODataset(55, DatasetFormat.TLV).with(0x9F26, AC); + icc.addElement(0x71, new ISOBinaryField(0x71, SCRIPT1), true); + icc.addElement(0x71, new ISOBinaryField(0x71, SCRIPT2), true); + f55.addDataset(icc); + m.set(f55); + m = roundTrip(cmfv3(), m); + + List scripts = m.findDatasetElements("55.0x71"); + assertEquals(2, scripts.size()); + assertArrayEquals(SCRIPT1, scripts.get(0).getBytes()); + assertArrayEquals(SCRIPT2, scripts.get(1).getBytes()); + assertTrue(scripts.get(0).isConstructed()); + assertTrue(scripts.get(1).isConstructed()); + assertArrayEquals(SCRIPT1, (byte[]) m.findValue("55.0x71").orElseThrow(), "first occurrence"); + assertThrows(UnsupportedOperationException.class, () -> scripts.add(scripts.get(0))); + + // constructed elements carry their encoded children + TLVList children = new TLVList(); + children.unpack(scripts.get(1).getBytes()); + assertArrayEquals(ISOUtil.hex2byte("55667788"), children.find(0x9F18).getValue()); + assertArrayEquals(ISOUtil.hex2byte("8424000000"), children.find(0x86).getValue()); + } + + @Test + public void repeatedDatasets() throws Exception { + ISOMsg m = new ISOMsg("0100"); + ISODatasetField f104 = new ISODatasetField(104); + f104.addDataset(new ISODataset(0x01, DatasetFormat.TLV).with(0xDF02, "A".getBytes())); + f104.addDataset(new ISODataset(0x01, DatasetFormat.TLV).with(0xDF01, "B".getBytes())); + f104.addDataset(new ISODataset(0x01, DatasetFormat.TLV).with(0xDF01, "C".getBytes())); + m.set(f104); + + assertNull(f104.getValue(0x01, 0xDF01), "ISODatasetField only reads the first dataset"); + assertArrayEquals("B".getBytes(), (byte[]) m.findValue("104.0x01.0xDF01").orElseThrow()); + List elements = m.findDatasetElements("104.0x01.0xDF01"); + assertEquals(2, elements.size()); + assertArrayEquals("B".getBytes(), elements.get(0).getBytes()); + assertArrayEquals("C".getBytes(), elements.get(1).getBytes()); + } + + // ---- setDatasetElement / withDatasetElement / unsetDatasetElement + + @Test + public void setDatasetElementWithoutPackager() throws Exception { + ISOMsg m = new ISOMsg("0100") + .withDatasetElement("55", 0x9F26, AC) + .withDatasetElement("55", 0x71, SCRIPT1) + .withDatasetElement("104", 0x01, 0xDF01, "CAFE".getBytes()) + .withDatasetElement("49", 0x71, 2, "1234") + .withDatasetElement("49", 0x71, 1, new ISOField(99, "1")); + + ISODatasetField f55 = (ISODatasetField) m.getComponent(55); + ISODataset icc = (ISODataset) f55.getDataset(55); + assertEquals(DatasetFormat.TLV, icc.getFormat()); + assertFalse(icc.getElement(0x9F26).isConstructed()); + assertTrue(icc.getElement(0x71).isConstructed()); + + ISODatasetField f49 = (ISODatasetField) m.getComponent(49); + ISODataset verification = (ISODataset) f49.getDataset(0x71); + assertEquals(DatasetFormat.DBM, verification.getFormat()); + assertEquals(1, verification.getComponent(1).getFieldNumber()); + assertFalse(verification.getElement(1).isConstructed(), "DBM elements are never constructed"); + assertEquals(DatasetFormat.TLV, ((ISODatasetField) m.getComponent(104)).getDataset(0x01).getFormat()); + + // the result packs with a dataset-aware packager + ISOMsg unpacked = roundTrip(cmfv3(), m); + assertArrayEquals(AC, (byte[]) unpacked.findValue("55.0x9F26").orElseThrow()); + assertArrayEquals(SCRIPT1, (byte[]) unpacked.findValue("55.0x71").orElseThrow()); + assertArrayEquals("CAFE".getBytes(), (byte[]) unpacked.findValue("104.0x01.0xDF01").orElseThrow()); + assertEquals(Optional.of("1"), unpacked.findValue("49.0x71.1")); + assertEquals(Optional.of("1234"), unpacked.findValue("49.0x71.2")); + } + + @Test + public void setDatasetElementReplacesAndCreatesHierarchy() throws Exception { + ISOMsg m = new ISOMsg("0100"); + m.setDatasetElement("127.55", 0x9F26, AC); + m.setDatasetElement("127.55", 0x9F26, IAD); + assertInstanceOf(ISOMsg.class, m.getComponent(127)); + List elements = m.findDatasetElements("127.55.0x9F26"); + assertEquals(1, elements.size()); + assertArrayEquals(IAD, elements.get(0).getBytes()); + + // a no-envelope field above 0x70 still gets a TLV dataset + m.setDatasetElement("120", 0x9F26, AC); + assertEquals(DatasetFormat.TLV, ((ISODatasetField) m.getComponent(120)).getDataset(120).getFormat()); + } + + @Test + public void setDatasetElementErrors() throws Exception { + ISOMsg m = populated(); + assertThrows(ISOException.class, () -> m.setDatasetElement("62", 0x9F26, AC), "leaf target"); + assertThrows(ISOException.class, () -> m.setDatasetElement("63", 0x9F26, AC), "composite target"); + assertThrows(ISOException.class, () -> m.setDatasetElement("62.55", 0x9F26, AC), "through a leaf"); + assertThrows(ISOException.class, () -> m.setDatasetElement("55.1", 0x9F26, AC), "through a dataset field"); + assertThrows(ISOException.class, () -> m.setDatasetElement("56", 0x9F26, 42), "unsupported value"); + assertThrows(IllegalArgumentException.class, () -> m.setDatasetElement("56", -1, AC)); + assertThrows(IllegalArgumentException.class, () -> m.setDatasetElement("104", -1, 1, AC)); + assertEquals("leaf62", m.getString(62)); + assertFalse(m.hasField(56)); + } + + @Test + public void unsetDatasetElement() throws Exception { + ISOMsg m = new ISOMsg("0100"); + ISODatasetField f104 = new ISODatasetField(104); + f104.addDataset(new ISODataset(0x01, DatasetFormat.TLV).with(0xDF01, "A".getBytes()).with(0xDF02, "B".getBytes())); + f104.addDataset(new ISODataset(0x01, DatasetFormat.TLV).with(0xDF01, "C".getBytes())); + m.set(f104); + + m.unsetDatasetElement("104", 0x01, 0xDF01); + assertTrue(m.findDatasetElements("104.0x01.0xDF01").isEmpty(), "every occurrence removed"); + assertEquals(1, f104.getDatasets().size(), "empty dataset removed"); + m.unsetDatasetElement("104", 0x01, 0xDF02); + assertFalse(m.hasField(104), "field removed with its last dataset"); + + m.setDatasetElement("55", 0x9F26, AC); + m.setDatasetElement("55", 0x9F26, null); + assertFalse(m.hasField(55), "null value removes"); + + m.set(62, "leaf62"); + m.unsetDatasetElement("56", 0x9F26); + m.unsetDatasetElement("64.55", 0x9F26); + m.unsetDatasetElement("62.55", 0x9F26); + assertEquals("leaf62", m.getString(62)); + assertThrows(ISOException.class, () -> m.unsetDatasetElement("62", 0x9F26)); + } + + // ---- set/unset by dataset path + + @Test + public void mismatchedDatasetPathDoesNotReplaceField() throws Exception { + ISOMsg m = new ISOMsg("0100"); + m.setPackager(cmfv3()); + m.set("104.0x01.0xDF01", "CAFE".getBytes()); + m.set("55.0x9F26", AC); + + assertThrows(IllegalArgumentException.class, () -> m.set("104.0xDF02", "BABE".getBytes())); + assertThrows(IllegalArgumentException.class, () -> m.set("104.0x01.0xDF02.1", "BABE")); + assertThrows(ISOException.class, () -> m.set("104.0xDF02", new ISOBinaryField(0, "BABE".getBytes()))); + assertThrows(IllegalArgumentException.class, () -> m.set("55.0x01.0x9F27", AC)); + assertThrows(IllegalArgumentException.class, () -> m.unset("104.0xDF01")); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> m.set("55.0x01.0x9F27", AC)); + assertEquals("Path '55.0x01.0x9F27' does not match the dataset layout of field 55 (expected 1 segment after the field)", e.getMessage()); + + assertInstanceOf(ISODatasetField.class, m.getComponent(104)); + assertInstanceOf(ISODatasetField.class, m.getComponent(55)); + assertArrayEquals("CAFE".getBytes(), (byte[]) m.findValue("104.0x01.0xDF01").orElseThrow()); + assertArrayEquals(AC, (byte[]) m.findValue("55.0x9F26").orElseThrow()); + } + + @Test + public void setByDatasetPathOnHighNoEnvelopeFieldUsesTLV() throws Exception { + GenericPackager packager = cmfv3(); + packager.setFieldPackager(120, packager.getFieldPackager(55)); + ISOMsg m = new ISOMsg("0100"); + m.setPackager(packager); + m.set("120.0x9F26", AC); + m.set("120.0x71", SCRIPT1); + ISODataset dataset = (ISODataset) ((ISODatasetField) m.getComponent(120)).getDataset(120); + assertEquals(DatasetFormat.TLV, dataset.getFormat()); + assertTrue(dataset.getElement(0x71).isConstructed()); + } + + @Test + public void unsetByDatasetPathRemovesFromRepeatedDatasets() throws Exception { + ISOMsg m = new ISOMsg("0100"); + m.setPackager(cmfv3()); + ISODatasetField f104 = new ISODatasetField(104); + f104.addDataset(new ISODataset(0x01, DatasetFormat.TLV).with(0xDF01, "A".getBytes()).with(0xDF02, "B".getBytes())); + f104.addDataset(new ISODataset(0x01, DatasetFormat.TLV).with(0xDF01, "C".getBytes())); + m.set(f104); + + m.unset("104.0x01.0xDF01"); + assertTrue(m.findDatasetElements("104.0x01.0xDF01").isEmpty()); + assertArrayEquals("B".getBytes(), (byte[]) m.findValue("104.0x01.0xDF02").orElseThrow()); + } + + // ---- clone + + @Test + public void cloneDatasetElementPath() throws Exception { + ISOMsg m = populated(); + ISOMsg c = m.clone("0", "55.0x9F26"); + + assertEquals("0100", c.getMTI()); + ISODatasetField f55 = (ISODatasetField) c.getComponent(55); + assertNotSame(m.getComponent(55), f55); + ISODataset icc = (ISODataset) f55.getDataset(55); + assertEquals(DatasetFormat.TLV, icc.getFormat()); + assertEquals(1, icc.getElements().size()); + byte[] cloned = icc.getBytes(0x9F26); + assertArrayEquals(AC, cloned); + + // deep copy: the source element shares its array with the caller + byte[] source = AC.clone(); + m.setDatasetElement("55", 0x9F26, source); + c = m.clone("55.0x9F26"); + source[0] = 0; + assertArrayEquals(AC, c.findValue("55.0x9F26").map(byte[].class::cast).orElseThrow()); + } + + @Test + public void cloneDatasetElementsOrderAndOverlap() throws Exception { + ISOMsg m = populated(); + + ISOMsg c = m.clone("55.0x9F10", "55.0x9F26"); + List elements = ((ISODatasetField) c.getComponent(55)).getDataset(55).getElements(); + assertEquals(0x9F10, elements.get(0).getId()); + assertEquals(0x9F26, elements.get(1).getId()); + + c = m.clone("55", "55.0x9F26", "55.0x9F26"); + assertEquals(1, c.findDatasetElements("55.0x9F26").size(), "overlapping paths replace"); + assertEquals(2, ((ISODatasetField) c.getComponent(55)).getDataset(55).getElements().size()); + + c = m.clone("55.0x9F27", "62.1", "64.0x9F26"); + assertFalse(c.hasFields(), "absent paths are skipped"); + } + + @Test + public void cloneRepeatedTagsAndDatasets() throws Exception { + ISOMsg m = new ISOMsg("0100"); + ISODatasetField f55 = new ISODatasetField(55); + ISODataset icc = new ISODataset(55, DatasetFormat.TLV).with(0x9F26, AC); + icc.addElement(0x71, new ISOBinaryField(0x71, SCRIPT1), true); + icc.addElement(0x71, new ISOBinaryField(0x71, SCRIPT2), true); + f55.addDataset(icc); + m.set(f55); + ISODatasetField f104 = new ISODatasetField(104); + f104.addDataset(new ISODataset(0x01, DatasetFormat.TLV).with(0xDF02, "A".getBytes())); + f104.addDataset(new ISODataset(0x01, DatasetFormat.TLV).with(0xDF01, "B".getBytes())); + f104.addDataset(new ISODataset(0x71, DatasetFormat.DBM).with(2, "1234")); + m.set(f104); + + ISOMsg c = m.clone("55.0x71"); + List scripts = c.findDatasetElements("55.0x71"); + assertEquals(2, scripts.size()); + assertArrayEquals(SCRIPT1, scripts.get(0).getBytes()); + assertArrayEquals(SCRIPT2, scripts.get(1).getBytes()); + assertTrue(scripts.get(0).isConstructed() && scripts.get(1).isConstructed()); + + c = m.clone("104.0x01.0xDF01"); + List datasets = ((ISODatasetField) c.getComponent(104)).getDatasets(); + assertEquals(1, datasets.size(), "dataset without the element is not kept"); + assertArrayEquals("B".getBytes(), datasets.get(0).getElement(0xDF01).getBytes()); + + c = m.clone("104.0x01.0xDF01", "104.0x01.0xDF02", "104.0x71.2"); + datasets = ((ISODatasetField) c.getComponent(104)).getDatasets(0x01); + assertEquals(2, datasets.size(), "same-identifier datasets stay apart"); + assertArrayEquals("A".getBytes(), datasets.get(0).getElement(0xDF02).getBytes()); + assertArrayEquals("B".getBytes(), datasets.get(1).getElement(0xDF01).getBytes()); + assertEquals(1, datasets.get(0).getElements().size()); + assertEquals(1, datasets.get(1).getElements().size()); + assertEquals(DatasetFormat.DBM, ((ISODatasetField) c.getComponent(104)).getDataset(0x71).getFormat()); + assertEquals(Optional.of("1234"), c.findValue("104.0x71.2")); + } + + @Test + public void cloneDatasetPathWithPackager() throws Exception { + GenericPackager packager = cmfv3(); + ISOMsg m = roundTrip(packager, cmfMsg(packager) + .with("55.0x9F26", AC) + .with("55.0x9F10", IAD) + .with("104.0x01.0xDF01", ISOUtil.hex2byte("CAFEBABE"))); + + ISOMsg c = m.clone("0", "55.0x9F26", "104.0x01.0xDF01"); + ISOMsg unpacked = roundTrip(packager, c); + assertArrayEquals(AC, (byte[]) unpacked.findValue("55.0x9F26").orElseThrow()); + assertEquals(Optional.empty(), unpacked.findValue("55.0x9F10")); + assertArrayEquals(ISOUtil.hex2byte("CAFEBABE"), (byte[]) unpacked.findValue("104.0x01.0xDF01").orElseThrow()); + } + + @Test + public void cloneNestedDatasetPath() throws Exception { + ISOMsg m = new ISOMsg("0100"); + m.setDatasetElement("127.55", 0x9F26, AC); + m.setDatasetElement("127.55", 0x9F10, IAD); + m.set("127.2", "value127.2"); + + ISOMsg c = m.clone("127.55.0x9F26"); + assertFalse(c.hasField("127.2")); + assertArrayEquals(AC, (byte[]) c.findValue("127.55.0x9F26").orElseThrow()); + assertEquals(Optional.empty(), c.findValue("127.55.0x9F10")); + } + + // ---- helpers + + private static ISOMsg populated() throws ISOException { + ISOMsg m = new ISOMsg("0100"); + m.set(3, "000000"); + m.set(52, ISOUtil.hex2byte("CAFEBABE")); + m.set(62, "leaf62"); + m.set("63.2.3", "value63.2.3"); + ISODatasetField f55 = new ISODatasetField(55); + f55.addDataset(new ISODataset(55, DatasetFormat.TLV).with(0x9F26, AC).with(0x9F10, IAD)); + m.set(f55); + return m; + } + + private static GenericPackager cmfv3() throws ISOException { + return new GenericPackager("jar:packager/cmfv3.xml"); + } + + private static ISOMsg cmfMsg(GenericPackager packager) { + ISOMsg m = new ISOMsg("0100"); + m.setPackager(packager); + return m; + } + + private static ISOMsg roundTrip(GenericPackager packager, ISOMsg m) throws ISOException { + m.setPackager(packager); + ISOMsg unpacked = new ISOMsg(); + unpacked.setPackager(packager); + unpacked.unpack(m.pack()); + return unpacked; + } +} diff --git a/jpos/src/test/java/org/jpos/iso/ISOMsgTest.java b/jpos/src/test/java/org/jpos/iso/ISOMsgTest.java index f0639e6ab8..0dbbf1cac7 100644 --- a/jpos/src/test/java/org/jpos/iso/ISOMsgTest.java +++ b/jpos/src/test/java/org/jpos/iso/ISOMsgTest.java @@ -234,14 +234,10 @@ public void testCloneFPathBrokenHierarchy() throws Exception { ISOMsg m = new ISOMsg("0100"); m.set(62, "leaf62"); m.set("63.3.1", "value63.3.1"); - ISODatasetField f55 = new ISODatasetField(55); - f55.addDataset(new ISODataset(55, DatasetFormat.TLV).with(0x9F26, "0102030405060708".getBytes())); - m.set(f55); - ISOMsg c = m.clone("0", "62.1", "55.0x9F26", "63.3.1"); + ISOMsg c = m.clone("0", "62.1", "63.3.1"); assertEquals("0100", c.getMTI()); assertFalse(c.hasField(62), "path through a leaf must not create a composite"); - assertFalse(c.hasField(55), "path through a dataset field must not copy the field"); assertEquals("value63.3.1", c.getString("63.3.1")); }