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.*;
022import org.jpos.iso.header.BaseHeader;
023import org.jpos.util.LogEvent;
024import org.jpos.util.LogSource;
025import org.jpos.util.Logger;
026import org.xml.sax.Attributes;
027import org.xml.sax.InputSource;
028import org.xml.sax.SAXException;
029import org.xml.sax.XMLReader;
030import org.xml.sax.helpers.DefaultHandler;
031import org.xml.sax.helpers.XMLReaderFactory;
032
033import java.io.*;
034import java.math.BigDecimal;
035import java.nio.charset.StandardCharsets;
036import java.util.Stack;
037import java.util.concurrent.locks.Lock;
038import java.util.concurrent.locks.ReentrantLock;
039
040/**
041 * packs/unpacks ISOMsgs into XML representation
042 *
043 * @author apr@cs.com.uy
044 * @version $Id$
045 * @see ISOPackager
046 */
047@SuppressWarnings("unchecked")
048public class XMLPackager extends DefaultHandler
049                         implements ISOPackager, LogSource
050{
051    /** Logger used to emit pack/unpack events. */
052    protected Logger logger = null;
053    /** Logging realm associated with this packager. */
054    protected String realm = null;
055    private XMLReader reader;
056    private Stack stk;
057    private Lock parserLock = new ReentrantLock();
058
059    /** XML element name used for ISO messages. */
060    public static final String ISOMSG_TAG    = "isomsg";
061    /** XML element name used for ISO fields. */
062    public static final String ISOFIELD_TAG  = "field";
063    /** XML attribute name holding field identifiers. */
064    public static final String ID_ATTR       = "id";
065    /** XML attribute name holding scalar values. */
066    public static final String VALUE_ATTR    = "value";
067    /** XML attribute name holding type metadata. */
068    public static final String TYPE_ATTR     = "type";
069    /** XML type marker for binary field content. */
070    public static final String TYPE_BINARY   = "binary";
071    /** XML type marker for bitmap field content. */
072    public static final String TYPE_BITMAP   = "bitmap";
073    /** XML type marker for amount field content. */
074    public static final String TYPE_AMOUNT   = "amount";
075    /** XML type marker for dataset field content. */
076    public static final String TYPE_DATASET  = "dataset";
077    /** XML attribute name holding currency metadata. */
078    public static final String CURRENCY_ATTR = "currency";
079    /** XML element name used for message headers. */
080    public static final String HEADER_TAG    = "header";
081    /** XML attribute name used for charset declarations. */
082    public static final String ENCODING_ATTR = "encoding";
083    /** Literal name for ASCII encoding declarations. */
084    public static final String ASCII_ENCODING= "ascii";
085    /** XML element name used for datasets. */
086    public static final String DATASET_TAG   = "dataset";
087    /** XML element name used for dataset elements. */
088    public static final String ELEMENT_TAG   = "element";
089    /** XML attribute name holding dataset format metadata. */
090    public static final String FORMAT_ATTR   = "format";
091
092    // fields that will be forced to be interpreted as binary data
093    private int[] binaryFields= null;
094
095    /**
096     * Creates an XML packager with a hardened SAX parser configuration.
097     *
098     * @throws ISOException if the XML reader cannot be created or configured
099     */
100    public XMLPackager() throws ISOException {
101        super();
102        stk = new Stack();
103        try {
104            reader = createXMLReader();
105
106            // some parser restrictions have been set for security and maybe PCI compliance
107            setXMLParserFeature("http://xml.org/sax/features/validation", false);
108            setXMLParserFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
109            setXMLParserFeature("http://xml.org/sax/features/external-general-entities", false);
110            setXMLParserFeature("http://xml.org/sax/features/external-parameter-entities", false);
111        } catch (Exception e) {
112            throw new ISOException (e.toString());
113        }
114    }
115
116    /**
117     * Marks the supplied fields so their XML values are always decoded as binary.
118     *
119     * @param bfields field numbers to force as binary
120     */
121    public void forceBinary(int ... bfields) {
122        binaryFields= bfields;
123    }
124
125    public byte[] pack (ISOComponent c) throws ISOException {
126        LogEvent evt = new LogEvent (this, "pack");
127
128        try {
129            if (!(c instanceof ISOMsg m))
130                throw new ISOException ("cannot pack "+c.getClass());
131
132            // heuristics for initial size 40: a typical <field> with 2 chars of id + 12 of value + 2 indent
133            ByteArrayOutputStream out = new ByteArrayOutputStream(40 * (m.getChildren().size()+2));
134            PrintStream p = new PrintStream(out, false, StandardCharsets.UTF_8);
135
136            m.setDirection(0);  // avoid "direction=xxxxxx" in XML msg
137            m.dump (p, "");
138            byte[] b = out.toByteArray();
139
140            if (logger != null)
141                evt.addMessage (m);
142            return b;
143        } catch (ISOException e) {
144            evt.addMessage (e);
145            throw e;
146        } finally {
147            Logger.log(evt);
148        }
149    }
150
151    public int unpack (ISOComponent c, byte[] b) throws ISOException {
152        unpack(c, new InputSource(new ByteArrayInputStream(b)));
153        return b.length;
154    }
155
156    public void unpack (ISOComponent c, InputStream in) throws ISOException, IOException {
157        unpack(c, new InputSource(in));
158    }
159
160    private void unpack (ISOComponent c, InputSource in) throws ISOException {
161        LogEvent evt = new LogEvent (this, "unpack");
162
163        parserLock.lock();
164        try {
165            if (!(c instanceof ISOMsg m))
166                throw new ISOException("Can't call packager on non Composite");
167
168            while (!stk.empty())    // purge from possible previous error
169                stk.pop();
170
171            reader.parse (in);
172            if (stk.empty())
173                throw new ISOException ("error parsing");
174
175            ISOMsg m1 = (ISOMsg) stk.pop();
176            m.merge (m1);
177            m.setHeader (m1.getHeader());
178
179            fixupBinary(m, binaryFields);
180
181            if (logger != null)
182                evt.addMessage (m);
183        } catch (ISOException e) {
184            evt.addMessage (e);
185            throw e;
186        } catch (IOException e) {
187            evt.addMessage (e);
188            throw new ISOException (e.toString());
189        } catch (SAXException e) {
190            evt.addMessage (e);
191            throw new ISOException (e.toString());
192        } finally {
193            Logger.log (evt);
194            parserLock.unlock();
195        }
196    }
197
198    public void startElement
199        (String ns, String name, String qName, Attributes atts)
200        throws SAXException
201    {
202        try {
203            if (name.equals (ISOMSG_TAG)) {
204                int fieldNumber = parseDecimalId(atts.getValue(ID_ATTR));
205                if (fieldNumber >= 0) {
206                    if (stk.empty())
207                        throw new SAXException ("inner without outer");
208
209                    ISOMsg inner = new ISOMsg(fieldNumber);
210                    ((ISOMsg)stk.peek()).set (inner);
211                    stk.push (inner);
212                } else {
213                    stk.push (new ISOMsg(0));
214                }
215            } else if (name.equals (ISOFIELD_TAG)) {
216                int fieldNumber = parseDecimalId(atts.getValue(ID_ATTR));
217                ISOMsg m     = (ISOMsg) stk.peek();
218                String id    = atts.getValue(ID_ATTR);
219                String value = atts.getValue(VALUE_ATTR);
220                String type  = atts.getValue(TYPE_ATTR);
221                if (id == null)
222                    throw new SAXException ("invalid field");
223                value = value == null ? "" : value;
224
225                ISOComponent ic;
226                if (TYPE_DATASET.equals(type)) {
227                    ic = new ISODatasetField(fieldNumber);
228                }
229                else if (TYPE_BINARY.equals (type)) {
230                    ic = new ISOBinaryField (
231                        fieldNumber,
232                            ISOUtil.hex2byte (
233                                value.getBytes(), 0, value.length()/2
234                            )
235                        );
236                }
237                else if (TYPE_AMOUNT.equals (type)) {
238                    ic =  new ISOAmount(
239                        fieldNumber,
240                        Integer.parseInt (atts.getValue(CURRENCY_ATTR)),
241                        new BigDecimal (value)
242                    );
243                }
244                else {
245                    ic = new ISOField (fieldNumber, ISOUtil.stripUnicode(value));
246                }
247                m.set (ic);
248                stk.push (ic);
249            } else if (DATASET_TAG.equals(name)) {
250                if (!(stk.peek() instanceof ISODatasetField))
251                    throw new SAXException("dataset without dataset field");
252                String id = atts.getValue(ID_ATTR);
253                String format = atts.getValue(FORMAT_ATTR);
254                if (id == null || format == null)
255                    throw new SAXException("invalid dataset");
256                ISODataset dataset = new ISODataset(parseHexId(id), DatasetFormat.valueOf(format));
257                ((ISODatasetField) stk.peek()).addDataset(dataset);
258                stk.push(dataset);
259            } else if (ELEMENT_TAG.equals(name)) {
260                if (!(stk.peek() instanceof ISODataset))
261                    throw new SAXException("element without dataset");
262                ISODataset dataset = (ISODataset) stk.peek();
263                String id = atts.getValue(ID_ATTR);
264                String value = atts.getValue(VALUE_ATTR);
265                if (id == null)
266                    throw new SAXException("invalid dataset element");
267                int elementId = dataset.getFormat() == DatasetFormat.TLV ? parseHexId(id) : parseDecimalOrHexId(id);
268                byte[] bytes = value == null ? new byte[0] : ISOUtil.hex2byte(value.getBytes(), 0, value.length() / 2);
269                dataset.addElement(elementId, new ISOBinaryField(elementId, bytes), dataset.getFormat() == DatasetFormat.TLV && isConstructedTag(elementId));
270            } else if (HEADER_TAG.equals (name)) {
271                BaseHeader bh = new BaseHeader();
272                bh.setAsciiEncoding (ASCII_ENCODING.equalsIgnoreCase(atts.getValue(ENCODING_ATTR)));
273                stk.push (bh);
274            }
275        } catch (ISOException e) {
276            throw new SAXException ("ISOException unpacking XML dataset", e);
277        }
278    }
279
280    public void characters (char ch[], int start, int length) {
281        Object obj = stk.peek();
282        if (obj instanceof ISOField) {
283            ISOField f = (ISOField) obj;
284            String value = f.getValue() + new String(ch, start, length);
285            try {
286                f.setValue(value);
287            } catch (ISOException e) {
288                try {
289                    f.setValue (e.getMessage());
290                } catch (ISOException ignored) {
291                    // giving up
292                }
293            }
294        }
295        else if (obj instanceof BaseHeader) {
296            BaseHeader bh = (BaseHeader) obj;
297            String s = new String(ch,start,length);
298            if (bh.isAsciiEncoding()) {
299                bh.unpack (s.getBytes());
300            } else {
301                bh.unpack (ISOUtil.hex2byte (s));
302            }
303        }
304    }
305
306    public void endElement (String ns, String name, String qname)
307        throws SAXException
308    {
309        if (name.equals (ISOMSG_TAG)) {
310            ISOMsg m = (ISOMsg) stk.pop();
311            if (stk.empty())
312                stk.push (m); // push outer message
313        } else if (DATASET_TAG.equals(name)) {
314            stk.pop();
315        } else if (ISOFIELD_TAG.equals (name)) {
316            stk.pop();
317        } else if (HEADER_TAG.equals (name)) {
318            BaseHeader h = (BaseHeader) stk.pop();
319            ISOMsg m = (ISOMsg) stk.peek ();
320            m.setHeader (h);
321        }
322    }
323
324    // we may want to force fome fields to be interpreted as binary data
325    /**
326     * Converts selected message fields from hexadecimal strings into binary values.
327     *
328     * @param m message being adjusted
329     * @param bfields field numbers to convert
330     * @throws ISOException if any field cannot be converted
331     */
332    protected void fixupBinary(ISOMsg m, int[] bfields) throws ISOException {
333        if (bfields != null) {
334            for (int f : bfields) {
335                if (m.hasField(f)) {
336                    ISOComponent c = m.getComponent(f);
337                    if (c instanceof ISOField)
338                        m.set(f, ((ISOField) c).getBytes());
339                }
340            }
341        }
342    }
343
344    public String getFieldDescription(ISOComponent m, int fldNumber) {
345        return "Data element " + fldNumber;
346    }
347    public void setLogger (Logger logger, String realm) {
348        this.logger = logger;
349        this.realm  = realm;
350    }
351    public String getRealm () {
352        return realm;
353    }
354    public Logger getLogger() {
355        return logger;
356    }
357    public ISOMsg createISOMsg () {
358        return new ISOMsg();
359    }
360    public String getDescription () {
361        return getClass().getName();
362    }
363
364    /**
365     * Creates the SAX reader used to parse XML ISO messages.
366     *
367     * @return configured XML reader instance
368     * @throws SAXException if the reader cannot be created
369     */
370    protected XMLReader createXMLReader () throws SAXException {
371        XMLReader reader;
372        try {
373            reader = XMLReaderFactory.createXMLReader();
374        } catch (SAXException e) {
375            reader = XMLReaderFactory.createXMLReader (
376                System.getProperty(
377                    "org.xml.sax.driver",
378                    "org.apache.crimson.parser.XMLReaderImpl"
379                )
380            );
381        }
382
383        reader.setContentHandler(this);
384        reader.setErrorHandler(this);
385        return reader;
386    }
387
388    /**
389     * Sets a SAX feature on the underlying XML parser.
390     *
391     * @param fname feature name URI
392     * @param val feature value to apply
393     * @throws SAXException if the parser rejects the feature
394     */
395    public void setXMLParserFeature(String fname, boolean val) throws SAXException  {
396        reader.setFeature(fname, val);
397    }
398
399    private int parseDecimalId(String id) throws SAXException {
400        if (id == null)
401            return -1;
402        try {
403            return Integer.parseInt(id);
404        } catch (NumberFormatException ex) {
405            throw new SAXException("Invalid id " + id, ex);
406        }
407    }
408
409    private int parseDecimalOrHexId(String id) throws SAXException {
410        try {
411            return id.startsWith("0x") || id.startsWith("0X") ? Integer.parseInt(id.substring(2), 16) : Integer.parseInt(id);
412        } catch (NumberFormatException ex) {
413            throw new SAXException("Invalid id " + id, ex);
414        }
415    }
416
417    private int parseHexId(String id) throws SAXException {
418        try {
419            String normalized = id.startsWith("0x") || id.startsWith("0X") ? id.substring(2) : id;
420            return Integer.parseInt(normalized, 16);
421        } catch (NumberFormatException ex) {
422            throw new SAXException("Invalid hex id " + id, ex);
423        }
424    }
425
426    private boolean isConstructedTag(int tag) {
427        String hexTag = Integer.toHexString(tag);
428        if ((hexTag.length() & 0x01) == 1)
429            hexTag = "0" + hexTag;
430        byte[] tagBytes = ISOUtil.hex2byte(hexTag);
431        return (tagBytes[0] & 0x20) == 0x20;
432    }
433}