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}